""" Single Match Orchestrator (V20+) ================================ Primary prediction orchestration for frontend/live match clicks and automation. Design goals: - One authoritative match package contract. - Scenario-consistent market board from a single prediction output. - Data quality and risk tagging for consumer UX. """ from __future__ import annotations import json import re import time import math import os import pickle import pandas as pd import numpy as np from collections import defaultdict from dataclasses import dataclass from typing import Any, Dict, List, Optional, Set, Tuple import psycopg2 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 try: from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge except ImportError: V27Predictor = None def compute_divergence(*args, **kwargs): return 0.0 def compute_value_edge(*args, **kwargs): return 0.0 from features.odds_band_analyzer import OddsBandAnalyzer try: from models.basketball_v25 import ( BasketballMatchPrediction, get_basketball_v25_predictor, ) except ImportError: BasketballMatchPrediction = Any def get_basketball_v25_predictor(): raise ImportError("Basketball predictor is not available") from core.engines.player_predictor import PlayerPrediction, get_player_predictor from services.feature_enrichment import FeatureEnrichmentService from services.betting_brain import BettingBrain from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine from utils.top_leagues import load_top_league_ids from utils.league_reliability import load_league_reliability @dataclass class MatchData: match_id: str home_team_id: str away_team_id: str home_team_name: str away_team_name: str match_date_ms: int sport: str league_id: Optional[str] league_name: str referee_name: Optional[str] odds_data: Dict[str, float] home_lineup: Optional[List[str]] away_lineup: Optional[List[str]] sidelined_data: Optional[Dict[str, Any]] home_goals_avg: float home_conceded_avg: float away_goals_avg: float away_conceded_avg: float home_position: int away_position: int lineup_source: str status: str = "" state: Optional[str] = None substate: Optional[str] = None current_score_home: Optional[int] = None current_score_away: Optional[int] = None lineup_confidence: float = 0.0 class SingleMatchOrchestrator: """Main V20+ application service used by API endpoints.""" DEFAULT_MS_H = 2.65 DEFAULT_MS_D = 3.20 DEFAULT_MS_A = 2.65 RELATIONAL_ODDS_KEYS = ( "ms_h", "ms_d", "ms_a", "dc_1x", "dc_x2", "dc_12", "ou15_o", "ou15_u", "ou25_o", "ou25_u", "ou35_o", "ou35_u", "btts_y", "btts_n", "ht_h", "ht_d", "ht_a", "ht_ou05_o", "ht_ou05_u", "ht_ou15_o", "ht_ou15_u", "cards_o", "cards_u", "hcap_h", "hcap_d", "hcap_a", "ml_h", "ml_a", "tot_line", "tot_o", "tot_u", "spread_home_line", "spread_h", "spread_a", ) V25_ODDS_FEATURE_KEYS = ( "ms_h", "ms_d", "ms_a", "ht_h", "ht_d", "ht_a", "ou05_o", "ou05_u", "ou15_o", "ou15_u", "ou25_o", "ou25_u", "ou35_o", "ou35_u", "ht_ou05_o", "ht_ou05_u", "ht_ou15_o", "ht_ou15_u", "btts_y", "btts_n", ) ODDS_REQUIRED_MARKETS = ( "MS", "DC", "OU15", "OU25", "OU35", "BTTS", "HT", "HT_OU05", "HT_OU15", "HTFT", "OE", "CARDS", "HCAP", ) def __init__(self) -> None: self.v25_predictor: Optional[V25Predictor] = None self.v26_shadow_engine: Optional[V26ShadowEngine] = None self.basketball_predictor: Optional[Any] = None self.dsn = get_clean_dsn() self.engine_mode = str(os.getenv("AI_ENGINE_MODE", "v28-pro-max")).strip().lower() self.top_league_ids = load_top_league_ids() self.league_reliability = load_league_reliability() self.enrichment = FeatureEnrichmentService() 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.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": 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": 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.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: 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: if getattr(self, "v26_shadow_engine", None) is None: 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 V27Predictor is None: return None 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 _get_score_model(self) -> Optional[Dict]: """Load XGBoost score prediction model (non-fatal).""" if hasattr(self, "_score_model_cache"): return self._score_model_cache score_model_path = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "models", "xgb_score.pkl", ) try: if os.path.exists(score_model_path): with open(score_model_path, "rb") as f: model_data = pickle.load(f) if all(k in model_data for k in ("home_model", "away_model", "ht_home_model", "ht_away_model", "features")): self._score_model_cache = model_data print(f"[SCORE] ✅ Score model loaded ({len(model_data['features'])} features)") return self._score_model_cache except Exception as e: print(f"[SCORE] ⚠ Load failed (non-fatal, using heuristic): {e}") self._score_model_cache = None return None def _predict_score_with_model(self, features: Dict[str, float]) -> Optional[Dict[str, float]]: """Predict FT/HT scores using XGBoost score model.""" score_model = self._get_score_model() if score_model is None: return None try: import pandas as _pd model_features = score_model["features"] row = {f: float(features.get(f, 0)) for f in model_features} df = _pd.DataFrame([row]) ft_home = max(0.0, float(score_model["home_model"].predict(df)[0])) ft_away = max(0.0, float(score_model["away_model"].predict(df)[0])) ht_home = max(0.0, float(score_model["ht_home_model"].predict(df)[0])) ht_away = max(0.0, float(score_model["ht_away_model"].predict(df)[0])) return { "ft_home": round(ft_home, 2), "ft_away": round(ft_away, 2), "ht_home": round(ht_home, 2), "ht_away": round(ht_away, 2), } except Exception as e: print(f"[SCORE] ⚠ Prediction error (fallback to heuristic): {e}") return None def _build_v25_features(self, data: MatchData) -> Dict[str, float]: """ Build the single authoritative V25 pre-match feature vector. """ odds = self._sanitize_v25_odds(data.odds_data or {}) ms_h = float(odds.get('ms_h', 0)) ms_d = float(odds.get('ms_d', 0)) ms_a = float(odds.get('ms_a', 0)) # Implied probabilities (vig-normalised) implied_home, implied_draw, implied_away = 0.33, 0.33, 0.33 if ms_h > 0 and ms_d > 0 and ms_a > 0: raw_sum = 1 / ms_h + 1 / ms_d + 1 / ms_a implied_home = (1 / ms_h) / raw_sum implied_draw = (1 / ms_d) / raw_sum implied_away = (1 / ms_a) / raw_sum upset_potential = max( 0.0, min( 1.0, 1.0 - abs(implied_home - implied_away) + (implied_draw * 0.35), ), ) # All enrichment queries in a single DB connection home_elo, away_elo = 1500.0, 1500.0 home_venue_elo, away_venue_elo = 1500.0, 1500.0 home_form_elo_val, away_form_elo_val = 1500.0, 1500.0 enr = self.enrichment try: with psycopg2.connect(self.dsn) as conn: with conn.cursor(cursor_factory=RealDictCursor) as cur: # ELO (overall + venue + form) - Updated for sport-partitioned schema cur.execute( "SELECT home_elo, away_elo, " " home_home_elo, away_away_elo, " " home_form_elo, away_form_elo " "FROM football_ai_features " "WHERE match_id = %s LIMIT 1", (data.match_id,), ) elo_row = cur.fetchone() if elo_row: home_elo = float(elo_row.get('home_elo') or 1500.0) away_elo = float(elo_row.get('away_elo') or 1500.0) home_venue_elo = float(elo_row.get('home_home_elo') or home_elo) away_venue_elo = float(elo_row.get('away_away_elo') or away_elo) home_form_elo_val = float(elo_row.get('home_form_elo') or home_elo) away_form_elo_val = float(elo_row.get('away_form_elo') or away_elo) # Enrichment queries home_stats = enr.compute_team_stats(cur, data.home_team_id, data.match_date_ms) away_stats = enr.compute_team_stats(cur, data.away_team_id, data.match_date_ms) h2h = enr.compute_h2h(cur, data.home_team_id, data.away_team_id, data.match_date_ms) home_form = enr.compute_form_streaks(cur, data.home_team_id, data.match_date_ms) away_form = enr.compute_form_streaks(cur, data.away_team_id, data.match_date_ms) ref = enr.compute_referee_stats(cur, data.referee_name, data.match_date_ms) 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) away_stats = dict(enr._DEFAULT_TEAM_STATS) h2h = dict(enr._DEFAULT_H2H) home_form = dict(enr._DEFAULT_FORM) away_form = dict(enr._DEFAULT_FORM) ref = dict(enr._DEFAULT_REFEREE) 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, 'odds_ms_d_present': 1.0 if ms_d > 1.01 else 0.0, 'odds_ms_a_present': 1.0 if ms_a > 1.01 else 0.0, 'odds_ht_ms_h_present': 1.0 if float(odds.get('ht_h', 0)) > 1.01 else 0.0, 'odds_ht_ms_d_present': 1.0 if float(odds.get('ht_d', 0)) > 1.01 else 0.0, 'odds_ht_ms_a_present': 1.0 if float(odds.get('ht_a', 0)) > 1.01 else 0.0, 'odds_ou05_o_present': 1.0 if float(odds.get('ou05_o', 0)) > 1.01 else 0.0, 'odds_ou05_u_present': 1.0 if float(odds.get('ou05_u', 0)) > 1.01 else 0.0, 'odds_ou15_o_present': 1.0 if float(odds.get('ou15_o', 0)) > 1.01 else 0.0, 'odds_ou15_u_present': 1.0 if float(odds.get('ou15_u', 0)) > 1.01 else 0.0, 'odds_ou25_o_present': 1.0 if float(odds.get('ou25_o', 0)) > 1.01 else 0.0, 'odds_ou25_u_present': 1.0 if float(odds.get('ou25_u', 0)) > 1.01 else 0.0, 'odds_ou35_o_present': 1.0 if float(odds.get('ou35_o', 0)) > 1.01 else 0.0, 'odds_ou35_u_present': 1.0 if float(odds.get('ou35_u', 0)) > 1.01 else 0.0, 'odds_ht_ou05_o_present': 1.0 if float(odds.get('ht_ou05_o', 0)) > 1.01 else 0.0, 'odds_ht_ou05_u_present': 1.0 if float(odds.get('ht_ou05_u', 0)) > 1.01 else 0.0, 'odds_ht_ou15_o_present': 1.0 if float(odds.get('ht_ou15_o', 0)) > 1.01 else 0.0, 'odds_ht_ou15_u_present': 1.0 if float(odds.get('ht_ou15_u', 0)) > 1.01 else 0.0, 'odds_btts_y_present': 1.0 if float(odds.get('btts_y', 0)) > 1.01 else 0.0, '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': 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': form_elo_diff, # Form (12) 'home_goals_avg': data.home_goals_avg, 'home_conceded_avg': data.home_conceded_avg, 'away_goals_avg': data.away_goals_avg, 'away_conceded_avg': data.away_conceded_avg, 'home_clean_sheet_rate': home_form['clean_sheet_rate'], 'away_clean_sheet_rate': away_form['clean_sheet_rate'], 'home_scoring_rate': home_form['scoring_rate'], 'away_scoring_rate': away_form['scoring_rate'], 'home_winning_streak': home_form['winning_streak'], 'away_winning_streak': away_form['winning_streak'], 'home_unbeaten_streak': home_form['unbeaten_streak'], 'away_unbeaten_streak': away_form['unbeaten_streak'], # 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'], 'home_avg_shots_on_target': home_stats['avg_shots_on_target'], 'away_avg_shots_on_target': away_stats['avg_shots_on_target'], 'home_shot_conversion': home_stats['shot_conversion'], 'away_shot_conversion': away_stats['shot_conversion'], 'home_avg_corners': home_stats['avg_corners'], 'away_avg_corners': away_stats['avg_corners'], # Odds (24) 'odds_ms_h': ms_h, 'odds_ms_d': ms_d, 'odds_ms_a': ms_a, 'implied_home': implied_home, 'implied_draw': implied_draw, 'implied_away': implied_away, 'odds_ht_ms_h': float(odds.get('ht_h', 0)), 'odds_ht_ms_d': float(odds.get('ht_d', 0)), 'odds_ht_ms_a': float(odds.get('ht_a', 0)), 'odds_ou05_o': float(odds.get('ou05_o', 0)), 'odds_ou05_u': float(odds.get('ou05_u', 0)), 'odds_ou15_o': float(odds.get('ou15_o', 0)), 'odds_ou15_u': float(odds.get('ou15_u', 0)), 'odds_ou25_o': float(odds.get('ou25_o', 0)), 'odds_ou25_u': float(odds.get('ou25_u', 0)), 'odds_ou35_o': float(odds.get('ou35_o', 0)), 'odds_ou35_u': float(odds.get('ou35_u', 0)), 'odds_ht_ou05_o': float(odds.get('ht_ou05_o', 0)), 'odds_ht_ou05_u': float(odds.get('ht_ou05_u', 0)), 'odds_ht_ou15_o': float(odds.get('ht_ou15_o', 0)), 'odds_ht_ou15_u': float(odds.get('ht_ou15_u', 0)), 'odds_btts_y': float(odds.get('btts_y', 0)), 'odds_btts_n': float(odds.get('btts_n', 0)), **odds_presence, # 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, 'upset_fatigue': 0.0, 'upset_potential': upset_potential, # Referee (5) 'referee_home_bias': ref['home_bias'], 'referee_avg_goals': ref['avg_goals'], 'referee_cards_total': ref['cards_total'], 'referee_avg_yellow': ref['avg_yellow'], 'referee_experience': ref['experience'], # Momentum (3) 'home_momentum_score': home_momentum, 'away_momentum_score': 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]: """Non-fatal squad analysis. Returns neutral-average defaults on failure. Design note (V32-fix): Previous 0.0 defaults caused the model to treat missing lineups as 'both teams have zero quality', producing overly conservative predictions (e.g. static 1.5 Under). Neutral averages let the model fall back on stronger signals (odds, ELO, form, H2H). """ defaults = { 'home_squad_quality': 0.50, 'away_squad_quality': 0.50, 'squad_diff': 0.0, 'home_key_players': 3.0, 'away_key_players': 3.0, 'home_missing_impact': 0.0, 'away_missing_impact': 0.0, 'home_goals_form': 1.3, 'away_goals_form': 1.3, } try: engine = get_player_predictor() pred = engine.predict( match_id=data.match_id, home_team_id=data.home_team_id, away_team_id=data.away_team_id, home_lineup=data.home_lineup, away_lineup=data.away_lineup, sidelined_data=data.sidelined_data, ) return { 'home_squad_quality': float(pred.home_squad_quality), 'away_squad_quality': float(pred.away_squad_quality), 'squad_diff': float(pred.squad_diff), 'home_key_players': float(pred.home_key_players), 'away_key_players': float(pred.away_key_players), 'home_missing_impact': float(pred.home_missing_impact), 'away_missing_impact': float(pred.away_missing_impact), 'home_goals_form': float(pred.home_goals_form), 'away_goals_form': float(pred.away_goals_form), } except Exception as e: print(f"⚠️ Squad features failed: {e}") return defaults # ── V25 internal key → _build_v25_prediction key mapping ── _V25_KEY_MAP = { "ms": "MS", "ou15": "OU15", "ou25": "OU25", "ou35": "OU35", "btts": "BTTS", "ht_result": "HT", "ht_ou05": "HT_OU05", "ht_ou15": "HT_OU15", "htft": "HTFT", "cards_ou45": "CARDS", "handicap_ms": "HCAP", "odd_even": "OE", } def _get_v25_signal( self, data: MatchData, features: Optional[Dict[str, float]] = None, ) -> Dict[str, Any]: """ Get V25 ensemble predictions for all available markets. Returns a dict keyed by UPPERCASE market name (MS, OU25, BTTS, etc.) each with a 'probs' sub-dict that _prob_map can consume. CRITICAL: Keys MUST be uppercase to match _build_v25_prediction lookups. """ v25 = self._get_v25_predictor() feature_row = features or self._build_v25_features(data) signal: Dict[str, Any] = {} def _temperature_scale(probs_dict: Dict[str, float], temperature: float = 2.5) -> Dict[str, float]: """ Apply temperature scaling to soften overconfident model outputs. LightGBM often produces extreme probabilities (e.g., 0.999 / 0.001). Temperature scaling converts to log-odds, divides by T, then re-normalizes. T=1.0 → no change, T>1 → softer probabilities. Standard approach for post-hoc model calibration (Guo et al., 2017). """ import math eps = 1e-7 # numerical stability n = len(probs_dict) # Determine appropriate temperature based on market type # Binary markets (2-class) tend to be more overconfident in LGB if n <= 2: T = max(temperature, 2.0) elif n == 3: T = max(temperature * 0.8, 1.5) # 3-way slightly less aggressive else: T = max(temperature * 0.6, 1.3) # 9-way (HTFT) already spread # Convert to log-odds and apply temperature labels = list(probs_dict.keys()) log_odds = [] for label in labels: p = max(eps, min(1.0 - eps, float(probs_dict[label]))) log_odds.append(math.log(p) / T) # Softmax re-normalization max_lo = max(log_odds) exp_vals = [math.exp(lo - max_lo) for lo in log_odds] total = sum(exp_vals) scaled = {} for i, label in enumerate(labels): scaled[label] = exp_vals[i] / total return scaled def _enrich_signal_entry(probs_dict: Dict[str, float]) -> Dict[str, Any]: """Add pick, probability, confidence to a signal entry from its probs. Applies temperature scaling to convert overconfident LightGBM outputs into realistic, calibrated probabilities. """ # Apply temperature scaling to soften extreme probabilities scaled_probs = _temperature_scale(probs_dict, temperature=2.5) best_label = max(scaled_probs, key=scaled_probs.get) best_prob = float(scaled_probs[best_label]) return { "probs": scaled_probs, "raw_probs": probs_dict, # keep originals for debugging "pick": best_label, "probability": best_prob, "confidence": round(best_prob * 100.0, 1), } # Core markets using dedicated methods h, d, a = v25.predict_ms(feature_row) signal["MS"] = _enrich_signal_entry({"1": h, "X": d, "2": a}) print(f" [V25-SIGNAL] MS → H={h:.4f} D={d:.4f} A={a:.4f}") over25, under25 = v25.predict_ou25(feature_row) signal["OU25"] = _enrich_signal_entry({"Over": over25, "Under": under25}) print(f" [V25-SIGNAL] OU25 → O={over25:.4f} U={under25:.4f}") btts_y, btts_n = v25.predict_btts(feature_row) signal["BTTS"] = _enrich_signal_entry({"Yes": btts_y, "No": btts_n}) print(f" [V25-SIGNAL] BTTS → Y={btts_y:.4f} N={btts_n:.4f}") # Additional markets via generic predict_market for model_key, label_map in [ ("ou15", {"Over": 0, "Under": None}), ("ou35", {"Over": 0, "Under": None}), ("ht_result", {"1": 0, "X": 1, "2": 2}), ("ht_ou05", {"Over": 0, "Under": None}), ("ht_ou15", {"Over": 0, "Under": None}), ("htft", None), ("cards_ou45", {"Over": 0, "Under": None}), ("handicap_ms", {"1": 0, "X": 1, "2": 2}), ("odd_even", {"Odd": 0, "Even": None}), ]: out_key = self._V25_KEY_MAP.get(model_key, model_key.upper()) if not v25.has_market(model_key): continue raw = v25.predict_market(model_key, feature_row) if raw is None: continue if label_map is None: # HTFT — 9 combinations htft_labels = ["1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2"] probs_dict = {} for i, label in enumerate(htft_labels): probs_dict[label] = float(raw[i]) if i < len(raw) else 0.0 signal[out_key] = _enrich_signal_entry(probs_dict) elif len(label_map) == 2: # Binary market labels = list(label_map.keys()) p = float(raw[0]) if len(raw) >= 1 else None if p is None: print(f" [V25-SIGNAL] {out_key} → EMPTY raw output, skipped") continue signal[out_key] = _enrich_signal_entry({labels[0]: p, labels[1]: 1.0 - p}) elif len(label_map) == 3: # 3-class market labels = list(label_map.keys()) probs_dict = {} for i, label in enumerate(labels): if i >= len(raw): print(f" [V25-SIGNAL] {out_key} → insufficient probabilities in raw output") break probs_dict[label] = float(raw[i]) else: signal[out_key] = _enrich_signal_entry(probs_dict) if out_key in signal: print(f" [V25-SIGNAL] {out_key} → {signal[out_key]['probs']}") print(f" [V25-SIGNAL] Total markets with real predictions: {len(signal)}") if not signal: raise RuntimeError("V25 model produced ZERO market predictions — cannot continue") return signal @staticmethod def _prob_map(signal: Optional[Dict[str, Any]], market: str, defaults: Dict[str, float]) -> Dict[str, float]: """Extract normalised probabilities from signal. If the signal contains real model output for this market, use it. If the market is missing from the signal, log a warning and return the defaults as a LAST RESORT (so the pipeline doesn't crash). The defaults are ONLY used for non-core / secondary markets that may not have a trained model yet (e.g. CARDS, HCAP, OE). """ market_payload = signal.get(market, {}) if isinstance(signal, dict) else {} probs = market_payload.get("probs", {}) if isinstance(market_payload, dict) else {} if not isinstance(probs, dict) or not probs: print(f" ⚠️ [PROB_MAP] Market '{market}' NOT found in V25 signal — model output missing") return dict(defaults) out = {key: float(probs.get(key, value)) for key, value in defaults.items()} total = sum(out.values()) if total <= 0: print(f" ⚠️ [PROB_MAP] Market '{market}' has zero total probability") return dict(defaults) return {key: value / total for key, value in out.items()} @staticmethod def _best_prob_pick(prob_map: Dict[str, float]) -> Tuple[str, float]: pick = max(prob_map, key=prob_map.get) return pick, float(prob_map[pick]) @staticmethod def _poisson_score_top5(home_xg: float, away_xg: float, max_goals: int = 5) -> List[Dict[str, Any]]: def poisson_p(lmbda: float, k: int) -> float: return math.exp(-lmbda) * (lmbda ** k) / math.factorial(k) scores: List[Tuple[str, float]] = [] for home_goals in range(max_goals + 1): for away_goals in range(max_goals + 1): prob = poisson_p(home_xg, home_goals) * poisson_p(away_xg, away_goals) scores.append((f"{home_goals}-{away_goals}", prob)) scores.sort(key=lambda item: item[1], reverse=True) return [ {"score": score, "prob": round(prob, 4)} for score, prob in scores[:5] ] def _build_v25_prediction( self, data: MatchData, features: Dict[str, float], v25_signal: Dict[str, Any], ) -> FullMatchPrediction: prediction = FullMatchPrediction( match_id=data.match_id, home_team=data.home_team_name, away_team=data.away_team_name, ) ms_probs = self._prob_map(v25_signal, "MS", {"1": 0.33, "X": 0.34, "2": 0.33}) ou15_probs = self._prob_map(v25_signal, "OU15", {"Under": 0.5, "Over": 0.5}) ou25_probs = self._prob_map(v25_signal, "OU25", {"Under": 0.5, "Over": 0.5}) ou35_probs = self._prob_map(v25_signal, "OU35", {"Under": 0.5, "Over": 0.5}) btts_probs = self._prob_map(v25_signal, "BTTS", {"No": 0.5, "Yes": 0.5}) ht_probs = self._prob_map(v25_signal, "HT", {"1": 0.33, "X": 0.34, "2": 0.33}) ht_ou05_probs = self._prob_map(v25_signal, "HT_OU05", {"Under": 0.5, "Over": 0.5}) ht_ou15_probs = self._prob_map(v25_signal, "HT_OU15", {"Under": 0.5, "Over": 0.5}) htft_probs = self._prob_map( v25_signal, "HTFT", {"1/1": 1 / 9, "1/X": 1 / 9, "1/2": 1 / 9, "X/1": 1 / 9, "X/X": 1 / 9, "X/2": 1 / 9, "2/1": 1 / 9, "2/X": 1 / 9, "2/2": 1 / 9}, ) oe_probs = self._prob_map(v25_signal, "OE", {"Even": 0.5, "Odd": 0.5}) cards_probs = self._prob_map(v25_signal, "CARDS", {"Under": 0.5, "Over": 0.5}) hcap_probs = self._prob_map(v25_signal, "HCAP", {"1": 0.33, "X": 0.34, "2": 0.33}) prediction.ms_home_prob = ms_probs["1"] prediction.ms_draw_prob = ms_probs["X"] prediction.ms_away_prob = ms_probs["2"] prediction.ms_pick, ms_top = self._best_prob_pick(ms_probs) prediction.ms_confidence = ms_top * 100.0 prediction.dc_1x_prob = prediction.ms_home_prob + prediction.ms_draw_prob prediction.dc_x2_prob = prediction.ms_draw_prob + prediction.ms_away_prob prediction.dc_12_prob = prediction.ms_home_prob + prediction.ms_away_prob dc_probs = {"1X": prediction.dc_1x_prob, "X2": prediction.dc_x2_prob, "12": prediction.dc_12_prob} prediction.dc_pick, dc_top = self._best_prob_pick(dc_probs) prediction.dc_confidence = dc_top * 100.0 prediction.over_15_prob = ou15_probs["Over"] prediction.under_15_prob = ou15_probs["Under"] prediction.ou15_pick = "1.5 Üst" if prediction.over_15_prob >= prediction.under_15_prob else "1.5 Alt" prediction.ou15_confidence = max(prediction.over_15_prob, prediction.under_15_prob) * 100.0 prediction.over_25_prob = ou25_probs["Over"] prediction.under_25_prob = ou25_probs["Under"] prediction.ou25_pick = "2.5 Üst" if prediction.over_25_prob >= prediction.under_25_prob else "2.5 Alt" prediction.ou25_confidence = max(prediction.over_25_prob, prediction.under_25_prob) * 100.0 prediction.over_35_prob = ou35_probs["Over"] prediction.under_35_prob = ou35_probs["Under"] prediction.ou35_pick = "3.5 Üst" if prediction.over_35_prob >= prediction.under_35_prob else "3.5 Alt" prediction.ou35_confidence = max(prediction.over_35_prob, prediction.under_35_prob) * 100.0 prediction.btts_yes_prob = btts_probs["Yes"] prediction.btts_no_prob = btts_probs["No"] prediction.btts_pick = "KG Var" if prediction.btts_yes_prob >= prediction.btts_no_prob else "KG Yok" prediction.btts_confidence = max(prediction.btts_yes_prob, prediction.btts_no_prob) * 100.0 prediction.ht_home_prob = ht_probs["1"] prediction.ht_draw_prob = ht_probs["X"] prediction.ht_away_prob = ht_probs["2"] prediction.ht_pick, ht_top = self._best_prob_pick(ht_probs) prediction.ht_confidence = ht_top * 100.0 prediction.ht_over_05_prob = ht_ou05_probs["Over"] prediction.ht_under_05_prob = ht_ou05_probs["Under"] prediction.ht_ou_pick = "İY 0.5 Üst" if prediction.ht_over_05_prob >= prediction.ht_under_05_prob else "İY 0.5 Alt" prediction.ht_over_15_prob = ht_ou15_probs["Over"] prediction.ht_under_15_prob = ht_ou15_probs["Under"] prediction.ht_ou15_pick = "İY 1.5 Üst" if prediction.ht_over_15_prob >= prediction.ht_under_15_prob else "İY 1.5 Alt" prediction.ht_ft_probs = htft_probs prediction.odd_prob = oe_probs["Odd"] prediction.even_prob = oe_probs["Even"] prediction.odd_even_pick = "Tek" if prediction.odd_prob >= prediction.even_prob else "Çift" prediction.cards_over_prob = cards_probs["Over"] prediction.cards_under_prob = cards_probs["Under"] prediction.card_pick = "4.5 Üst" if prediction.cards_over_prob >= prediction.cards_under_prob else "4.5 Alt" prediction.cards_confidence = max(prediction.cards_over_prob, prediction.cards_under_prob) * 100.0 prediction.handicap_home_prob = hcap_probs["1"] prediction.handicap_draw_prob = hcap_probs["X"] prediction.handicap_away_prob = hcap_probs["2"] prediction.handicap_pick, hcap_top = self._best_prob_pick(hcap_probs) prediction.handicap_confidence = hcap_top * 100.0 # ── Score Prediction: Model-first, heuristic fallback ────────── score_result = self._predict_score_with_model(features) if score_result is not None: # ML model predicted scores prediction.home_xg = score_result["ft_home"] prediction.away_xg = score_result["ft_away"] prediction.total_xg = round(prediction.home_xg + prediction.away_xg, 2) ht_home_xg = score_result["ht_home"] ht_away_xg = score_result["ht_away"] prediction.predicted_ft_score = f"{int(round(prediction.home_xg))}-{int(round(prediction.away_xg))}" prediction.predicted_ht_score = f"{int(round(ht_home_xg))}-{int(round(ht_away_xg))}" else: # Heuristic fallback (original formula) base_home_xg = max(0.25, (float(data.home_goals_avg) + float(features.get("away_xga", data.away_conceded_avg))) / 2.0) base_away_xg = max(0.25, (float(data.away_goals_avg) + float(features.get("home_xga", data.home_conceded_avg))) / 2.0) ms_edge = prediction.ms_home_prob - prediction.ms_away_prob total_target = max( 1.4, min( 4.8, (float(features.get("league_avg_goals", 2.7)) * 0.55) + ((float(data.home_goals_avg) + float(data.away_goals_avg)) * 0.45) + ((prediction.over_25_prob - prediction.under_25_prob) * 1.15), ), ) home_xg = max(0.2, base_home_xg + (ms_edge * 0.55) + ((prediction.btts_yes_prob - 0.5) * 0.18)) away_xg = max(0.2, base_away_xg - (ms_edge * 0.55) + ((prediction.btts_yes_prob - 0.5) * 0.18)) scale = total_target / max(home_xg + away_xg, 0.1) prediction.home_xg = round(home_xg * scale, 2) prediction.away_xg = round(away_xg * scale, 2) prediction.total_xg = round(prediction.home_xg + prediction.away_xg, 2) prediction.predicted_ft_score = f"{int(round(prediction.home_xg))}-{int(round(prediction.away_xg))}" prediction.predicted_ht_score = f"{int(round(prediction.home_xg * 0.45))}-{int(round(prediction.away_xg * 0.45))}" prediction.ft_scores_top5 = self._poisson_score_top5(prediction.home_xg, prediction.away_xg) max_market_conf = max( prediction.ms_confidence, prediction.ou15_confidence, prediction.ou25_confidence, prediction.ou35_confidence, prediction.btts_confidence, prediction.ht_confidence, prediction.cards_confidence, prediction.handicap_confidence, ) lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0))) lineup_penalty = 12.0 if data.lineup_source == "none" else max(1.5, (1.0 - lineup_conf) * 8.0) if data.lineup_source == "probable_xi" else 0.0 referee_penalty = 6.0 if not data.referee_name else 0.0 parity_penalty = 8.0 if abs(ms_edge) < 0.08 else 0.0 prediction.risk_score = round(min(100.0, max(10.0, 100.0 - max_market_conf + lineup_penalty + referee_penalty + parity_penalty)), 1) if prediction.risk_score >= 78: prediction.risk_level = "EXTREME" elif prediction.risk_score >= 62: prediction.risk_level = "HIGH" elif prediction.risk_score >= 40: prediction.risk_level = "MEDIUM" else: prediction.risk_level = "LOW" prediction.is_surprise_risk = prediction.risk_level in {"HIGH", "EXTREME"} or prediction.ms_draw_prob >= 0.30 prediction.surprise_type = "balanced_match_risk" if abs(ms_edge) < 0.08 else "draw_pressure" if prediction.ms_draw_prob >= 0.30 else "" prediction.risk_warnings = [] if data.lineup_source == "probable_xi": prediction.risk_warnings.append("lineup_probable_not_confirmed") if lineup_conf < 0.65: prediction.risk_warnings.append("lineup_projection_low_confidence") if data.lineup_source == "none": prediction.risk_warnings.append("lineup_unavailable") if not data.referee_name: prediction.risk_warnings.append("missing_referee") if prediction.ms_draw_prob >= 0.30: prediction.risk_warnings.append("draw_probability_elevated") prediction.upset_score = int(round(max(0.0, min(100.0, (prediction.ms_draw_prob + min(prediction.ms_home_prob, prediction.ms_away_prob)) * 100.0)))) prediction.upset_level = "HIGH" if prediction.upset_score >= 65 else "MEDIUM" if prediction.upset_score >= 45 else "LOW" prediction.upset_reasons = [prediction.surprise_type] if prediction.surprise_type else [] surprise = self._build_surprise_profile(data, prediction) prediction.surprise_score = surprise["score"] prediction.surprise_comment = surprise["comment"] prediction.surprise_reasons = surprise["reasons"] prediction.team_confidence = round(max(35.0, min(95.0, 45.0 + (abs(ms_edge) * 85.0) + (abs(float(features.get("form_elo_diff", 0.0))) / 40.0))), 1) prediction.player_confidence = round(max(20.0, min(95.0, 38.0 + (float(features.get("home_key_players", 0.0)) + float(features.get("away_key_players", 0.0))) * 2.0 - (float(features.get("home_missing_impact", 0.0)) + float(features.get("away_missing_impact", 0.0))) * 22.0)), 1) prediction.odds_confidence = round(max(30.0, min(95.0, np.mean([prediction.ms_confidence, prediction.ou25_confidence, prediction.btts_confidence]))), 1) prediction.referee_confidence = 62.0 if data.referee_name else 35.0 prediction.total_cards_pred = 4.8 if prediction.cards_over_prob >= prediction.cards_under_prob else 4.1 prediction.total_corners_pred = round(8.8 + (prediction.over_25_prob - 0.5) * 2.5, 1) prediction.corner_pick = "9.5 Üst" if prediction.total_corners_pred >= 9.5 else "9.5 Alt" prediction.analysis_details = { "primary_model": "v25", "features_source": "v25.pre_match", "market_count": len([key for key in v25_signal.keys() if key != "value_bets"]), "lineup_source": data.lineup_source, } return prediction def _get_basketball_predictor(self) -> Any: if self.basketball_predictor is None: self.basketball_predictor = get_basketball_v25_predictor() return self.basketball_predictor def analyze_match(self, match_id: str) -> Optional[Dict[str, Any]]: data = self._load_match_data(match_id) if data is None: return None # ── Pre-Match Simulation Mode ──────────────────────────── # Force all matches (live and finished) into pre-match state so the # engine purely predicts based on pre-match odds and context, ignoring # current live scores and preventing live state penalties. _status_upper = str(data.status or "").upper() if _status_upper not in {"NS", "POSTPONED", "CANC", "ABD"}: data.status = "NS" data.state = "preGame" 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( match_id=data.match_id, home_team_id=data.home_team_id, away_team_id=data.away_team_id, home_team_name=data.home_team_name, away_team_name=data.away_team_name, match_date_ms=data.match_date_ms, league_id=data.league_id, league_name=data.league_name, odds_data=data.odds_data, sidelined_data=data.sidelined_data, ) return self._build_basketball_prediction_package(data, prediction) features = self._build_v25_features(data) v25_signal = self._get_v25_signal(data, features) 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) market_board = base_package.get("market_board") if isinstance(market_board, dict) and isinstance(market_board.get("MS"), dict): market_board["MS"]["confidence"] = round(float(prediction.ms_confidence), 1) 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 base_package = self._apply_upper_brain_guards(base_package) mode = str(getattr(self, "engine_mode", "v28-pro-max") or "v28-pro-max").lower() if mode not in {"v25", "v26", "dual", "v28", "v28-pro-max"}: mode = "v25" quality = base_package.get("data_quality", self._compute_data_quality(data)) shadow_package = self._get_v26_shadow_engine().build_package( data=data, prediction=prediction, v25_signal=v25_signal, quality=quality, ) if mode == "v26": return shadow_package if mode == "dual": merged = dict(base_package) merged.update( { "shadow_engine": shadow_package, "shadow_engine_version": shadow_package.get("model_version"), "calibration_version": shadow_package.get("calibration_version"), "decision_trace_id": shadow_package.get("decision_trace_id"), "market_reliability": shadow_package.get("market_reliability", {}), } ) return merged return base_package def _apply_upper_brain_guards(self, package: Dict[str, Any]) -> Dict[str, Any]: return BettingBrain().judge(package) v27_engine = package.get("v27_engine") if not isinstance(v27_engine, dict) or not v27_engine.get("triple_value"): return package guarded = dict(package) vetoed_keys = set() guarded_keys = set() def mark_guard(item: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(item, dict): return item out = dict(item) assessment = self._upper_brain_assessment(out, guarded) if not assessment.get("applies"): return out key = f"{out.get('market')}:{out.get('pick')}" guarded_keys.add(key) out["upper_brain"] = assessment reason_key = "decision_reasons" if "decision_reasons" in out else "reasons" reasons = list(out.get(reason_key) or []) for reason in assessment.get("reason_codes", []): if reason not in reasons: reasons.append(reason) out[reason_key] = reasons[:6] if assessment.get("veto"): vetoed_keys.add(key) out["playable"] = False out["stake_units"] = 0.0 out["bet_grade"] = "PASS" out["is_guaranteed"] = False out["pick_reason"] = "upper_brain_veto" if "signal_tier" in out: out["signal_tier"] = "PASS" elif assessment.get("downgrade"): out["is_guaranteed"] = False if out.get("signal_tier") == "CORE": out["signal_tier"] = "LEAN" if out.get("pick_reason") == "high_accuracy_market": out["pick_reason"] = "upper_brain_downgraded" return out main_pick = mark_guard(guarded.get("main_pick") or {}) value_pick = mark_guard(guarded.get("value_pick") or {}) if guarded.get("value_pick") else None supporting = [ mark_guard(row) for row in list(guarded.get("supporting_picks") or []) if isinstance(row, dict) ] bet_summary = [ mark_guard(row) for row in list(guarded.get("bet_summary") or []) if isinstance(row, dict) ] main_safe = bool(main_pick and main_pick.get("playable") and not main_pick.get("upper_brain", {}).get("veto")) if not main_safe: candidates = [ row for row in supporting if row.get("playable") and not row.get("upper_brain", {}).get("veto") and float(row.get("odds", 0.0) or 0.0) >= 1.30 ] candidates.sort(key=lambda row: float(row.get("play_score", 0.0) or 0.0), reverse=True) if candidates: main_pick = dict(candidates[0]) main_pick["is_guaranteed"] = False main_pick["pick_reason"] = "upper_brain_reselected" reasons = list(main_pick.get("decision_reasons") or []) if "upper_brain_reselected_after_veto" not in reasons: reasons.append("upper_brain_reselected_after_veto") main_pick["decision_reasons"] = reasons[:6] elif main_pick: main_pick["is_guaranteed"] = False main_pick["pick_reason"] = "upper_brain_no_safe_pick" if main_pick: supporting = [ row for row in supporting if not ( row.get("market") == main_pick.get("market") and row.get("pick") == main_pick.get("pick") ) ][:6] guarded["main_pick"] = main_pick if main_pick else None guarded["value_pick"] = value_pick guarded["supporting_picks"] = supporting guarded["bet_summary"] = bet_summary playable = bool(main_pick and main_pick.get("playable") and not main_pick.get("upper_brain", {}).get("veto")) advice = dict(guarded.get("bet_advice") or {}) advice["playable"] = playable advice["suggested_stake_units"] = float(main_pick.get("stake_units", 0.0)) if playable else 0.0 if playable: advice["reason"] = "playable_pick_found" elif vetoed_keys: advice["reason"] = "upper_brain_no_safe_pick" else: advice["reason"] = "no_bet_conditions_met" guarded["bet_advice"] = advice guarded["upper_brain"] = { "applied": True, "guarded_count": len(guarded_keys), "vetoed_count": len(vetoed_keys), "vetoed": sorted(vetoed_keys)[:8], "rules": { "min_band_sample": 8, "max_v25_v27_divergence": 0.18, "dc_requires_triple_value": True, }, } guarded.setdefault("analysis_details", {}) guarded["analysis_details"]["upper_brain_guards_applied"] = True guarded["analysis_details"]["upper_brain_vetoed_count"] = len(vetoed_keys) return guarded def _upper_brain_assessment( self, item: Dict[str, Any], package: Dict[str, Any], ) -> Dict[str, Any]: market = str(item.get("market") or "") pick = str(item.get("pick") or "") if not market or not pick: return {"applies": False} v27_engine = package.get("v27_engine") or {} triple_value = v27_engine.get("triple_value") or {} model_prob = self._upper_brain_market_probability(item, package) v27_prob = self._upper_brain_v27_probability(market, pick, v27_engine) triple_key = self._upper_brain_triple_key(market, pick) triple = triple_value.get(triple_key) if triple_key else None veto = False downgrade = False reasons: List[str] = [] divergence = None if model_prob is not None and v27_prob is not None: divergence = abs(float(model_prob) - float(v27_prob)) if divergence >= 0.18: veto = True reasons.append("upper_brain_v25_v27_divergence") elif divergence >= 0.12: downgrade = True reasons.append("upper_brain_v25_v27_warning") if isinstance(triple, dict): band_sample = int(float(triple.get("band_sample", 0) or 0)) is_value = bool(triple.get("is_value")) if market == "DC": if band_sample < 8: veto = True reasons.append("upper_brain_band_sample_too_low") elif not is_value: veto = True reasons.append("upper_brain_triple_value_rejected") elif market in {"MS", "OU25"} and band_sample > 0 and band_sample < 8: downgrade = True reasons.append("upper_brain_band_sample_thin") elif market in {"OU15", "HT_OU05"} and band_sample < 8: downgrade = True reasons.append("upper_brain_band_sample_thin") consensus = str(v27_engine.get("consensus") or "").upper() if consensus == "DISAGREE" and market in {"MS", "DC"} and not veto: downgrade = True reasons.append("upper_brain_consensus_disagree") applies = bool(reasons or triple is not None or v27_prob is not None) return { "applies": applies, "veto": veto, "downgrade": downgrade, "reason_codes": reasons, "model_prob": round(float(model_prob), 4) if model_prob is not None else None, "v27_prob": round(float(v27_prob), 4) if v27_prob is not None else None, "divergence": round(float(divergence), 4) if divergence is not None else None, "triple_key": triple_key, "triple_value": triple, } def _upper_brain_market_probability( self, item: Dict[str, Any], package: Dict[str, Any], ) -> Optional[float]: raw_prob = item.get("probability") if raw_prob is not None: try: return float(raw_prob) except (TypeError, ValueError): pass market = str(item.get("market") or "") pick = str(item.get("pick") or "") board = package.get("market_board") or {} payload = board.get(market) if isinstance(board, dict) else None probs = payload.get("probs") if isinstance(payload, dict) else None if not isinstance(probs, dict): return None prob_key = self._upper_brain_prob_key(market, pick) if prob_key is None: return None try: return float(probs.get(prob_key)) except (TypeError, ValueError): return None def _upper_brain_v27_probability( self, market: str, pick: str, v27_engine: Dict[str, Any], ) -> Optional[float]: predictions = v27_engine.get("predictions") or {} ms = predictions.get("ms") or {} ou25 = predictions.get("ou25") or {} if market == "MS": return self._safe_float(ms.get({"1": "home", "X": "draw", "2": "away"}.get(pick, ""))) if market == "DC": if pick == "1X": return self._safe_float(ms.get("home"), 0.0) + self._safe_float(ms.get("draw"), 0.0) if pick == "X2": return self._safe_float(ms.get("draw"), 0.0) + self._safe_float(ms.get("away"), 0.0) if pick == "12": return self._safe_float(ms.get("home"), 0.0) + self._safe_float(ms.get("away"), 0.0) if market == "OU25": prob_key = self._upper_brain_prob_key(market, pick) return self._safe_float(ou25.get(prob_key)) if prob_key else None return None @staticmethod def _upper_brain_prob_key(market: str, pick: str) -> Optional[str]: pick_norm = str(pick or "").strip().casefold() if market in {"MS", "HT", "HCAP"}: return pick if pick in {"1", "X", "2"} else None if market == "DC": return pick.upper() if pick.upper() in {"1X", "X2", "12"} else None if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}: if "over" in pick_norm or "st" in pick_norm: return "over" if "under" in pick_norm or "alt" in pick_norm: return "under" if market == "BTTS": if "yes" in pick_norm or "var" in pick_norm: return "yes" if "no" in pick_norm or "yok" in pick_norm: return "no" if market == "OE": if "odd" in pick_norm or "tek" in pick_norm: return "odd" if "even" in pick_norm or "ift" in pick_norm: return "even" if market == "HTFT" and "/" in pick: return pick return None def _upper_brain_triple_key(self, market: str, pick: str) -> Optional[str]: prob_key = self._upper_brain_prob_key(market, pick) if market == "MS": return {"1": "home", "2": "away"}.get(pick) if market == "DC": return f"dc_{pick.lower()}" if pick.upper() in {"1X", "X2", "12"} else None if market in {"OU15", "OU25", "OU35"} and prob_key == "over": return f"{market.lower()}_over" if market == "BTTS" and prob_key == "yes": return "btts_yes" if market == "HT": return {"1": "ht_home", "2": "ht_away"}.get(pick) if market in {"HT_OU05", "HT_OU15"} and prob_key == "over": return f"{market.lower()}_over" if market == "OE" and prob_key == "odd": return "oe_odd" if market == "CARDS" and prob_key == "over": return "cards_over" if market == "HTFT" and "/" in pick: return f"htft_{pick.replace('/', '').lower()}" return None @staticmethod def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]: try: return float(value) except (TypeError, ValueError): return default def analyze_match_htms(self, match_id: str) -> Optional[Dict[str, Any]]: """ HT/MS focused response for upset-hunting workflows. This endpoint is intentionally additive and does not mutate the standard /v20plus/analyze package contract. """ data = self._load_match_data(match_id) if data is None: return None if str(data.sport or "").lower() != "football": return { "status": "skip", "match_id": match_id, "reason": "unsupported_sport", "engine_used": "htms_router", } is_top_league = self._is_top_league(data.league_id) engine_used = "v20plus_top_htms" # Hard gate: HT/MS upset model is trained on top leagues only. if not is_top_league: return { "status": "skip", "match_id": match_id, "reason": "out_of_training_scope", "engine_used": engine_used, "data_quality": { "label": "LOW", "flags": ["league_out_of_scope"], }, } missing_requirements = self._missing_htms_requirements(data) if missing_requirements: return { "status": "skip", "match_id": match_id, "reason": "missing_critical_data", "missing": missing_requirements, "engine_used": engine_used, "data_quality": { "label": "LOW", "flags": [f"missing_{item}" for item in missing_requirements], }, } base_package = self.analyze_match(match_id) if not base_package: return None data_quality = base_package.get("data_quality", {}) market_board = base_package.get("market_board", {}) ms_market = market_board.get("MS", {}) ht_market = market_board.get("HT", {}) htft_probs = market_board.get("HTFT", {}).get("probs", {}) reversal_probs = { "1/2": float(htft_probs.get("1/2", 0.0)), "2/1": float(htft_probs.get("2/1", 0.0)), "X/1": float(htft_probs.get("X/1", 0.0)), "X/2": float(htft_probs.get("X/2", 0.0)), } top_reversal = max(reversal_probs.items(), key=lambda item: item[1]) ms_conf = float(ms_market.get("confidence", 0.0)) ht_conf = float(ht_market.get("confidence", 0.0)) base_conf = (ms_conf + ht_conf) / 2.0 confidence_cap = 100.0 penalties: List[str] = [] if data.lineup_source == "probable_xi": confidence_cap = min(confidence_cap, 72.0) penalties.append("lineup_probable_xi") if data.lineup_source == "none": confidence_cap = min(confidence_cap, 58.0) penalties.append("lineup_unavailable") if str(data_quality.get("label", "LOW")).upper() == "LOW": confidence_cap = min(confidence_cap, 55.0) penalties.append("low_data_quality") final_conf = min(base_conf, confidence_cap) upset_score = self._compute_htms_upset_score( reversal_probs=reversal_probs, odds_data=data.odds_data, is_top_league=is_top_league, ) upset_threshold = 58.0 if is_top_league else 54.0 upset_playable = ( upset_score >= upset_threshold and top_reversal[1] >= 0.045 and final_conf >= 45.0 and "low_data_quality" not in penalties ) return { "status": "ok", "engine_used": engine_used, "match_info": base_package.get("match_info", {}), "data_quality": data_quality, "htms_core": { "ms_pick": ms_market.get("pick"), "ms_confidence": round(ms_conf, 1), "ht_pick": ht_market.get("pick"), "ht_confidence": round(ht_conf, 1), "combined_confidence": round(final_conf, 1), "confidence_cap": round(confidence_cap, 1), "penalties": penalties, }, "surprise_hunter": { "upset_score": round(upset_score, 1), "threshold": upset_threshold, "playable": upset_playable, "top_reversal_pick": top_reversal[0], "top_reversal_prob": round(top_reversal[1], 4), "reversal_probs": { key: round(value, 4) for key, value in reversal_probs.items() }, }, "risk": base_package.get("risk", {}), "reasoning_factors": base_package.get("reasoning_factors", []), } def _is_top_league(self, league_id: Optional[str]) -> bool: if not league_id: return False return str(league_id) in self.top_league_ids def _missing_htms_requirements(self, data: MatchData) -> List[str]: missing: List[str] = [] ms_keys = ("ms_h", "ms_d", "ms_a") ht_keys = ("ht_h", "ht_d", "ht_a") if not all(float(data.odds_data.get(k, 0.0) or 0.0) > 1.0 for k in ms_keys): missing.append("ms_odds") if not all(float(data.odds_data.get(k, 0.0) or 0.0) > 1.0 for k in ht_keys): missing.append("ht_odds") return missing def _compute_htms_upset_score( self, reversal_probs: Dict[str, float], odds_data: Dict[str, float], is_top_league: bool, ) -> float: ms_h = self._to_float(odds_data.get("ms_h"), 0.0) ms_a = self._to_float(odds_data.get("ms_a"), 0.0) if ms_h <= 1.0 or ms_a <= 1.0: favorite_gap = 0.0 else: favorite_gap = abs(ms_h - ms_a) reversal_max = max(reversal_probs.values()) if reversal_probs else 0.0 reversal_sum = sum(reversal_probs.values()) # Strong favorite + reversal probability is the core upset signal. gap_factor = min(1.0, favorite_gap / 2.0) score = ( (reversal_max * 100.0 * 0.60) + (reversal_sum * 100.0 * 0.25) + (gap_factor * 100.0 * 0.15) ) if not is_top_league: # Non-top leagues are noisier; keep it slightly conservative. score *= 0.92 return max(0.0, min(100.0, score)) def build_coupon( self, match_ids: List[str], strategy: str = "BALANCED", max_matches: Optional[int] = None, min_confidence: Optional[float] = None, ) -> Dict[str, Any]: strategy_name = (strategy or "BALANCED").upper() strategy_config = { "SAFE": {"max_matches": 4, "min_conf": 66.0}, "BALANCED": {"max_matches": 5, "min_conf": 58.0}, "AGGRESSIVE": {"max_matches": 8, "min_conf": 52.0}, "VALUE": {"max_matches": 8, "min_conf": 48.0}, "MIRACLE": {"max_matches": 10, "min_conf": 44.0}, } cfg = strategy_config.get(strategy_name, strategy_config["BALANCED"]) max_allowed = max_matches if max_matches is not None else cfg["max_matches"] min_conf = min_confidence if min_confidence is not None else cfg["min_conf"] candidates: List[Dict[str, Any]] = [] rejected: List[Dict[str, Any]] = [] for match_id in match_ids: package = self.analyze_match(match_id) if not package: rejected.append({"match_id": match_id, "reason": "match_not_found"}) continue risk_level = str(package.get("risk", {}).get("level", "MEDIUM")).upper() data_quality = str(package.get("data_quality", {}).get("label", "MEDIUM")).upper() match_candidates: List[Dict[str, Any]] = [] seen_keys: Set[Tuple[str, str]] = set() bet_summary = package.get("bet_summary") or [] raw_picks = [] for candidate in [ package.get("main_pick"), package.get("value_pick"), *(package.get("supporting_picks") or []), ]: if isinstance(candidate, dict): raw_picks.append(candidate) for candidate in bet_summary: if isinstance(candidate, dict): raw_picks.append(candidate) for candidate in raw_picks: market = str(candidate.get("market") or "") pick = str(candidate.get("pick") or "") if not market or not pick: continue dedupe_key = (market, pick) if dedupe_key in seen_keys: continue seen_keys.add(dedupe_key) calibrated_conf = float( candidate.get("calibrated_confidence", candidate.get("confidence", 0.0)) or 0.0 ) odds = float(candidate.get("odds", 0.0) or 0.0) probability = float(candidate.get("probability", 0.0) or 0.0) play_score = float(candidate.get("play_score", 0.0) or 0.0) ev_edge = float( candidate.get("ev_edge", candidate.get("edge", 0.0)) or 0.0 ) playable = bool(candidate.get("playable")) bet_grade = str(candidate.get("bet_grade", "PASS")).upper() if odds <= 1.01: continue strict_candidate = ( playable and calibrated_conf >= min_conf and bet_grade != "PASS" ) if strategy_name == "SAFE": strict_pass = strict_candidate if odds > 2.35 or play_score < 60.0 or risk_level in {"HIGH", "EXTREME"}: strict_pass = False if data_quality == "LOW" or ev_edge < 0.01 or bet_grade == "PASS": strict_pass = False strict_score = ( calibrated_conf * 1.10 + play_score * 0.90 + (ev_edge * 180.0) - abs(odds - 1.55) * 12.0 ) soft_pass = ( calibrated_conf >= max(min_conf - 10.0, 56.0) and odds <= 2.70 and play_score >= 50.0 and risk_level != "EXTREME" and data_quality != "LOW" and ev_edge >= -0.01 ) soft_score = ( calibrated_conf + play_score * 0.85 + (ev_edge * 140.0) - abs(odds - 1.65) * 9.0 ) elif strategy_name == "BALANCED": strict_pass = strict_candidate if odds > 3.40 or play_score < 52.0 or risk_level == "EXTREME": strict_pass = False if ev_edge < 0.0 or bet_grade == "PASS": strict_pass = False strict_score = ( calibrated_conf + play_score + (ev_edge * 220.0) + min(odds, 3.0) * 3.0 ) soft_pass = ( calibrated_conf >= max(min_conf - 10.0, 48.0) and odds <= 4.20 and play_score >= 44.0 and risk_level != "EXTREME" and ev_edge >= -0.015 ) soft_score = ( calibrated_conf * 0.95 + play_score * 0.90 + (ev_edge * 180.0) + min(odds, 3.5) * 3.5 ) elif strategy_name == "AGGRESSIVE": strict_pass = strict_candidate if odds < 1.35 or odds > 7.50 or play_score < 46.0: strict_pass = False if risk_level == "EXTREME" or bet_grade == "PASS": strict_pass = False strict_score = ( calibrated_conf * 0.85 + play_score * 0.75 + (ev_edge * 260.0) + min(odds, 6.0) * 7.0 ) soft_pass = ( calibrated_conf >= max(min_conf - 10.0, 42.0) and 1.25 <= odds <= 8.50 and play_score >= 40.0 and risk_level != "EXTREME" and ev_edge >= -0.02 ) soft_score = ( calibrated_conf * 0.80 + play_score * 0.70 + (ev_edge * 210.0) + min(odds, 7.0) * 7.5 ) elif strategy_name == "VALUE": strict_pass = strict_candidate if odds < 1.55 or play_score < 48.0 or ev_edge < 0.03: strict_pass = False if risk_level == "EXTREME" or data_quality == "LOW" or bet_grade == "PASS": strict_pass = False strict_score = ( calibrated_conf * 0.75 + play_score * 0.85 + (ev_edge * 320.0) + min(odds, 6.5) * 8.0 ) soft_pass = ( calibrated_conf >= max(min_conf - 10.0, 40.0) and odds >= 1.35 and play_score >= 40.0 and risk_level != "EXTREME" and data_quality != "LOW" and ev_edge >= 0.0 ) soft_score = ( calibrated_conf * 0.70 + play_score * 0.80 + (ev_edge * 260.0) + min(odds, 7.0) * 7.0 ) else: # MIRACLE strict_pass = strict_candidate if odds < 2.10 or play_score < 40.0 or ev_edge < 0.01: strict_pass = False if risk_level == "EXTREME" or bet_grade == "PASS": strict_pass = False strict_score = ( calibrated_conf * 0.55 + play_score * 0.60 + (ev_edge * 260.0) + min(odds, 10.0) * 10.0 ) soft_pass = ( calibrated_conf >= max(min_conf - 10.0, 36.0) and odds >= 1.60 and play_score >= 34.0 and risk_level != "EXTREME" and ev_edge >= -0.02 ) soft_score = ( calibrated_conf * 0.50 + play_score * 0.55 + (ev_edge * 200.0) + min(odds, 10.0) * 9.0 ) fallback_pass = ( calibrated_conf >= max(min_conf - 14.0, 34.0) and odds >= 1.20 and play_score >= 32.0 and risk_level != "EXTREME" ) fallback_score = ( calibrated_conf * 0.60 + play_score * 0.65 + (ev_edge * 120.0) + min(odds, 6.0) * 4.0 ) strategy_score = strict_score selection_mode = "strict" if strict_pass: pass elif soft_pass: strategy_score = soft_score selection_mode = "soft" elif fallback_pass: strategy_score = fallback_score selection_mode = "fallback" else: continue match_candidates.append( { "match_id": package["match_info"]["match_id"], "match_name": package["match_info"]["match_name"], "market": market, "pick": pick, "probability": probability, "confidence": calibrated_conf, "odds": odds, "risk_level": risk_level, "data_quality": data_quality, "bet_grade": bet_grade, "playable": playable, "play_score": round(play_score, 1), "ev_edge": round(ev_edge, 4), "selection_mode": selection_mode, "strategy_score": round(strategy_score, 3), } ) if not match_candidates: rejected.append( { "match_id": match_id, "reason": "no_strategy_fit", "threshold": min_conf, } ) continue match_candidates.sort( key=lambda item: ( float(item.get("strategy_score", 0.0)), float(item.get("confidence", 0.0)), float(item.get("ev_edge", 0.0)), ), reverse=True, ) candidates.append(match_candidates[0]) candidates.sort( key=lambda item: ( float(item.get("strategy_score", 0.0)), float(item.get("confidence", 0.0)), float(item.get("ev_edge", 0.0)), ), reverse=True, ) selected = candidates[: max(1, max_allowed)] total_odds = 1.0 win_probability = 1.0 for pick in selected: odd = float(pick.get("odds") or 1.0) prob = float(pick.get("probability") or 0.0) total_odds *= odd if odd > 1.0 else 1.0 win_probability *= prob return { "strategy": strategy_name, "generated_at": __import__("datetime").datetime.utcnow().isoformat() + "Z", "match_count": len(selected), "bets": selected, "total_odds": round(total_odds, 2), "expected_win_rate": round(win_probability, 4), "rejected_matches": rejected, } def get_daily_bankers(self, count: int = 3) -> List[Dict[str, Any]]: with psycopg2.connect(self.dsn) as conn: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT id FROM live_matches WHERE mst_utc > EXTRACT(EPOCH FROM NOW()) * 1000 AND mst_utc < EXTRACT(EPOCH FROM NOW() + INTERVAL '24 hours') * 1000 ORDER BY mst_utc ASC LIMIT 60 """, ) ids = [row["id"] for row in cur.fetchall()] if not ids: return [] coupon = self.build_coupon( match_ids=ids, strategy="SAFE", max_matches=max(1, count), min_confidence=78.0, ) return coupon.get("bets", [])[: max(1, count)] def get_reversal_watchlist( self, count: int = 20, horizon_hours: int = 72, min_score: float = 45.0, top_leagues_only: bool = False, ) -> Dict[str, Any]: safe_count = max(1, min(100, int(count))) safe_horizon = max(6, min(168, int(horizon_hours))) safe_min_score = max(0.0, min(100.0, float(min_score))) now_ms = int(time.time() * 1000) horizon_ms = now_ms + (safe_horizon * 60 * 60 * 1000) with psycopg2.connect(self.dsn) as conn: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT lm.id, lm.home_team_id, lm.away_team_id, lm.league_id, lm.mst_utc FROM live_matches lm WHERE lm.sport = 'football' AND lm.mst_utc >= %s AND lm.mst_utc <= %s ORDER BY lm.mst_utc ASC LIMIT 200 """, (now_ms, horizon_ms), ) raw_candidates = cur.fetchall() candidates = [ row for row in raw_candidates if row.get("home_team_id") and row.get("away_team_id") ] if top_leagues_only: candidates = [ row for row in candidates if self._is_top_league(row.get("league_id")) ] team_ids: Set[str] = set() pair_keys: Set[Tuple[str, str]] = set() for row in candidates: home_id = str(row["home_team_id"]) away_id = str(row["away_team_id"]) team_ids.add(home_id) team_ids.add(away_id) pair_keys.add(tuple(sorted((home_id, away_id)))) team_cycle = self._fetch_team_reversal_cycle_metrics(cur, team_ids, now_ms) h2h_ctx = self._fetch_h2h_reversal_context(cur, pair_keys, now_ms) watch_items_all: List[Dict[str, Any]] = [] scanned = 0 for row in candidates: match_id = str(row["id"]) data = self._load_match_data(match_id) if data is None: continue package = self.analyze_match(match_id) if not package: continue scanned += 1 htft_probs = package.get("market_board", {}).get("HTFT", {}).get("probs", {}) prob_12 = float(htft_probs.get("1/2", 0.0)) prob_21 = float(htft_probs.get("2/1", 0.0)) if prob_12 <= 0.0 and prob_21 <= 0.0: continue overall_htft_pick = None overall_htft_prob = 0.0 if htft_probs: overall_htft_pick, overall_htft_prob = max( htft_probs.items(), key=lambda item: float(item[1]), ) reversal_sum = prob_12 + prob_21 reversal_max = max(prob_12, prob_21) top_pick = "2/1" if prob_21 >= prob_12 else "1/2" top_prob = prob_21 if top_pick == "2/1" else prob_12 ms_h = self._to_float(data.odds_data.get("ms_h"), 0.0) ms_a = self._to_float(data.odds_data.get("ms_a"), 0.0) gap = abs(ms_h - ms_a) if ms_h > 1.0 and ms_a > 1.0 else 0.0 favorite_odd = min(ms_h, ms_a) if ms_h > 1.0 and ms_a > 1.0 else 0.0 # Reversal events are rare (~5% baseline), so convert raw probs to a more useful # watchlist scale where p in [0.02, 0.08] becomes meaningfully separable. base_score = (reversal_max * 100.0 * 8.0) + (reversal_sum * 100.0 * 4.0) balance_bonus = 0.0 if gap > 0.0: balance_bonus = max(0.0, (1.0 - min(gap, 1.2) / 1.2) * 7.0) elif ms_h > 1.0 and ms_a > 1.0: balance_bonus = 2.0 favorite_bonus = 0.0 if favorite_odd > 0.0 and favorite_odd <= 1.70 and reversal_max >= 0.02: favorite_bonus = min(8.0, (1.70 - favorite_odd) * 12.0) home_metrics = team_cycle.get(data.home_team_id, {}) away_metrics = team_cycle.get(data.away_team_id, {}) cycle_pressure = max( float(home_metrics.get("cycle_pressure", 0.0)), float(away_metrics.get("cycle_pressure", 0.0)), ) cycle_bonus = cycle_pressure * 10.0 pair_key = tuple(sorted((data.home_team_id, data.away_team_id))) pair_ctx = h2h_ctx.get(pair_key, {}) blowout_bonus = 0.0 last_diff = int(pair_ctx.get("goal_diff", 0)) if abs(last_diff) >= 3: blowout_bonus = 6.0 if abs(last_diff) >= 5: blowout_bonus += 3.0 ou25_o = self._to_float(data.odds_data.get("ou25_o"), 0.0) tempo_bonus = 0.0 if ou25_o > 1.0 and ou25_o <= 1.72: tempo_bonus = 2.5 watch_score = max( 0.0, min( 100.0, base_score + balance_bonus + favorite_bonus + cycle_bonus + blowout_bonus + tempo_bonus, ), ) reason_codes: List[str] = [] if top_prob >= 0.045: reason_codes.append("reversal_prob_hot") elif top_prob >= 0.030: reason_codes.append("reversal_prob_warm") if gap > 0.0 and gap <= 0.80: reason_codes.append("balanced_matchup") if favorite_bonus > 0.0: reason_codes.append("strong_favorite_reversal_window") if cycle_pressure >= 0.55: reason_codes.append("team_reversal_cycle_pressure") if blowout_bonus > 0.0: reason_codes.append("h2h_blowout_rematch") if tempo_bonus > 0.0: reason_codes.append("high_tempo_profile") if not reason_codes: reason_codes.append("model_signal_only") item = ( { "match_id": data.match_id, "match_name": f"{data.home_team_name} vs {data.away_team_name}", "match_date_ms": data.match_date_ms, "league_id": data.league_id, "league": data.league_name, "risk_band": self._watchlist_risk_band(watch_score), "watch_score": round(watch_score, 2), "top_pick": top_pick, "top_pick_prob": round(top_prob, 4), "top_pick_scope": "reversal_only", "overall_htft_pick": overall_htft_pick, "overall_htft_pick_prob": round(float(overall_htft_prob), 4), "reversal_probs": { "1/2": round(prob_12, 4), "2/1": round(prob_21, 4), }, "odds_snapshot": { "ms_h": round(ms_h, 2) if ms_h > 0 else None, "ms_a": round(ms_a, 2) if ms_a > 0 else None, "ms_gap": round(gap, 3), "favorite_odd": round(favorite_odd, 2) if favorite_odd > 0 else None, }, "pattern_signals": { "home_cycle_pressure": round(float(home_metrics.get("cycle_pressure", 0.0)), 3), "away_cycle_pressure": round(float(away_metrics.get("cycle_pressure", 0.0)), 3), "home_matches_since_last_reversal": int(home_metrics.get("matches_since_last_reversal", 99)), "away_matches_since_last_reversal": int(away_metrics.get("matches_since_last_reversal", 99)), "h2h_last_goal_diff": last_diff if pair_ctx else None, "h2h_last_result": pair_ctx.get("result"), }, "reason_codes": reason_codes, } ) watch_items_all.append(item) watch_items_all.sort( key=lambda item: ( float(item.get("watch_score", 0.0)), float(item.get("top_pick_prob", 0.0)), ), reverse=True, ) selected = [ item for item in watch_items_all if float(item.get("watch_score", 0.0)) >= safe_min_score ][:safe_count] preview = watch_items_all[: min(5, len(watch_items_all))] return { "engine": "v28.main", "generated_at": __import__("datetime").datetime.utcnow().isoformat() + "Z", "horizon_hours": safe_horizon, "min_score": round(safe_min_score, 2), "top_leagues_only": bool(top_leagues_only), "scanned_matches": scanned, "candidate_matches": len(candidates), "listed_matches": len(selected), "watchlist": selected, "top_candidates_preview": preview, } def _fetch_team_reversal_cycle_metrics( self, cur: RealDictCursor, team_ids: Set[str], now_ms: int, ) -> Dict[str, Dict[str, float]]: if not team_ids: return {} cur.execute( """ WITH team_matches AS ( SELECT m.home_team_id AS team_id, m.mst_utc, CASE WHEN m.ht_score_home > m.ht_score_away THEN 'L' WHEN m.ht_score_home < m.ht_score_away THEN 'T' ELSE 'D' END AS ht_state, CASE WHEN m.score_home > m.score_away THEN 'W' WHEN m.score_home < m.score_away THEN 'L' ELSE 'D' END AS ft_state FROM matches m WHERE m.status = 'FT' AND m.score_home IS NOT NULL AND m.score_away IS NOT NULL AND m.ht_score_home IS NOT NULL AND m.ht_score_away IS NOT NULL AND m.home_team_id = ANY(%s) AND m.mst_utc < %s UNION ALL SELECT m.away_team_id AS team_id, m.mst_utc, CASE WHEN m.ht_score_away > m.ht_score_home THEN 'L' WHEN m.ht_score_away < m.ht_score_home THEN 'T' ELSE 'D' END AS ht_state, CASE WHEN m.score_away > m.score_home THEN 'W' WHEN m.score_away < m.score_home THEN 'L' ELSE 'D' END AS ft_state FROM matches m WHERE m.status = 'FT' AND m.score_home IS NOT NULL AND m.score_away IS NOT NULL AND m.ht_score_home IS NOT NULL AND m.ht_score_away IS NOT NULL AND m.away_team_id = ANY(%s) AND m.mst_utc < %s ), ranked AS ( SELECT team_id, mst_utc, ht_state, ft_state, ROW_NUMBER() OVER (PARTITION BY team_id ORDER BY mst_utc DESC) AS rn FROM team_matches ) SELECT team_id, mst_utc, ht_state, ft_state FROM ranked WHERE rn <= 80 ORDER BY team_id ASC, mst_utc DESC """, (list(team_ids), now_ms, list(team_ids), now_ms), ) rows = cur.fetchall() by_team: Dict[str, List[Dict[str, Any]]] = defaultdict(list) for row in rows: by_team[str(row["team_id"])].append(row) out: Dict[str, Dict[str, float]] = {} for team_id in team_ids: team_rows = by_team.get(str(team_id), []) if not team_rows: out[str(team_id)] = { "recent_reversal_rate": 0.0, "matches_since_last_reversal": 99.0, "avg_gap_matches": 12.0, "cycle_pressure": 0.0, } continue reversal_indexes: List[int] = [] recent_reversal = 0 recent_n = min(15, len(team_rows)) for idx, row in enumerate(team_rows, start=1): ht_state = str(row.get("ht_state") or "") ft_state = str(row.get("ft_state") or "") is_reversal = (ht_state == "L" and ft_state == "L") or (ht_state == "T" and ft_state == "W") if idx <= recent_n and is_reversal: recent_reversal += 1 if is_reversal: reversal_indexes.append(idx) recent_rate = (recent_reversal / recent_n) if recent_n > 0 else 0.0 since_last = float(reversal_indexes[0]) if reversal_indexes else 99.0 gaps: List[float] = [] if len(reversal_indexes) >= 2: for i in range(1, len(reversal_indexes)): gaps.append(float(reversal_indexes[i] - reversal_indexes[i - 1])) avg_gap = (sum(gaps) / len(gaps)) if gaps else 12.0 if avg_gap <= 0: avg_gap = 12.0 cycle_pressure = 0.0 if reversal_indexes: tolerance = max(3.0, avg_gap * 0.7) diff = abs(since_last - avg_gap) cycle_pressure = max(0.0, 1.0 - (diff / tolerance)) out[str(team_id)] = { "recent_reversal_rate": round(recent_rate, 4), "matches_since_last_reversal": round(since_last, 2), "avg_gap_matches": round(avg_gap, 2), "cycle_pressure": round(cycle_pressure, 4), } return out def _fetch_h2h_reversal_context( self, cur: RealDictCursor, pair_keys: Set[Tuple[str, str]], now_ms: int, ) -> Dict[Tuple[str, str], Dict[str, Any]]: if not pair_keys: return {} team_ids = sorted({team_id for pair in pair_keys for team_id in pair}) cur.execute( """ SELECT m.home_team_id, m.away_team_id, m.score_home, m.score_away, m.ht_score_home, m.ht_score_away, m.mst_utc FROM matches m WHERE m.status = 'FT' AND m.score_home IS NOT NULL AND m.score_away IS NOT NULL AND m.home_team_id = ANY(%s) AND m.away_team_id = ANY(%s) AND m.mst_utc < %s ORDER BY m.mst_utc DESC LIMIT 4000 """, (team_ids, team_ids, now_ms), ) rows = cur.fetchall() out: Dict[Tuple[str, str], Dict[str, Any]] = {} for row in rows: home_id = str(row["home_team_id"]) away_id = str(row["away_team_id"]) key = tuple(sorted((home_id, away_id))) if key not in pair_keys or key in out: continue score_home = int(row["score_home"]) score_away = int(row["score_away"]) goal_diff = score_home - score_away out[key] = { "goal_diff": goal_diff, "result": f"{score_home}-{score_away}", "match_date_ms": int(row["mst_utc"] or 0), } if len(out) >= len(pair_keys): break return out @staticmethod def _watchlist_risk_band(score: float) -> str: if score >= 68.0: return "HIGH" if score >= 54.0: return "MEDIUM" return "LOW" def _load_match_data(self, match_id: str) -> Optional[MatchData]: with psycopg2.connect(self.dsn) as conn: with conn.cursor(cursor_factory=RealDictCursor) as cur: row = self._fetch_live_match(cur, match_id) if not row: row = self._fetch_hist_match(cur, match_id) if not row: return None home_team_id = row.get("home_team_id") away_team_id = row.get("away_team_id") if not home_team_id or not away_team_id: # Hard gate: predictions with unknown teams are noisy and misleading. return None odds_data = self._extract_odds(cur, row) home_lineup, away_lineup, lineup_source, lineup_confidence = self._extract_lineups(cur, row) sidelined = self._parse_json_dict(row.get("sidelined")) match_date_ms = int(row.get("match_date_ms") or 0) league_id = str(row.get("league_id")) if row.get("league_id") else None home_id_str = str(home_team_id) away_id_str = str(away_team_id) home_goals_avg, home_conceded_avg = self._calculate_team_form( cur=cur, team_id=home_id_str, before_date_ms=match_date_ms, ) away_goals_avg, away_conceded_avg = self._calculate_team_form( cur=cur, team_id=away_id_str, before_date_ms=match_date_ms, ) home_position = self._estimate_league_position( cur=cur, team_id=home_id_str, league_id=league_id, before_date_ms=match_date_ms, ) away_position = self._estimate_league_position( cur=cur, team_id=away_id_str, league_id=league_id, before_date_ms=match_date_ms, ) return MatchData( match_id=str(row["match_id"]), home_team_id=home_id_str, away_team_id=away_id_str, home_team_name=row.get("home_team_name") or "Home", away_team_name=row.get("away_team_name") or "Away", match_date_ms=match_date_ms, sport=str(row.get("sport") or "football").lower(), league_id=league_id, league_name=row.get("league_name") or "", referee_name=row.get("referee_name"), odds_data=odds_data, home_lineup=home_lineup, away_lineup=away_lineup, sidelined_data=sidelined, home_goals_avg=home_goals_avg, home_conceded_avg=home_conceded_avg, away_goals_avg=away_goals_avg, away_conceded_avg=away_conceded_avg, home_position=home_position, away_position=away_position, lineup_source=lineup_source, status=str(row.get("status") or ""), state=row.get("state"), substate=row.get("substate"), lineup_confidence=lineup_confidence, current_score_home=( int(row.get("score_home")) if row.get("score_home") is not None else None ), current_score_away=( int(row.get("score_away")) if row.get("score_away") is not None else None ), ) def _fetch_live_match(self, cur: RealDictCursor, match_id: str) -> Optional[Dict[str, Any]]: cur.execute( """ SELECT lm.id as match_id, lm.home_team_id, lm.away_team_id, lm.league_id, lm.sport, lm.mst_utc as match_date_ms, lm.status, lm.state, lm.substate, lm.score_home, lm.score_away, lm.odds, lm.lineups, lm.sidelined, lm.referee_name, ht.name as home_team_name, at.name as away_team_name, l.name as league_name FROM live_matches lm LEFT JOIN teams ht ON ht.id = lm.home_team_id LEFT JOIN teams at ON at.id = lm.away_team_id LEFT JOIN leagues l ON l.id = lm.league_id WHERE lm.id = %s LIMIT 1 """, (match_id,), ) return cur.fetchone() def _fetch_hist_match(self, cur: RealDictCursor, match_id: str) -> Optional[Dict[str, Any]]: cur.execute( """ SELECT m.id as match_id, m.home_team_id, m.away_team_id, m.league_id, m.sport, m.mst_utc as match_date_ms, m.status, m.state, NULL::text as substate, m.score_home, m.score_away, NULL::jsonb as odds, NULL::jsonb as lineups, NULL::jsonb as sidelined, ref.name as referee_name, ht.name as home_team_name, at.name as away_team_name, l.name as league_name FROM matches m LEFT JOIN teams ht ON ht.id = m.home_team_id LEFT JOIN teams at ON at.id = m.away_team_id LEFT JOIN leagues l ON l.id = m.league_id LEFT JOIN match_officials ref ON ref.match_id = m.id AND ref.role_id = 1 WHERE m.id = %s LIMIT 1 """, (match_id,), ) return cur.fetchone() def _extract_odds(self, cur: RealDictCursor, row: Dict[str, Any]) -> Dict[str, float]: odds_data = self._parse_odds_json(row.get("odds")) sport_key = str(row.get("sport") or "football").lower() missing_relational_keys = [k for k in self.RELATIONAL_ODDS_KEYS if k not in odds_data] if missing_relational_keys: # fallback to relational odds tables when live odds JSON is incomplete cur.execute( """ SELECT oc.name as category_name, os.name as selection_name, os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = %s ORDER BY oc.db_id ASC, os.db_id ASC """, (row["match_id"],), ) relational_rows = cur.fetchall() rel_odds = self._parse_relational_odds(relational_rows) if rel_odds: for key, value in rel_odds.items(): odds_data.setdefault(key, value) if sport_key == "basketball": # Reuse football aliases when source only publishes generic match-result naming. if "ml_h" not in odds_data and "ms_h" in odds_data: odds_data["ml_h"] = float(odds_data["ms_h"]) if "ml_a" not in odds_data and "ms_a" in odds_data: odds_data["ml_a"] = float(odds_data["ms_a"]) if "ml_h" not in odds_data: odds_data["ml_h"] = 1.90 if "ml_a" not in odds_data: odds_data["ml_a"] = 1.90 if "tot_line" in odds_data and "tot_o" not in odds_data: odds_data["tot_o"] = 1.90 if "tot_line" in odds_data and "tot_u" not in odds_data: odds_data["tot_u"] = 1.90 else: if "ms_h" not in odds_data: odds_data["ms_h"] = self.DEFAULT_MS_H if "ms_d" not in odds_data: odds_data["ms_d"] = self.DEFAULT_MS_D if "ms_a" not in odds_data: odds_data["ms_a"] = self.DEFAULT_MS_A return odds_data def _extract_lineups( self, cur: RealDictCursor, row: Dict[str, Any], ) -> Tuple[Optional[List[str]], Optional[List[str]], str, float]: live_lineups = row.get("lineups") status_upper = str(row.get("status") or "").upper() state_upper = str(row.get("state") or "").upper() substate_upper = str(row.get("substate") or "").upper() can_trust_feed_lineups = ( status_upper in {"LIVE", "1H", "2H", "HT", "FT", "FINISHED"} or state_upper in {"LIVE", "FIRSTHALF", "SECONDHALF", "POSTGAME", "POST_GAME"} or substate_upper in {"LIVE", "FIRSTHALF", "SECONDHALF"} ) home, away = self._parse_lineups_json(live_lineups) if can_trust_feed_lineups else (None, None) if (home and len(home) >= 9) and (away and len(away) >= 9): return home, away, "confirmed_live", 1.0 home_id = str(row["home_team_id"]) away_id = str(row["away_team_id"]) # fallback 1: current match participation table. # Trust this only for live/finished matches; pre-match rows can be stale feed snapshots. if can_trust_feed_lineups: cur.execute( """ SELECT team_id, player_id FROM match_player_participation WHERE match_id = %s AND is_starting = true """, (row["match_id"],), ) rows = cur.fetchall() if rows: home_players = [str(r["player_id"]) for r in rows if str(r["team_id"]) == home_id] away_players = [str(r["player_id"]) for r in rows if str(r["team_id"]) == away_id] if not home and home_players: home = home_players if not away and away_players: away = away_players if (home and len(home) >= 9) and (away and len(away) >= 9): return home, away, "confirmed_participation", 0.98 # fallback 2: probable XI from historical starts before match date before_date_ms = int(row.get("match_date_ms") or 0) sidelined = self._parse_json_dict(row.get("sidelined")) or {} home_excluded = self._sidelined_player_ids(sidelined.get("homeTeam")) away_excluded = self._sidelined_player_ids(sidelined.get("awayTeam")) used_probable = False home_conf = 0.0 away_conf = 0.0 if not home or len(home) < 9: home, home_conf = self._build_probable_xi( cur, home_id, before_date_ms, excluded_player_ids=home_excluded, ) used_probable = used_probable or bool(home) if not away or len(away) < 9: away, away_conf = self._build_probable_xi( cur, away_id, before_date_ms, excluded_player_ids=away_excluded, ) used_probable = used_probable or bool(away) if used_probable: inferred_conf = min( home_conf if home else 0.0, away_conf if away else 0.0, ) return home, away, "probable_xi", inferred_conf return home, away, "none", 0.0 def _calculate_team_form( self, cur: RealDictCursor, team_id: str, before_date_ms: int, limit: int = 5, ) -> Tuple[float, float]: if not team_id: return 1.5, 1.2 cur.execute( """ SELECT m.home_team_id, m.away_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 %s """, (team_id, team_id, before_date_ms, limit), ) rows = cur.fetchall() if not rows: return 1.5, 1.2 weighted_for = 0.0 weighted_against = 0.0 total_weight = 0.0 for idx, row in enumerate(rows): weight = float(limit - idx) is_home = str(row["home_team_id"]) == team_id goals_for = float(row["score_home"] if is_home else row["score_away"]) goals_against = float(row["score_away"] if is_home else row["score_home"]) weighted_for += goals_for * weight weighted_against += goals_against * weight total_weight += weight if total_weight <= 0: return 1.5, 1.2 return weighted_for / total_weight, weighted_against / total_weight def _estimate_league_position( self, cur: RealDictCursor, team_id: str, league_id: Optional[str], before_date_ms: int, ) -> int: if not team_id or not league_id: return 10 try: cur.execute( """ SELECT tm.team_id, SUM(tm.points)::int AS points FROM ( SELECT m.home_team_id AS team_id, CASE WHEN m.score_home > m.score_away THEN 3 WHEN m.score_home = m.score_away THEN 1 ELSE 0 END AS points FROM matches m WHERE m.league_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 UNION ALL SELECT m.away_team_id AS team_id, CASE WHEN m.score_away > m.score_home THEN 3 WHEN m.score_away = m.score_home THEN 1 ELSE 0 END AS points FROM matches m WHERE m.league_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 ) tm GROUP BY tm.team_id ORDER BY points DESC """, (league_id, before_date_ms, league_id, before_date_ms), ) rows = cur.fetchall() if not rows: return 10 for idx, row in enumerate(rows, start=1): if str(row["team_id"]) == team_id: return idx return min(20, len(rows)) except Exception: return 10 def _build_probable_xi( self, cur: RealDictCursor, team_id: str, before_date_ms: int, match_limit: int = 5, lookback_days: int = 370, max_staleness_days: int = 120, excluded_player_ids: Optional[Set[str]] = None, ) -> Tuple[Optional[List[str]], float]: if not team_id: return None, 0.0 min_date_ms = max(0, before_date_ms - (lookback_days * 24 * 60 * 60 * 1000)) cur.execute( """ SELECT mpp.player_id, m.id AS match_id, m.mst_utc, m.home_team_id, m.away_team_id FROM match_player_participation mpp JOIN matches m ON m.id = mpp.match_id WHERE mpp.team_id = %s AND mpp.is_starting = true AND NOT EXISTS ( SELECT 1 FROM match_player_participation later_mpp JOIN matches later_m ON later_m.id = later_mpp.match_id WHERE later_mpp.player_id = mpp.player_id AND later_mpp.team_id <> %s AND later_m.mst_utc > m.mst_utc AND later_m.mst_utc < %s AND ( later_m.status = 'FT' OR later_m.state = 'postGame' OR (later_m.score_home IS NOT NULL AND later_m.score_away IS NOT NULL) ) ) AND m.id IN ( SELECT m2.id FROM matches m2 JOIN match_player_participation recent_mpp ON recent_mpp.match_id = m2.id AND recent_mpp.team_id = %s AND recent_mpp.is_starting = true WHERE (m2.home_team_id = %s OR m2.away_team_id = %s) AND ( m2.status = 'FT' OR m2.state = 'postGame' OR (m2.score_home IS NOT NULL AND m2.score_away IS NOT NULL) ) AND m2.mst_utc < %s AND m2.mst_utc >= %s GROUP BY m2.id HAVING COUNT(recent_mpp.*) >= 9 ORDER BY MAX(m2.mst_utc) DESC LIMIT %s ) ORDER BY m.mst_utc DESC """, ( team_id, team_id, before_date_ms, team_id, team_id, team_id, before_date_ms, min_date_ms, match_limit, ), ) rows = cur.fetchall() if not rows: return None, 0.0 latest_mst = max(int(row.get("mst_utc") or 0) for row in rows) age_days = (before_date_ms - latest_mst) / (24 * 60 * 60 * 1000) stale_projection = age_days > max_staleness_days excluded = {str(pid) for pid in (excluded_player_ids or set()) if pid} match_order: Dict[str, int] = {} for row in rows: match_id = str(row["match_id"]) if match_id not in match_order: match_order[match_id] = len(match_order) player_scores: Dict[str, Dict[str, float]] = {} for row in rows: player_id = str(row["player_id"]) if player_id in excluded: continue idx = match_order.get(str(row["match_id"]), match_limit) recency_weight = max(1.0, float(match_limit - idx)) score = recency_weight if idx == 0: score += 3.0 elif idx == 1: score += 1.5 stats = player_scores.setdefault( player_id, { "score": 0.0, "starts": 0.0, "last_seen_rank": float(idx), }, ) stats["score"] += score stats["starts"] += 1.0 stats["last_seen_rank"] = min(stats["last_seen_rank"], float(idx)) if not player_scores: return None, 0.0 ranked = sorted( player_scores.items(), key=lambda item: ( item[1]["score"], item[1]["starts"], -item[1]["last_seen_rank"], ), reverse=True, ) lineup = [player_id for player_id, _ in ranked[:11]] coverage = min(1.0, len(lineup) / 11.0) available_matches = max(1, len(match_order)) history_score = min(1.0, available_matches / float(match_limit)) core_stability = 0.0 if ranked: stable_core = sum(1 for _, stats in ranked[:11] if stats["starts"] >= 2.0) core_stability = stable_core / 11.0 staleness_factor = max( 0.35, min(1.0, float(max_staleness_days) / max(age_days, 1.0)), ) confidence = ( (coverage * 0.45) + (history_score * 0.25) + (core_stability * 0.30) ) * staleness_factor if excluded: confidence *= 0.92 confidence_cap = 0.58 if stale_projection else 0.88 return lineup or None, round(max(0.0, min(confidence_cap, confidence)), 3) @staticmethod def _sidelined_player_ids(team_data: Any) -> Set[str]: if not isinstance(team_data, dict): return set() players = team_data.get("players") if not isinstance(players, list): return set() ids: Set[str] = set() for player in players: if not isinstance(player, dict): continue player_id = ( player.get("playerId") or player.get("player_id") or player.get("id") or player.get("personId") ) if player_id: ids.add(str(player_id)) return ids def _parse_odds_json(self, odds_json: Any) -> Dict[str, float]: odds_json = self._parse_json_dict(odds_json) if odds_json is None: return {} parsed: Dict[str, float] = {} for category, selections in odds_json.items(): if not isinstance(selections, dict): continue category_text = str(category or "") category_norm = self._normalize_text(category) if category_norm in ("ms", "maç sonucu", "mac sonucu"): parsed["ms_h"] = self._selection_value(selections, ("1",), 0.0) parsed["ms_d"] = self._selection_value(selections, ("x", "0"), 0.0) parsed["ms_a"] = self._selection_value(selections, ("2",), 0.0) elif "maç sonucu (uzt. dahil)" in category_norm or "mac sonucu (uzt. dahil)" in category_norm: parsed["ml_h"] = self._selection_value(selections, ("1",), 0.0) parsed["ml_a"] = self._selection_value(selections, ("2",), 0.0) elif category_norm in ("1. yarı sonucu", "1. yari sonucu", "ilk yarı sonucu", "ilk yari sonucu", "iy sonucu"): parsed["ht_h"] = self._selection_value(selections, ("1",), 0.0) parsed["ht_d"] = self._selection_value(selections, ("x", "0"), 0.0) parsed["ht_a"] = self._selection_value(selections, ("2",), 0.0) elif self._is_first_half_ou05_category(category_norm): parsed["ht_ou05_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0) parsed["ht_ou05_u"] = self._selection_value(selections, ("alt", "under"), 0.0) elif self._is_first_half_ou15_category(category_norm): parsed["ht_ou15_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0) parsed["ht_ou15_u"] = self._selection_value(selections, ("alt", "under"), 0.0) elif category_norm in ("2.5 alt/üst", "2,5 alt/üst"): parsed["ou25_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0) parsed["ou25_u"] = self._selection_value(selections, ("alt", "under"), 0.0) elif category_norm in ("1.5 alt/üst", "1,5 alt/üst"): parsed["ou15_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0) parsed["ou15_u"] = self._selection_value(selections, ("alt", "under"), 0.0) elif category_norm in ("3.5 alt/üst", "3,5 alt/üst"): parsed["ou35_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0) parsed["ou35_u"] = self._selection_value(selections, ("alt", "under"), 0.0) elif category_norm in ("karşılıklı gol", "karsilikli gol", "kg"): parsed["btts_y"] = self._selection_value(selections, ("var", "yes"), 0.0) parsed["btts_n"] = self._selection_value(selections, ("yok", "no"), 0.0) elif category_norm in ("çifte şans", "cifte sans"): parsed["dc_1x"] = self._selection_value(selections, ("1-x", "1x"), 0.0) parsed["dc_x2"] = self._selection_value(selections, ("x-2", "x2"), 0.0) parsed["dc_12"] = self._selection_value(selections, ("1-2", "12"), 0.0) elif category_norm in ("tek/çift", "tek/cift"): parsed["oe_odd"] = self._selection_value(selections, ("tek", "odd"), 0.0) parsed["oe_even"] = self._selection_value(selections, ("çift", "cift", "even"), 0.0) elif self._is_cards_ou_category(category_norm): parsed["cards_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0) parsed["cards_u"] = self._selection_value(selections, ("alt", "under"), 0.0) elif category_norm in ( "ilk yarı/maç sonucu", "ilk yari/mac sonucu", "iy/ms", ): for sel_key, sel_val in selections.items(): norm_sel = self._normalize_text(sel_key) if "/" in norm_sel: odds_key = f"htft_{norm_sel.replace('/', '').lower()}" parsed[odds_key] = self._to_float(sel_val, 0.0) # Basketball full-game total line, e.g. "Alt/Üst (163,5)" if self._is_basketball_total_category(category_norm): if "tot_line" not in parsed: line = self._extract_parenthesized_number(category_text) if line is not None: parsed["tot_line"] = line parsed.setdefault("tot_o", self._selection_value(selections, ("üst", "ust", "over"), 0.0)) parsed.setdefault("tot_u", self._selection_value(selections, ("alt", "under"), 0.0)) # Basketball spread, e.g. "Hnd. MS (0:5,5)" if ( "hnd. ms" in category_norm or "hand. ms" in category_norm or "hnd ms" in category_norm ): home_line = self._parse_handicap_home_line(category_text) if home_line is not None and "spread_home_line" not in parsed: parsed["spread_home_line"] = home_line if home_line is not None: self._set_basketball_handicap_odds(parsed, selections, home_line) elif self._is_football_handicap_category(category_norm): self._set_football_handicap_odds(parsed, selections) return parsed def _parse_relational_odds(self, rows: List[Dict[str, Any]]) -> Dict[str, float]: parsed: Dict[str, float] = {} for row in rows: category_name = str(row.get("category_name") or "") selection_name = str(row.get("selection_name") or "") category_norm = self._normalize_text(category_name) selection_norm = self._normalize_text(selection_name) odd_val = self._to_float(row.get("odd_value"), 0.0) if odd_val <= 0: continue if category_norm in ("maç sonucu", "mac sonucu", "ms"): if selection_norm == "1": parsed["ms_h"] = odd_val elif selection_norm in ("x", "0"): parsed["ms_d"] = odd_val elif selection_norm == "2": parsed["ms_a"] = odd_val elif "maç sonucu (uzt. dahil)" in category_norm or "mac sonucu (uzt. dahil)" in category_norm: if selection_norm == "1": parsed.setdefault("ml_h", odd_val) elif selection_norm == "2": parsed.setdefault("ml_a", odd_val) elif category_norm in ("1. yarı sonucu", "1. yari sonucu", "ilk yarı sonucu", "ilk yari sonucu", "iy sonucu"): if selection_norm == "1": parsed["ht_h"] = odd_val elif selection_norm in ("x", "0"): parsed["ht_d"] = odd_val elif selection_norm == "2": parsed["ht_a"] = odd_val elif self._is_first_half_ou05_category(category_norm): if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm: parsed["ht_ou05_o"] = odd_val elif "alt" in selection_norm or "under" in selection_norm: parsed["ht_ou05_u"] = odd_val elif self._is_first_half_ou15_category(category_norm): if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm: parsed["ht_ou15_o"] = odd_val elif "alt" in selection_norm or "under" in selection_norm: parsed["ht_ou15_u"] = odd_val elif category_norm in ("2,5 alt/üst", "2.5 alt/üst"): if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm: parsed["ou25_o"] = odd_val elif "alt" in selection_norm or "under" in selection_norm: parsed["ou25_u"] = odd_val elif category_norm in ("1,5 alt/üst", "1.5 alt/üst"): if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm: parsed["ou15_o"] = odd_val elif "alt" in selection_norm or "under" in selection_norm: parsed["ou15_u"] = odd_val elif category_norm in ("3,5 alt/üst", "3.5 alt/üst"): if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm: parsed["ou35_o"] = odd_val elif "alt" in selection_norm or "under" in selection_norm: parsed["ou35_u"] = odd_val elif category_norm in ("karşılıklı gol", "karsilikli gol", "kg"): if selection_norm == "var" or "yes" in selection_norm: parsed["btts_y"] = odd_val elif selection_norm == "yok" or "no" in selection_norm: parsed["btts_n"] = odd_val elif category_norm in ("çifte şans", "cifte sans"): if selection_norm in ("1-x", "1x"): parsed["dc_1x"] = odd_val elif selection_norm in ("x-2", "x2"): parsed["dc_x2"] = odd_val elif selection_norm in ("1-2", "12"): parsed["dc_12"] = odd_val elif category_norm in ("tek/çift", "tek/cift"): if selection_norm in ("tek", "odd"): parsed["oe_odd"] = odd_val elif selection_norm in ("çift", "cift", "even"): parsed["oe_even"] = odd_val elif self._is_cards_ou_category(category_norm): if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm: parsed["cards_o"] = odd_val elif "alt" in selection_norm or "under" in selection_norm: parsed["cards_u"] = odd_val elif category_norm in ( "ilk yarı/maç sonucu", "ilk yari/mac sonucu", "iy/ms", ): if "/" in selection_norm: odds_key = f"htft_{selection_norm.replace('/', '').lower()}" parsed[odds_key] = odd_val if self._is_basketball_total_category(category_norm): if "tot_line" not in parsed: line = self._extract_parenthesized_number(category_name) if line is not None: parsed["tot_line"] = line if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm: parsed.setdefault("tot_o", odd_val) elif "alt" in selection_norm or "under" in selection_norm: parsed.setdefault("tot_u", odd_val) if ( "hnd. ms" in category_norm or "hand. ms" in category_norm or "hnd ms" in category_norm ): home_line = self._parse_handicap_home_line(category_name) if home_line is not None and "spread_home_line" not in parsed: parsed["spread_home_line"] = home_line if home_line is not None: sel_map = {selection_name: odd_val} self._set_basketball_handicap_odds(parsed, sel_map, home_line) elif self._is_football_handicap_category(category_norm): self._set_football_handicap_odds(parsed, {selection_name: odd_val}) return parsed def _is_basketball_total_category(self, category_norm: str) -> bool: if "alt/üst" not in category_norm and "alt/ust" not in category_norm: return False banned = ( "1. yarı", "1. yari", "periyot", "ev sahibi", "deplasman", ) return not any(token in category_norm for token in banned) def _is_first_half_ou05_category(self, category_norm: str) -> bool: if "alt/üst" not in category_norm and "alt/ust" not in category_norm: return False if not any( token in category_norm for token in ("1. yarı", "1. yari", "ilk yarı", "ilk yari") ): if not re.search(r"\biy\b", category_norm): return False # Exclude team-specific first-half totals (home/away) and non-goal props. if any(token in category_norm for token in ("ev sahibi", "deplasman", "korner", "kart")): return False # Match only exact 0.5 line (avoid false positives like 100,5 / 90,5 in basketball totals). for token in re.findall(r"\d+(?:[.,]\d+)?", category_norm): try: if abs(float(token.replace(",", ".")) - 0.5) < 1e-9: return True except Exception: continue return False def _is_first_half_ou15_category(self, category_norm: str) -> bool: if "alt/üst" not in category_norm and "alt/ust" not in category_norm: return False if not any( token in category_norm for token in ("1. yarı", "1. yari", "ilk yarı", "ilk yari") ): if not re.search(r"\biy\b", category_norm): return False if any(token in category_norm for token in ("ev sahibi", "deplasman", "korner", "kart")): return False for token in re.findall(r"\d+(?:[.,]\d+)?", category_norm): try: if abs(float(token.replace(",", ".")) - 1.5) < 1e-9: return True except Exception: continue return False def _is_cards_ou_category(self, category_norm: str) -> bool: if "kart" not in category_norm and "card" not in category_norm: return False return "alt/üst" in category_norm or "alt/ust" in category_norm def _is_football_handicap_category(self, category_norm: str) -> bool: if any(token in category_norm for token in ("hnd. ms", "hand. ms", "hnd ms")): return False return any( token in category_norm for token in ( "handikapli maç sonucu", "handikapli mac sonucu", "handikaplı maç sonucu", "hnd. maç sonucu", "hnd. mac sonucu", "hnd maç sonucu", "hnd mac sonucu", ) ) def _extract_parenthesized_number(self, category_name: str) -> Optional[float]: if not category_name: return None try: left = category_name.find("(") right = category_name.find(")", left + 1) if left < 0 or right < 0: return None raw = category_name[left + 1 : right].strip().replace(",", ".") out = float(raw) return out if out > 0 else None except Exception: return None def _parse_handicap_home_line(self, category_name: str) -> Optional[float]: if not category_name: return None try: left = category_name.find("(") right = category_name.find(")", left + 1) if left < 0 or right < 0: return None payload = category_name[left + 1 : right].strip().replace(",", ".") if ":" not in payload: return None home_raw, away_raw = payload.split(":", 1) home_hcp = float(home_raw.strip()) away_hcp = float(away_raw.strip()) if abs(home_hcp) < 1e-6 and away_hcp > 0: return -away_hcp if home_hcp > 0 and abs(away_hcp) < 1e-6: return home_hcp if abs(home_hcp - away_hcp) < 1e-6 and home_hcp > 0: return 0.0 except Exception: return None return None def _set_basketball_handicap_odds( self, out: Dict[str, float], selections: Dict[str, Any], home_line: float, ) -> None: if not isinstance(selections, dict): return has_home_plus = False home_plus_odd = 0.0 one_odd = 0.0 two_odd = 0.0 for key, value in selections.items(): norm_key = self._normalize_text(key) odd = self._to_float(value, 0.0) if odd <= 1.0: continue if norm_key == "1": one_odd = odd elif norm_key == "2": two_odd = odd if "+h" in norm_key or norm_key.endswith("h"): has_home_plus = True home_plus_odd = odd if home_line < 0: # Home gives points. \"1\" normally means home -line covers. if one_odd > 1.0: out.setdefault("spread_h", one_odd) if home_plus_odd > 1.0: out.setdefault("spread_a", home_plus_odd) elif two_odd > 1.0: out.setdefault("spread_a", two_odd) elif home_line > 0: # Home receives points. +h entry or \"1\" means home side. if home_plus_odd > 1.0: out.setdefault("spread_h", home_plus_odd) elif one_odd > 1.0: out.setdefault("spread_h", one_odd) if two_odd > 1.0: out.setdefault("spread_a", two_odd) else: if one_odd > 1.0: out.setdefault("spread_h", one_odd) if two_odd > 1.0: out.setdefault("spread_a", two_odd) def _set_football_handicap_odds( self, out: Dict[str, float], selections: Dict[str, Any], ) -> None: if not isinstance(selections, dict): return for key, value in selections.items(): norm_key = self._normalize_text(key) odd = self._to_float(value, 0.0) if odd <= 1.0: continue if norm_key == "1": out["hcap_h"] = odd elif norm_key in ("x", "0"): out["hcap_d"] = odd elif norm_key == "2": out["hcap_a"] = odd def _parse_lineups_json( self, lineups_json: Any, ) -> Tuple[Optional[List[str]], Optional[List[str]]]: if isinstance(lineups_json, str): try: lineups_json = json.loads(lineups_json) except Exception: lineups_json = None if not isinstance(lineups_json, dict): return None, None def parse_side(side: str) -> Optional[List[str]]: # Try direct access first (home/away at root level) side_obj = lineups_json.get(side) # Fallback: Check if inside "stats" key (Mackolik format) if not isinstance(side_obj, (dict, list)): stats = lineups_json.get("stats") if isinstance(stats, dict): side_obj = stats.get(side) if not isinstance(side_obj, (dict, list)): return None # Try standard formats (xi, starting, lineup) entries = None if isinstance(side_obj, dict): entries = side_obj.get("xi") or side_obj.get("starting") or side_obj.get("lineup") # If the dict itself contains player dicts (no wrapper keys) if not entries and "position" in side_obj: # side_obj is likely a single player dict, wrap it entries = [side_obj] elif isinstance(side_obj, list): # side_obj is already a list of players entries = side_obj if not isinstance(entries, list): return None ids: List[str] = [] for p in entries: if isinstance(p, dict): player_id = p.get("id") or p.get("playerId") or p.get("personId") if player_id: ids.append(str(player_id)) elif p: ids.append(str(p)) return ids or None return parse_side("home"), parse_side("away") def _build_prediction_package( self, data: MatchData, prediction: FullMatchPrediction, v25_signal: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: quality = self._compute_data_quality(data) raw_market_rows = self._build_market_rows(data, prediction, v25_signal) raw_market_rows = self._apply_market_consistency( raw_market_rows, data, prediction, ) market_rows = [ self._decorate_market_row(data, prediction, quality, row) for row in raw_market_rows ] market_rows.sort( key=lambda row: ( 1 if row.get("playable") else 0, float(row.get("play_score", 0.0)), ), reverse=True, ) playable_rows = [row for row in market_rows if row.get("playable")] # 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 >= 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 = 44.0 # V32: lowered from 52 to match new calibration # High-accuracy markets from backtest (prioritize these) HIGH_ACCURACY_MARKETS = {"DC", "OU15", "HT_OU05"} # Priority 1: High-accuracy markets with good odds and confidence high_accuracy_picks = [ row for row in playable_rows 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 ] if high_accuracy_picks: # Sort by play_score, pick the best high_accuracy_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) main_pick = high_accuracy_picks[0] main_pick["is_guaranteed"] = True main_pick["pick_reason"] = "high_accuracy_market" else: # Priority 2: Any playable with odds >= 1.30 and confidence >= 40% guaranteed_picks = [ row for row in playable_rows if float(row.get("odds", 0.0)) >= MIN_ODDS and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE ] if guaranteed_picks: guaranteed_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) main_pick = guaranteed_picks[0] main_pick["is_guaranteed"] = True main_pick["pick_reason"] = "confidence_threshold_met" else: # Priority 3: Fallback - playable with odds >= 1.30 playable_with_odds = [ row for row in playable_rows if float(row.get("odds", 0.0)) >= MIN_ODDS ] if playable_with_odds: playable_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) main_pick = playable_with_odds[0] main_pick["is_guaranteed"] = False main_pick["pick_reason"] = "odds_only_fallback" else: # Priority 4: Last resort - any playable or first market WITH ODDS > 0 fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0] main_pick = playable_rows[0] if playable_rows else (fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None)) if main_pick: main_pick["is_guaranteed"] = False main_pick["pick_reason"] = "last_resort" aggressive_pick = None htft_probs = prediction.ht_ft_probs or {} aggressive_candidates = [ ("1/2", float(htft_probs.get("1/2", 0.0))), ("2/1", float(htft_probs.get("2/1", 0.0))), ("X/1", float(htft_probs.get("X/1", 0.0))), ("X/2", float(htft_probs.get("X/2", 0.0))), ] aggressive_candidates.sort(key=lambda item: item[1], reverse=True) if ( aggressive_candidates and aggressive_candidates[0][1] > 0.03 and self._market_has_real_pick_odds("HTFT", aggressive_candidates[0][0], data.odds_data or {}) ): aggressive_pick = { "market": "HT/FT", "pick": aggressive_candidates[0][0], "probability": round(aggressive_candidates[0][1], 4), "confidence": round(aggressive_candidates[0][1] * 100, 1), "odds": None, } value_pick = None # Esnek/Değerli (Value) Pick: Yüksek oran (>= 1.60) ve fena olmayan güven (>= %40) value_candidates = [ row for row in playable_rows if float(row.get("odds", 0.0)) >= 1.60 and float(row.get("calibrated_confidence", 0.0)) >= 40.0 ] if value_candidates: # Score them by (play_score * odds) to reward higher odds value_candidates.sort(key=lambda r: float(r.get("play_score", 0.0)) * float(r.get("odds", 1.0)), reverse=True) for v_cand in value_candidates: if not main_pick or (v_cand["market"] != main_pick["market"] or v_cand["pick"] != main_pick["pick"]): value_pick = v_cand break supporting: List[Dict[str, Any]] = [] for row in market_rows: if main_pick and row["market"] == main_pick["market"] and row["pick"] == main_pick["pick"]: continue supporting.append(row) supporting = supporting[:6] bet_summary = [self._to_bet_summary_item(row) for row in market_rows] reasons = self._build_reasoning_factors(data, prediction, quality) market_board = { "MS": { "pick": prediction.ms_pick, "confidence": round(float(prediction.ms_confidence), 1), "probs": { "1": round(float(prediction.ms_home_prob), 4), "X": round(float(prediction.ms_draw_prob), 4), "2": round(float(prediction.ms_away_prob), 4), }, }, "DC": { "pick": prediction.dc_pick, "confidence": round(float(prediction.dc_confidence), 1), "probs": { "1X": round(float(prediction.dc_1x_prob), 4), "X2": round(float(prediction.dc_x2_prob), 4), "12": round(float(prediction.dc_12_prob), 4), }, }, "OU15": { "pick": prediction.ou15_pick, "confidence": round(float(prediction.ou15_confidence), 1), "probs": { "over": round(float(prediction.over_15_prob), 4), "under": round(float(prediction.under_15_prob), 4), }, }, "OU25": { "pick": prediction.ou25_pick, "confidence": round(float(prediction.ou25_confidence), 1), "probs": { "over": round(float(prediction.over_25_prob), 4), "under": round(float(prediction.under_25_prob), 4), }, }, "OU35": { "pick": prediction.ou35_pick, "confidence": round(float(prediction.ou35_confidence), 1), "probs": { "over": round(float(prediction.over_35_prob), 4), "under": round(float(prediction.under_35_prob), 4), }, }, "BTTS": { "pick": prediction.btts_pick, "confidence": round(float(prediction.btts_confidence), 1), "probs": { "yes": round(float(prediction.btts_yes_prob), 4), "no": round(float(prediction.btts_no_prob), 4), }, }, "HT": { "pick": prediction.ht_pick, "confidence": round(float(prediction.ht_confidence), 1), "probs": { "1": round(float(prediction.ht_home_prob), 4), "X": round(float(prediction.ht_draw_prob), 4), "2": round(float(prediction.ht_away_prob), 4), }, }, "HTFT": { "probs": {k: round(float(v), 4) for k, v in htft_probs.items()}, }, "OE": { "pick": prediction.odd_even_pick, "probs": { "odd": round(float(prediction.odd_prob), 4), "even": round(float(prediction.even_prob), 4), }, }, "HT_OU05": { "pick": prediction.ht_ou_pick, "confidence": round(float(max(prediction.ht_over_05_prob, prediction.ht_under_05_prob) * 100), 1), "probs": { "over": round(float(prediction.ht_over_05_prob), 4), "under": round(float(prediction.ht_under_05_prob), 4), }, }, "HT_OU15": { "pick": prediction.ht_ou15_pick, "confidence": round(float(max(prediction.ht_over_15_prob, prediction.ht_under_15_prob) * 100), 1), "probs": { "over": round(float(prediction.ht_over_15_prob), 4), "under": round(float(prediction.ht_under_15_prob), 4), }, }, "CARDS": { "pick": prediction.card_pick, "confidence": round(float(prediction.cards_confidence), 1), "total": round(float(prediction.total_cards_pred), 1), "probs": { "over": round(float(prediction.cards_over_prob), 4), "under": round(float(prediction.cards_under_prob), 4), }, }, "HCAP": { "pick": prediction.handicap_pick, "confidence": round(float(prediction.handicap_confidence), 1), "probs": { "1": round(float(prediction.handicap_home_prob), 4), "X": round(float(prediction.handicap_draw_prob), 4), "2": round(float(prediction.handicap_away_prob), 4), }, }, } if v25_signal: market_board = self._merge_v25_market_board(market_board, v25_signal) available_markets = {str(row.get("market") or "") for row in market_rows} market_board = { market: payload for market, payload in market_board.items() 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": "v28-pro-max", "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}", "home_team": data.home_team_name, "away_team": data.away_team_name, "league": data.league_name, "match_date_ms": data.match_date_ms, "sport": data.sport, }, "data_quality": quality, "risk": { "level": prediction.risk_level, "score": round(float(prediction.risk_score), 1), "is_surprise_risk": bool(prediction.is_surprise_risk), "surprise_type": prediction.surprise_type, "surprise_score": round(float(getattr(prediction, "surprise_score", 0.0) or 0.0), 1), "surprise_comment": str(getattr(prediction, "surprise_comment", "") or ""), "surprise_reasons": list(getattr(prediction, "surprise_reasons", []) or []), "warnings": prediction.risk_warnings, }, "engine_breakdown": { "team": round(float(prediction.team_confidence), 1), "player": round(float(prediction.player_confidence), 1), "odds": round(float(prediction.odds_confidence), 1), "referee": round(float(prediction.referee_confidence), 1), }, "main_pick": main_pick, "value_pick": value_pick, "bet_advice": { "playable": bool(main_pick and main_pick.get("playable")), "suggested_stake_units": float(main_pick.get("stake_units", 0.0)) if (main_pick and main_pick.get("playable")) else 0.0, "reason": "playable_pick_found" if (main_pick and main_pick.get("playable")) else "no_bet_conditions_met", }, "bet_summary": bet_summary, "supporting_picks": supporting, "aggressive_pick": aggressive_pick, "scenario_top5": prediction.ft_scores_top5, "score_prediction": { "ft": prediction.predicted_ft_score, "ht": prediction.predicted_ht_score, "xg_home": round(float(prediction.home_xg), 2), "xg_away": round(float(prediction.away_xg), 2), "xg_total": round(float(prediction.total_xg), 2), }, "market_board": market_board, "others": { "handicap": prediction.handicap_pick, "cards": { "total": round(float(prediction.total_cards_pred), 1), "pick": prediction.card_pick, }, }, "v25_signal": { "available": v25_signal is not None, "markets": v25_signal if v25_signal else None, "value_bets": v25_signal.get('value_bets', []) if v25_signal else [], "ensemble_weights": {"v25": 1.0}, }, "reasoning_factors": reasons, } def _build_basketball_prediction_package( self, data: MatchData, prediction: Dict[str, Any], ) -> Dict[str, Any]: quality = self._compute_data_quality(data) raw_market_rows = self._build_basketball_market_rows(data, prediction) market_rows = [ self._decorate_basketball_market_row(data, prediction, quality, row) for row in raw_market_rows ] market_rows.sort( key=lambda row: ( 1 if row.get("playable") else 0, float(row.get("play_score", 0.0)), ), reverse=True, ) playable_rows = [row for row in market_rows if row.get("playable")] # GUARANTEED PICK LOGIC (Optimized - same as football) MIN_ODDS = 1.30 MIN_CONFIDENCE = 40.0 HIGH_ACCURACY_MARKETS = {"ML", "TOT", "SPREAD"} high_accuracy_picks = [ row for row in playable_rows if row.get("market_type") in HIGH_ACCURACY_MARKETS and float(row.get("odds", 0.0)) >= MIN_ODDS and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE ] if high_accuracy_picks: high_accuracy_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) main_pick = high_accuracy_picks[0] main_pick["is_guaranteed"] = True main_pick["pick_reason"] = "high_accuracy_market" else: guaranteed_picks = [ row for row in playable_rows if float(row.get("odds", 0.0)) >= MIN_ODDS and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE ] if guaranteed_picks: guaranteed_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) main_pick = guaranteed_picks[0] main_pick["is_guaranteed"] = True main_pick["pick_reason"] = "confidence_threshold_met" else: playable_with_odds = [ row for row in playable_rows if float(row.get("odds", 0.0)) >= MIN_ODDS ] if playable_with_odds: playable_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) main_pick = playable_with_odds[0] main_pick["is_guaranteed"] = False main_pick["pick_reason"] = "odds_only_fallback" else: fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0] main_pick = playable_rows[0] if playable_rows else (fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None)) if main_pick: main_pick["is_guaranteed"] = False main_pick["pick_reason"] = "last_resort" supporting: List[Dict[str, Any]] = [] for row in market_rows: if main_pick and row["market"] == main_pick["market"] and row["pick"] == main_pick["pick"]: continue supporting.append(row) supporting = supporting[:5] bet_summary = [self._to_bet_summary_item(row) for row in market_rows] scenarios = self._build_basketball_scenarios(prediction) reasons = self._build_basketball_reasoning_factors(data, prediction, quality) aggressive_pick: Optional[Dict[str, Any]] = None risk_level = prediction.get("risk_level", "MEDIUM") risk_score = float(prediction.get("risk_score", 50.0) or 50.0) # Build aggressive pick if available from Spreak in market_board board = prediction.get("market_board", {}) if risk_level in ("LOW", "MEDIUM") and "Spread" in board: spr_data = board["Spread"] probs = list(spr_data.values()) keys = list(spr_data.keys()) if len(probs) >= 2: prob_a = float(str(probs[0]).replace('%', '')) / 100.0 prob_h = float(str(probs[1]).replace('%', '')) / 100.0 max_prob = max(prob_a, prob_h) spr_pick = "Home" if prob_h >= prob_a else "Away" conf = 50.0 line_str = "Spread" for b in prediction.get("bet_summary", []): if b["market"] == "Spread": conf = float(b["confidence"]) line_str = b["pick"] aggressive_pick = { "market": "SPREAD", "pick": line_str, "probability": round(max_prob, 4), "confidence": round(conf, 1), "odds": round( float( data.odds_data.get( "spread_h" if spr_pick == "Home" else "spread_a", 0.0 ) ), 2, ), } scores = prediction.get("score_prediction", {}) home_score = scores.get("home_expected", 80.0) away_score = scores.get("away_expected", 80.0) total_score = scores.get("total_expected", 160.0) mb_out = { "PLAYER_TOP": board.get("PLAYER_TOP", []), } if "ML" in board: ml_data = board["ML"] keys = list(ml_data.keys()) if len(keys) >= 2: mb_out["ML"] = { "pick": prediction.get("main_pick", ""), "confidence": 60.0, "probs": { "1": round(float(str(ml_data[keys[0]]).replace('%', '')) / 100.0, 4), "2": round(float(str(ml_data[keys[1]]).replace('%', '')) / 100.0, 4), }, } if "Totals" in board: tot_data = board["Totals"] keys = list(tot_data.keys()) if len(keys) >= 2: mb_out["TOTAL"] = { "line": 160.5, "pick": prediction.get("main_pick", ""), "confidence": 60.0, "probs": { "under": round(float(str(tot_data[keys[0]]).replace('%', '')) / 100.0, 4), "over": round(float(str(tot_data[keys[1]]).replace('%', '')) / 100.0, 4), }, } if "Spread" in board: spr_data = board["Spread"] keys = list(spr_data.keys()) if len(keys) >= 2: mb_out["SPREAD"] = { "line_home": 0.0, "pick": prediction.get("main_pick", ""), "confidence": 60.0, "probs": { "away_cover": round(float(str(spr_data[keys[0]]).replace('%', '')) / 100.0, 4), "home_cover": round(float(str(spr_data[keys[1]]).replace('%', '')) / 100.0, 4), }, } return { "model_version": str(prediction.get("engine_version") or "v28.main.basketball"), "match_info": { "match_id": data.match_id, "match_name": f"{data.home_team_name} vs {data.away_team_name}", "home_team": data.home_team_name, "away_team": data.away_team_name, "league": data.league_name, "match_date_ms": data.match_date_ms, "sport": data.sport, }, "data_quality": quality, "risk": { "level": risk_level, "score": round(risk_score, 1), "is_surprise_risk": False, "surprise_type": "", "warnings": [], }, "engine_breakdown": prediction.get("engine_breakdown") or { "team": 60.0, "player": 60.0, "odds": 80.0, "referee": 50.0, }, "main_pick": main_pick, "bet_advice": { "playable": bool(main_pick and main_pick.get("playable")), "suggested_stake_units": float(main_pick.get("stake_units", 0.0)) if (main_pick and main_pick.get("playable")) else 0.0, "reason": "playable_pick_found" if (main_pick and main_pick.get("playable")) else "no_bet_conditions_met", }, "bet_summary": bet_summary, "supporting_picks": supporting, "aggressive_pick": aggressive_pick, "scenario_top5": scenarios, "score_prediction": { "ft": f"{int(round(home_score))}-{int(round(away_score))}", "ht": f"{int(round(home_score * 0.52))}-{int(round(away_score * 0.52))}", "xg_home": round(float(home_score), 2), "xg_away": round(float(away_score), 2), "xg_total": round(float(total_score), 2), }, "market_board": mb_out, "reasoning_factors": reasons, } def _build_basketball_market_rows( self, data: MatchData, pred: Dict[str, Any], ) -> List[Dict[str, Any]]: odds = data.odds_data market_board = pred.get("market_board", {}) # 1. Moneyline ml_row = None if "ML" in market_board: ml_data = market_board["ML"] # To get specific pick (MS 1 or MS 2), look at the probability values probs = list(ml_data.values()) keys = list(ml_data.keys()) if len(probs) >= 2: prob_1 = float(str(probs[0]).replace('%', '')) / 100.0 prob_2 = float(str(probs[1]).replace('%', '')) / 100.0 max_prob = max(prob_1, prob_2) # Derive pick string ml_pick_val = keys[0] if prob_1 >= prob_2 else keys[1] ml_pick = "1" if "1" in ml_pick_val else "2" ml_odd_key = "ml_h" if ml_pick == "1" else "ml_a" # Find confidence from bet summary conf = 50.0 for b in pred.get("bet_summary", []): if b["market"] == "Moneyline": conf = float(b["confidence"]) ml_row = { "market": "ML", "pick": ml_pick, "probability": round(max_prob, 4), "confidence": round(conf, 1), "odds": round(float(odds.get(ml_odd_key, 0.0)), 2), } # 2. Totals tot_row = None if "Totals" in market_board: tot_data = market_board["Totals"] probs = list(tot_data.values()) keys = list(tot_data.keys()) if len(probs) >= 2: prob_u = float(str(probs[0]).replace('%', '')) / 100.0 prob_o = float(str(probs[1]).replace('%', '')) / 100.0 max_prob = max(prob_u, prob_o) pick_str = keys[1] if prob_o >= prob_u else keys[0] tot_pick = "Over" if "Over" in pick_str else "Under" line_val = pick_str.replace("Over", "").replace("Under", "").strip() conf = 50.0 for b in pred.get("bet_summary", []): if b["market"] == "Totals": conf = float(b["confidence"]) tot_row = { "market": "TOTAL", "pick": f"{tot_pick} {line_val}", "probability": round(max_prob, 4), "confidence": round(conf, 1), "odds": round(float(odds.get("tot_o" if tot_pick == "Over" else "tot_u", 0.0)), 2), } # 3. Spread spr_row = None if "Spread" in market_board: spr_data = market_board["Spread"] probs = list(spr_data.values()) keys = list(spr_data.keys()) if len(probs) >= 2: prob_a = float(str(probs[0]).replace('%', '')) / 100.0 prob_h = float(str(probs[1]).replace('%', '')) / 100.0 max_prob = max(prob_a, prob_h) spr_pick = "Home" if prob_h >= prob_a else "Away" conf = 50.0 line_str = "" for b in pred.get("bet_summary", []): if b["market"] == "Spread": conf = float(b["confidence"]) line_str = b["pick"] spr_row = { "market": "SPREAD", "pick": spr_pick + " " + line_str, "probability": round(max_prob, 4), "confidence": round(conf, 1), "odds": round(float(odds.get("spread_h" if spr_pick == "Home" else "spread_a", 0.0)), 2), } # Return valid rows rows = [] if ml_row: rows.append(ml_row) if tot_row: rows.append(tot_row) if spr_row: rows.append(spr_row) return rows def _decorate_basketball_market_row( self, data: MatchData, prediction: Dict[str, Any], quality: Dict[str, Any], row: Dict[str, Any], ) -> Dict[str, Any]: market = str(row.get("market") or "") raw_conf = float(row.get("confidence") or 0.0) prob = float(row.get("probability") or 0.0) odd = float(row.get("odds") or 0.0) calibration = {"ML": 0.90, "TOTAL": 0.88, "SPREAD": 0.86}.get(market, 0.88) min_conf = {"ML": 55.0, "TOTAL": 56.0, "SPREAD": 55.0}.get(market, 55.0) calibrated_conf = max(1.0, min(99.0, raw_conf * calibration)) implied_prob = (1.0 / odd) if odd > 1.0 else 0.0 edge = prob - implied_prob if implied_prob > 0 else 0.0 risk_level = str(prediction.get("risk_level", "MEDIUM")).upper() risk_penalty = {"LOW": 0.0, "MEDIUM": 3.0, "HIGH": 8.0, "EXTREME": 12.0}.get( risk_level, 4.0, ) quality_label = str(quality.get("label") or "MEDIUM").upper() quality_penalty = {"HIGH": 0.0, "MEDIUM": 2.0, "LOW": 6.0}.get( quality_label, 4.0, ) base_score = calibrated_conf + (edge * 100.0) play_score = max(0.0, min(100.0, base_score - risk_penalty - quality_penalty)) reasons: List[str] = [] playable = True min_play_score = self.market_min_play_score.get(market, 68.0) min_edge = self.market_min_edge.get(market, 0.02) if calibrated_conf < min_conf: playable = False reasons.append("below_calibrated_conf_threshold") if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01: playable = False reasons.append("market_odds_missing") if risk_level in ("HIGH", "EXTREME") and quality_label == "LOW": playable = False reasons.append("high_risk_low_data_quality") if odd > 1.0 and edge < -0.05: playable = False reasons.append("negative_model_edge") if not reasons: reasons.append("market_passed_all_gates") if not playable: grade = "PASS" stake_units = 0.0 elif play_score >= 72: grade = "A" stake_units = 1.0 elif play_score >= 61: grade = "B" stake_units = 0.5 else: grade = "C" stake_units = 0.25 out = dict(row) out.update( { "raw_confidence": round(raw_conf, 1), "calibrated_confidence": round(calibrated_conf, 1), "min_required_confidence": round(min_conf, 1), "edge": round(edge, 4), "play_score": round(play_score, 1), "playable": playable, "bet_grade": grade, "stake_units": stake_units, "decision_reasons": reasons[:3], }, ) return out def _build_basketball_scenarios( self, prediction: Dict[str, Any], ) -> List[Dict[str, Any]]: scores = prediction.get("score_prediction", {}) home = float(scores.get("home_expected", 80.0)) away = float(scores.get("away_expected", 80.0)) templates = [ (0.00, 0.23), (+3.5, 0.20), (-3.5, 0.19), (+6.0, 0.16), (-6.0, 0.14), ] out: List[Dict[str, Any]] = [] for delta, prob in templates: h = int(round(home + delta)) a = int(round(away - delta)) out.append({"score": f"{h}-{a}", "prob": prob}) return out def _build_basketball_reasoning_factors( self, data: MatchData, prediction: Dict[str, Any], quality: Dict[str, Any], ) -> List[str]: factors: List[str] = [] # XGBoost models are odds-aware, weight it heavily factors.append("market_signal_dominant") if quality.get("label") in ("HIGH", "MEDIUM"): factors.append("player_form_signal_strong") else: factors.append("player_form_signal_limited") if prediction.get("is_surprise_risk"): factors.append("upset_risk_detected") if quality.get("label") == "LOW": factors.append("limited_data_confidence") factors.append("basketball_points_model") return factors def _real_market_odds(self, odds_data: Dict[str, Any], key: str) -> float: """ Return the odds value for a given key, but 1.0 if it's a known default or missing. The prediction engine needs default odds (2.65/3.20) as ML features, but market rows must NOT use them for EV edge / Kelly calculations. Returning 1.0 acts as a neutral multiplier, avoiding zero-out errors. """ val = float(odds_data.get(key, 1.0)) if val <= 1.01: return 1.0 _DEFAULTS: Dict[str, float] = { "ms_h": self.DEFAULT_MS_H, "ms_d": self.DEFAULT_MS_D, "ms_a": self.DEFAULT_MS_A, "ml_h": 1.90, "ml_a": 1.90, "ht_h": 2.4, "ht_d": 1.9, "ht_a": 3.1, "ht_ou05_o": 1.9, "ht_ou05_u": 1.9, "ht_ou15_o": 2.4, "ht_ou15_u": 1.5, "ou15_o": 1.4, "ou15_u": 2.6, "ou25_o": 1.9, "ou25_u": 1.9, "ou35_o": 2.7, "ou35_u": 1.4, "btts_y": 1.9, "btts_n": 1.9, "dc_1x": 1.2, "dc_x2": 1.4, "dc_12": 1.2, "oe_odd": 1.9, "oe_even": 1.9, "cards_o": 1.9, "cards_u": 1.9, "tot_o": 1.90, "tot_u": 1.90, } if key in _DEFAULTS and abs(val - _DEFAULTS[key]) < 1e-6: return 1.0 return val def _sanitize_v25_odds(self, odds_data: Dict[str, Any]) -> Dict[str, float]: sanitized: Dict[str, float] = {} for key in self.V25_ODDS_FEATURE_KEYS: sanitized[key] = self._real_market_odds(odds_data, key) for key in ("dc_1x", "dc_x2", "dc_12", "oe_odd", "oe_even", "cards_o", "cards_u", "hcap_h", "hcap_d", "hcap_a"): if key in odds_data: sanitized[key] = self._real_market_odds(odds_data, key) return sanitized @staticmethod def _v25_pick_to_market_pick(market: str, pick: str) -> str: if market == "BTTS": return "KG Var" if pick == "Yes" else "KG Yok" if pick == "No" else pick if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}: return "Üst" if pick == "Over" else "Alt" if pick == "Under" else pick if market == "OE": return "Tek" if pick == "Odd" else "Çift" if pick == "Even" else pick return pick def _v25_market_odds(self, odds: Dict[str, Any], market: str, pick: str) -> float: normalized_pick = str(pick or "").strip() if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}: normalized_pick = "Over" if ("Üst" in normalized_pick or "Over" in normalized_pick) else "Under" elif market == "BTTS": normalized_pick = "Yes" if normalized_pick in {"KG Var", "Var", "Yes"} else "No" elif market == "OE": normalized_pick = "Odd" if normalized_pick in {"Tek", "Odd"} else "Even" elif market == "DC": normalized_pick = normalized_pick.replace("-", "").upper() elif market == "HCAP" and normalized_pick.startswith("Handikap"): if " 1" in normalized_pick: normalized_pick = "1" elif " X" in normalized_pick: normalized_pick = "X" elif " 2" in normalized_pick: normalized_pick = "2" key_map = { "MS": {"1": "ms_h", "X": "ms_d", "2": "ms_a"}, "DC": {"1X": "dc_1x", "X2": "dc_x2", "12": "dc_12"}, "OU15": {"Over": "ou15_o", "Under": "ou15_u"}, "OU25": {"Over": "ou25_o", "Under": "ou25_u"}, "OU35": {"Over": "ou35_o", "Under": "ou35_u"}, "BTTS": {"Yes": "btts_y", "No": "btts_n"}, "HT": {"1": "ht_h", "X": "ht_d", "2": "ht_a"}, "HT_OU05": {"Over": "ht_ou05_o", "Under": "ht_ou05_u"}, "HT_OU15": {"Over": "ht_ou15_o", "Under": "ht_ou15_u"}, "OE": {"Odd": "oe_odd", "Even": "oe_even"}, "CARDS": {"Over": "cards_o", "Under": "cards_u"}, "HCAP": {"1": "hcap_h", "X": "hcap_d", "2": "hcap_a"}, } if market == "HTFT": return round(float(odds.get(f"htft_{normalized_pick.replace('/', '').lower()}", 1.0)), 2) odds_key = key_map.get(market, {}).get(normalized_pick) if not odds_key: return 1.0 return round(self._real_market_odds(odds, odds_key), 2) def _market_has_real_pick_odds(self, market: str, pick: str, odds: Dict[str, Any]) -> bool: if market not in self.ODDS_REQUIRED_MARKETS: return True return self._v25_market_odds(odds, market, pick) > 1.01 @staticmethod def _goal_line_for_market(market: str) -> Optional[float]: return { "OU15": 1.5, "OU25": 2.5, "OU35": 3.5, "HT_OU05": 0.5, "HT_OU15": 1.5, "CARDS": 4.5, }.get(market) def _is_live_match(self, data: MatchData) -> bool: status = str(data.status or "").upper() if status in {"NS", "FT", "POSTPONED", "CANC", "ABD"}: return False return data.current_score_home is not None and data.current_score_away is not None def _apply_market_consistency( self, rows: List[Dict[str, Any]], data: MatchData, prediction: FullMatchPrediction, ) -> List[Dict[str, Any]]: if not rows: return rows is_live = self._is_live_match(data) current_goals = ( int(data.current_score_home or 0) + int(data.current_score_away or 0) if is_live else 0 ) both_scored = ( bool(data.current_score_home and data.current_score_home > 0) and bool(data.current_score_away and data.current_score_away > 0) ) predicted_total = float(getattr(prediction, "total_xg", 0.0) or 0.0) over25_prob = float(getattr(prediction, "over_25_prob", 0.0) or 0.0) over35_prob = float(getattr(prediction, "over_35_prob", 0.0) or 0.0) btts_yes_prob = float(getattr(prediction, "btts_yes_prob", 0.0) or 0.0) home_xg = float(getattr(prediction, "home_xg", 0.0) or 0.0) away_xg = float(getattr(prediction, "away_xg", 0.0) or 0.0) xg_gap = abs(home_xg - away_xg) ht_under05_prob = float(getattr(prediction, "ht_under_05_prob", 0.0) or 0.0) ht_over05_prob = float(getattr(prediction, "ht_over_05_prob", 0.0) or 0.0) ht_home_prob = float(getattr(prediction, "ht_home_prob", 0.0) or 0.0) ht_draw_prob = float(getattr(prediction, "ht_draw_prob", 0.0) or 0.0) ht_away_prob = float(getattr(prediction, "ht_away_prob", 0.0) or 0.0) htft_probs = getattr(prediction, "ht_ft_probs", {}) or {} first_half_goal_from_htft = float( sum( float(prob or 0.0) for outcome, prob in htft_probs.items() if str(outcome).startswith(("1/", "2/")) ) ) adjusted: List[Dict[str, Any]] = [] for row in rows: market = str(row.get("market") or "") pick = str(row.get("pick") or "") probability = float(row.get("probability") or 0.0) confidence = float(row.get("confidence") or (probability * 100.0)) reasons = list(row.get("consistency_reasons") or []) impossible = False if is_live: if market == "BTTS" and pick == "KG Yok" and both_scored: impossible = True reasons.append("live_state_impossible_market") line = self._goal_line_for_market(market) if line is not None and "Alt" in pick and current_goals > line: impossible = True reasons.append("live_score_exceeds_under_line") if impossible: continue penalty = 0.0 line = self._goal_line_for_market(market) if line is not None: if "Alt" in pick and predicted_total > (line + 0.35): penalty += min(32.0, (predicted_total - line) * 18.0) reasons.append("score_model_conflicts_with_under_pick") if "Üst" in pick and predicted_total < (line - 0.35): penalty += min(24.0, (line - predicted_total) * 16.0) reasons.append("score_model_conflicts_with_over_pick") if market == "OU35" and "Alt" in pick: if over25_prob >= 0.78: penalty += 14.0 reasons.append("market_stack_conflict_over25") if btts_yes_prob >= 0.74: penalty += 10.0 reasons.append("market_stack_conflict_btts") if is_live and current_goals >= 3: penalty += 24.0 reasons.append("live_total_goals_close_to_line") if market == "BTTS" and pick == "KG Yok" and predicted_total >= 2.8: penalty += 16.0 reasons.append("score_model_conflicts_with_btts_no") if market == "MS": if pick == "X" and xg_gap >= 0.95: penalty += 18.0 reasons.append("score_model_conflicts_with_draw_pick") if pick == "1" and (away_xg - home_xg) >= 0.85: penalty += 20.0 reasons.append("score_model_conflicts_with_home_pick") if pick == "2" and (home_xg - away_xg) >= 0.85: penalty += 20.0 reasons.append("score_model_conflicts_with_away_pick") if market == "HT_OU05": if "Alt" in pick: if max(ht_home_prob, ht_away_prob) >= 0.42: penalty += 22.0 reasons.append("first_half_result_conflicts_with_goalless_half") if first_half_goal_from_htft >= 0.45: penalty += 20.0 reasons.append("first_half_htft_conflicts_with_goalless_half") if "Üst" in pick and ht_draw_prob >= 0.56 and ht_under05_prob >= 0.54: penalty += 14.0 reasons.append("first_half_draw_conflicts_with_goal_pick") if market == "HT" and pick in {"1", "2"} and ht_under05_prob >= 0.56: penalty += 28.0 reasons.append("first_half_goalless_conflicts_with_result_pick") if market == "HTFT": htft_first_half = pick.split("/")[0] if "/" in pick else "" if htft_first_half in {"1", "2"} and ht_under05_prob >= 0.56: penalty += 34.0 reasons.append("first_half_goalless_conflicts_with_htft_pick") if htft_first_half == "X" and ht_over05_prob >= 0.68: penalty += 16.0 reasons.append("first_half_goal_pressure_conflicts_with_htft_draw") if penalty > 0: probability *= max(0.35, 1.0 - (penalty / 100.0)) confidence = max(1.0, confidence - penalty) next_row = dict(row) next_row["probability"] = round(probability, 4) next_row["confidence"] = round(confidence, 1) if reasons: next_row["consistency_reasons"] = reasons adjusted.append(next_row) return adjusted def _build_surprise_profile( self, data: MatchData, prediction: FullMatchPrediction, ) -> Dict[str, Any]: reasons: List[str] = [] score = 22.0 ms_home = float(getattr(prediction, "ms_home_prob", 0.0) or 0.0) ms_draw = float(getattr(prediction, "ms_draw_prob", 0.0) or 0.0) ms_away = float(getattr(prediction, "ms_away_prob", 0.0) or 0.0) top_prob = max(ms_home, ms_draw, ms_away) second_prob = sorted([ms_home, ms_draw, ms_away], reverse=True)[1] parity_gap = top_prob - second_prob total_xg = float(getattr(prediction, "total_xg", 0.0) or 0.0) btts_yes = float(getattr(prediction, "btts_yes_prob", 0.0) or 0.0) over35 = float(getattr(prediction, "over_35_prob", 0.0) or 0.0) if parity_gap <= 0.08: score += 18.0 reasons.append("balanced_match_risk") if ms_draw >= 0.30: score += 14.0 reasons.append("draw_probability_elevated") if total_xg >= 3.25: score += 10.0 reasons.append("high_total_goal_volatility") if btts_yes >= 0.68: score += 8.0 reasons.append("mutual_goal_pressure") if over35 >= 0.52: score += 8.0 reasons.append("late_goal_swing_risk") if data.lineup_source == "probable_xi": score += 8.0 reasons.append("lineup_probable_not_confirmed") if data.lineup_source == "none": score += 12.0 reasons.append("lineup_unavailable") if not data.referee_name: score += 6.0 reasons.append("missing_referee") if self._is_live_match(data): current_goals = int(data.current_score_home or 0) + int(data.current_score_away or 0) if current_goals >= 3: score += 18.0 reasons.append("live_match_open_state") elif current_goals >= 2: score += 10.0 reasons.append("live_match_active_state") score = max(0.0, min(100.0, score)) if score >= 75: comment = "Bu maçta sürpriz ve kırılma riski yüksek. Ana tahminler yatabilir; tekli yerine daha temkinli yaklaşım gerekir." elif score >= 55: comment = "Bu maçta belirgin sürpriz sinyalleri var. Tahminler yön verse de kupon kararında temkinli olunmalı." elif score >= 40: comment = "Maçta orta seviyede belirsizlik var. Tahminler yorum için faydalı ama güven payı sınırlı." else: comment = "Sürpriz riski düşük görünüyor. Tahminler normal güven bandında okunabilir." return { "score": round(score, 1), "comment": comment, "reasons": list(dict.fromkeys(reasons))[:6], } @staticmethod def _normalize_v25_probs(market: str, probs: Dict[str, Any]) -> Dict[str, float]: out: Dict[str, float] = {} for key, value in (probs or {}).items(): if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}: norm_key = "over" if key == "Over" else "under" if key == "Under" else str(key).lower() elif market == "BTTS": norm_key = "yes" if key == "Yes" else "no" if key == "No" else str(key).lower() elif market == "OE": norm_key = "odd" if key == "Odd" else "even" if key == "Even" else str(key).lower() else: norm_key = str(key) out[norm_key] = round(float(value), 4) return out def _merge_v25_market_rows( self, rows: List[Dict[str, Any]], odds: Dict[str, Any], v25_signal: Optional[Dict[str, Any]], ) -> List[Dict[str, Any]]: if not v25_signal: return rows by_market = {row.get("market"): dict(row) for row in rows} for market, payload in v25_signal.items(): if market == "value_bets" or not isinstance(payload, dict): continue pick = str(payload.get("pick") or "") if not self._market_has_real_pick_odds(market, pick, odds): continue probability = float(payload.get("probability") or 0.0) by_market[market] = { "market": market, "pick": self._v25_pick_to_market_pick(market, pick), "probability": round(probability, 4), "confidence": round(float(payload.get("confidence") or probability * 100.0), 1), "odds": self._v25_market_odds(odds, market, pick), } preferred_order = [ "MS", "DC", "OU15", "OU25", "OU35", "BTTS", "HT", "HT_OU05", "HT_OU15", "HTFT", "OE", "CARDS", "HCAP", ] return [by_market[key] for key in preferred_order if key in by_market] def _merge_v25_market_board( self, market_board: Dict[str, Any], v25_signal: Optional[Dict[str, Any]], ) -> Dict[str, Any]: if not v25_signal: return market_board merged = dict(market_board) for market, payload in v25_signal.items(): if market == "value_bets" or not isinstance(payload, dict): continue merged[market] = { "pick": self._v25_pick_to_market_pick(market, str(payload.get("pick") or "")), "confidence": round(float(payload.get("confidence") or 0.0), 1), "probs": self._normalize_v25_probs(market, payload.get("probs") or {}), } return merged def _build_market_rows( self, data: MatchData, pred: FullMatchPrediction, v25_signal: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: odds = data.odds_data rows = [ { "market": "MS", "pick": pred.ms_pick, "probability": round( float(max(pred.ms_home_prob, pred.ms_draw_prob, pred.ms_away_prob)), 4, ), "confidence": round(float(pred.ms_confidence), 1), "odds": round(self._real_market_odds(odds, {"1": "ms_h", "X": "ms_d", "2": "ms_a"}.get(pred.ms_pick, "ms_h")), 2), }, { "market": "DC", "pick": pred.dc_pick, "probability": round( float(max(pred.dc_1x_prob, pred.dc_x2_prob, pred.dc_12_prob)), 4, ), "confidence": round(float(pred.dc_confidence), 1), "odds": round(float(odds.get(f"dc_{pred.dc_pick.lower()}", 1.0)), 2), }, { "market": "OU15", "pick": pred.ou15_pick, "probability": round(float(pred.over_15_prob if "Üst" in pred.ou15_pick or "Over" in pred.ou15_pick else pred.under_15_prob), 4), "confidence": round(float(pred.ou15_confidence), 1), "odds": round(float(odds.get("ou15_o" if "Üst" in pred.ou15_pick or "Over" in pred.ou15_pick else "ou15_u", 1.0)), 2), }, { "market": "OU25", "pick": pred.ou25_pick, "probability": round(float(pred.over_25_prob if "Üst" in pred.ou25_pick or "Over" in pred.ou25_pick else pred.under_25_prob), 4), "confidence": round(float(pred.ou25_confidence), 1), "odds": round(float(odds.get("ou25_o" if "Üst" in pred.ou25_pick or "Over" in pred.ou25_pick else "ou25_u", 1.0)), 2), }, { "market": "OU35", "pick": pred.ou35_pick, "probability": round(float(pred.over_35_prob if "Üst" in pred.ou35_pick or "Over" in pred.ou35_pick else pred.under_35_prob), 4), "confidence": round(float(pred.ou35_confidence), 1), "odds": round(float(odds.get("ou35_o" if "Üst" in pred.ou35_pick or "Over" in pred.ou35_pick else "ou35_u", 1.0)), 2), }, { "market": "BTTS", "pick": pred.btts_pick, "probability": round(float(pred.btts_yes_prob if "Var" in pred.btts_pick or "Yes" in pred.btts_pick else pred.btts_no_prob), 4), "confidence": round(float(pred.btts_confidence), 1), "odds": round(float(odds.get("btts_y" if "Var" in pred.btts_pick or "Yes" in pred.btts_pick else "btts_n", 1.0)), 2), }, { "market": "HT", "pick": pred.ht_pick, "probability": round(float(max(pred.ht_home_prob, pred.ht_draw_prob, pred.ht_away_prob)), 4), "confidence": round(float(pred.ht_confidence), 1), "odds": round(float(odds.get({"1": "ht_h", "X": "ht_d", "2": "ht_a"}.get(pred.ht_pick, "ht_h"), 1.0)), 2), }, { "market": "HT_OU05", "pick": pred.ht_ou_pick, "probability": round(float(pred.ht_over_05_prob if "Üst" in pred.ht_ou_pick or "Over" in pred.ht_ou_pick else pred.ht_under_05_prob), 4), "confidence": round(float(max(pred.ht_over_05_prob, pred.ht_under_05_prob) * 100), 1), "odds": round(float(odds.get("ht_ou05_o" if "Üst" in pred.ht_ou_pick or "Over" in pred.ht_ou_pick else "ht_ou05_u", 1.0)), 2), }, { "market": "HT_OU15", "pick": pred.ht_ou15_pick, "probability": round(float(pred.ht_over_15_prob if "Üst" in pred.ht_ou15_pick or "Over" in pred.ht_ou15_pick else pred.ht_under_15_prob), 4), "confidence": round(float(max(pred.ht_over_15_prob, pred.ht_under_15_prob) * 100), 1), "odds": round(float(odds.get("ht_ou15_o" if "Üst" in pred.ht_ou15_pick or "Over" in pred.ht_ou15_pick else "ht_ou15_u", 1.0)), 2), }, { "market": "OE", "pick": pred.odd_even_pick, "probability": round(float(pred.odd_prob if "Tek" in pred.odd_even_pick else pred.even_prob), 4), "confidence": round(float(max(pred.odd_prob, pred.even_prob) * 100), 1), "odds": round(float(odds.get("oe_odd" if "Tek" in pred.odd_even_pick else "oe_even", 1.0)), 2), }, { "market": "CARDS", "pick": pred.card_pick, "probability": round(float(pred.cards_over_prob if "Üst" in pred.card_pick or "Over" in pred.card_pick else pred.cards_under_prob), 4), "confidence": round(float(pred.cards_confidence), 1), "odds": round(float(odds.get("cards_o" if "Üst" in pred.card_pick or "Over" in pred.card_pick else "cards_u", 1.0)), 2), }, { "market": "HCAP", "pick": pred.handicap_pick, "probability": round(float( pred.handicap_home_prob if pred.handicap_pick == "1" else pred.handicap_draw_prob if pred.handicap_pick == "X" else pred.handicap_away_prob ), 4), "confidence": round(float(pred.handicap_confidence), 1), "odds": round(float( odds.get( {"1": "hcap_h", "X": "hcap_d", "2": "hcap_a"}.get(pred.handicap_pick, "hcap_h"), 1.0, ) ), 2), }, ] # HT/FT Market - 9 possible outcomes htft_probs = pred.ht_ft_probs or {} if htft_probs: # Find the highest probability HT/FT outcome htft_labels = ("1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2") best_htft = max(htft_labels, key=lambda x: float(htft_probs.get(x, 0.0))) best_htft_prob = float(htft_probs.get(best_htft, 0.0)) # Map HT/FT labels to odds keys htft_odds_key = f"htft_{best_htft.replace('/', '').lower()}" # e.g., htft_11, htft_1x, htft_12 htft_odds = float(odds.get(htft_odds_key, 1.0)) rows.append({ "market": "HTFT", "pick": best_htft, "probability": round(best_htft_prob, 4), "confidence": round(best_htft_prob * 100, 1), "odds": round(htft_odds, 2), }) rows = [ row for row in rows if self._market_has_real_pick_odds( str(row.get("market") or ""), str(row.get("pick") or ""), odds, ) ] return self._merge_v25_market_rows(rows, odds, v25_signal) def _decorate_market_row( self, data: MatchData, prediction: FullMatchPrediction, quality: Dict[str, Any], row: Dict[str, Any], ) -> Dict[str, Any]: """ Decorate a raw market row with playability, grading, and staking. V20+Quant hybrid: - All existing V20+ safety gates preserved (lineup, risk, quality, conf) - Edge: EV formula → (prob × odds) - 1.0 (not simple prob - implied) - Staking: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll) - Grading: Edge-based → A(>10%), B(>5%), C(>2%), PASS """ market = str(row.get("market") or "") raw_conf = float(row.get("confidence") or 0.0) prob = float(row.get("probability") or 0.0) odd = float(row.get("odds") or 0.0) calibration = self.market_calibration.get(market, 0.85) calibrated_conf = max(1.0, min(99.0, raw_conf * calibration)) min_conf = self.market_min_conf.get(market, 55.0) # ── V2 Quant: EV Edge formula ────────────────────────────────── # Old: edge = prob - (1/odd) ← simple probability difference # New: edge = (prob × odd) - 1 ← Expected Value (what a quant uses) implied_prob = (1.0 / odd) if odd > 1.0 else 0.0 ev_edge = (prob * odd) - 1.0 if odd > 1.0 else 0.0 simple_edge = prob - implied_prob if implied_prob > 0 else 0.0 # ── V31: League-specific odds reliability ────────────────────── # Higher reliability → trust odds-based edge more in play_score # Lower reliability → lean more on model confidence, less on edge odds_rel = self.league_reliability.get( str(data.league_id or ""), 0.35 # default for unknown leagues ) # Edge weight: reliable league → edge matters more (up to 120%) # unreliable league → edge matters less (down to 60%) edge_multiplier = 0.60 + (odds_rel * 0.60) # range: 0.60 – 1.20 risk_level = str(prediction.risk_level or "MEDIUM").upper() risk_penalty = {"LOW": 0.0, "MEDIUM": 3.0, "HIGH": 8.0, "EXTREME": 12.0}.get( risk_level, 5.0, ) quality_label = str(quality.get("label") or "MEDIUM").upper() quality_penalty = {"HIGH": 0.0, "MEDIUM": 3.0, "LOW": 7.0}.get( quality_label, 5.0, ) home_n = len(data.home_lineup or []) away_n = len(data.away_lineup or []) lineup_missing = home_n < 9 or away_n < 9 lineup_sensitive = market in ("MS", "BTTS", "HT", "HTFT") lineup_penalty = 5.0 if lineup_missing and lineup_sensitive else 0.0 if data.lineup_source == "probable_xi" and lineup_sensitive: lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0))) lineup_penalty += max(1.0, (1.0 - lineup_conf) * 5.0) # V31: edge contribution weighted by league odds reliability base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier) play_score = max( 0.0, min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty), ) # ── V20+ Safety gates (PRESERVED) ───────────────────────────── min_play_score = self.market_min_play_score.get(market, 68.0) min_edge = self.market_min_edge.get(market, 0.02) reasons: List[str] = [] playable = True if calibrated_conf < min_conf: playable = False reasons.append("below_calibrated_conf_threshold") if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01: playable = False reasons.append("market_odds_missing") if risk_level in ("HIGH", "EXTREME") and quality_label == "LOW": playable = False reasons.append("high_risk_low_data_quality") if lineup_missing and lineup_sensitive: # 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: # 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 if odd > 1.0 and simple_edge < neg_edge_threshold: playable = False reasons.append(f"negative_model_edge_{simple_edge:+.3f}") if odd > 1.0 and ev_edge < min_edge: playable = False reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}") if play_score < min_play_score: playable = False reasons.append("insufficient_play_score") if not reasons: reasons.append("market_passed_all_gates") consistency_reasons = [ str(reason) for reason in row.get("consistency_reasons", []) if reason ] if consistency_reasons: reasons.extend(consistency_reasons) reasons = list(dict.fromkeys(reasons)) # ── V2 Quant: Edge-based grading (replaces play_score bands) ── if not playable: grade = "PASS" stake_units = 0.0 elif ev_edge > 0.10: grade = "A" # V2 Quant: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll) stake_units = self._kelly_stake(prob, odd) reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A") elif ev_edge > 0.05: grade = "B" stake_units = self._kelly_stake(prob, odd) reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B") elif ev_edge > 0.02: grade = "C" stake_units = self._kelly_stake(prob, odd) reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_C") else: # Passes all V20+ gates but no mathematical edge over bookie grade = "C" stake_units = 0.25 # minimum stake (conservative) reasons.append("no_ev_edge_minimum_stake") out = dict(row) out.update( { "raw_confidence": round(raw_conf, 1), "calibrated_confidence": round(calibrated_conf, 1), "min_required_confidence": round(min_conf, 1), "min_required_play_score": round(min_play_score, 1), "min_required_edge": round(min_edge, 4), "edge": round(ev_edge, 4), "implied_prob": round(implied_prob, 4), "ev_edge": round(ev_edge, 4), "odds_reliability": round(odds_rel, 4), "play_score": round(play_score, 1), "playable": playable, "bet_grade": grade, "stake_units": stake_units, "decision_reasons": reasons[:5], }, ) return out @staticmethod def _kelly_stake(true_prob: float, decimal_odds: float) -> float: """ Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll). Full Kelly: f* = ((b × p) - q) / b where b = odds - 1, p = true_prob, q = 1 - p Quarter-Kelly reduces variance and ruin risk on noisy sports data. Returns stake in units, capped at 3.0. """ if decimal_odds <= 1.0 or true_prob <= 0.0 or true_prob >= 1.0: return 0.25 # minimum fallback b = decimal_odds - 1.0 p = true_prob q = 1.0 - p f_star = ((b * p) - q) / b if f_star <= 0.0: return 0.25 # minimum fallback kelly_fraction = 0.25 # quarter-Kelly bankroll_units = 10.0 stake = f_star * kelly_fraction * bankroll_units stake = min(stake, 3.0) # cap return round(max(0.25, stake), 1) @staticmethod def _to_bet_summary_item(row: Dict[str, Any]) -> Dict[str, Any]: return { "market": row.get("market"), "pick": row.get("pick"), "raw_confidence": row.get("raw_confidence", row.get("confidence")), "calibrated_confidence": row.get("calibrated_confidence", row.get("confidence")), "bet_grade": row.get("bet_grade", "PASS"), "playable": bool(row.get("playable")), "stake_units": float(row.get("stake_units", 0.0)), "play_score": row.get("play_score", 0.0), "ev_edge": row.get("ev_edge", row.get("edge", 0.0)), "implied_prob": row.get("implied_prob", 0.0), "odds_reliability": row.get("odds_reliability", 0.35), "odds": row.get("odds", 0.0), "reasons": row.get("decision_reasons", []), } def _compute_data_quality(self, data: MatchData) -> Dict[str, Any]: if str(data.sport or "football").lower() == "basketball": return self._compute_basketball_data_quality(data) flags: List[str] = [] ms_keys = ("ms_h", "ms_d", "ms_a") has_ms = all(k in data.odds_data for k in ms_keys) has_market_depth = any(k not in ms_keys for k in data.odds_data.keys()) is_default_ms = ( abs(float(data.odds_data.get("ms_h", 0.0)) - self.DEFAULT_MS_H) < 1e-6 and abs(float(data.odds_data.get("ms_d", 0.0)) - self.DEFAULT_MS_D) < 1e-6 and abs(float(data.odds_data.get("ms_a", 0.0)) - self.DEFAULT_MS_A) < 1e-6 ) has_real_ms = has_ms and (has_market_depth or (not is_default_ms)) odds_score = 1.0 if has_real_ms else (0.6 if has_ms else 0.4) if odds_score < 1.0: flags.append("missing_full_ms_odds") home_n = len(data.home_lineup or []) away_n = len(data.away_lineup or []) lineup_score = min(home_n, away_n) / 11.0 if min(home_n, away_n) > 0 else 0.0 if data.lineup_source == "probable_xi": lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0))) lineup_score *= max(0.45, min(0.88, lineup_conf)) flags.append("lineup_probable_not_confirmed") if lineup_conf < 0.65: flags.append("lineup_projection_low_confidence") elif data.lineup_source == "none": flags.append("lineup_unavailable") if lineup_score < 0.7: flags.append("lineup_incomplete") ref_score = 1.0 if data.referee_name else 0.6 if not data.referee_name: flags.append("missing_referee") total_score = (odds_score * 0.45) + (lineup_score * 0.45) + (ref_score * 0.10) if total_score >= 0.8: label = "HIGH" elif total_score >= 0.55: label = "MEDIUM" else: label = "LOW" return { "label": label, "score": round(total_score, 3), "home_lineup_count": home_n, "away_lineup_count": away_n, "lineup_source": data.lineup_source, "lineup_confidence": round(float(getattr(data, "lineup_confidence", 0.0) or 0.0), 3), "flags": flags, } def _compute_basketball_data_quality(self, data: MatchData) -> Dict[str, Any]: flags: List[str] = [] has_ml = float(data.odds_data.get("ml_h", 0.0)) > 1.0 and float(data.odds_data.get("ml_a", 0.0)) > 1.0 has_total = ( float(data.odds_data.get("tot_line", 0.0)) > 0.0 and float(data.odds_data.get("tot_o", 0.0)) > 1.0 and float(data.odds_data.get("tot_u", 0.0)) > 1.0 ) has_spread = ( "spread_home_line" in data.odds_data and float(data.odds_data.get("spread_h", 0.0)) > 1.0 and float(data.odds_data.get("spread_a", 0.0)) > 1.0 ) odds_components = [has_ml, has_total, has_spread] odds_score = sum(1.0 for x in odds_components if x) / 3.0 if not has_ml: flags.append("missing_moneyline_odds") if not has_total: flags.append("missing_total_odds") if not has_spread: flags.append("missing_spread_odds") # Basketball live lineup/referee coverage is structurally lower in this project. # Keep neutral baseline and rely mostly on odds depth. lineup_score = 0.7 ref_score = 0.7 total_score = (odds_score * 0.75) + (lineup_score * 0.15) + (ref_score * 0.10) if total_score >= 0.75: label = "HIGH" elif total_score >= 0.52: label = "MEDIUM" else: label = "LOW" return { "label": label, "score": round(total_score, 3), "home_lineup_count": len(data.home_lineup or []), "away_lineup_count": len(data.away_lineup or []), "lineup_source": data.lineup_source, "flags": flags, } def _build_reasoning_factors( self, data: MatchData, prediction: FullMatchPrediction, quality: Dict[str, Any], ) -> List[str]: factors: List[str] = [] if prediction.odds_confidence >= prediction.team_confidence: factors.append("market_signal_dominant") else: factors.append("team_form_signal_dominant") if prediction.player_confidence >= 60: factors.append("lineup_signal_strong") elif not data.home_lineup or not data.away_lineup: factors.append("lineup_signal_weak") if data.lineup_source == "probable_xi": factors.append("lineup_probable_xi_used") if prediction.is_surprise_risk: factors.append("upset_risk_detected") if quality["label"] == "LOW": factors.append("limited_data_confidence") if prediction.risk_warnings: factors.extend([f"risk:{w}" for w in prediction.risk_warnings[:2]]) return factors @staticmethod def _to_float(value: Any, default: float) -> float: try: if value is None: return default return float(value) except Exception: return default @staticmethod def _normalize_text(value: Any) -> str: text = str(value or "").casefold().replace("i̇", "i") return " ".join(text.split()) def _selection_value( self, selections: Dict[str, Any], aliases: Tuple[str, ...], default: float, ) -> float: if not isinstance(selections, dict): return default normalized_aliases = {self._normalize_text(alias) for alias in aliases} for key, value in selections.items(): key_norm = self._normalize_text(key) if key_norm in normalized_aliases: return self._to_float(value, default) # Secondary match for entries like "2,5 Üst" or "Toplam Alt" for key, value in selections.items(): key_norm = self._normalize_text(key) if any(alias in key_norm for alias in normalized_aliases): return self._to_float(value, default) return default def _parse_json_dict(self, payload: Any) -> Optional[Dict[str, Any]]: if isinstance(payload, str): try: payload = json.loads(payload) except Exception: return None return payload if isinstance(payload, dict) else None def get_daily_bankers(self, count: int = 3) -> List[Dict[str, Any]]: """ Identifies the safest, highest value bets for the next 24 hours. """ now_ms = int(time.time() * 1000) horizon_ms = now_ms + (24 * 60 * 60 * 1000) with psycopg2.connect(self.dsn) as conn: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT m.id, m.match_name, m.mst_utc FROM matches m WHERE m.mst_utc >= %s AND m.mst_utc <= %s AND m.status = 'NS' AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id) ORDER BY m.mst_utc ASC LIMIT 50 """, (now_ms, horizon_ms)) matches = cur.fetchall() potential_bankers = [] print(f"🔍 Scanning {len(matches)} upcoming matches for Bankers...") for match in matches: try: data = self._load_match_data(match['id']) if data is None: continue result = self.analyze_match(match['id']) if result and 'main_pick' in result: pick = result['main_pick'] conf = pick.get('calibrated_confidence', pick.get('confidence', 0)) odds = pick.get('odds', 0) market = pick.get('market', '') pick_name = pick.get('pick', '') # Banker Criteria: High Confidence (>75%) AND Decent Odds (>1.30) if conf >= 75.0 and odds >= 1.30: score = conf * (odds - 1.0) potential_bankers.append({ "match_id": match['id'], "match_name": match['match_name'] or f"{data.home_team_name} vs {data.away_team_name}", "league": data.league_name, "pick": f"{market} - {pick_name}", "confidence": conf, "odds": odds, "value_score": score }) except Exception: pass potential_bankers.sort(key=lambda x: x['value_score'], reverse=True) return potential_bankers[:count] _orchestrator: Optional[SingleMatchOrchestrator] = None def get_single_match_orchestrator() -> SingleMatchOrchestrator: global _orchestrator if _orchestrator is None: _orchestrator = SingleMatchOrchestrator() return _orchestrator