amarorn / pipelines /wc_sofascore_features.py
beAnalytic's picture
feat: sync main with feature/superbet-live-inplay
16c19b8 verified
Raw
History Blame Contribute Delete
8.54 kB
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import pandas as pd
from ingest.sofascore.stats_dataset import load_match_stats_history
SOFASCORE_FEATURE_NAMES = [
"sofa_xg_for_diff_last5",
"sofa_xg_against_diff_last5",
"sofa_possession_diff_last5",
"sofa_shots_on_target_diff_last5",
"sofa_big_chances_diff_last5",
"sofa_stats_available",
]
ROLLING_WINDOW = 5
MIN_TEAM_MATCHES = 2
@dataclass(frozen=True)
class TeamRollingStats:
xg_for: float
xg_against: float
possession: float
shots_on_target: float
big_chances: float
samples: int
def _team_perspective_rows(df: pd.DataFrame, team: str) -> pd.DataFrame:
if df.empty:
return df
home = df[df["home_team"] == team].copy()
away = df[df["away_team"] == team].copy()
home["xg_for"] = home["home_xg"]
home["xg_against"] = home["away_xg"]
home["possession"] = home["home_possession_pct"]
home["shots_on_target"] = home["home_shots_on_target"]
home["big_chances"] = home["home_big_chances"]
away["xg_for"] = away["away_xg"]
away["xg_against"] = away["home_xg"]
away["possession"] = away["away_possession_pct"]
away["shots_on_target"] = away["away_shots_on_target"]
away["big_chances"] = away["away_big_chances"]
cols = ["match_date", "xg_for", "xg_against", "possession", "shots_on_target", "big_chances"]
combined = pd.concat([home[cols], away[cols]], ignore_index=True)
return combined.sort_values("match_date")
def team_rolling_stats(
df: pd.DataFrame,
team: str,
*,
window: int = ROLLING_WINDOW,
) -> TeamRollingStats | None:
rows = _team_perspective_rows(df, team)
if rows.empty:
return None
usable = rows.dropna(subset=["xg_for", "xg_against"])
if len(usable) < MIN_TEAM_MATCHES:
return None
tail = usable.tail(window)
def _mean(col: str) -> float:
series = pd.to_numeric(tail[col], errors="coerce").dropna()
return float(series.mean()) if not series.empty else 0.0
return TeamRollingStats(
xg_for=_mean("xg_for"),
xg_against=_mean("xg_against"),
possession=_mean("possession"),
shots_on_target=_mean("shots_on_target"),
big_chances=_mean("big_chances"),
samples=len(tail),
)
def _rolling_to_dict(stats: TeamRollingStats) -> dict[str, float | int]:
return {
"xg_for": round(stats.xg_for, 2),
"xg_against": round(stats.xg_against, 2),
"possession_pct": round(stats.possession, 1),
"shots_on_target": round(stats.shots_on_target, 1),
"big_chances": round(stats.big_chances, 1),
"samples": stats.samples,
}
def build_gold_wc_match_features_df(*, stats_df: pd.DataFrame | None = None) -> pd.DataFrame:
history = stats_df if stats_df is not None else load_match_stats_history()
if history.empty:
return pd.DataFrame(
columns=[
"event_id",
"home_team",
"away_team",
"match_date",
*SOFASCORE_FEATURE_NAMES,
"built_at",
]
)
ordered = history.sort_values("match_date").reset_index(drop=True)
rows: list[dict[str, Any]] = []
built_at = datetime.now(timezone.utc).isoformat()
for _, match in ordered.iterrows():
match_date = match.get("match_date")
if pd.isna(match_date):
continue
before = pd.to_datetime(match_date, utc=True).to_pydatetime()
vec = sofascore_feature_vector(
str(match["home_team"]),
str(match["away_team"]),
before_date=before,
stats_df=history,
)
rows.append(
{
"event_id": int(match["event_id"]),
"home_team": match["home_team"],
"away_team": match["away_team"],
"match_date": before,
**dict(zip(SOFASCORE_FEATURE_NAMES, vec, strict=True)),
"built_at": built_at,
}
)
if not rows:
return pd.DataFrame()
out = pd.DataFrame(rows)
out["match_date"] = pd.to_datetime(out["match_date"], utc=True, errors="coerce")
return out
def sofascore_feature_vector(
home_team: str,
away_team: str,
*,
before_date: datetime | None = None,
stats_df: pd.DataFrame | None = None,
) -> list[float]:
df = stats_df if stats_df is not None else load_match_stats_history(before_date=before_date)
home = team_rolling_stats(df, home_team)
away = team_rolling_stats(df, away_team)
if home is None or away is None:
return [0.0] * (len(SOFASCORE_FEATURE_NAMES) - 1) + [0.0]
return [
home.xg_for - away.xg_for,
home.xg_against - away.xg_against,
home.possession - away.possession,
home.shots_on_target - away.shots_on_target,
home.big_chances - away.big_chances,
1.0,
]
def sofascore_breakdown(
home_team: str,
away_team: str,
*,
before_date: datetime | None = None,
stats_df: pd.DataFrame | None = None,
) -> dict:
df = stats_df if stats_df is not None else load_match_stats_history(before_date=before_date)
home = team_rolling_stats(df, home_team)
away = team_rolling_stats(df, away_team)
vec = sofascore_feature_vector(
home_team, away_team, before_date=before_date, stats_df=df
)
features = dict(zip(SOFASCORE_FEATURE_NAMES, vec, strict=True))
return {
"available": features["sofa_stats_available"] == 1.0,
"window": ROLLING_WINDOW,
"min_team_matches": MIN_TEAM_MATCHES,
"features": {k: round(v, 3) for k, v in features.items()},
"home_last5": _rolling_to_dict(home) if home else None,
"away_last5": _rolling_to_dict(away) if away else None,
"parquet_matches_before": len(df),
}
def format_sofascore_context(
home_team: str,
away_team: str,
*,
before_date: datetime | None = None,
) -> str | None:
info = sofascore_breakdown(home_team, away_team, before_date=before_date)
if not info["available"]:
return (
"## Sofascore (últimos 5 jogos)\n"
"- Dados indisponíveis no histórico parquet antes desta data "
f"({info['parquet_matches_before']} jogos no recorte)."
)
h = info["home_last5"]
a = info["away_last5"]
f = info["features"]
lines = [
"## Sofascore (média últimos 5 jogos)",
"",
f"### {home_team}",
f"- xG a favor: {h['xg_for']} | xG contra: {h['xg_against']} | posse: {h['possession_pct']}%",
f"- Chutes no gol: {h['shots_on_target']} | grandes chances: {h['big_chances']} (n={h['samples']})",
"",
f"### {away_team}",
f"- xG a favor: {a['xg_for']} | xG contra: {a['xg_against']} | posse: {a['possession_pct']}%",
f"- Chutes no gol: {a['shots_on_target']} | grandes chances: {a['big_chances']} (n={a['samples']})",
"",
"### Diferenciais (mandante − visitante)",
f"- Δ xG a favor: {f['sofa_xg_for_diff_last5']:+.2f}",
f"- Δ xG contra: {f['sofa_xg_against_diff_last5']:+.2f}",
f"- Δ posse: {f['sofa_possession_diff_last5']:+.1f} pp",
f"- Δ chutes no gol: {f['sofa_shots_on_target_diff_last5']:+.1f}",
f"- Δ grandes chances: {f['sofa_big_chances_diff_last5']:+.1f}",
]
return "\n".join(lines)
def apply_sofascore_nudge(
probs: dict[str, float],
home_team: str,
away_team: str,
*,
before_date: datetime | None = None,
scale: float = 0.06,
) -> tuple[dict[str, float], dict | None]:
"""Desloca levemente 1/X/2 quando há rolling Sofascore (sinal de xG + posse)."""
info = sofascore_breakdown(home_team, away_team, before_date=before_date)
if not info["available"]:
return probs, None
feats = info["features"]
xg_diff = float(feats["sofa_xg_for_diff_last5"])
poss_diff = float(feats["sofa_possession_diff_last5"]) / 100.0
signal = 0.75 * xg_diff + 0.25 * poss_diff
delta = scale * max(-1.0, min(1.0, signal / 2.0))
p1 = probs["1"] + delta
p2 = probs["2"] - delta
px = probs["X"]
total = max(p1 + px + p2, 1e-9)
adjusted = {"1": p1 / total, "X": px / total, "2": p2 / total}
meta = {
"delta_home": round(delta, 4),
"signal": round(signal, 3),
"scale": scale,
}
return adjusted, meta