From 634204acf0cf73ba513d425bb4d1192abc62dd77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahri=20Can=20Se=C3=A7er?= Date: Thu, 23 Apr 2026 22:22:59 +0300 Subject: [PATCH] v28 --- ai-engine/features/odds_band_analyzer.py | 1224 +++++++++++++++++ ai-engine/services/feature_enrichment.py | 240 ++++ .../services/single_match_orchestrator.py | 624 ++++++++- src/modules/leagues/leagues.controller.ts | 11 +- src/modules/leagues/leagues.service.ts | 47 +- src/modules/predictions/dto/index.ts | 8 + 6 files changed, 2064 insertions(+), 90 deletions(-) create mode 100644 ai-engine/features/odds_band_analyzer.py diff --git a/ai-engine/features/odds_band_analyzer.py b/ai-engine/features/odds_band_analyzer.py new file mode 100644 index 0000000..ba4aacc --- /dev/null +++ b/ai-engine/features/odds_band_analyzer.py @@ -0,0 +1,1224 @@ +""" +Odds-Band Historical Performance Analyzer (V28) +================================================ +Uses historical betting odds as a lookup key to analyze how a team +actually performed when priced at similar odds ranges. + +Instead of using odds as raw model features (V25 approach), this +module treats odds as a *historical reference* — answering the +question: "When this team was priced at ~1.55, what actually happened?" + +This creates powerful calibration features that reveal systematic +market inefficiencies specific to each team. + +All methods are fail-safe and return sensible defaults. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Tuple + +from psycopg2.extras import RealDictCursor + + +# ─── Band Range Configuration ────────────────────────────────────── +def get_band_range(odds: float) -> Tuple[float, float]: + """ + Calculate the odds band range for historical lookup. + + Narrower bands for low odds (strong favorites — more data), + wider bands for high odds (underdogs — less data). + """ + if odds <= 0.0: + return (1.01, 1.50) # Invalid odds → default band + if odds <= 1.30: + margin = 0.05 # Very strong favorite → tight band + elif odds <= 2.00: + margin = 0.10 # Normal favorite + elif odds <= 3.50: + margin = 0.15 # Draw zone / slight underdog + else: + margin = 0.25 # Big underdog → wide band (less data) + return (max(1.01, odds - margin), odds + margin) + + +# ─── Minimum sample sizes ────────────────────────────────────────── +MIN_TEAM_SAMPLE = 8 # Min matches for team-level band stats +MIN_LEAGUE_SAMPLE = 15 # Min matches for league-level fallback +MAX_LOOKBACK = 50 # Max historical matches to consider + + +class OddsBandAnalyzer: + """ + Stateless service — receives a psycopg2 cursor and computes + odds-band historical features for any team + market combination. + """ + + # Default features when no data is available + _DEFAULTS: Dict[str, float] = { + "band_ms_win_rate": 0.33, + "band_ms_draw_rate": 0.33, + "band_ms_loss_rate": 0.34, + "band_ms_sample": 0.0, + "band_ou25_over_rate": 0.50, + "band_ou25_sample": 0.0, + "band_ou15_over_rate": 0.65, + "band_ou15_sample": 0.0, + "band_ou35_over_rate": 0.35, + "band_ou35_sample": 0.0, + "band_btts_rate": 0.50, + "band_btts_sample": 0.0, + "band_ms_value_signal": 0.0, + "band_ou25_value_signal": 0.0, + "band_btts_value_signal": 0.0, + } + + # ─── Public Interface ────────────────────────────────────────── + + def compute_all( + self, + cur: RealDictCursor, + home_team_id: str, + away_team_id: str, + league_id: Optional[str], + odds: Dict[str, float], + before_ts: int, + referee_name: Optional[str] = None, + ) -> Dict[str, float]: + """ + Compute odds-band features for both home and away teams. + + Args: + cur: psycopg2 RealDictCursor + home_team_id: Home team ID + away_team_id: Away team ID + league_id: League ID for fallback queries + odds: Current match odds dict (ms_h, ms_d, ms_a, ou25_o, etc.) + before_ts: Match timestamp (ms) — only look at matches before this + referee_name: Optional referee name for card profiling + + Returns: + Dict with odds-band features (home_ and away_ prefixed) + """ + result = {} + + ms_home_odds = float(odds.get("ms_h", 0)) + ms_away_odds = float(odds.get("ms_a", 0)) + ou25_over_odds = float(odds.get("ou25_o", 0)) + ou25_under_odds = float(odds.get("ou25_u", 0)) + ou15_over_odds = float(odds.get("ou15_o", 0)) + ou35_over_odds = float(odds.get("ou35_o", 0)) + btts_yes_odds = float(odds.get("btts_y", 0)) + + # ── Home team band analysis ─────────────────────────────── + home_ms = self._compute_ms_band( + cur, home_team_id, league_id, ms_home_odds, + is_home=True, before_ts=before_ts, + ) + for key, val in home_ms.items(): + result[f"home_{key}"] = val + + # ── Away team band analysis ─────────────────────────────── + away_ms = self._compute_ms_band( + cur, away_team_id, league_id, ms_away_odds, + is_home=False, before_ts=before_ts, + ) + for key, val in away_ms.items(): + result[f"away_{key}"] = val + + # ── Match-level OU/BTTS band analysis ───────────────────── + ou25_band = self._compute_ou_band( + cur, home_team_id, away_team_id, league_id, + ou25_over_odds, line=2.5, before_ts=before_ts, + ) + for key, val in ou25_band.items(): + result[f"band_ou25_{key}"] = val + + ou15_band = self._compute_ou_band( + cur, home_team_id, away_team_id, league_id, + ou15_over_odds, line=1.5, before_ts=before_ts, + ) + for key, val in ou15_band.items(): + result[f"band_ou15_{key}"] = val + + ou35_band = self._compute_ou_band( + cur, home_team_id, away_team_id, league_id, + ou35_over_odds, line=3.5, before_ts=before_ts, + ) + for key, val in ou35_band.items(): + result[f"band_ou35_{key}"] = val + + btts_band = self._compute_btts_band( + cur, home_team_id, away_team_id, league_id, + btts_yes_odds, before_ts=before_ts, + ) + for key, val in btts_band.items(): + result[f"band_btts_{key}"] = val + + # ── DC (Çifte Şans) band ────────────────────────────────── + dc_1x_odds = float(odds.get("dc_1x", 0)) + dc_x2_odds = float(odds.get("dc_x2", 0)) + dc_12_odds = float(odds.get("dc_12", 0)) + dc_band = self._compute_dc_band( + cur, home_team_id, away_team_id, league_id, + dc_1x_odds, dc_x2_odds, dc_12_odds, before_ts=before_ts, + ) + for key, val in dc_band.items(): + result[f"band_dc_{key}"] = val + + # ── HT (İlk Yarı Sonucu) band ──────────────────────────── + ht_h_odds = float(odds.get("ht_h", 0)) + ht_a_odds = float(odds.get("ht_a", 0)) + home_ht = self._compute_ht_band( + cur, home_team_id, league_id, ht_h_odds, + is_home=True, before_ts=before_ts, + ) + for key, val in home_ht.items(): + result[f"home_{key}"] = val + away_ht = self._compute_ht_band( + cur, away_team_id, league_id, ht_a_odds, + is_home=False, before_ts=before_ts, + ) + for key, val in away_ht.items(): + result[f"away_{key}"] = val + + # ── HT OU 0.5 / 1.5 bands ──────────────────────────────── + ht_ou05_odds = float(odds.get("ht_ou05_o", 0)) + ht_ou05_band = self._compute_ou_band( + cur, home_team_id, away_team_id, league_id, + ht_ou05_odds, line=0.5, before_ts=before_ts, + half_time=True, + ) + for key, val in ht_ou05_band.items(): + result[f"band_ht_ou05_{key}"] = val + + ht_ou15_odds = float(odds.get("ht_ou15_o", 0)) + ht_ou15_band = self._compute_ou_band( + cur, home_team_id, away_team_id, league_id, + ht_ou15_odds, line=1.5, before_ts=before_ts, + half_time=True, + ) + for key, val in ht_ou15_band.items(): + result[f"band_ht_ou15_{key}"] = val + + # ── OE (Tek/Çift) band ──────────────────────────────────── + oe_odd_odds = float(odds.get("oe_odd", 0)) + oe_band = self._compute_oe_band( + cur, home_team_id, away_team_id, league_id, + oe_odd_odds, before_ts=before_ts, + ) + for key, val in oe_band.items(): + result[f"band_oe_{key}"] = val + + # ── Cards (Kart Alt/Üst) — Hakem + Takım profili ────────── + cards_o_odds = float(odds.get("cards_o", 0)) + cards_band = self._compute_cards_band( + cur, home_team_id, away_team_id, league_id, + cards_o_odds, before_ts=before_ts, + referee_name=referee_name, + ) + for key, val in cards_band.items(): + result[f"band_cards_{key}"] = val + + # ── HTFT (İY/MS) — 9 Kombinasyon ────────────────────────── + htft_band = self._compute_htft_band( + cur, home_team_id, away_team_id, league_id, + odds, before_ts=before_ts, + ) + for key, val in htft_band.items(): + result[key] = val # already prefixed with band_htft_ + + # ── Value signals ───────────────────────────────────────── + self._add_value_signal(result, "home_band_ms_value_signal", + result.get("home_band_ms_win_rate", 0.33), ms_home_odds) + self._add_value_signal(result, "away_band_ms_value_signal", + result.get("away_band_ms_win_rate", 0.33), ms_away_odds) + self._add_value_signal(result, "band_ou25_value_signal", + result.get("band_ou25_over_rate", 0.50), ou25_over_odds) + self._add_value_signal(result, "band_ou15_value_signal", + result.get("band_ou15_over_rate", 0.65), ou15_over_odds) + self._add_value_signal(result, "band_ou35_value_signal", + result.get("band_ou35_over_rate", 0.35), ou35_over_odds) + self._add_value_signal(result, "band_btts_value_signal", + result.get("band_btts_yes_rate", 0.50), btts_yes_odds) + self._add_value_signal(result, "band_dc_1x_value_signal", + result.get("band_dc_1x_rate", 0.60), dc_1x_odds) + self._add_value_signal(result, "band_dc_x2_value_signal", + result.get("band_dc_x2_rate", 0.60), dc_x2_odds) + self._add_value_signal(result, "band_dc_12_value_signal", + result.get("band_dc_12_rate", 0.67), dc_12_odds) + self._add_value_signal(result, "home_band_ht_value_signal", + result.get("home_band_ht_win_rate", 0.33), ht_h_odds) + self._add_value_signal(result, "away_band_ht_value_signal", + result.get("away_band_ht_win_rate", 0.33), ht_a_odds) + self._add_value_signal(result, "band_ht_ou05_value_signal", + result.get("band_ht_ou05_over_rate", 0.50), ht_ou05_odds) + self._add_value_signal(result, "band_ht_ou15_value_signal", + result.get("band_ht_ou15_over_rate", 0.35), ht_ou15_odds) + self._add_value_signal(result, "band_oe_value_signal", + result.get("band_oe_odd_rate", 0.50), oe_odd_odds) + # Cards value signal + self._add_value_signal(result, "band_cards_value_signal", + result.get("band_cards_combined_over_rate", 0.50), cards_o_odds) + # HTFT value signals (9 combinations) + for combo in ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22"): + htft_key = f"htft_{combo}" + htft_odds_key = f"htft_{combo}" + htft_odds_val = float(odds.get(htft_odds_key, 0)) + self._add_value_signal(result, f"band_htft_{combo}_value_signal", + result.get(f"band_htft_{combo}_rate", 0.11), htft_odds_val) + + return result + + # ─── Shared value signal helper ─────────────────────────────── + @staticmethod + def _add_value_signal( + result: Dict[str, float], key: str, + actual_rate: float, odds_val: float, + ) -> None: + if odds_val > 1.0: + result[key] = round(actual_rate - (1.0 / odds_val), 4) + else: + result[key] = 0.0 + + # ─── MS Band ────────────────────────────────────────────────── + + def _compute_ms_band( + self, + cur: RealDictCursor, + team_id: str, + league_id: Optional[str], + team_odds: float, + is_home: bool, + before_ts: int, + ) -> Dict[str, float]: + """ + Compute MS win/draw/loss rate for a team in a given odds band. + + Looks up: "When this team had odds ~X, how often did they win?" + """ + defaults = { + "band_ms_win_rate": 0.33, + "band_ms_draw_rate": 0.33, + "band_ms_loss_rate": 0.34, + "band_ms_sample": 0.0, + "band_ms_avg_goals_scored": 1.3, + "band_ms_avg_goals_conceded": 1.1, + } + + if team_odds <= 1.0: + return defaults + + band_low, band_high = get_band_range(team_odds) + + try: + # Query: find finished matches where this team's odds + # fell within the band, then calculate actual outcomes + cur.execute(""" + WITH team_matches_in_band AS ( + SELECT + m.id, + m.score_home, + m.score_away, + m.home_team_id, + m.away_team_id, + CASE + WHEN m.home_team_id = %(team_id)s THEN os_sel.odd_value + ELSE os_sel2.odd_value + END AS team_odds + FROM matches m + JOIN odd_categories oc + ON oc.match_id = m.id + AND oc.name IN ('Maç Sonucu', 'Mac Sonucu', 'Match Result', '1x2') + LEFT JOIN odd_selections os_sel + ON os_sel.odd_category_db_id = oc.db_id + AND os_sel.name = '1' + AND m.home_team_id = %(team_id)s + LEFT JOIN odd_selections os_sel2 + ON os_sel2.odd_category_db_id = oc.db_id + AND os_sel2.name = '2' + AND m.away_team_id = %(team_id)s + WHERE (m.home_team_id = %(team_id)s OR m.away_team_id = %(team_id)s) + AND m.sport = 'football' + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.score_away IS NOT NULL + AND m.mst_utc < %(before_ts)s + AND COALESCE(os_sel.odd_value, os_sel2.odd_value) + BETWEEN %(band_low)s AND %(band_high)s + ORDER BY m.mst_utc DESC + LIMIT %(max_lookback)s + ) + SELECT + COUNT(*) AS sample_size, + COALESCE(AVG(CASE + WHEN (home_team_id = %(team_id)s AND score_home > score_away) + OR (away_team_id = %(team_id)s AND score_away > score_home) + THEN 1.0 ELSE 0.0 END), 0.33) AS win_rate, + COALESCE(AVG(CASE + WHEN score_home = score_away THEN 1.0 + ELSE 0.0 END), 0.33) AS draw_rate, + COALESCE(AVG(CASE + WHEN (home_team_id = %(team_id)s AND score_home < score_away) + OR (away_team_id = %(team_id)s AND score_away < score_home) + THEN 1.0 ELSE 0.0 END), 0.34) AS loss_rate, + COALESCE(AVG(CASE + WHEN home_team_id = %(team_id)s THEN score_home + ELSE score_away END), 1.3) AS avg_goals_scored, + COALESCE(AVG(CASE + WHEN home_team_id = %(team_id)s THEN score_away + ELSE score_home END), 1.1) AS avg_goals_conceded + FROM team_matches_in_band + """, { + "team_id": team_id, + "before_ts": before_ts, + "band_low": band_low, + "band_high": band_high, + "max_lookback": MAX_LOOKBACK, + }) + + row = cur.fetchone() + if not row or int(row["sample_size"]) < MIN_TEAM_SAMPLE: + # Fallback to league-level if team sample is too small + return self._compute_ms_band_league_fallback( + cur, league_id, team_odds, before_ts, defaults + ) + + return { + "band_ms_win_rate": round(float(row["win_rate"]), 4), + "band_ms_draw_rate": round(float(row["draw_rate"]), 4), + "band_ms_loss_rate": round(float(row["loss_rate"]), 4), + "band_ms_sample": float(row["sample_size"]), + "band_ms_avg_goals_scored": round(float(row["avg_goals_scored"]), 2), + "band_ms_avg_goals_conceded": round(float(row["avg_goals_conceded"]), 2), + } + + except Exception as e: + print(f"[OddsBand] ⚠ MS band query failed: {e}") + return defaults + + def _compute_ms_band_league_fallback( + self, + cur: RealDictCursor, + league_id: Optional[str], + team_odds: float, + before_ts: int, + defaults: Dict[str, float], + ) -> Dict[str, float]: + """League-level fallback when team-specific sample is too small.""" + if not league_id or team_odds <= 1.0: + return defaults + + band_low, band_high = get_band_range(team_odds) + + try: + cur.execute(""" + WITH league_matches_in_band AS ( + SELECT + m.id, + m.score_home, + m.score_away, + os_h.odd_value AS home_odds + FROM matches m + JOIN odd_categories oc + ON oc.match_id = m.id + AND oc.name IN ('Maç Sonucu', 'Mac Sonucu', 'Match Result', '1x2') + JOIN odd_selections os_h + ON os_h.odd_category_db_id = oc.db_id + AND os_h.name = '1' + WHERE m.league_id = %(league_id)s + AND m.sport = 'football' + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.score_away IS NOT NULL + AND m.mst_utc < %(before_ts)s + AND os_h.odd_value BETWEEN %(band_low)s AND %(band_high)s + ORDER BY m.mst_utc DESC + LIMIT %(max_lookback)s + ) + SELECT + COUNT(*) AS sample_size, + COALESCE(AVG(CASE WHEN score_home > score_away + THEN 1.0 ELSE 0.0 END), 0.33) AS home_win_rate, + COALESCE(AVG(CASE WHEN score_home = score_away + THEN 1.0 ELSE 0.0 END), 0.33) AS draw_rate, + COALESCE(AVG(score_home + score_away), 2.5) AS avg_total_goals + FROM league_matches_in_band + """, { + "league_id": league_id, + "before_ts": before_ts, + "band_low": band_low, + "band_high": band_high, + "max_lookback": MAX_LOOKBACK * 2, # Wider for league + }) + + row = cur.fetchone() + if not row or int(row["sample_size"]) < MIN_LEAGUE_SAMPLE: + return defaults + + win_rate = float(row["home_win_rate"]) + draw_rate = float(row["draw_rate"]) + return { + "band_ms_win_rate": round(win_rate, 4), + "band_ms_draw_rate": round(draw_rate, 4), + "band_ms_loss_rate": round(1.0 - win_rate - draw_rate, 4), + "band_ms_sample": float(row["sample_size"]), + "band_ms_avg_goals_scored": round(float(row["avg_total_goals"]) / 2, 2), + "band_ms_avg_goals_conceded": round(float(row["avg_total_goals"]) / 2, 2), + } + + except Exception as e: + print(f"[OddsBand] ⚠ League MS fallback failed: {e}") + return defaults + + # ─── OU Band ────────────────────────────────────────────────── + + def _compute_ou_band( + self, + cur: RealDictCursor, + home_team_id: str, + away_team_id: str, + league_id: Optional[str], + over_odds: float, + line: float, + before_ts: int, + half_time: bool = False, + ) -> Dict[str, float]: + """ + Compute Over/Under rate for matches where teams had similar OU odds. + When half_time=True, uses IY category names and score_ht fields. + """ + defaults = { + "over_rate": 0.50, + "under_rate": 0.50, + "avg_total_goals": 2.5 if not half_time else 1.0, + "sample": 0.0, + } + + if over_odds <= 1.0: + return defaults + + band_low, band_high = get_band_range(over_odds) + + line_str = str(line).replace(".", ",") + if half_time: + cat_names = [ + f"1. Yarı {line_str} Alt/Üst", + f"1. Yari {line_str} Alt/Ust", + f"İlk Yarı {line_str} Alt/Üst", + f"Ilk Yari {line_str} Alt/Ust", + ] + score_expr = "COALESCE(m.score_ht_home, 0) + COALESCE(m.score_ht_away, 0)" + else: + cat_names = [ + f"{line_str} Alt/Üst", + f"{line_str} Alt/Ust", + f"Over/Under {line}", + ] + score_expr = "m.score_home + m.score_away" + + try: + query = f""" + WITH ou_matches AS ( + SELECT + {score_expr} AS total_goals + FROM matches m + JOIN odd_categories oc + ON oc.match_id = m.id + AND oc.name = ANY(%(cat_names)s) + JOIN odd_selections os_over + ON os_over.odd_category_db_id = oc.db_id + AND os_over.name IN ('Üst', 'Ust', 'Over') + WHERE (m.home_team_id = %(home_id)s OR m.away_team_id = %(home_id)s + OR m.home_team_id = %(away_id)s OR m.away_team_id = %(away_id)s) + AND m.sport = 'football' + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.mst_utc < %(before_ts)s + AND os_over.odd_value BETWEEN %(band_low)s AND %(band_high)s + ORDER BY m.mst_utc DESC + LIMIT %(max_lookback)s + ) + SELECT + COUNT(*) AS sample_size, + COALESCE(AVG(CASE WHEN total_goals > %(line)s + THEN 1.0 ELSE 0.0 END), 0.5) AS over_rate, + COALESCE(AVG(total_goals), %(default_avg)s) AS avg_total + FROM ou_matches + """ + cur.execute(query, { + "home_id": home_team_id, + "away_id": away_team_id, + "cat_names": cat_names, + "before_ts": before_ts, + "band_low": band_low, + "band_high": band_high, + "line": line, + "default_avg": 1.0 if half_time else 2.5, + "max_lookback": MAX_LOOKBACK, + }) + + row = cur.fetchone() + if not row or int(row["sample_size"]) < MIN_TEAM_SAMPLE: + return defaults + + over_rate = float(row["over_rate"]) + return { + "over_rate": round(over_rate, 4), + "under_rate": round(1.0 - over_rate, 4), + "avg_total_goals": round(float(row["avg_total"]), 2), + "sample": float(row["sample_size"]), + } + + except Exception as e: + ht_label = "HT_" if half_time else "" + print(f"[OddsBand] ⚠ {ht_label}OU{line} band query failed: {e}") + return defaults + + # ─── BTTS Band ──────────────────────────────────────────────── + + def _compute_btts_band( + self, + cur: RealDictCursor, + home_team_id: str, + away_team_id: str, + league_id: Optional[str], + btts_yes_odds: float, + before_ts: int, + ) -> Dict[str, float]: + """ + Compute BTTS rate for matches where teams had similar BTTS odds. + """ + defaults = { + "yes_rate": 0.50, + "no_rate": 0.50, + "sample": 0.0, + } + + if btts_yes_odds <= 1.0: + return defaults + + band_low, band_high = get_band_range(btts_yes_odds) + + try: + cur.execute(""" + WITH btts_matches AS ( + SELECT + m.score_home, + m.score_away + FROM matches m + JOIN odd_categories oc + ON oc.match_id = m.id + AND oc.name IN ('Karşılıklı Gol', 'Karsilikli Gol', + 'Both Teams to Score', 'BTTS') + JOIN odd_selections os_yes + ON os_yes.odd_category_db_id = oc.db_id + AND os_yes.name IN ('Var', 'Yes') + WHERE (m.home_team_id = %(home_id)s OR m.away_team_id = %(home_id)s + OR m.home_team_id = %(away_id)s OR m.away_team_id = %(away_id)s) + AND m.sport = 'football' + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.mst_utc < %(before_ts)s + AND os_yes.odd_value BETWEEN %(band_low)s AND %(band_high)s + ORDER BY m.mst_utc DESC + LIMIT %(max_lookback)s + ) + SELECT + COUNT(*) AS sample_size, + COALESCE(AVG(CASE WHEN score_home > 0 AND score_away > 0 + THEN 1.0 ELSE 0.0 END), 0.5) AS btts_rate + FROM btts_matches + """, { + "home_id": home_team_id, + "away_id": away_team_id, + "before_ts": before_ts, + "band_low": band_low, + "band_high": band_high, + "max_lookback": MAX_LOOKBACK, + }) + + row = cur.fetchone() + if not row or int(row["sample_size"]) < MIN_TEAM_SAMPLE: + return defaults + + yes_rate = float(row["btts_rate"]) + return { + "yes_rate": round(yes_rate, 4), + "no_rate": round(1.0 - yes_rate, 4), + "sample": float(row["sample_size"]), + } + + except Exception as e: + print(f"[OddsBand] ⚠ BTTS band query failed: {e}") + return defaults + + # ─── DC (Çifte Şans) Band ───────────────────────────────────── + + def _compute_dc_band( + self, + cur: RealDictCursor, + home_team_id: str, + away_team_id: str, + league_id: Optional[str], + dc_1x_odds: float, + dc_x2_odds: float, + dc_12_odds: float, + before_ts: int, + ) -> Dict[str, float]: + """Compute DC hit rates from matches with similar DC odds.""" + defaults = { + "1x_rate": 0.60, "x2_rate": 0.60, "12_rate": 0.67, + "1x_sample": 0.0, "x2_sample": 0.0, "12_sample": 0.0, + } + result = {} + for sel_key, odds_val, label in [ + ("1x", dc_1x_odds, "1-X"), + ("x2", dc_x2_odds, "X-2"), + ("12", dc_12_odds, "1-2"), + ]: + if odds_val <= 1.0: + result[f"{sel_key}_rate"] = defaults[f"{sel_key}_rate"] + result[f"{sel_key}_sample"] = 0.0 + continue + band_low, band_high = get_band_range(odds_val) + try: + cur.execute(""" + WITH dc_matches AS ( + SELECT m.score_home, m.score_away + FROM matches m + JOIN odd_categories oc ON oc.match_id = m.id + AND oc.name IN ('Çifte Şans', 'Cifte Sans', 'Double Chance') + JOIN odd_selections os_sel ON os_sel.odd_category_db_id = oc.db_id + AND os_sel.name IN (%(label)s, %(label_compact)s) + WHERE (m.home_team_id = %(hid)s OR m.away_team_id = %(hid)s + OR m.home_team_id = %(aid)s OR m.away_team_id = %(aid)s) + AND m.sport = 'football' AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.mst_utc < %(before_ts)s + AND os_sel.odd_value BETWEEN %(bl)s AND %(bh)s + ORDER BY m.mst_utc DESC LIMIT %(ml)s + ) + SELECT COUNT(*) AS ss, + COALESCE(AVG(CASE + WHEN %(sel_key)s = '1x' AND (score_home >= score_away) THEN 1.0 + WHEN %(sel_key)s = 'x2' AND (score_away >= score_home) THEN 1.0 + WHEN %(sel_key)s = '12' AND (score_home != score_away) THEN 1.0 + ELSE 0.0 END), %(def_rate)s) AS hit_rate + FROM dc_matches + """, { + "hid": home_team_id, "aid": away_team_id, + "label": label, "label_compact": label.replace("-", ""), + "before_ts": before_ts, "bl": band_low, "bh": band_high, + "ml": MAX_LOOKBACK, "sel_key": sel_key, + "def_rate": defaults[f"{sel_key}_rate"], + }) + row = cur.fetchone() + if row and int(row["ss"]) >= MIN_TEAM_SAMPLE: + result[f"{sel_key}_rate"] = round(float(row["hit_rate"]), 4) + result[f"{sel_key}_sample"] = float(row["ss"]) + else: + result[f"{sel_key}_rate"] = defaults[f"{sel_key}_rate"] + result[f"{sel_key}_sample"] = 0.0 + except Exception as e: + print(f"[OddsBand] ⚠ DC-{sel_key} band failed: {e}") + result[f"{sel_key}_rate"] = defaults[f"{sel_key}_rate"] + result[f"{sel_key}_sample"] = 0.0 + return result + + # ─── HT (İlk Yarı Sonucu) Band ─────────────────────────────── + + def _compute_ht_band( + self, + cur: RealDictCursor, + team_id: str, + league_id: Optional[str], + team_odds: float, + is_home: bool, + before_ts: int, + ) -> Dict[str, float]: + """Compute HT win/draw/loss rate for a team in a given HT odds band.""" + defaults = { + "band_ht_win_rate": 0.33, "band_ht_draw_rate": 0.40, + "band_ht_loss_rate": 0.27, "band_ht_sample": 0.0, + } + if team_odds <= 1.0: + return defaults + band_low, band_high = get_band_range(team_odds) + try: + cur.execute(""" + WITH ht_matches AS ( + SELECT m.score_ht_home, m.score_ht_away, + m.home_team_id, m.away_team_id + FROM matches m + JOIN odd_categories oc ON oc.match_id = m.id + AND oc.name IN ('1. Yarı Sonucu', '1. Yari Sonucu', + 'İlk Yarı Sonucu', 'Ilk Yari Sonucu', + 'Half Time Result') + LEFT JOIN odd_selections os1 ON os1.odd_category_db_id = oc.db_id + AND os1.name = '1' AND m.home_team_id = %(tid)s + LEFT JOIN odd_selections os2 ON os2.odd_category_db_id = oc.db_id + AND os2.name = '2' AND m.away_team_id = %(tid)s + WHERE (m.home_team_id = %(tid)s OR m.away_team_id = %(tid)s) + AND m.sport = 'football' AND m.status = 'FT' + AND m.score_ht_home IS NOT NULL + AND m.mst_utc < %(before_ts)s + AND COALESCE(os1.odd_value, os2.odd_value) + BETWEEN %(bl)s AND %(bh)s + ORDER BY m.mst_utc DESC LIMIT %(ml)s + ) + SELECT COUNT(*) AS ss, + COALESCE(AVG(CASE + WHEN (home_team_id = %(tid)s AND score_ht_home > score_ht_away) + OR (away_team_id = %(tid)s AND score_ht_away > score_ht_home) + THEN 1.0 ELSE 0.0 END), 0.33) AS win_rate, + COALESCE(AVG(CASE WHEN score_ht_home = score_ht_away + THEN 1.0 ELSE 0.0 END), 0.40) AS draw_rate + FROM ht_matches + """, { + "tid": team_id, "before_ts": before_ts, + "bl": band_low, "bh": band_high, "ml": MAX_LOOKBACK, + }) + row = cur.fetchone() + if not row or int(row["ss"]) < MIN_TEAM_SAMPLE: + return defaults + w = float(row["win_rate"]) + d = float(row["draw_rate"]) + return { + "band_ht_win_rate": round(w, 4), + "band_ht_draw_rate": round(d, 4), + "band_ht_loss_rate": round(1.0 - w - d, 4), + "band_ht_sample": float(row["ss"]), + } + except Exception as e: + print(f"[OddsBand] ⚠ HT band query failed: {e}") + return defaults + + # ─── OE (Tek/Çift) Band ────────────────────────────────────── + + def _compute_oe_band( + self, + cur: RealDictCursor, + home_team_id: str, + away_team_id: str, + league_id: Optional[str], + oe_odd_odds: float, + before_ts: int, + ) -> Dict[str, float]: + """Compute Odd/Even rate from matches with similar OE odds.""" + defaults = {"odd_rate": 0.50, "even_rate": 0.50, "sample": 0.0} + if oe_odd_odds <= 1.0: + return defaults + band_low, band_high = get_band_range(oe_odd_odds) + try: + cur.execute(""" + WITH oe_matches AS ( + SELECT m.score_home + m.score_away AS total + FROM matches m + JOIN odd_categories oc ON oc.match_id = m.id + AND oc.name IN ('Tek/Çift', 'Tek/Cift', 'Odd/Even') + JOIN odd_selections os_odd ON os_odd.odd_category_db_id = oc.db_id + AND os_odd.name IN ('Tek', 'Odd') + WHERE (m.home_team_id = %(hid)s OR m.away_team_id = %(hid)s + OR m.home_team_id = %(aid)s OR m.away_team_id = %(aid)s) + AND m.sport = 'football' AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.mst_utc < %(before_ts)s + AND os_odd.odd_value BETWEEN %(bl)s AND %(bh)s + ORDER BY m.mst_utc DESC LIMIT %(ml)s + ) + SELECT COUNT(*) AS ss, + COALESCE(AVG(CASE WHEN total %% 2 = 1 + THEN 1.0 ELSE 0.0 END), 0.5) AS odd_rate + FROM oe_matches + """, { + "hid": home_team_id, "aid": away_team_id, + "before_ts": before_ts, "bl": band_low, "bh": band_high, + "ml": MAX_LOOKBACK, + }) + row = cur.fetchone() + if not row or int(row["ss"]) < MIN_TEAM_SAMPLE: + return defaults + odd_rate = float(row["odd_rate"]) + return { + "odd_rate": round(odd_rate, 4), + "even_rate": round(1.0 - odd_rate, 4), + "sample": float(row["ss"]), + } + except Exception as e: + print(f"[OddsBand] ⚠ OE band query failed: {e}") + return defaults + + # ─── Cards (Kart Alt/Üst) Band — 3-Layer Profiling ─────────── + + def _compute_cards_band( + self, + cur: RealDictCursor, + home_team_id: str, + away_team_id: str, + league_id: Optional[str], + cards_o_odds: float, + before_ts: int, + referee_name: Optional[str] = None, + ) -> Dict[str, float]: + """ + 3-layer card analysis: + 1) Referee card profile (avg cards/match, over-line rate) + 2) Team card profile (both teams' card averages) + 3) Composite weighted average + + Card data comes from match_player_events (event_type='card'), + NOT from score columns. + """ + # Detect the card line from odds (3.5, 4.5, 5.5 etc.) + card_line = self._detect_card_line(cards_o_odds) + + defaults: Dict[str, float] = { + "referee_avg": 0.0, + "referee_over_rate": 0.50, + "referee_sample": 0.0, + "team_avg": 0.0, + "team_over_rate": 0.50, + "team_sample": 0.0, + "combined_over_rate": 0.50, + "sample": 0.0, + } + + referee_avg = 0.0 + referee_over_rate = 0.50 + referee_sample = 0.0 + team_avg = 0.0 + team_over_rate = 0.50 + team_sample = 0.0 + + # ── Layer 1: Referee card profile ────────────────────────── + if referee_name: + try: + cur.execute(""" + WITH ref_matches AS ( + SELECT m.id AS match_id + FROM matches m + JOIN match_officials mo ON mo.match_id = m.id + WHERE mo.name = %(ref)s + AND mo.role_id = 1 + AND m.status = 'FT' + AND m.mst_utc < %(before_ts)s + ORDER BY m.mst_utc DESC + LIMIT 50 + ), + card_counts AS ( + SELECT rm.match_id, + COUNT(*) AS total_cards + FROM ref_matches rm + JOIN match_player_events mpe ON mpe.match_id = rm.match_id + WHERE mpe.event_type = 'card' + GROUP BY rm.match_id + ) + SELECT + COUNT(DISTINCT rm.match_id) AS ss, + COALESCE(AVG(COALESCE(cc.total_cards, 0)), 0) AS avg_cards, + COALESCE(AVG(CASE WHEN COALESCE(cc.total_cards, 0) > %(line)s + THEN 1.0 ELSE 0.0 END), 0.5) AS over_rate + FROM ref_matches rm + LEFT JOIN card_counts cc ON cc.match_id = rm.match_id + """, { + "ref": referee_name, + "before_ts": before_ts, + "line": card_line, + }) + row = cur.fetchone() + if row and int(row["ss"]) >= 5: + referee_avg = float(row["avg_cards"]) + referee_over_rate = float(row["over_rate"]) + referee_sample = float(row["ss"]) + except Exception as e: + print(f"[OddsBand] ⚠ Cards referee query failed: {e}") + + # ── Layer 2: Team card profile ───────────────────────────── + try: + cur.execute(""" + WITH team_matches AS ( + SELECT m.id AS match_id + FROM matches m + WHERE (m.home_team_id = %(hid)s OR m.away_team_id = %(hid)s + OR m.home_team_id = %(aid)s OR m.away_team_id = %(aid)s) + AND m.sport = 'football' + AND m.status = 'FT' + AND m.mst_utc < %(before_ts)s + ORDER BY m.mst_utc DESC + LIMIT %(ml)s + ), + card_counts AS ( + SELECT tm.match_id, + COUNT(*) AS total_cards + FROM team_matches tm + JOIN match_player_events mpe ON mpe.match_id = tm.match_id + WHERE mpe.event_type = 'card' + GROUP BY tm.match_id + ) + SELECT + COUNT(DISTINCT tm.match_id) AS ss, + COALESCE(AVG(COALESCE(cc.total_cards, 0)), 0) AS avg_cards, + COALESCE(AVG(CASE WHEN COALESCE(cc.total_cards, 0) > %(line)s + THEN 1.0 ELSE 0.0 END), 0.5) AS over_rate + FROM team_matches tm + LEFT JOIN card_counts cc ON cc.match_id = tm.match_id + """, { + "hid": home_team_id, "aid": away_team_id, + "before_ts": before_ts, "line": card_line, + "ml": MAX_LOOKBACK, + }) + row = cur.fetchone() + if row and int(row["ss"]) >= MIN_TEAM_SAMPLE: + team_avg = float(row["avg_cards"]) + team_over_rate = float(row["over_rate"]) + team_sample = float(row["ss"]) + except Exception as e: + print(f"[OddsBand] ⚠ Cards team query failed: {e}") + + # ── Layer 3: Composite ───────────────────────────────────── + total_sample = referee_sample + team_sample + if total_sample > 0: + # Referee weight = 60% when available (referee insight is more predictive) + ref_weight = 0.6 if referee_sample >= 5 else 0.0 + team_weight = 1.0 - ref_weight + combined = (referee_over_rate * ref_weight) + (team_over_rate * team_weight) + else: + combined = 0.50 + + return { + "referee_avg": round(referee_avg, 2), + "referee_over_rate": round(referee_over_rate, 4), + "referee_sample": referee_sample, + "team_avg": round(team_avg, 2), + "team_over_rate": round(team_over_rate, 4), + "team_sample": team_sample, + "combined_over_rate": round(combined, 4), + "sample": max(referee_sample, team_sample), + } + + @staticmethod + def _detect_card_line(cards_o_odds: float) -> float: + """ + Detect the card line from over odds. + Low odds (< 1.60) → probably 3.5 line + Medium odds (1.60-2.20) → probably 4.5 line + High odds (> 2.20) → probably 5.5 line + """ + if cards_o_odds <= 0: + return 4.5 # Default assumption + if cards_o_odds < 1.60: + return 3.5 + if cards_o_odds < 2.20: + return 4.5 + return 5.5 + + # ─── HTFT (İY/MS) Band — 9 Combination Analysis ────────────── + + _HTFT_COMBOS = ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22") + + def _compute_htft_band( + self, + cur: RealDictCursor, + home_team_id: str, + away_team_id: str, + league_id: Optional[str], + odds: Dict[str, float], + before_ts: int, + ) -> Dict[str, float]: + """ + Compute HTFT (İlk Yarı / Maç Sonucu) hit rates for all 9 combinations. + + For each combination, looks at historical matches where the HTFT odds + were in a similar band, and computes how often that specific HTFT + outcome actually occurred. + + Uses ht_score_home, ht_score_away (half-time) and + score_home, score_away (full-time) from matches table. + """ + defaults: Dict[str, float] = {} + for combo in self._HTFT_COMBOS: + defaults[f"band_htft_{combo}_rate"] = 0.11 # ~1/9 + defaults[f"band_htft_{combo}_sample"] = 0.0 + + # Check if any HTFT odds exist + has_htft_odds = any( + float(odds.get(f"htft_{combo}", 0)) > 1.0 + for combo in self._HTFT_COMBOS + ) + if not has_htft_odds: + return defaults + + # Pick the most-traded HTFT combo (lowest odds = most likely) for band + # This gives us the best sample for the band lookup + try: + # Strategy: query all matches for both teams where HTFT odds exist, + # then compute hit rates for each combination + cur.execute(""" + WITH htft_matches AS ( + SELECT + m.id, + m.ht_score_home, + m.ht_score_away, + m.score_home, + m.score_away, + CASE + WHEN m.ht_score_home > m.ht_score_away THEN '1' + WHEN m.ht_score_home = m.ht_score_away THEN 'x' + ELSE '2' + END AS ht_result, + CASE + WHEN m.score_home > m.score_away THEN '1' + WHEN m.score_home = m.score_away THEN 'x' + ELSE '2' + END AS ft_result + FROM matches m + WHERE (m.home_team_id = %(hid)s OR m.away_team_id = %(hid)s + OR m.home_team_id = %(aid)s OR m.away_team_id = %(aid)s) + AND m.sport = 'football' + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.ht_score_home IS NOT NULL + AND m.mst_utc < %(before_ts)s + ORDER BY m.mst_utc DESC + LIMIT %(ml)s + ) + SELECT + COUNT(*) AS total_matches, + COUNT(*) FILTER (WHERE ht_result || ft_result = '11') AS c_11, + COUNT(*) FILTER (WHERE ht_result || ft_result = '1x') AS c_1x, + COUNT(*) FILTER (WHERE ht_result || ft_result = '12') AS c_12, + COUNT(*) FILTER (WHERE ht_result || ft_result = 'x1') AS c_x1, + COUNT(*) FILTER (WHERE ht_result || ft_result = 'xx') AS c_xx, + COUNT(*) FILTER (WHERE ht_result || ft_result = 'x2') AS c_x2, + COUNT(*) FILTER (WHERE ht_result || ft_result = '21') AS c_21, + COUNT(*) FILTER (WHERE ht_result || ft_result = '2x') AS c_2x, + COUNT(*) FILTER (WHERE ht_result || ft_result = '22') AS c_22 + FROM htft_matches + """, { + "hid": home_team_id, "aid": away_team_id, + "before_ts": before_ts, + "ml": MAX_LOOKBACK, + }) + base_row = cur.fetchone() + base_total = int(base_row["total_matches"]) if base_row else 0 + + if base_total < MIN_TEAM_SAMPLE: + # Fallback to league level + if league_id: + cur.execute(""" + WITH htft_lg AS ( + SELECT + CASE + WHEN m.ht_score_home > m.ht_score_away THEN '1' + WHEN m.ht_score_home = m.ht_score_away THEN 'x' + ELSE '2' + END AS ht_result, + CASE + WHEN m.score_home > m.score_away THEN '1' + WHEN m.score_home = m.score_away THEN 'x' + ELSE '2' + END AS ft_result + FROM matches m + WHERE m.league_id = %(lid)s + AND m.sport = 'football' + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.ht_score_home IS NOT NULL + AND m.mst_utc < %(before_ts)s + ORDER BY m.mst_utc DESC + LIMIT 200 + ) + SELECT + COUNT(*) AS total_matches, + COUNT(*) FILTER (WHERE ht_result || ft_result = '11') AS c_11, + COUNT(*) FILTER (WHERE ht_result || ft_result = '1x') AS c_1x, + COUNT(*) FILTER (WHERE ht_result || ft_result = '12') AS c_12, + COUNT(*) FILTER (WHERE ht_result || ft_result = 'x1') AS c_x1, + COUNT(*) FILTER (WHERE ht_result || ft_result = 'xx') AS c_xx, + COUNT(*) FILTER (WHERE ht_result || ft_result = 'x2') AS c_x2, + COUNT(*) FILTER (WHERE ht_result || ft_result = '21') AS c_21, + COUNT(*) FILTER (WHERE ht_result || ft_result = '2x') AS c_2x, + COUNT(*) FILTER (WHERE ht_result || ft_result = '22') AS c_22 + FROM htft_lg + """, {"lid": league_id, "before_ts": before_ts}) + base_row = cur.fetchone() + base_total = int(base_row["total_matches"]) if base_row else 0 + + if base_total < MIN_TEAM_SAMPLE: + return defaults + + result: Dict[str, float] = {} + for combo in self._HTFT_COMBOS: + count = int(base_row[f"c_{combo}"]) + rate = count / base_total if base_total > 0 else 0.11 + result[f"band_htft_{combo}_rate"] = round(rate, 4) + result[f"band_htft_{combo}_sample"] = float(base_total) + + # ── Odds-band refinement: for combos with odds, check + # if similar-odds matches had different hit rates + for combo in self._HTFT_COMBOS: + combo_odds = float(odds.get(f"htft_{combo}", 0)) + if combo_odds <= 1.0: + continue + band_low, band_high = get_band_range(combo_odds) + try: + cur.execute(""" + WITH band_htft AS ( + SELECT + CASE + WHEN m.ht_score_home > m.ht_score_away THEN '1' + WHEN m.ht_score_home = m.ht_score_away THEN 'x' + ELSE '2' + END || CASE + WHEN m.score_home > m.score_away THEN '1' + WHEN m.score_home = m.score_away THEN 'x' + ELSE '2' + END AS htft_outcome + FROM matches m + JOIN odd_categories oc ON oc.match_id = m.id + AND oc.name IN ( + 'İlk Yarı/Maç Sonucu', + 'Ilk Yarı/Maç Sonucu', + 'İlk Yarı/Mac Sonucu', + 'Ilk Yari/Mac Sonucu', + 'IY/MS' + ) + JOIN odd_selections os ON os.odd_category_db_id = oc.db_id + AND os.odd_value BETWEEN %(bl)s AND %(bh)s + WHERE m.sport = 'football' + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.ht_score_home IS NOT NULL + AND m.mst_utc < %(before_ts)s + ORDER BY m.mst_utc DESC + LIMIT %(ml)s + ) + SELECT + COUNT(*) AS ss, + COALESCE(AVG(CASE WHEN htft_outcome = %(target)s + THEN 1.0 ELSE 0.0 END), 0.0) AS hit_rate + FROM band_htft + """, { + "bl": band_low, "bh": band_high, + "before_ts": before_ts, + "ml": MAX_LOOKBACK, + "target": combo, + }) + brow = cur.fetchone() + if brow and int(brow["ss"]) >= MIN_TEAM_SAMPLE: + # Blend base rate with band-specific rate (60/40 band preference) + base_rate = result[f"band_htft_{combo}_rate"] + band_rate = float(brow["hit_rate"]) + blended = (band_rate * 0.6) + (base_rate * 0.4) + result[f"band_htft_{combo}_rate"] = round(blended, 4) + result[f"band_htft_{combo}_sample"] = float(brow["ss"]) + except Exception: + pass # Keep base rate + + return result + + except Exception as e: + print(f"[OddsBand] ⚠ HTFT band query failed: {e}") + return defaults + diff --git a/ai-engine/services/feature_enrichment.py b/ai-engine/services/feature_enrichment.py index 486e994..45ebe1a 100644 --- a/ai-engine/services/feature_enrichment.py +++ b/ai-engine/services/feature_enrichment.py @@ -36,6 +36,11 @@ class FeatureEnrichmentService: 'avg_goals': 2.5, 'btts_rate': 0.5, 'over25_rate': 0.5, + # V27 expanded + 'home_goals_avg': 1.3, + 'away_goals_avg': 1.1, + 'recent_trend': 0.0, + 'venue_advantage': 0.0, } _DEFAULT_FORM = { 'clean_sheet_rate': 0.2, @@ -53,6 +58,25 @@ class FeatureEnrichmentService: _DEFAULT_LEAGUE = { 'avg_goals': 2.7, 'zero_goal_rate': 0.07, + # V27 expanded + 'home_win_rate': 0.46, + 'draw_rate': 0.26, + 'btts_rate': 0.50, + 'ou25_rate': 0.50, + 'reliability_score': 0.0, + } + _DEFAULT_ROLLING = { + 'rolling5_goals': 1.3, + 'rolling5_conceded': 1.2, + 'rolling10_goals': 1.3, + 'rolling10_conceded': 1.2, + 'rolling20_goals': 1.3, + 'rolling20_conceded': 1.2, + 'rolling5_cs': 0.2, + } + _DEFAULT_VENUE = { + 'venue_goals': 1.4, + 'venue_conceded': 1.1, } # ─── 1. Team Stats ────────────────────────────────────────────── @@ -186,6 +210,13 @@ class FeatureEnrichmentService: total_goals = 0 btts_count = 0 over25_count = 0 + # V27 expanded trackers + home_team_goals_list = [] + away_team_goals_list = [] + home_team_venue_wins = 0 + home_team_venue_total = 0 + away_team_venue_wins = 0 + away_team_venue_total = 0 for row in rows: sh = int(row['score_home']) @@ -195,14 +226,22 @@ class FeatureEnrichmentService: # Normalise: who is "home team" in THIS prediction context if str(row['home_team_id']) == home_team_id: + home_team_goals_list.append(sh) + away_team_goals_list.append(sa) + home_team_venue_total += 1 if sh > sa: home_wins += 1 + home_team_venue_wins += 1 elif sh == sa: draws += 1 else: # Reversed fixture: away_team was at home + home_team_goals_list.append(sa) + away_team_goals_list.append(sh) + away_team_venue_total += 1 if sa > sh: home_wins += 1 + away_team_venue_wins += 1 elif sh == sa: draws += 1 @@ -211,6 +250,29 @@ class FeatureEnrichmentService: if match_goals > 2: over25_count += 1 + # V27: recent_trend = last-5 home_win_rate - first-5 home_win_rate + recent_trend = 0.0 + if total >= 6: + recent_5_wins = sum( + 1 for r in rows[:5] + if (str(r['home_team_id']) == home_team_id and int(r['score_home']) > int(r['score_away'])) + or (str(r['home_team_id']) != home_team_id and int(r['score_away']) > int(r['score_home'])) + ) + older_5_wins = sum( + 1 for r in rows[-5:] + if (str(r['home_team_id']) == home_team_id and int(r['score_home']) > int(r['score_away'])) + or (str(r['home_team_id']) != home_team_id and int(r['score_away']) > int(r['score_home'])) + ) + recent_trend = (recent_5_wins - older_5_wins) / 5.0 + + # V27: venue_advantage = home_win_rate_at_home - home_win_rate_away + venue_advantage = 0.0 + if home_team_venue_total > 0 and away_team_venue_total > 0: + venue_advantage = ( + home_team_venue_wins / home_team_venue_total + - away_team_venue_wins / away_team_venue_total + ) + return { 'total_matches': total, 'home_win_rate': home_wins / total, @@ -218,6 +280,11 @@ class FeatureEnrichmentService: 'avg_goals': total_goals / total, 'btts_rate': btts_count / total, 'over25_rate': over25_count / total, + # V27 expanded + 'home_goals_avg': _safe_avg(home_team_goals_list, 1.3), + 'away_goals_avg': _safe_avg(away_team_goals_list, 1.1), + 'recent_trend': round(recent_trend, 4), + 'venue_advantage': round(venue_advantage, 4), } # ─── 3. Form & Streaks ────────────────────────────────────────── @@ -433,6 +500,10 @@ class FeatureEnrichmentService: total = len(rows) total_goals = 0 zero_goal_matches = 0 + home_wins = 0 + draw_count = 0 + btts_count = 0 + over25_count = 0 for row in rows: sh = int(row['score_home']) @@ -441,10 +512,24 @@ class FeatureEnrichmentService: total_goals += match_goals if match_goals == 0: zero_goal_matches += 1 + if sh > sa: + home_wins += 1 + elif sh == sa: + draw_count += 1 + if sh > 0 and sa > 0: + btts_count += 1 + if match_goals > 2: + over25_count += 1 return { 'avg_goals': total_goals / total, 'zero_goal_rate': zero_goal_matches / total, + # V27 expanded + 'home_win_rate': home_wins / total, + 'draw_rate': draw_count / total, + 'btts_rate': btts_count / total, + 'ou25_rate': over25_count / total, + 'reliability_score': min(total / 50.0, 1.0), } # ─── 6. Momentum ─────────────────────────────────────────────── @@ -514,6 +599,161 @@ class FeatureEnrichmentService: return round(weighted_score / max_possible, 4) + # ─── 7. Rolling Stats (V27) ───────────────────────────────────── + + def compute_rolling_stats( + self, + cur: RealDictCursor, + team_id: str, + before_date_ms: int, + ) -> Dict[str, float]: + """ + Rolling goal averages and clean-sheet rates over the last 5/10/20 matches. + Single DB query, three windows computed programmatically. + """ + if not team_id: + return dict(self._DEFAULT_ROLLING) + try: + cur.execute( + """ + SELECT + m.home_team_id, + m.score_home, + m.score_away + FROM matches m + WHERE (m.home_team_id = %s OR m.away_team_id = %s) + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.score_away IS NOT NULL + AND m.mst_utc < %s + ORDER BY m.mst_utc DESC + LIMIT 20 + """, + (team_id, team_id, before_date_ms), + ) + rows = cur.fetchall() + except Exception: + return dict(self._DEFAULT_ROLLING) + + if not rows: + return dict(self._DEFAULT_ROLLING) + + goals = [] + conceded = [] + clean_sheets = [] + + for row in rows: + is_home = str(row['home_team_id']) == team_id + gf = int(row['score_home'] if is_home else row['score_away']) + ga = int(row['score_away'] if is_home else row['score_home']) + goals.append(gf) + conceded.append(ga) + clean_sheets.append(1 if ga == 0 else 0) + + n = len(goals) + return { + 'rolling5_goals': _safe_avg(goals[:5], 1.3), + 'rolling5_conceded': _safe_avg(conceded[:5], 1.2), + 'rolling10_goals': _safe_avg(goals[:min(10, n)], 1.3), + 'rolling10_conceded': _safe_avg(conceded[:min(10, n)], 1.2), + 'rolling20_goals': _safe_avg(goals[:n], 1.3), + 'rolling20_conceded': _safe_avg(conceded[:n], 1.2), + 'rolling5_cs': _safe_avg(clean_sheets[:5], 0.2), + } + + # ─── 8. Venue Stats (V27) ────────────────────────────────────── + + def compute_venue_stats( + self, + cur: RealDictCursor, + team_id: str, + before_date_ms: int, + is_home: bool = True, + ) -> Dict[str, float]: + """ + Team goals scored/conceded at specific venue (home or away only). + """ + if not team_id: + return dict(self._DEFAULT_VENUE) + venue_col = 'home_team_id' if is_home else 'away_team_id' + try: + cur.execute( + f""" + SELECT m.score_home, m.score_away + FROM matches m + WHERE m.{venue_col} = %s + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.score_away IS NOT NULL + AND m.mst_utc < %s + ORDER BY m.mst_utc DESC + LIMIT 20 + """, + (team_id, before_date_ms), + ) + rows = cur.fetchall() + except Exception: + return dict(self._DEFAULT_VENUE) + + if not rows: + return dict(self._DEFAULT_VENUE) + + goals = [] + conceded_list = [] + for row in rows: + sh = int(row['score_home']) + sa = int(row['score_away']) + if is_home: + goals.append(sh) + conceded_list.append(sa) + else: + goals.append(sa) + conceded_list.append(sh) + + return { + 'venue_goals': _safe_avg(goals, 1.4), + 'venue_conceded': _safe_avg(conceded_list, 1.1), + } + + # ─── 9. Days Rest (V27) ──────────────────────────────────────── + + def compute_days_rest( + self, + cur: RealDictCursor, + team_id: str, + before_date_ms: int, + ) -> float: + """ + Returns number of days since the team's last match. + Default: 7.0 (one-week rest). + """ + if not team_id: + return 7.0 + try: + cur.execute( + """ + SELECT m.mst_utc + FROM matches m + WHERE (m.home_team_id = %s OR m.away_team_id = %s) + AND m.status = 'FT' + AND m.mst_utc < %s + ORDER BY m.mst_utc DESC + LIMIT 1 + """, + (team_id, team_id, before_date_ms), + ) + row = cur.fetchone() + except Exception: + return 7.0 + + if not row or not row.get('mst_utc'): + return 7.0 + + last_match_ms = int(row['mst_utc']) + diff_days = (before_date_ms - last_match_ms) / (1000 * 86400) + return round(max(0.0, min(diff_days, 30.0)), 1) + + # ─── Utility ──────────────────────────────────────────────────────── def _safe_avg(values: list, default: float) -> float: diff --git a/ai-engine/services/single_match_orchestrator.py b/ai-engine/services/single_match_orchestrator.py index c21fcbb..a706489 100755 --- a/ai-engine/services/single_match_orchestrator.py +++ b/ai-engine/services/single_match_orchestrator.py @@ -28,6 +28,8 @@ from psycopg2.extras import RealDictCursor from data.db import get_clean_dsn from models.v20_ensemble import FullMatchPrediction from models.v25_ensemble import V25Predictor, get_v25_predictor +from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +from features.odds_band_analyzer import OddsBandAnalyzer from models.basketball_v25 import ( BasketballMatchPrediction, get_basketball_v25_predictor, @@ -146,74 +148,87 @@ class SingleMatchOrchestrator: self.top_league_ids = load_top_league_ids() self.league_reliability = load_league_reliability() self.enrichment = FeatureEnrichmentService() - # Market calibration multipliers — V31 rebalance - # Previous values created mathematical impossibilities: - # BTTS: max reachable = 100×0.45 = 45, but min_conf was 55 → NEVER playable - # New approach: calibration = blend(backtest_accuracy, 0.80) to avoid crushing raw signal + self.odds_band_analyzer = OddsBandAnalyzer() + # ── V32 Calibration Rebalance ────────────────────────────────── + # RULE: max_reachable = 100 × calibration MUST be > min_conf + 8 + # Previous values had 5 markets where this was IMPOSSIBLE: + # HT(0.42×100=42 < 45), HCAP(0.40×100=40 < 46), HTFT(0.28×100=28 < 32) + # HT_OU15(0.46×100=46 < 48), CARDS(0.45×100=45 < 48) + # These markets could NEVER become playable → all predictions were PASS. + # + # New calibration: conservative but mathematically achievable. + # Each market's calibration ensures high-confidence model outputs CAN pass. self.market_calibration: Dict[str, float] = { - "MS": 0.48, - "DC": 0.82, - "OU15": 0.84, - "OU25": 0.54, - "OU35": 0.44, - "BTTS": 0.50, - "HT": 0.42, - "HT_OU05": 0.68, - "HT_OU15": 0.46, - "OE": 0.58, - "CARDS": 0.45, - "HCAP": 0.40, - "HTFT": 0.28, + "MS": 0.62, # max=62 vs min=42 ✓ (was 0.48→max=48 vs 44 ⚠️) + "DC": 0.82, # max=82 vs min=52 ✓ (unchanged, already good) + "OU15": 0.84, # max=84 vs min=55 ✓ (unchanged, already good) + "OU25": 0.68, # max=68 vs min=48 ✓ (was 0.54→max=54 vs 52 ⚠️) + "OU35": 0.60, # max=60 vs min=48 ✓ (was 0.44→max=44 vs 54 ❌) + "BTTS": 0.65, # max=65 vs min=46 ✓ (was 0.50→max=50 vs 50 ⚠️) + "HT": 0.58, # max=58 vs min=40 ✓ (was 0.42→max=42 vs 45 ❌) + "HT_OU05": 0.68, # max=68 vs min=50 ✓ (unchanged) + "HT_OU15": 0.60, # max=60 vs min=42 ✓ (was 0.46→max=46 vs 48 ❌) + "OE": 0.62, # max=62 vs min=46 ✓ (was 0.58→max=58 vs 50 ok) + "CARDS": 0.58, # max=58 vs min=42 ✓ (was 0.45→max=45 vs 48 ❌) + "HCAP": 0.56, # max=56 vs min=40 ✓ (was 0.40→max=40 vs 46 ❌) + "HTFT": 0.45, # max=45 vs min=28 ✓ (was 0.28→max=28 vs 32 ❌) } + # Min confidence: lowered to be achievable (max_reachable - 16 to -20) self.market_min_conf: Dict[str, float] = { - "MS": 44.0, - "DC": 55.0, - "OU15": 58.0, - "OU25": 52.0, - "OU35": 54.0, - "BTTS": 50.0, - "HT": 45.0, - "HT_OU05": 54.0, - "HT_OU15": 48.0, - "OE": 50.0, - "CARDS": 48.0, - "HCAP": 46.0, - "HTFT": 32.0, + "MS": 42.0, # was 44 — 3-way market, hard to get high conf + "DC": 52.0, # was 55 — double chance is easier + "OU15": 55.0, # was 58 — binary + usually high conf + "OU25": 48.0, # was 52 — core market, allow more through + "OU35": 48.0, # was 54 — lowered to let signals pass + "BTTS": 46.0, # was 50 — binary market + "HT": 40.0, # was 45 — was ❌ impossible, now achievable + "HT_OU05": 50.0, # was 54 — binary HT market + "HT_OU15": 42.0, # was 48 — was ❌ impossible, now achievable + "OE": 46.0, # was 50 — coin-flip market, lower bar + "CARDS": 42.0, # was 48 — was ❌ impossible, now achievable + "HCAP": 40.0, # was 46 — was ❌ impossible, now achievable + "HTFT": 28.0, # was 32 — was ❌ impossible, 9-way market } + # Min play score: moderate reduction to allow more C-grade bets self.market_min_play_score: Dict[str, float] = { - "MS": 72.0, - "DC": 62.0, - "OU15": 64.0, - "OU25": 70.0, - "OU35": 76.0, - "BTTS": 70.0, - "HT": 74.0, - "HT_OU05": 64.0, - "HT_OU15": 72.0, - "OE": 66.0, - "CARDS": 74.0, - "HCAP": 76.0, - "HTFT": 82.0, + "MS": 65.0, # was 72 — let more MS through for tracking + "DC": 58.0, # was 62 — DC is high accuracy + "OU15": 60.0, # was 64 — strong market per backtest + "OU25": 64.0, # was 70 — core market + "OU35": 68.0, # was 76 — riskier market + "BTTS": 64.0, # was 70 — allow more signals + "HT": 66.0, # was 74 — was never reachable anyway + "HT_OU05": 60.0, # was 64 — strong backtest market + "HT_OU15": 64.0, # was 72 — moderate + "OE": 60.0, # was 66 — low priority market + "CARDS": 66.0, # was 74 — niche market + "HCAP": 68.0, # was 76 — risky + "HTFT": 72.0, # was 82 — 9-way, very risky } self.market_min_edge: Dict[str, float] = { - "MS": 0.03, - "DC": 0.01, - "OU15": 0.01, - "OU25": 0.02, - "OU35": 0.04, - "BTTS": 0.03, - "HT": 0.04, - "HT_OU05": 0.01, - "HT_OU15": 0.03, - "OE": 0.02, - "CARDS": 0.03, - "HCAP": 0.04, - "HTFT": 0.06, + "MS": 0.02, # was 0.03 — slight relaxation + "DC": 0.01, # unchanged + "OU15": 0.01, # unchanged + "OU25": 0.02, # unchanged + "OU35": 0.03, # was 0.04 + "BTTS": 0.02, # was 0.03 + "HT": 0.03, # was 0.04 + "HT_OU05": 0.01, # unchanged + "HT_OU15": 0.02, # was 0.03 + "OE": 0.02, # unchanged + "CARDS": 0.02, # was 0.03 + "HCAP": 0.03, # was 0.04 + "HTFT": 0.05, # was 0.06 } def _get_v25_predictor(self) -> V25Predictor: if self.v25_predictor is None: - self.v25_predictor = get_v25_predictor() + try: + self.v25_predictor = get_v25_predictor() + print(f"[V25] ✅ Predictor loaded: {len(self.v25_predictor.models)} market models") + except Exception as e: + print(f"[V25] ❌ PREDICTOR LOAD FAILED: {e}") + raise return self.v25_predictor def _get_v26_shadow_engine(self) -> V26ShadowEngine: @@ -221,6 +236,21 @@ class SingleMatchOrchestrator: self.v26_shadow_engine = get_v26_shadow_engine() return self.v26_shadow_engine + def _get_v27_predictor(self) -> Optional[V27Predictor]: + """Non-fatal V27 loader — returns None if models can't load.""" + if getattr(self, "_v27", None) is not None: + return self._v27 + try: + pred = V27Predictor() + if pred.load_models(): + self._v27 = pred + print(f"[V27] ✅ Predictor loaded: {sum(len(v) for v in pred.models.values())} models") + return self._v27 + except Exception as e: + print(f"[V27] ⚠ Load failed (non-fatal): {e}") + self._v27 = None + return None + def _build_v25_features(self, data: MatchData) -> Dict[str, float]: """ Build the single authoritative V25 pre-match feature vector. @@ -281,6 +311,23 @@ class SingleMatchOrchestrator: league = enr.compute_league_averages(cur, data.league_id, data.match_date_ms) home_momentum = enr.compute_momentum(cur, data.home_team_id, data.match_date_ms) away_momentum = enr.compute_momentum(cur, data.away_team_id, data.match_date_ms) + # V27 enrichment + home_rolling = enr.compute_rolling_stats(cur, data.home_team_id, data.match_date_ms) + away_rolling = enr.compute_rolling_stats(cur, data.away_team_id, data.match_date_ms) + home_venue = enr.compute_venue_stats(cur, data.home_team_id, data.match_date_ms, is_home=True) + away_venue = enr.compute_venue_stats(cur, data.away_team_id, data.match_date_ms, is_home=False) + home_rest = enr.compute_days_rest(cur, data.home_team_id, data.match_date_ms) + away_rest = enr.compute_days_rest(cur, data.away_team_id, data.match_date_ms) + # V28 Odds-Band Historical Performance + odds_band_features = self.odds_band_analyzer.compute_all( + cur=cur, + home_team_id=data.home_team_id, + away_team_id=data.away_team_id, + league_id=data.league_id, + odds=odds, + before_ts=data.match_date_ms, + referee_name=data.referee_name, + ) except Exception: # Full fallback — use all defaults home_stats = dict(enr._DEFAULT_TEAM_STATS) @@ -292,6 +339,14 @@ class SingleMatchOrchestrator: league = dict(enr._DEFAULT_LEAGUE) home_momentum = 0.0 away_momentum = 0.0 + # V27 fallbacks + home_rolling = dict(enr._DEFAULT_ROLLING) + away_rolling = dict(enr._DEFAULT_ROLLING) + home_venue = dict(enr._DEFAULT_VENUE) + away_venue = dict(enr._DEFAULT_VENUE) + home_rest = 7.0 + away_rest = 7.0 + odds_band_features = {} # V28 fallback odds_presence = { 'odds_ms_h_present': 1.0 if ms_h > 1.01 else 0.0, @@ -316,16 +371,38 @@ class SingleMatchOrchestrator: 'odds_btts_n_present': 1.0 if float(odds.get('btts_n', 0)) > 1.01 else 0.0, } + # ── Calendar features (V27) ── + import datetime + match_dt = datetime.datetime.utcfromtimestamp(data.match_date_ms / 1000) + match_month = match_dt.month + is_season_start = 1.0 if match_month in (7, 8, 9) else 0.0 + is_season_end = 1.0 if match_month in (5, 6) else 0.0 + + # ── Derived / Interaction features (V27) ── + elo_diff = home_elo - away_elo + form_elo_diff = home_form_elo_val - away_form_elo_val + attack_vs_defense_home = data.home_goals_avg - data.away_conceded_avg + attack_vs_defense_away = data.away_goals_avg - data.home_conceded_avg + xga_home = data.home_conceded_avg + xga_away = data.away_conceded_avg + xg_diff = xga_home - xga_away + mom_diff = home_momentum - away_momentum + form_momentum_interaction = mom_diff * form_elo_diff / 1000.0 + elo_form_consistency = 1.0 - abs(elo_diff - form_elo_diff) / max(abs(elo_diff), 100.0) + upset_x_elo_gap = upset_potential * abs(elo_diff) / 500.0 + return { + # META (1) + 'mst_utc': float(data.match_date_ms), # ELO (8) 'home_overall_elo': home_elo, 'away_overall_elo': away_elo, - 'elo_diff': home_elo - away_elo, + 'elo_diff': elo_diff, 'home_home_elo': home_venue_elo, 'away_away_elo': away_venue_elo, 'home_form_elo': home_form_elo_val, 'away_form_elo': away_form_elo_val, - 'form_elo_diff': home_form_elo_val - away_form_elo_val, + 'form_elo_diff': form_elo_diff, # Form (12) 'home_goals_avg': data.home_goals_avg, 'home_conceded_avg': data.home_conceded_avg, @@ -339,13 +416,17 @@ class SingleMatchOrchestrator: 'away_winning_streak': away_form['winning_streak'], 'home_unbeaten_streak': home_form['unbeaten_streak'], 'away_unbeaten_streak': away_form['unbeaten_streak'], - # H2H (6) + # H2H (10 — original 6 + V27 expanded 4) 'h2h_total_matches': h2h['total_matches'], 'h2h_home_win_rate': h2h['home_win_rate'], 'h2h_draw_rate': h2h['draw_rate'], 'h2h_avg_goals': h2h['avg_goals'], 'h2h_btts_rate': h2h['btts_rate'], 'h2h_over25_rate': h2h['over25_rate'], + 'h2h_home_goals_avg': h2h['home_goals_avg'], + 'h2h_away_goals_avg': h2h['away_goals_avg'], + 'h2h_recent_trend': h2h['recent_trend'], + 'h2h_venue_advantage': h2h['venue_advantage'], # Stats (8) 'home_avg_possession': home_stats['avg_possession'], 'away_avg_possession': away_stats['avg_possession'], @@ -380,11 +461,16 @@ class SingleMatchOrchestrator: 'odds_btts_y': float(odds.get('btts_y', 0)), 'odds_btts_n': float(odds.get('btts_n', 0)), **odds_presence, - # League (4) - 'home_xga': data.home_conceded_avg, - 'away_xga': data.away_conceded_avg, + # League (9 — original 2 + V27 expanded 5 + xga 2) + 'home_xga': xga_home, + 'away_xga': xga_away, 'league_avg_goals': league['avg_goals'], 'league_zero_goal_rate': league['zero_goal_rate'], + 'league_home_win_rate': league['home_win_rate'], + 'league_draw_rate': league['draw_rate'], + 'league_btts_rate': league['btts_rate'], + 'league_ou25_rate': league['ou25_rate'], + 'league_reliability_score': league['reliability_score'], # Upset (4) 'upset_atmosphere': 0.0, 'upset_motivation': 0.0, @@ -399,9 +485,45 @@ class SingleMatchOrchestrator: # Momentum (3) 'home_momentum_score': home_momentum, 'away_momentum_score': away_momentum, - 'momentum_diff': home_momentum - away_momentum, + 'momentum_diff': mom_diff, + # ── V27 Rolling Stats (13) ── + 'home_rolling5_goals': home_rolling['rolling5_goals'], + 'home_rolling5_conceded': home_rolling['rolling5_conceded'], + 'home_rolling10_goals': home_rolling['rolling10_goals'], + 'home_rolling10_conceded': home_rolling['rolling10_conceded'], + 'home_rolling20_goals': home_rolling['rolling20_goals'], + 'home_rolling20_conceded': home_rolling['rolling20_conceded'], + 'away_rolling5_goals': away_rolling['rolling5_goals'], + 'away_rolling5_conceded': away_rolling['rolling5_conceded'], + 'away_rolling10_goals': away_rolling['rolling10_goals'], + 'away_rolling10_conceded': away_rolling['rolling10_conceded'], + 'home_rolling5_cs': home_rolling['rolling5_cs'], + 'away_rolling5_cs': away_rolling['rolling5_cs'], + # ── V27 Venue Stats (4) ── + 'home_venue_goals': home_venue['venue_goals'], + 'home_venue_conceded': home_venue['venue_conceded'], + 'away_venue_goals': away_venue['venue_goals'], + 'away_venue_conceded': away_venue['venue_conceded'], + # ── V27 Goal Trend (2) ── + 'home_goal_trend': home_rolling['rolling5_goals'] - home_rolling['rolling10_goals'], + 'away_goal_trend': away_rolling['rolling5_goals'] - away_rolling['rolling10_goals'], + # ── V27 Calendar (4) ── + 'home_days_rest': home_rest, + 'away_days_rest': away_rest, + 'match_month': float(match_month), + 'is_season_start': is_season_start, + 'is_season_end': is_season_end, + # ── V27 Interaction (6) ── + 'attack_vs_defense_home': attack_vs_defense_home, + 'attack_vs_defense_away': attack_vs_defense_away, + 'xg_diff': xg_diff, + 'form_momentum_interaction': form_momentum_interaction, + 'elo_form_consistency': elo_form_consistency, + 'upset_x_elo_gap': upset_x_elo_gap, # Squad Features (9) — PlayerPredictorEngine **self._get_squad_features(data), + # V28 Odds-Band Historical Performance Features + **odds_band_features, } def _get_squad_features(self, data: MatchData) -> Dict[str, float]: @@ -666,6 +788,17 @@ class SingleMatchOrchestrator: if data is None: return None + # ── Pre-Match Simulation Mode ──────────────────────────── + # For finished (FT/postGame) matches, strip live scores so the + # entire pipeline treats them as if they haven't kicked off yet. + # _is_live_match already returns False for FT, but this adds + # defense-in-depth against any code path that reads scores directly. + _status_upper = str(data.status or "").upper() + _state_upper = str(data.state or "").upper() + if _status_upper in {"FT", "FINISHED"} or _state_upper in {"POSTGAME", "POST_GAME"}: + data.current_score_home = None + data.current_score_away = None + sport_key = str(data.sport or "football").lower() if sport_key == "basketball": prediction = self._get_basketball_predictor().predict( @@ -687,6 +820,343 @@ class SingleMatchOrchestrator: prediction = self._build_v25_prediction(data, features, v25_signal) base_package = self._build_prediction_package(data, prediction, v25_signal) + # ── V27 Dual-Engine Divergence ────────────────────────────── + v27_predictor = self._get_v27_predictor() + if v27_predictor is not None: + try: + v27_preds = v27_predictor.predict_all(features) + + # MS divergence + v27_ms = v27_preds.get("ms") + if v27_ms: + v25_ms_probs = { + "home": prediction.ms_home_prob, + "draw": prediction.ms_draw_prob, + "away": prediction.ms_away_prob, + } + ms_divergence = compute_divergence(v25_ms_probs, v27_ms) + ms_odds = { + "home": float((data.odds_data or {}).get("ms_h", 0)), + "draw": float((data.odds_data or {}).get("ms_d", 0)), + "away": float((data.odds_data or {}).get("ms_a", 0)), + } + ms_value = compute_value_edge(v25_ms_probs, v27_ms, ms_odds) + else: + ms_divergence = {} + ms_value = {} + + # OU25 divergence + v27_ou25 = v27_preds.get("ou25") + if v27_ou25: + v25_ou25_probs = { + "under": prediction.under_25_prob, + "over": prediction.over_25_prob, + } + ou25_divergence = compute_divergence(v25_ou25_probs, v27_ou25) + ou25_odds = { + "under": float((data.odds_data or {}).get("ou25_u", 0)), + "over": float((data.odds_data or {}).get("ou25_o", 0)), + } + ou25_value = compute_value_edge(v25_ou25_probs, v27_ou25, ou25_odds) + else: + ou25_divergence = {} + ou25_value = {} + + # ── V28 Odds-Band Historical Performance ───────────── + odds_band_ms_home = { + "win_rate": features.get("home_band_ms_win_rate", 0.33), + "draw_rate": features.get("home_band_ms_draw_rate", 0.33), + "loss_rate": features.get("home_band_ms_loss_rate", 0.34), + "sample": features.get("home_band_ms_sample", 0), + "avg_goals_scored": features.get("home_band_ms_avg_goals_scored", 1.3), + "avg_goals_conceded": features.get("home_band_ms_avg_goals_conceded", 1.1), + } + odds_band_ms_away = { + "win_rate": features.get("away_band_ms_win_rate", 0.33), + "draw_rate": features.get("away_band_ms_draw_rate", 0.33), + "loss_rate": features.get("away_band_ms_loss_rate", 0.34), + "sample": features.get("away_band_ms_sample", 0), + "avg_goals_scored": features.get("away_band_ms_avg_goals_scored", 1.3), + "avg_goals_conceded": features.get("away_band_ms_avg_goals_conceded", 1.1), + } + odds_band_ou25 = { + "over_rate": features.get("band_ou25_over_rate", 0.50), + "under_rate": features.get("band_ou25_under_rate", 0.50), + "avg_total_goals": features.get("band_ou25_avg_total_goals", 2.5), + "sample": features.get("band_ou25_sample", 0), + } + odds_band_ou15 = { + "over_rate": features.get("band_ou15_over_rate", 0.65), + "under_rate": features.get("band_ou15_under_rate", 0.35), + "avg_total_goals": features.get("band_ou15_avg_total_goals", 2.5), + "sample": features.get("band_ou15_sample", 0), + } + odds_band_ou35 = { + "over_rate": features.get("band_ou35_over_rate", 0.35), + "under_rate": features.get("band_ou35_under_rate", 0.65), + "avg_total_goals": features.get("band_ou35_avg_total_goals", 2.5), + "sample": features.get("band_ou35_sample", 0), + } + odds_band_btts = { + "yes_rate": features.get("band_btts_yes_rate", 0.50), + "no_rate": features.get("band_btts_no_rate", 0.50), + "sample": features.get("band_btts_sample", 0), + } + odds_band_dc = { + "1x_rate": features.get("band_dc_1x_rate", 0.60), + "x2_rate": features.get("band_dc_x2_rate", 0.60), + "12_rate": features.get("band_dc_12_rate", 0.67), + "1x_sample": features.get("band_dc_1x_sample", 0), + "x2_sample": features.get("band_dc_x2_sample", 0), + "12_sample": features.get("band_dc_12_sample", 0), + } + odds_band_ht_home = { + "win_rate": features.get("home_band_ht_win_rate", 0.33), + "draw_rate": features.get("home_band_ht_draw_rate", 0.40), + "loss_rate": features.get("home_band_ht_loss_rate", 0.27), + "sample": features.get("home_band_ht_sample", 0), + } + odds_band_ht_away = { + "win_rate": features.get("away_band_ht_win_rate", 0.33), + "draw_rate": features.get("away_band_ht_draw_rate", 0.40), + "loss_rate": features.get("away_band_ht_loss_rate", 0.27), + "sample": features.get("away_band_ht_sample", 0), + } + odds_band_ht_ou05 = { + "over_rate": features.get("band_ht_ou05_over_rate", 0.50), + "under_rate": features.get("band_ht_ou05_under_rate", 0.50), + "sample": features.get("band_ht_ou05_sample", 0), + } + odds_band_ht_ou15 = { + "over_rate": features.get("band_ht_ou15_over_rate", 0.35), + "under_rate": features.get("band_ht_ou15_under_rate", 0.65), + "sample": features.get("band_ht_ou15_sample", 0), + } + odds_band_oe = { + "odd_rate": features.get("band_oe_odd_rate", 0.50), + "even_rate": features.get("band_oe_even_rate", 0.50), + "sample": features.get("band_oe_sample", 0), + } + + # Cards (Kart) band — hakem + takım profili + odds_band_cards = { + "referee_avg": features.get("band_cards_referee_avg", 0.0), + "referee_over_rate": features.get("band_cards_referee_over_rate", 0.50), + "referee_sample": features.get("band_cards_referee_sample", 0), + "team_avg": features.get("band_cards_team_avg", 0.0), + "team_over_rate": features.get("band_cards_team_over_rate", 0.50), + "team_sample": features.get("band_cards_team_sample", 0), + "combined_over_rate": features.get("band_cards_combined_over_rate", 0.50), + "sample": features.get("band_cards_sample", 0), + } + + # HTFT (İY/MS) 9 combination rates + odds_band_htft = {} + for combo in ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22"): + odds_band_htft[combo] = { + "rate": features.get(f"band_htft_{combo}_rate", 0.11), + "sample": features.get(f"band_htft_{combo}_sample", 0), + } + + # ── Triple Value Detection ──────────────────────────── + ms_odds = { + "home": float((data.odds_data or {}).get("ms_h", 0)), + "draw": float((data.odds_data or {}).get("ms_d", 0)), + "away": float((data.odds_data or {}).get("ms_a", 0)), + } + triple_value = {} + for outcome_key, band_key, odds_key in [ + ("home", "home", "home"), + ("away", "away", "away"), + ]: + v27_prob = (v27_ms or {}).get(outcome_key, 0) + band_rate = (odds_band_ms_home if band_key == "home" + else odds_band_ms_away)["win_rate"] + mkt_odds = ms_odds.get(odds_key, 0) + implied_prob = (1.0 / mkt_odds) if mkt_odds > 1.0 else 0.33 + + combined_prob = (v27_prob + band_rate) / 2.0 if v27_prob > 0 else band_rate + edge = combined_prob - implied_prob + band_sample = (odds_band_ms_home if band_key == "home" + else odds_band_ms_away)["sample"] + + v27_confirms = v27_prob > implied_prob + band_confirms = band_rate > implied_prob + confirmation_count = sum([v27_confirms, band_confirms]) + + triple_value[outcome_key] = { + "v27_prob": round(v27_prob, 4), + "band_rate": round(band_rate, 4), + "implied_prob": round(implied_prob, 4), + "combined_prob": round(combined_prob, 4), + "edge": round(edge, 4), + "band_sample": band_sample, + "confirmations": confirmation_count, + "is_value": ( + confirmation_count >= 2 + and edge > 0.05 + and band_sample >= 8 + ), + } + + # OU25 triple value + ou25_over_odds = float((data.odds_data or {}).get("ou25_o", 0)) + v27_ou25_over = (v27_ou25 or {}).get("over", 0) if v27_ou25 else 0 + ou25_band_rate = odds_band_ou25["over_rate"] + ou25_implied = (1.0 / ou25_over_odds) if ou25_over_odds > 1.0 else 0.50 + ou25_combined = (v27_ou25_over + ou25_band_rate) / 2.0 if v27_ou25_over > 0 else ou25_band_rate + ou25_edge = ou25_combined - ou25_implied + ou25_v27_confirms = v27_ou25_over > ou25_implied + ou25_band_confirms = ou25_band_rate > ou25_implied + ou25_conf_count = sum([ou25_v27_confirms, ou25_band_confirms]) + + triple_value["ou25_over"] = { + "v27_prob": round(v27_ou25_over, 4), + "band_rate": round(ou25_band_rate, 4), + "implied_prob": round(ou25_implied, 4), + "combined_prob": round(ou25_combined, 4), + "edge": round(ou25_edge, 4), + "band_sample": odds_band_ou25["sample"], + "confirmations": ou25_conf_count, + "is_value": ( + ou25_conf_count >= 2 + and ou25_edge > 0.05 + and odds_band_ou25["sample"] >= 8 + ), + } + + # BTTS triple value + btts_yes_odds = float((data.odds_data or {}).get("btts_y", 0)) + btts_implied = (1.0 / btts_yes_odds) if btts_yes_odds > 1.0 else 0.50 + btts_band_rate = odds_band_btts["yes_rate"] + btts_combined = btts_band_rate + btts_edge = btts_combined - btts_implied + btts_band_confirms = btts_band_rate > btts_implied + + triple_value["btts_yes"] = { + "band_rate": round(btts_band_rate, 4), + "implied_prob": round(btts_implied, 4), + "combined_prob": round(btts_combined, 4), + "edge": round(btts_edge, 4), + "band_sample": odds_band_btts["sample"], + "confirmations": 1 if btts_band_confirms else 0, + "is_value": ( + btts_band_confirms + and btts_edge > 0.05 + and odds_band_btts["sample"] >= 8 + ), + } + + # ── Band-only value for new markets ─────────────────── + def _band_value(label, band_rate, odds_key, sample): + o = float((data.odds_data or {}).get(odds_key, 0)) + imp = (1.0 / o) if o > 1.0 else 0.50 + e = band_rate - imp + conf = band_rate > imp + return { + "band_rate": round(band_rate, 4), + "implied_prob": round(imp, 4), + "edge": round(e, 4), + "band_sample": sample, + "is_value": conf and e > 0.05 and sample >= 8, + } + + triple_value["ou15_over"] = _band_value( + "ou15", odds_band_ou15["over_rate"], "ou15_o", odds_band_ou15["sample"]) + triple_value["ou35_over"] = _band_value( + "ou35", odds_band_ou35["over_rate"], "ou35_o", odds_band_ou35["sample"]) + triple_value["dc_1x"] = _band_value( + "dc1x", odds_band_dc["1x_rate"], "dc_1x", odds_band_dc["1x_sample"]) + triple_value["dc_x2"] = _band_value( + "dcx2", odds_band_dc["x2_rate"], "dc_x2", odds_band_dc["x2_sample"]) + triple_value["dc_12"] = _band_value( + "dc12", odds_band_dc["12_rate"], "dc_12", odds_band_dc["12_sample"]) + triple_value["ht_home"] = _band_value( + "ht_h", odds_band_ht_home["win_rate"], "ht_h", odds_band_ht_home["sample"]) + triple_value["ht_away"] = _band_value( + "ht_a", odds_band_ht_away["win_rate"], "ht_a", odds_band_ht_away["sample"]) + triple_value["ht_ou05_over"] = _band_value( + "htou05", odds_band_ht_ou05["over_rate"], "ht_ou05_o", odds_band_ht_ou05["sample"]) + triple_value["ht_ou15_over"] = _band_value( + "htou15", odds_band_ht_ou15["over_rate"], "ht_ou15_o", odds_band_ht_ou15["sample"]) + triple_value["oe_odd"] = _band_value( + "oe", odds_band_oe["odd_rate"], "oe_odd", odds_band_oe["sample"]) + + # Cards triple value — composite (hakem + takım) + triple_value["cards_over"] = _band_value( + "cards", odds_band_cards["combined_over_rate"], "cards_o", + odds_band_cards["sample"]) + + # HTFT triple value — 9 combinations + for combo in ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22"): + htft_combo_data = odds_band_htft.get(combo, {}) + triple_value[f"htft_{combo}"] = _band_value( + f"htft_{combo}", htft_combo_data.get("rate", 0.11), + f"htft_{combo}", htft_combo_data.get("sample", 0)) + + # Attach to package + base_package["v27_engine"] = { + "version": "v28-pro-max", + "approach": "odds-free fundamentals + full odds-band analytics + cards + htft", + "predictions": { + "ms": v27_ms or {}, + "ou25": v27_ou25 or {}, + }, + "divergence": { + "ms": ms_divergence, + "ou25": ou25_divergence, + }, + "value_edge": { + "ms": ms_value, + "ou25": ou25_value, + }, + "odds_band": { + "ms_home": odds_band_ms_home, + "ms_away": odds_band_ms_away, + "ou25": odds_band_ou25, + "ou15": odds_band_ou15, + "ou35": odds_band_ou35, + "btts": odds_band_btts, + "dc": odds_band_dc, + "ht_home": odds_band_ht_home, + "ht_away": odds_band_ht_away, + "ht_ou05": odds_band_ht_ou05, + "ht_ou15": odds_band_ht_ou15, + "oe": odds_band_oe, + "cards": odds_band_cards, + "htft": odds_band_htft, + }, + "triple_value": triple_value, + } + + # Boost confidence when V27 agrees with V25 + if v27_ms: + v27_best = max(v27_ms, key=v27_ms.get) + v25_best_map = {"1": "home", "X": "draw", "2": "away"} + v25_best_mapped = v25_best_map.get(prediction.ms_pick, "") + if v27_best == v25_best_mapped: + # Engines agree → boost confidence by up to 5% + boost = min(5.0, abs(ms_divergence.get(v27_best, 0)) * 50) + # Additional boost if odds-band also confirms + band_val = triple_value.get(v25_best_mapped, {}) + if band_val.get("is_value"): + boost = min(8.0, boost + 3.0) # Triple confirmation extra boost + prediction.ms_confidence = min(95.0, prediction.ms_confidence + boost) + base_package["prediction"]["ms_confidence"] = prediction.ms_confidence + base_package["v27_engine"]["consensus"] = "AGREE" + else: + base_package["v27_engine"]["consensus"] = "DISAGREE" + + # Update analysis details + base_package.setdefault("analysis_details", {}) + base_package["analysis_details"]["dual_engine"] = True + base_package["analysis_details"]["v27_loaded"] = True + base_package["analysis_details"]["odds_band_loaded"] = True + except Exception as e: + print(f"[V27] ⚠ Prediction failed (non-fatal): {e}") + base_package.setdefault("analysis_details", {}) + base_package["analysis_details"]["v27_loaded"] = False + mode = str(getattr(self, "engine_mode", "v25") or "v25").lower() if mode not in {"v25", "v26", "dual"}: mode = "v25" @@ -2463,17 +2933,17 @@ class SingleMatchOrchestrator: playable_rows = [row for row in market_rows if row.get("playable")] - # GUARANTEED PICK LOGIC (Optimized based on backtest results): + # GUARANTEED PICK LOGIC (V32 - Calibration-aware): # Runtime replay insights: # - Trust only markets that remain robust after pre-match replay. # - Current strongest football markets: DC, OU15, HT_OU05. # - # Priority 1: High-accuracy market (DC/OU15/HT_OU05/OU25) + Odds >= 1.30 + Conf >= 40% - # Priority 2: Any playable + Odds >= 1.30 + Conf >= 40% + # Priority 1: High-accuracy market (DC/OU15/HT_OU05/OU25) + Odds >= 1.30 + Conf >= 44% + # Priority 2: Any playable + Odds >= 1.30 + Conf >= 44% # Priority 3: Playable + Odds >= 1.30 # Priority 4: Best non-playable (fallback) MIN_ODDS = 1.30 - MIN_CONFIDENCE = 52.0 + MIN_CONFIDENCE = 44.0 # V32: lowered from 52 to match new calibration # High-accuracy markets from backtest (prioritize these) HIGH_ACCURACY_MARKETS = {"DC", "OU15", "HT_OU05"} @@ -2481,7 +2951,7 @@ class SingleMatchOrchestrator: # Priority 1: High-accuracy markets with good odds and confidence high_accuracy_picks = [ row for row in playable_rows - if row.get("market_type") in HIGH_ACCURACY_MARKETS + if row.get("market") in HIGH_ACCURACY_MARKETS and float(row.get("odds", 0.0)) >= MIN_ODDS and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE ] @@ -2686,8 +3156,14 @@ class SingleMatchOrchestrator: if market in available_markets } + # Determine simulation mode for the response + _resp_status = str(data.status or "").upper() + _resp_state = str(data.state or "").upper() + is_simulation = _resp_status in {"FT", "FINISHED"} or _resp_state in {"POSTGAME", "POST_GAME"} + return { "model_version": "v25.main", + "simulation_mode": "pre_match" if is_simulation else None, "match_info": { "match_id": data.match_id, "match_name": f"{data.home_team_name} vs {data.away_team_name}", @@ -3816,11 +4292,15 @@ class SingleMatchOrchestrator: playable = False reasons.append("high_risk_low_data_quality") if lineup_missing and lineup_sensitive: - playable = False + # V32: Don't hard-block, apply heavy penalty instead + # This allows high-confidence predictions to still surface + lineup_penalty += 8.0 reasons.append("lineup_insufficient_for_market") if data.lineup_source == "probable_xi" and lineup_sensitive: - playable = False - reasons.append("lineup_not_confirmed") + # V32: Penalty instead of hard block + # Most pre-match predictions use probable_xi — blocking kills all output + lineup_penalty += 6.0 + reasons.append("lineup_probable_xi_penalty") # V31: negative edge threshold adapts to league reliability # Reliable league: stricter (-0.03), unreliable: looser (-0.08) neg_edge_threshold = -0.03 - (1.0 - odds_rel) * 0.05 diff --git a/src/modules/leagues/leagues.controller.ts b/src/modules/leagues/leagues.controller.ts index c93dd13..103f18f 100755 --- a/src/modules/leagues/leagues.controller.ts +++ b/src/modules/leagues/leagues.controller.ts @@ -119,20 +119,23 @@ export class LeaguesController { /** * GET /leagues/teams/:id/matches - * Get team's recent matches + * Get team's recent matches (paginated) */ @Get("teams/:id/matches") @Public() - @ApiOperation({ summary: "Get team's recent matches" }) + @ApiOperation({ summary: "Get team's recent matches (paginated)" }) @ApiParam({ name: "id", description: "Team ID" }) - @ApiQuery({ name: "limit", required: false, type: Number }) + @ApiQuery({ name: "page", required: false, type: Number, description: "Page number (default: 1)" }) + @ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default: 20)" }) async getTeamMatches( @Param("id") id: string, + @Query("page") page?: string, @Query("limit") limit?: string, ) { return this.leaguesService.getTeamRecentMatches( id, - parseInt(limit || "10", 10), + parseInt(page || "1", 10), + parseInt(limit || "20", 10), ); } diff --git a/src/modules/leagues/leagues.service.ts b/src/modules/leagues/leagues.service.ts index 02fa315..dae55cf 100755 --- a/src/modules/leagues/leagues.service.ts +++ b/src/modules/leagues/leagues.service.ts @@ -99,21 +99,40 @@ export class LeaguesService { } /** - * Get team's matches (past + upcoming) + * Get team's matches (past + upcoming) with pagination */ - async getTeamRecentMatches(teamId: string, limit: number = 50) { - return this.prisma.match.findMany({ - where: { - OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }], - }, - include: { - homeTeam: true, - awayTeam: true, - league: { include: { country: true } }, - }, - orderBy: { mstUtc: "desc" }, - take: limit, - }); + async getTeamRecentMatches( + teamId: string, + page: number = 1, + limit: number = 20, + ) { + const skip = (page - 1) * limit; + const where = { + OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }], + }; + + const [data, total] = await this.prisma.$transaction([ + this.prisma.match.findMany({ + where, + include: { + homeTeam: true, + awayTeam: true, + league: { include: { country: true } }, + }, + orderBy: { mstUtc: "desc" }, + skip, + take: limit, + }), + this.prisma.match.count({ where }), + ]); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; } /** diff --git a/src/modules/predictions/dto/index.ts b/src/modules/predictions/dto/index.ts index b33d89b..ed7bcaf 100755 --- a/src/modules/predictions/dto/index.ts +++ b/src/modules/predictions/dto/index.ts @@ -418,6 +418,14 @@ export class MatchPredictionDto { @ApiProperty({ type: Object, required: false }) surprise_hunter?: Record; + + @ApiProperty({ + type: Object, + required: false, + description: + "V28 Odds-Band engine output: historical band analytics, triple-value detection, cards profiling, and HTFT 9-combo analysis", + }) + v27_engine?: Record; } export class ValueBetDto {