From 94c7a4481a00f48b7742d086ecb1c6eae4b09105 Mon Sep 17 00:00:00 2001 From: Fahri Can Date: Sun, 17 May 2026 02:17:22 +0300 Subject: [PATCH] main --- ai-engine/config/ensemble_config.yaml | 11 + ai-engine/core/engines/__init__.py | 8 +- ai-engine/core/engines/odds_predictor.py | 237 - ai-engine/core/engines/player_predictor.py | 214 +- ai-engine/core/engines/referee_predictor.py | 188 - ai-engine/core/engines/team_predictor.py | 286 - ai-engine/features/upset_engine_v2.py | 4 - ai-engine/main.py | 64 +- ai-engine/models/calibration.py | 6 + ai-engine/models/league_model.py | 191 + ai-engine/models/v20_ensemble.py | 1289 -- ai-engine/models/v25_ensemble.py | 96 +- ai-engine/reports/backtest_consistency.json | 160 + .../reports/backtest_league_results.json | 7209 ++++++++++ ai-engine/reports/backtest_real_odds.json | 5 + ai-engine/reports/backtest_results.json | 267 + .../league_models/league_models_report.json | 10834 ++++++++++++++++ ai-engine/schemas/match_data.py | 40 + ai-engine/schemas/prediction.py | 292 + ai-engine/scripts/backfill_calibration.py | 510 + ai-engine/scripts/backtest_consistency.py | 352 + ai-engine/scripts/backtest_league_models.py | 310 + ai-engine/scripts/backtest_real.py | 313 +- ai-engine/scripts/extract_training_data.py | 191 +- .../scripts/extract_training_data_colab.ipynb | 166 + .../scripts/run_backtest_and_calibrate.py | 806 ++ ai-engine/scripts/train_league_models.py | 459 + .../scripts/train_league_models_colab.ipynb | 259 + ai-engine/scripts/train_v25_colab.ipynb | 108 + ai-engine/scripts/train_v25_pro.py | 33 + ai-engine/scripts/train_v25_pro_colab.ipynb | 343 + ai-engine/services/betting_brain.py | 59 +- ai-engine/services/match_commentary.py | 8 +- ai-engine/services/orchestrator/__init__.py | 28 + ai-engine/services/orchestrator/basketball.py | 538 + ai-engine/services/orchestrator/coupon.py | 444 + .../services/orchestrator/data_loader.py | 1111 ++ .../services/orchestrator/feature_builder.py | 498 + ai-engine/services/orchestrator/htms.py | 231 + .../services/orchestrator/market_board.py | 1470 +++ ai-engine/services/orchestrator/prediction.py | 662 + ai-engine/services/orchestrator/reversal.py | 469 + .../services/orchestrator/upper_brain.py | 350 + ai-engine/services/orchestrator/utils.py | 174 + .../services/single_match_orchestrator.py | 5183 +------- ai-engine/tests/test_engine_null_safety.py | 75 - .../tests/test_single_match_orchestrator.py | 5 +- prisma.config.ts | 14 +- .../migration.sql | 10 + prisma/schema.prisma | 31 +- qualified_leagues.json | 710 +- .../feeder/feeder-persistence.service.ts | 1 + src/tasks/prediction-settlement.task.ts | 112 + 53 files changed, 29602 insertions(+), 7832 deletions(-) delete mode 100755 ai-engine/core/engines/odds_predictor.py delete mode 100755 ai-engine/core/engines/referee_predictor.py delete mode 100755 ai-engine/core/engines/team_predictor.py create mode 100644 ai-engine/models/league_model.py delete mode 100644 ai-engine/models/v20_ensemble.py create mode 100644 ai-engine/reports/backtest_consistency.json create mode 100644 ai-engine/reports/backtest_league_results.json create mode 100644 ai-engine/reports/backtest_real_odds.json create mode 100644 ai-engine/reports/backtest_results.json create mode 100644 ai-engine/reports/league_models/league_models_report.json create mode 100644 ai-engine/schemas/match_data.py create mode 100644 ai-engine/schemas/prediction.py create mode 100644 ai-engine/scripts/backfill_calibration.py create mode 100644 ai-engine/scripts/backtest_consistency.py create mode 100644 ai-engine/scripts/backtest_league_models.py create mode 100644 ai-engine/scripts/extract_training_data_colab.ipynb create mode 100644 ai-engine/scripts/run_backtest_and_calibrate.py create mode 100644 ai-engine/scripts/train_league_models.py create mode 100644 ai-engine/scripts/train_league_models_colab.ipynb create mode 100644 ai-engine/scripts/train_v25_colab.ipynb create mode 100644 ai-engine/scripts/train_v25_pro_colab.ipynb create mode 100644 ai-engine/services/orchestrator/__init__.py create mode 100644 ai-engine/services/orchestrator/basketball.py create mode 100644 ai-engine/services/orchestrator/coupon.py create mode 100644 ai-engine/services/orchestrator/data_loader.py create mode 100644 ai-engine/services/orchestrator/feature_builder.py create mode 100644 ai-engine/services/orchestrator/htms.py create mode 100644 ai-engine/services/orchestrator/market_board.py create mode 100644 ai-engine/services/orchestrator/prediction.py create mode 100644 ai-engine/services/orchestrator/reversal.py create mode 100644 ai-engine/services/orchestrator/upper_brain.py create mode 100644 ai-engine/services/orchestrator/utils.py delete mode 100644 ai-engine/tests/test_engine_null_safety.py create mode 100644 prisma/migrations/20260515120000_add_opening_value_and_odds_movement/migration.sql diff --git a/ai-engine/config/ensemble_config.yaml b/ai-engine/config/ensemble_config.yaml index f153822..287b4a6 100755 --- a/ai-engine/config/ensemble_config.yaml +++ b/ai-engine/config/ensemble_config.yaml @@ -1,3 +1,14 @@ +model_ensemble: + xgb_weight: 0.50 + lgb_weight: 0.50 + temperature: 1.5 + default_ms_odds: + home: 2.65 + draw: 3.20 + away: 2.65 + elo_staleness_days: 14 + odds_staleness_hours: 48 + engine_weights: team: 0.30 player: 0.25 diff --git a/ai-engine/core/engines/__init__.py b/ai-engine/core/engines/__init__.py index 6cf3438..fe7af8f 100755 --- a/ai-engine/core/engines/__init__.py +++ b/ai-engine/core/engines/__init__.py @@ -1,16 +1,10 @@ # ai-engine/core/engines/__init__.py """ -V20 Ensemble Prediction Engines +Prediction Engines """ -from .team_predictor import TeamPredictorEngine, get_team_predictor from .player_predictor import PlayerPredictorEngine, get_player_predictor -from .odds_predictor import OddsPredictorEngine, get_odds_predictor -from .referee_predictor import RefereePredictorEngine, get_referee_predictor __all__ = [ - "TeamPredictorEngine", "get_team_predictor", "PlayerPredictorEngine", "get_player_predictor", - "OddsPredictorEngine", "get_odds_predictor", - "RefereePredictorEngine", "get_referee_predictor" ] diff --git a/ai-engine/core/engines/odds_predictor.py b/ai-engine/core/engines/odds_predictor.py deleted file mode 100755 index f27ae23..0000000 --- a/ai-engine/core/engines/odds_predictor.py +++ /dev/null @@ -1,237 +0,0 @@ -""" -Odds Predictor Engine - V20 Ensemble Component -Uses market odds and Poisson mathematics for predictions. - -Weight: 30% in ensemble -""" - -import os -import sys -from typing import Dict, Optional -from dataclasses import dataclass - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from features.poisson_engine import get_poisson_engine -from features.value_calculator import get_value_calculator - - -@dataclass -class OddsPrediction: - """Odds engine prediction output.""" - # Market-implied probabilities - market_home_prob: float = 0.33 - market_draw_prob: float = 0.33 - market_away_prob: float = 0.33 - - # Poisson xG - poisson_home_xg: float = 1.3 - poisson_away_xg: float = 1.1 - - # Over/Under probabilities - over_15_prob: float = 0.75 - over_25_prob: float = 0.55 - over_35_prob: float = 0.30 - - # BTTS - btts_yes_prob: float = 0.50 - - # Most likely scores - most_likely_score: str = "1-1" - second_likely_score: str = "1-0" - third_likely_score: str = "2-1" - - # Value bet opportunities - value_bets: Optional[list] = None - - confidence: float = 0.0 - - def __post_init__(self): - if self.value_bets is None: - self.value_bets = [] - - def to_dict(self) -> dict: - return { - "market_home_prob": round(self.market_home_prob * 100, 1), - "market_draw_prob": round(self.market_draw_prob * 100, 1), - "market_away_prob": round(self.market_away_prob * 100, 1), - "poisson_home_xg": round(self.poisson_home_xg, 2), - "poisson_away_xg": round(self.poisson_away_xg, 2), - "over_15_prob": round(self.over_15_prob * 100, 1), - "over_25_prob": round(self.over_25_prob * 100, 1), - "over_35_prob": round(self.over_35_prob * 100, 1), - "btts_yes_prob": round(self.btts_yes_prob * 100, 1), - "most_likely_score": self.most_likely_score, - "second_likely_score": self.second_likely_score, - "third_likely_score": self.third_likely_score, - "value_bets": self.value_bets, - "confidence": round(self.confidence, 1) - } - - -class OddsPredictorEngine: - """ - Odds-based prediction engine. - - Uses: - - Market odds to extract implied probabilities - - Poisson distribution for mathematical xG - - Value calculator for EV+ opportunities - """ - - def __init__(self): - self.poisson_engine = get_poisson_engine() - try: - self.value_calc = get_value_calculator() - except Exception: - self.value_calc = None # type: ignore[assignment] - self.default_ms_h = 2.65 - self.default_ms_d = 3.20 - self.default_ms_a = 2.65 - print("βœ… OddsPredictorEngine initialized") - - def _odds_to_prob(self, odds: float) -> float: - """Convert decimal odds to probability.""" - try: - odds = float(odds) - except (TypeError, ValueError): - return 0.0 - if odds <= 1.0: - return 0.0 - return 1.0 / odds - - def predict(self, - odds_data: Dict[str, float], - home_goals_avg: float = 1.5, - home_conceded_avg: float = 1.2, - away_goals_avg: float = 1.2, - away_conceded_avg: float = 1.4) -> OddsPrediction: - """ - Generate odds-based prediction. - - Args: - odds_data: Dict with keys like 'ms_h', 'ms_d', 'ms_a', 'ou25_o', 'btts_y' - home_goals_avg: Home team's average goals scored - home_conceded_avg: Home team's average goals conceded - away_goals_avg: Away team's average goals scored - away_conceded_avg: Away team's average goals conceded - - Returns: - OddsPrediction with market and Poisson analysis - """ - - # 1. Extract market probabilities from odds - ms_h = odds_data.get("ms_h", self.default_ms_h) - ms_d = odds_data.get("ms_d", self.default_ms_d) - ms_a = odds_data.get("ms_a", self.default_ms_a) - - # Remove vig to get fair probabilities - raw_probs = [ - self._odds_to_prob(ms_h), - self._odds_to_prob(ms_d), - self._odds_to_prob(ms_a) - ] - total = sum(raw_probs) or 1 - - market_home = raw_probs[0] / total - market_draw = raw_probs[1] / total - market_away = raw_probs[2] / total - - # 2. Poisson prediction - poisson_pred = self.poisson_engine.predict( - home_goals_avg, home_conceded_avg, - away_goals_avg, away_conceded_avg - ) - - # 3. Get most likely scores - likely_scores = poisson_pred.most_likely_scores[:3] if poisson_pred.most_likely_scores else [] - score_1 = likely_scores[0]["score"] if len(likely_scores) > 0 else "1-1" - score_2 = likely_scores[1]["score"] if len(likely_scores) > 1 else "1-0" - score_3 = likely_scores[2]["score"] if len(likely_scores) > 2 else "2-1" - - # 4. Value bet detection - value_bets = [] - - # Check if our Poisson model disagrees with market significantly - if abs(poisson_pred.home_win_prob - market_home) > 0.10: - if poisson_pred.home_win_prob > market_home: - value_bets.append({ - "market": "MS 1", - "edge": round((poisson_pred.home_win_prob - market_home) * 100, 1), - "confidence": "medium" - }) - else: - value_bets.append({ - "market": "MS 2", - "edge": round((poisson_pred.away_win_prob - market_away) * 100, 1), - "confidence": "medium" - }) - - # O/U value check - ou25_o = odds_data.get("ou25_o", 1.9) - market_over25 = self._odds_to_prob(ou25_o) - if abs(poisson_pred.over_25_prob - market_over25) > 0.08: - pick = "2.5 Üst" if poisson_pred.over_25_prob > market_over25 else "2.5 Alt" - edge = abs(poisson_pred.over_25_prob - market_over25) * 100 - value_bets.append({ - "market": pick, - "edge": round(edge, 1), - "confidence": "high" if edge > 10 else "medium" - }) - - # Calculate confidence - # Higher when market and Poisson agree - agreement = 1.0 - abs(poisson_pred.home_win_prob - market_home) - confidence = 50.0 + (agreement * 40) + (len(value_bets) * 5) - - return OddsPrediction( - market_home_prob=market_home, - market_draw_prob=market_draw, - market_away_prob=market_away, - poisson_home_xg=poisson_pred.home_xg, - poisson_away_xg=poisson_pred.away_xg, - over_15_prob=poisson_pred.over_15_prob, - over_25_prob=poisson_pred.over_25_prob, - over_35_prob=poisson_pred.over_35_prob, - btts_yes_prob=poisson_pred.btts_yes_prob, - most_likely_score=score_1, - second_likely_score=score_2, - third_likely_score=score_3, - value_bets=value_bets, - confidence=min(99.9, confidence) - ) - - -# Singleton -_engine: Optional[OddsPredictorEngine] = None - - -def get_odds_predictor() -> OddsPredictorEngine: - global _engine - if _engine is None: - _engine = OddsPredictorEngine() - return _engine - - -if __name__ == "__main__": - engine = get_odds_predictor() - - print("\nπŸ§ͺ Odds Predictor Engine Test") - print("=" * 50) - - pred = engine.predict( - odds_data={ - "ms_h": 1.85, - "ms_d": 3.40, - "ms_a": 4.20, - "ou25_o": 1.90 - }, - home_goals_avg=1.8, - home_conceded_avg=1.0, - away_goals_avg=1.2, - away_conceded_avg=1.5 - ) - - print(f"\nπŸ“Š Prediction:") - for k, v in pred.to_dict().items(): - print(f" {k}: {v}") diff --git a/ai-engine/core/engines/player_predictor.py b/ai-engine/core/engines/player_predictor.py index ba6c154..d4c43ed 100755 --- a/ai-engine/core/engines/player_predictor.py +++ b/ai-engine/core/engines/player_predictor.py @@ -24,32 +24,29 @@ class PlayerPrediction: extract_training_data.py so that inference values match the distribution the model was trained on (~3-36 range). """ - home_squad_quality: float = 12.0 # training-scale composite (~3-36) + home_squad_quality: float = 12.0 away_squad_quality: float = 12.0 - squad_diff: float = 0.0 # home - away (training scale) + squad_diff: float = 0.0 home_key_players: int = 0 away_key_players: int = 0 - home_missing_impact: float = 0.0 # 0-1, how much weaker due to missing players + home_missing_impact: float = 0.0 away_missing_impact: float = 0.0 - home_goals_form: int = 0 # Goals in last 5 matches + home_goals_form: int = 0 away_goals_form: int = 0 + home_lineup_goals_per90: float = 0.0 + away_lineup_goals_per90: float = 0.0 + home_lineup_assists_per90: float = 0.0 + away_lineup_assists_per90: float = 0.0 + home_squad_continuity: float = 0.5 + away_squad_continuity: float = 0.5 + home_top_scorer_form: int = 0 + away_top_scorer_form: int = 0 + home_avg_player_exp: float = 0.0 + away_avg_player_exp: float = 0.0 + home_goals_diversity: float = 0.0 + away_goals_diversity: float = 0.0 lineup_available: bool = False confidence: float = 0.0 - - def to_dict(self) -> dict: - return { - "home_squad_quality": round(self.home_squad_quality, 1), - "away_squad_quality": round(self.away_squad_quality, 1), - "squad_diff": round(self.squad_diff, 1), - "home_key_players": self.home_key_players, - "away_key_players": self.away_key_players, - "home_missing_impact": round(self.home_missing_impact, 2), - "away_missing_impact": round(self.away_missing_impact, 2), - "home_goals_form": self.home_goals_form, - "away_goals_form": self.away_goals_form, - "lineup_available": self.lineup_available, - "confidence": round(self.confidence, 1) - } class PlayerPredictorEngine: @@ -90,8 +87,9 @@ class PlayerPredictorEngine: """ # Get squad features + home_analysis = None + away_analysis = None if home_lineup and away_lineup: - # Use provided lineups (for live matches) home_analysis = self.squad_engine.analyze_squad_from_list( home_lineup, home_team_id ) @@ -99,7 +97,6 @@ class PlayerPredictorEngine: away_lineup, away_team_id ) lineup_available = True - # Build features dict from analysis objects features = { "home_starting_11": home_analysis.starting_count or 11, "home_goals_last_5": home_analysis.total_goals_last_5, @@ -113,7 +110,6 @@ class PlayerPredictorEngine: "away_forwards": away_analysis.forward_count or 2, } elif match_id: - # Try to get from database try: features = self.squad_engine.get_features( match_id, home_team_id, away_team_id @@ -132,58 +128,42 @@ class PlayerPredictorEngine: home_team_id, away_team_id ) lineup_available = False - - # Extract features + home_goals = int(features.get("home_goals_last_5", 0)) away_goals = int(features.get("away_goals_last_5", 0)) home_key = int(features.get("home_key_players", 0)) away_key = int(features.get("away_key_players", 0)) - home_assists = features.get("home_assists_last_5", 0) - away_assists = features.get("away_assists_last_5", 0) home_starting = features.get("home_starting_11", 11) away_starting = features.get("away_starting_11", 11) home_fwd = features.get("home_forwards", 2) away_fwd = features.get("away_forwards", 2) - - # Calculate squad quality β€” MUST match extract_training_data.py formula - # Formula: starting_count * 0.3 + goals * 2.0 + assists * 1.0 - # + key_players * 3.0 + fwd_count * 1.5 - # Typical range: ~3 – 36 (model trained on this distribution) - home_quality = ( - home_starting * 0.3 + - home_goals * 2.0 + - home_assists * 1.0 + - home_key * 3.0 + - home_fwd * 1.5 - ) - away_quality = ( - away_starting * 0.3 + - away_goals * 2.0 + - away_assists * 1.0 + - away_key * 3.0 + - away_fwd * 1.5 - ) - - # Squad difference + + # Squad quality β€” matches V25 extract_training_data.py:579 + home_quality = home_starting * 0.3 + home_key * 3.0 + home_fwd * 1.5 + away_quality = away_starting * 0.3 + away_key * 3.0 + away_fwd * 1.5 squad_diff = home_quality - away_quality - + # Missing player impact - # Priority: sidelined data (position-weighted) > lineup count (basic) if sidelined_data: home_impact, away_impact = self.sidelined_analyzer.analyze_match(sidelined_data) home_missing = min(1.0, max(0.0, home_impact.impact_score)) away_missing = min(1.0, max(0.0, away_impact.impact_score)) sidelined_available = True else: - # Fallback: basic lineup count method expected_xi = 11 actual_home_xi = features.get("home_starting_11", 11) actual_away_xi = features.get("away_starting_11", 11) home_missing = (expected_xi - actual_home_xi) / expected_xi if actual_home_xi < expected_xi else 0 away_missing = (expected_xi - actual_away_xi) / expected_xi if actual_away_xi < expected_xi else 0 sidelined_available = False - - # Confidence: more data sources = higher confidence + + # Player-level features (matches extract_training_data.py:594-650) + player_feats = self._compute_player_level_features( + home_lineup or [], away_lineup or [], + home_team_id, away_team_id, + home_analysis, away_analysis, + ) + confidence = 70.0 if lineup_available else 35.0 if home_goals + away_goals > 10: confidence += 15 @@ -191,7 +171,7 @@ class PlayerPredictorEngine: confidence += self.sidelined_analyzer.config.get("sidelined.confidence_boost", 10) if not lineup_available: confidence -= 5.0 - + return PlayerPrediction( home_squad_quality=home_quality, away_squad_quality=away_quality, @@ -202,9 +182,137 @@ class PlayerPredictorEngine: away_missing_impact=away_missing, home_goals_form=home_goals, away_goals_form=away_goals, + home_lineup_goals_per90=player_feats['home_lineup_goals_per90'], + away_lineup_goals_per90=player_feats['away_lineup_goals_per90'], + home_lineup_assists_per90=player_feats['home_lineup_assists_per90'], + away_lineup_assists_per90=player_feats['away_lineup_assists_per90'], + home_squad_continuity=player_feats['home_squad_continuity'], + away_squad_continuity=player_feats['away_squad_continuity'], + home_top_scorer_form=player_feats['home_top_scorer_form'], + away_top_scorer_form=player_feats['away_top_scorer_form'], + home_avg_player_exp=player_feats['home_avg_player_exp'], + away_avg_player_exp=player_feats['away_avg_player_exp'], + home_goals_diversity=player_feats['home_goals_diversity'], + away_goals_diversity=player_feats['away_goals_diversity'], lineup_available=lineup_available, confidence=max(5.0, confidence) ) + + def _compute_player_level_features( + self, + home_lineup: List[str], + away_lineup: List[str], + home_team_id: str, + away_team_id: str, + home_analysis, + away_analysis, + ) -> Dict[str, float]: + defaults = { + 'home_lineup_goals_per90': 0.0, 'away_lineup_goals_per90': 0.0, + 'home_lineup_assists_per90': 0.0, 'away_lineup_assists_per90': 0.0, + 'home_squad_continuity': 0.5, 'away_squad_continuity': 0.5, + 'home_top_scorer_form': 0, 'away_top_scorer_form': 0, + 'home_avg_player_exp': 0.0, 'away_avg_player_exp': 0.0, + 'home_goals_diversity': 0.0, 'away_goals_diversity': 0.0, + } + conn = self.squad_engine.get_conn() + if conn is None: + return defaults + + try: + from psycopg2.extras import RealDictCursor + result = {} + for prefix, lineup, team_id in [ + ('home', home_lineup, home_team_id), + ('away', away_lineup, away_team_id), + ]: + if not lineup: + for k in ('lineup_goals_per90', 'lineup_assists_per90', + 'squad_continuity', 'top_scorer_form', + 'avg_player_exp', 'goals_diversity'): + result[f'{prefix}_{k}'] = defaults[f'{prefix}_{k}'] + continue + + g90, a90, total_exp = 0.0, 0.0, 0 + best_scorer_total, best_scorer_id = 0, None + scorers_in_lineup = 0 + + with conn.cursor(cursor_factory=RealDictCursor) as cur: + for pid in lineup: + cur.execute(""" + SELECT + COUNT(*) as starts, + COALESCE(SUM(CASE WHEN e.event_type = 'goal' + AND (e.event_subtype IS NULL OR e.event_subtype NOT ILIKE '%%penaltΔ± kaΓ§Δ±rma%%') + THEN 1 ELSE 0 END), 0) as goals, + COALESCE((SELECT COUNT(*) FROM match_player_events + WHERE assist_player_id = %s), 0) as assists + FROM match_player_participation mpp + LEFT JOIN match_player_events e + ON e.match_id = mpp.match_id AND e.player_id = mpp.player_id + WHERE mpp.player_id = %s AND mpp.is_starting = true + """, (pid, pid)) + row = cur.fetchone() + if not row or not row['starts']: + continue + starts = row['starts'] + goals = row['goals'] or 0 + assists = row['assists'] or 0 + g90 += goals / starts + a90 += assists / starts + total_exp += starts + if goals > 0: + scorers_in_lineup += 1 + if goals > best_scorer_total: + best_scorer_total = goals + best_scorer_id = pid + + n_st = len(lineup) or 1 + + # Top scorer recent form (goals in last 5 starts) + top_scorer_form = 0 + if best_scorer_id: + cur.execute(""" + SELECT COUNT(*) as goals + FROM match_player_events mpe + WHERE mpe.player_id = %s AND mpe.event_type = 'goal' + AND mpe.match_id IN ( + SELECT match_id FROM match_player_participation + WHERE player_id = %s AND is_starting = true + ORDER BY match_id DESC LIMIT 5 + ) + """, (best_scorer_id, best_scorer_id)) + tsf_row = cur.fetchone() + if tsf_row: + top_scorer_form = tsf_row['goals'] or 0 + + # Squad continuity (overlap with previous match lineup) + squad_continuity = 0.5 + cur.execute(""" + SELECT mpp.player_id + FROM match_player_participation mpp + JOIN matches m ON mpp.match_id = m.id + WHERE mpp.team_id = %s AND mpp.is_starting = true + AND m.status = 'FT' + ORDER BY m.mst_utc DESC + LIMIT 11 + """, (team_id,)) + prev_starters = {r['player_id'] for r in cur.fetchall()} + if prev_starters: + overlap = len(set(lineup) & prev_starters) + squad_continuity = overlap / n_st + + result[f'{prefix}_lineup_goals_per90'] = round(g90, 3) + result[f'{prefix}_lineup_assists_per90'] = round(a90, 3) + result[f'{prefix}_squad_continuity'] = round(squad_continuity, 3) + result[f'{prefix}_top_scorer_form'] = top_scorer_form + result[f'{prefix}_avg_player_exp'] = round(total_exp / n_st, 1) + result[f'{prefix}_goals_diversity'] = round(scorers_in_lineup / n_st, 3) + + return result + except Exception as e: + print(f"[PlayerPredictor] Player-level features failed: {e}") + return defaults def get_1x2_modifier(self, prediction: PlayerPrediction) -> Dict[str, float]: """ diff --git a/ai-engine/core/engines/referee_predictor.py b/ai-engine/core/engines/referee_predictor.py deleted file mode 100755 index 7dc62eb..0000000 --- a/ai-engine/core/engines/referee_predictor.py +++ /dev/null @@ -1,188 +0,0 @@ -""" -Referee Predictor Engine - V20 Ensemble Component -Analyzes referee patterns for cards, goals, and home bias. - -Weight: 15% in ensemble -""" - -import os -import sys -from typing import Dict, Optional -from dataclasses import dataclass - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from features.referee_engine import get_referee_engine - - -@dataclass -class RefereePrediction: - """Referee engine prediction output.""" - referee_name: str = "" - matches_officiated: int = 0 - - # Card tendencies - avg_yellow_cards: float = 4.0 - avg_red_cards: float = 0.2 - is_card_heavy: bool = False # Above average cards - - # Goal tendencies - avg_goals_per_match: float = 2.5 - over_25_rate: float = 0.50 - is_high_scoring: bool = False # Above average goals - - # Home bias - home_win_rate: float = 0.45 - home_bias: float = 0.0 # -1 to +1, positive = favors home - - # Penalty tendency - penalty_rate: float = 0.15 - - confidence: float = 0.0 - - def to_dict(self) -> dict: - return { - "referee_name": self.referee_name, - "matches_officiated": self.matches_officiated, - "avg_yellow_cards": round(self.avg_yellow_cards, 1), - "avg_red_cards": round(self.avg_red_cards, 2), - "is_card_heavy": self.is_card_heavy, - "avg_goals_per_match": round(self.avg_goals_per_match, 2), - "over_25_rate": round(self.over_25_rate * 100, 1), - "is_high_scoring": self.is_high_scoring, - "home_win_rate": round(self.home_win_rate * 100, 1), - "home_bias": round(self.home_bias, 2), - "penalty_rate": round(self.penalty_rate * 100, 1), - "confidence": round(self.confidence, 1) - } - - -class RefereePredictorEngine: - """ - Referee-based prediction engine. - - Analyzes: - - Card tendency (sarΔ±/kΔ±rmΔ±zΔ± kart ortalamasΔ±) - - Goal tendency (maΓ§ başına gol, 2.5 ΓΌst oranΔ±) - - Home bias (ev sahibi lehine karar oranΔ±) - - Penalty tendency (penaltΔ± verme oranΔ±) - """ - - # League average benchmarks - LEAGUE_AVG_GOALS = 2.65 - LEAGUE_AVG_YELLOW = 4.0 - LEAGUE_HOME_WIN_RATE = 0.45 - - def __init__(self): - self.referee_engine = get_referee_engine() - print("βœ… RefereePredictorEngine initialized") - - def predict(self, - match_id: Optional[str] = None, - referee_name: Optional[str] = None, - league_id: Optional[str] = None) -> RefereePrediction: - """ - Generate referee-based prediction. - - Args: - match_id: Match ID to find referee - referee_name: Or provide referee name directly - league_id: League ID to scope stats (prevents name collisions) - - Returns: - RefereePrediction with referee analysis - """ - - # Get referee features - if match_id: - features = self.referee_engine.get_features(match_id, league_id=league_id or "") - # Live flows may already have referee_name while match_officials table is sparse. - # Prefer the richer profile if direct-name lookup has more history. - if referee_name: - name_features = self.referee_engine.get_features_by_name(referee_name, league_id=league_id or "") - if (name_features.get("referee_matches", 0) or 0) > (features.get("referee_matches", 0) or 0): - features = name_features - elif referee_name: - features = self.referee_engine.get_features_by_name(referee_name, league_id=league_id or "") - else: - # Return default - return RefereePrediction(confidence=10.0) - - ref_name = str(features.get("referee_name", "Unknown")) - matches = int(features.get("referee_matches", 0)) - - if matches < 5: - # Not enough data - return RefereePrediction( - referee_name=ref_name, - matches_officiated=matches, - confidence=20.0 - ) - - # Extract features - avg_yellow = features.get("referee_avg_yellow", 4.0) - avg_red = features.get("referee_avg_red", 0.2) - avg_goals = features.get("referee_avg_goals", 2.5) - over25_rate = features.get("referee_over25_rate", 0.5) - home_win_rate = features.get("referee_home_win_rate", 0.45) if "referee_home_win_rate" in features else 0.45 - home_bias = features.get("referee_home_bias", 0.0) - penalty_rate = features.get("referee_penalty_rate", 0.15) - - # Determine tendencies - is_card_heavy = (avg_yellow + avg_red * 4) > (self.LEAGUE_AVG_YELLOW + 1) - is_high_scoring = avg_goals > self.LEAGUE_AVG_GOALS - - # Confidence based on matches officiated - confidence = min(90.0, 30.0 + matches * 2) - - return RefereePrediction( - referee_name=ref_name, - matches_officiated=matches, - avg_yellow_cards=avg_yellow, - avg_red_cards=avg_red, - is_card_heavy=is_card_heavy, - avg_goals_per_match=avg_goals, - over_25_rate=over25_rate, - is_high_scoring=is_high_scoring, - home_win_rate=home_win_rate, - home_bias=home_bias, - penalty_rate=penalty_rate, - confidence=confidence - ) - - def get_modifiers(self, prediction: RefereePrediction) -> Dict[str, float]: - """ - Get modifiers to apply to other predictions based on referee profile. - """ - return { - # Home team gets slight boost if referee has home bias - "home_modifier": 1.0 + (prediction.home_bias * 0.05), - # O/U modifier - "over_25_modifier": 1.0 + (prediction.avg_goals_per_match - self.LEAGUE_AVG_GOALS) * 0.1, - # Card modifier for card markets - "cards_modifier": 1.0 + (prediction.avg_yellow_cards - self.LEAGUE_AVG_YELLOW) * 0.05 - } - - -# Singleton -_engine: Optional[RefereePredictorEngine] = None - - -def get_referee_predictor() -> RefereePredictorEngine: - global _engine - if _engine is None: - _engine = RefereePredictorEngine() - return _engine - - -if __name__ == "__main__": - engine = get_referee_predictor() - - print("\nπŸ§ͺ Referee Predictor Engine Test") - print("=" * 50) - - pred = engine.predict(referee_name="CΓΌneyt Γ‡akΔ±r") - - print(f"\nπŸ“Š Prediction:") - for k, v in pred.to_dict().items(): - print(f" {k}: {v}") diff --git a/ai-engine/core/engines/team_predictor.py b/ai-engine/core/engines/team_predictor.py deleted file mode 100755 index 08e9830..0000000 --- a/ai-engine/core/engines/team_predictor.py +++ /dev/null @@ -1,286 +0,0 @@ -""" -Team Predictor Engine - V20 Ensemble Component -Combines ELO ratings, form stats, H2H records and team statistics. - -Weight: 30% in ensemble -""" - -import os -import sys -from typing import Dict, Optional, Tuple, Any -from dataclasses import dataclass, field - -# Add parent to path -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from features.elo_system import get_elo_system -from features.h2h_engine import get_h2h_engine -from features.momentum_engine import get_momentum_engine, MomentumData -from features.team_stats_engine import get_team_stats_engine - - -@dataclass -class TeamPrediction: - """Team engine prediction output.""" - home_win_prob: float = 0.33 - draw_prob: float = 0.33 - away_win_prob: float = 0.33 - home_xg: float = 1.3 - away_xg: float = 1.1 - form_advantage: float = 0.0 # -1 to +1, positive = home advantage - h2h_advantage: float = 0.0 # -1 to +1 - elo_diff: float = 0.0 - confidence: float = 0.0 - - def to_dict(self) -> dict: - return { - "home_win_prob": round(self.home_win_prob * 100, 1), - "draw_prob": round(self.draw_prob * 100, 1), - "away_win_prob": round(self.away_win_prob * 100, 1), - "home_xg": round(self.home_xg, 2), - "away_xg": round(self.away_xg, 2), - "form_advantage": round(self.form_advantage, 2), - "h2h_advantage": round(self.h2h_advantage, 2), - "elo_diff": round(self.elo_diff, 0), - "confidence": round(self.confidence, 1) - } - - raw_features: Dict[str, Any] = field(default_factory=dict) - - -class TeamPredictorEngine: - """ - Team-based prediction engine. - - Uses: - - ELO Rating System (venue-adjusted, league-weighted) - - H2H Engine (head-to-head history) - - Momentum Engine (recent form) - - Team Stats Engine (possession, shots, corners) - """ - - def __init__(self): - self.elo_system = get_elo_system() - self.h2h_engine = get_h2h_engine() - self.momentum_engine = get_momentum_engine() - self.team_stats_engine = get_team_stats_engine() - - print("βœ… TeamPredictorEngine initialized") - - def predict(self, - home_team_id: str, - away_team_id: str, - match_date_ms: int, - home_team_name: str = "", - away_team_name: str = "") -> TeamPrediction: - """ - Generate team-based prediction. - - Args: - home_team_id: Home team ID - away_team_id: Away team ID - match_date_ms: Match date in milliseconds - home_team_name: Home team name (for ELO) - away_team_name: Away team name (for ELO) - - Returns: - TeamPrediction with 1X2 probabilities and xG - """ - - # 1. Get ELO predictions - elo_pred = self.elo_system.predict_match(home_team_id, away_team_id) - elo_features = self.elo_system.get_match_features(home_team_id, away_team_id) - - # 2. Get H2H features - try: - h2h_features = self.h2h_engine.get_features( - home_team_id, away_team_id, match_date_ms - ) - except Exception: - h2h_features = { - "h2h_home_win_rate": 0.5, - "h2h_away_win_rate": 0.5, - "h2h_avg_goals": 2.5, - "h2h_btts_rate": 0.5 - } - - # 3. Get Momentum/Form features - try: - # key: form_score should be 0-1 derived from momentum_score (-1 to 1) - home_mom_data = self.momentum_engine.calculate_momentum(home_team_id, match_date_ms) - away_mom_data = self.momentum_engine.calculate_momentum(away_team_id, match_date_ms) - - home_form_score = (home_mom_data.momentum_score + 1) / 2 - away_form_score = (away_mom_data.momentum_score + 1) / 2 - except Exception as e: - print(f"⚠️ MomentumEngine error: {e}") - home_mom_data = MomentumData() - away_mom_data = MomentumData() - home_form_score = 0.5 - away_form_score = 0.5 - - # 4. Get Team Stats - home_stats = self.team_stats_engine.get_features(home_team_id, match_date_ms) - away_stats = self.team_stats_engine.get_features(away_team_id, match_date_ms) - - # 5. Combine predictions - # ELO-based 1X2 (60% weight) - elo_home = elo_pred.get("home_win_prob", 0.33) - elo_draw = elo_pred.get("draw_prob", 0.33) - elo_away = elo_pred.get("away_win_prob", 0.33) - - # Adjust based on H2H (20% weight) - h2h_home_rate = h2h_features.get("h2h_home_win_rate", 0.5) - h2h_away_rate = h2h_features.get("h2h_away_win_rate", 0.5) - - # Adjust based on form (20% weight) - home_form = home_form_score - away_form = away_form_score - form_diff = (home_form - away_form) # -1 to +1 - - # Weighted combination - final_home = elo_home * 0.6 + h2h_home_rate * 0.2 + (0.5 + form_diff * 0.3) * 0.2 - final_away = elo_away * 0.6 + h2h_away_rate * 0.2 + (0.5 - form_diff * 0.3) * 0.2 - final_draw = 1.0 - final_home - final_away - - # Normalize - total = final_home + final_draw + final_away - if total > 0: - final_home /= total - final_draw /= total - final_away /= total - - # Calculate xG based on stats and form (conservative base) - home_conversion = home_stats.get("shot_conversion_rate", 0.1) - away_conversion = away_stats.get("shot_conversion_rate", 0.1) - - base_home_xg = 1.35 + (home_conversion * 3.0) - base_away_xg = 1.10 + (away_conversion * 2.5) - - # Defense weakness factor: opponent's defensive quality affects xG - # Higher shots on target against = weaker defense - away_def_weakness = away_stats.get("shot_accuracy", 0.35) # opponent's shot accuracy as proxy - home_def_weakness = home_stats.get("shot_accuracy", 0.35) - - # Adjust xG: stronger opponent defense β†’ lower xG - home_xg = base_home_xg * (1 + form_diff * 0.15) * (0.8 + away_def_weakness * 0.6) - away_xg = base_away_xg * (1 - form_diff * 0.15) * (0.8 + home_def_weakness * 0.6) - - # Apply xG Underperformance Penalty directly to calculated xG - # If a team chronically underperforms its xG, we subtract that historical difference here - if hasattr(home_mom_data, 'xg_underperformance') and home_mom_data.xg_underperformance > 0.2: - home_xg -= min(0.5, home_mom_data.xg_underperformance * 0.5) - - if hasattr(away_mom_data, 'xg_underperformance') and away_mom_data.xg_underperformance > 0.2: - away_xg -= min(0.5, away_mom_data.xg_underperformance * 0.5) - - # H2H adjustment (more conservative) - h2h_avg_goals = h2h_features.get("h2h_avg_goals", 2.5) - if h2h_avg_goals > 3.0: - home_xg *= 1.05 - away_xg *= 1.05 - elif h2h_avg_goals < 2.0: - home_xg *= 0.95 - away_xg *= 0.95 - - # Clamp xG to reasonable range - home_xg = max(0.5, min(3.5, home_xg)) - away_xg = max(0.3, min(3.0, away_xg)) - - # Calculate confidence - # Higher when ELO, H2H, and Form all agree - elo_winner = "H" if elo_home > max(elo_draw, elo_away) else ("A" if elo_away > elo_draw else "D") - h2h_winner = "H" if h2h_home_rate > h2h_away_rate else "A" - form_winner = "H" if form_diff > 0.1 else ("A" if form_diff < -0.1 else "D") - - agreement = sum([ - elo_winner == h2h_winner, - elo_winner == form_winner, - h2h_winner == form_winner - ]) - - max_prob = max(final_home, final_draw, final_away) - confidence = max_prob * 100 * (0.7 + agreement * 0.1) - - # Collect Raw Features for XGBoost - # Note: home_mom_data is an object now - def get_rate(val): return val if val is not None else 0.5 - - raw_features = { - **elo_features, # 8 features - - # Form Features (need key mapping to match extract_training_data.py) - "home_goals_avg": 1.5 + home_mom_data.goals_trend, # Proxy - "home_conceded_avg": 1.5 - home_mom_data.conceded_trend, # Proxy - "away_goals_avg": 1.5 + away_mom_data.goals_trend, - "away_conceded_avg": 1.5 - away_mom_data.conceded_trend, - - "home_clean_sheet_rate": 0.2, # Not in new MomentumData - "away_clean_sheet_rate": 0.2, - "home_scoring_rate": 0.8, - "away_scoring_rate": 0.8, - - "home_winning_streak": home_mom_data.winning_streak, - "away_winning_streak": away_mom_data.winning_streak, - "home_unbeaten_streak": home_mom_data.unbeaten_streak, - "away_unbeaten_streak": away_mom_data.unbeaten_streak, - - # H2H Features - **h2h_features, - - # Team Stats - "home_avg_possession": home_stats.get("avg_possession", 0.5), - "away_avg_possession": away_stats.get("avg_possession", 0.5), - "home_avg_shots_on_target": home_stats.get("avg_shots_on_target", 3.5), - "away_avg_shots_on_target": away_stats.get("avg_shots_on_target", 3.5), - "home_shot_conversion": home_stats.get("shot_conversion_rate", 0.1), - "away_shot_conversion": away_stats.get("shot_conversion_rate", 0.1), - "home_avg_corners": home_stats.get("avg_corners", 4.5), - "away_avg_corners": away_stats.get("avg_corners", 4.5), - - # Derived - "home_xga": 1.5 - home_mom_data.conceded_trend, # reusing as proxy - "away_xga": 1.5 - away_mom_data.conceded_trend - } - - return TeamPrediction( - home_win_prob=final_home, - draw_prob=final_draw, - away_win_prob=final_away, - home_xg=home_xg, - away_xg=away_xg, - form_advantage=form_diff, - h2h_advantage=h2h_home_rate - h2h_away_rate, - elo_diff=elo_features.get("elo_diff", 0), - confidence=confidence, - raw_features=raw_features - ) - - -# Singleton -_engine: Optional[TeamPredictorEngine] = None - - -def get_team_predictor() -> TeamPredictorEngine: - global _engine - if _engine is None: - _engine = TeamPredictorEngine() - return _engine - - -if __name__ == "__main__": - engine = get_team_predictor() - - print("\nπŸ§ͺ Team Predictor Engine Test") - print("=" * 50) - - # Test with sample IDs - pred = engine.predict( - home_team_id="test_home", - away_team_id="test_away", - match_date_ms=1707393600000 - ) - - print(f"\nπŸ“Š Prediction:") - for k, v in pred.to_dict().items(): - print(f" {k}: {v}") diff --git a/ai-engine/features/upset_engine_v2.py b/ai-engine/features/upset_engine_v2.py index e4a29cf..0e12568 100644 --- a/ai-engine/features/upset_engine_v2.py +++ b/ai-engine/features/upset_engine_v2.py @@ -15,13 +15,9 @@ Orijinal FaktΓΆrler: - Tarihsel upset pattern """ -import os -import sys from typing import Dict, Any, Optional, Tuple, List from dataclasses import dataclass, field -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - try: import psycopg2 from psycopg2.extras import RealDictCursor diff --git a/ai-engine/main.py b/ai-engine/main.py index 0d19682..323117a 100755 --- a/ai-engine/main.py +++ b/ai-engine/main.py @@ -21,6 +21,7 @@ except ImportError: HAS_BASKETBALL = False from services.single_match_orchestrator import get_single_match_orchestrator from services.v26_shadow_engine import get_v26_shadow_engine +from models.league_model import get_league_model_loader load_dotenv() @@ -123,7 +124,15 @@ def health_check() -> dict[str, Any]: try: orchestrator = get_single_match_orchestrator() shadow_engine = get_v26_shadow_engine() - + + # Per-market V25 model status + v25_readiness: dict[str, Any] = {"fully_loaded": False} + try: + v25_predictor = orchestrator._get_v25_predictor() + v25_readiness = v25_predictor.readiness_summary() + except Exception as v25_err: + v25_readiness = {"fully_loaded": False, "error": str(v25_err)} + if HAS_BASKETBALL: basketball_predictor = get_basketball_v25_predictor() basketball_readiness = basketball_predictor.readiness_summary() @@ -131,35 +140,52 @@ def health_check() -> dict[str, Any]: else: basketball_readiness = {"fully_loaded": False, "error": "Basketball module not found"} ready = True - + + league_readiness = get_league_model_loader().readiness_summary() + overall_ready = ready and v25_readiness.get("fully_loaded", False) return { - "status": "healthy" if ready else "degraded", + "status": "healthy" if overall_ready else "degraded", "engine": "v28.main", "mode": os.getenv("AI_ENGINE_MODE", "v28"), - "ready": ready, + "ready": overall_ready, + "v25_football": v25_readiness, + "league_specific": league_readiness, "basketball_v25": basketball_readiness, "v26_shadow": shadow_engine.readiness_summary(), "prediction_service_ready": True, - "model_loaded": ready, + "model_loaded": overall_ready, "orchestrator_mode": getattr(orchestrator, "engine_mode", "v28"), } except Exception as error: return {"status": "unhealthy", "ready": False, "error": str(error)} +_REQUIRED_RESPONSE_FIELDS = ("match_info", "market_board", "main_pick", "bet_summary", "data_quality") + + @app.post("/v20plus/analyze/{match_id}") async def analyze_match_v20plus(match_id: str) -> dict[str, Any]: + started_at = time.time() orchestrator = get_single_match_orchestrator() - result = orchestrator.analyze_match(match_id) + result = await asyncio.to_thread(orchestrator.analyze_match, match_id) + elapsed_ms = int((time.time() - started_at) * 1000) + if not result: raise HTTPException(status_code=404, detail=f"Match not found: {match_id}") + + # Response validation: log missing required fields (non-fatal) + missing_fields = [f for f in _REQUIRED_RESPONSE_FIELDS if f not in result] + if missing_fields: + print(f"⚠️ [API] analyze/{match_id} response missing fields: {missing_fields} ({elapsed_ms}ms)") + + result["timing_ms"] = elapsed_ms return result @app.get("/v20plus/analyze-htms/{match_id}") async def analyze_match_htms_v20plus(match_id: str) -> dict[str, Any]: orchestrator = get_single_match_orchestrator() - result = orchestrator.analyze_match_htms(match_id) + result = await asyncio.to_thread(orchestrator.analyze_match_htms, match_id) if not result: raise HTTPException(status_code=404, detail=f"Match not found: {match_id}") return result @@ -230,11 +256,12 @@ async def analyze_match_htft_v20plus(match_id: str, timeout_sec: int = 30) -> di @app.post("/v20plus/coupon") async def generate_coupon_v20plus(request: CouponRequest) -> dict[str, Any]: orchestrator = get_single_match_orchestrator() - return orchestrator.build_coupon( - match_ids=request.match_ids, - strategy=request.strategy or "BALANCED", - max_matches=request.max_matches, - min_confidence=request.min_confidence, + return await asyncio.to_thread( + orchestrator.build_coupon, + request.match_ids, + request.strategy or "BALANCED", + request.max_matches, + request.min_confidence, ) @@ -244,7 +271,7 @@ async def get_daily_banker_v20plus(count: int = 3) -> dict[str, Any]: raise HTTPException(status_code=400, detail="count must be >= 1") orchestrator = get_single_match_orchestrator() - bankers = orchestrator.get_daily_bankers(count=count) + bankers = await asyncio.to_thread(orchestrator.get_daily_bankers, count) return {"count": len(bankers), "bankers": bankers} @app.get("/v20plus/reversal-watchlist") @@ -262,11 +289,12 @@ async def get_reversal_watchlist_v20plus( raise HTTPException(status_code=400, detail="min_score must be between 0 and 100") orchestrator = get_single_match_orchestrator() - return orchestrator.get_reversal_watchlist( - count=count, - horizon_hours=horizon_hours, - min_score=min_score, - top_leagues_only=top_leagues_only, + return await asyncio.to_thread( + orchestrator.get_reversal_watchlist, + count, + horizon_hours, + min_score, + top_leagues_only, ) diff --git a/ai-engine/models/calibration.py b/ai-engine/models/calibration.py index 000e656..2ffba17 100644 --- a/ai-engine/models/calibration.py +++ b/ai-engine/models/calibration.py @@ -46,6 +46,9 @@ SUPPORTED_MARKETS = [ "ht_ft", # Half-Time/Full-Time "dc", # Double Chance "ht", # Half-Time Result + "ht_home", # Half-Time Home win + "ht_draw", # Half-Time Draw + "ht_away", # Half-Time Away win ] @@ -111,6 +114,9 @@ class Calibrator: "ht_ft": 0.92, "dc": 0.97, "ht": 0.92, + "ht_home": 0.92, + "ht_draw": 0.92, + "ht_away": 0.92, } self._load_calibrators() diff --git a/ai-engine/models/league_model.py b/ai-engine/models/league_model.py new file mode 100644 index 0000000..5960085 --- /dev/null +++ b/ai-engine/models/league_model.py @@ -0,0 +1,191 @@ +""" +League-Specific Model Loader +============================= +Loads per-league XGBoost models + isotonic calibrators trained by +scripts/train_league_models.py and provides a unified prediction interface. + +Falls back to general V25 for any market/league without a dedicated model. +""" + +import os +import json +import pickle +from functools import lru_cache +from typing import Dict, Optional, Tuple + +import numpy as np +import pandas as pd +import xgboost as xgb + +AI_ENGINE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LEAGUE_MODEL_DIR = os.path.join(AI_ENGINE_DIR, "models", "league_specific") + +# Market file name β†’ (num_class, label_list) +MARKET_META: Dict[str, Tuple[int, list]] = { + "ms": (3, ["1", "X", "2"]), + "ou15": (2, ["Over", "Under"]), + "ou25": (2, ["Over", "Under"]), + "ou35": (2, ["Over", "Under"]), + "btts": (2, ["Yes", "No"]), + "ht": (3, ["1", "X", "2"]), + "ht_ou05": (2, ["Over", "Under"]), + "ht_ou15": (2, ["Over", "Under"]), + "htft": (9, ["1/1","1/X","1/2","X/1","X/X","X/2","2/1","2/X","2/2"]), + "oe": (2, ["Odd", "Even"]), + "cards": (2, ["Over", "Under"]), + "handicap": (3, ["1", "X", "2"]), +} + +# Signal key map (file key β†’ uppercase signal key used in _get_v25_signal) +FILE_TO_SIGNAL = { + "ms": "MS", "ou15": "OU15", "ou25": "OU25", "ou35": "OU35", + "btts": "BTTS", "ht": "HT", "ht_ou05": "HT_OU05", "ht_ou15": "HT_OU15", + "htft": "HTFT", "oe": "OE", "cards": "CARDS", "handicap": "HCAP", +} + + +class LeagueModel: + """Holds XGBoost models + isotonic calibrators for one league.""" + + def __init__(self, league_id: str): + self.league_id = league_id + self.league_dir = os.path.join(LEAGUE_MODEL_DIR, league_id) + self.models: Dict[str, xgb.Booster] = {} # market_key β†’ booster + self.calibrators: Dict[str, object] = {} # cal_key β†’ isotonic + self.feature_cols: Optional[list] = None + self._loaded = False + + def load(self) -> bool: + if not os.path.isdir(self.league_dir): + return False + try: + fc_path = os.path.join(self.league_dir, "feature_cols.json") + if os.path.exists(fc_path): + with open(fc_path) as f: + self.feature_cols = json.load(f) + + for mkey in MARKET_META: + xgb_path = os.path.join(self.league_dir, f"xgb_{mkey}.json") + if os.path.exists(xgb_path) and os.path.getsize(xgb_path) > 100: + b = xgb.Booster() + b.load_model(xgb_path) + self.models[mkey] = b + + for fname in os.listdir(self.league_dir): + if fname.startswith("cal_") and fname.endswith(".pkl"): + cal_key = fname[4:-4] # strip cal_ and .pkl + with open(os.path.join(self.league_dir, fname), "rb") as f: + self.calibrators[cal_key] = pickle.load(f) + + self._loaded = bool(self.models or self.calibrators) + return self._loaded + except Exception as e: + print(f"[LeagueModel] Load failed for {self.league_id}: {e}") + return False + + def has_market(self, mkey: str) -> bool: + return mkey in self.models + + def predict_market( + self, + mkey: str, + feature_row: Dict[str, float], + ) -> Optional[Dict[str, float]]: + """ + Predict one market using league-specific XGBoost + isotonic calibration. + Returns {label: prob} dict or None if no model available. + """ + if mkey not in self.models: + return None + + num_class, labels = MARKET_META[mkey] + fc = self.feature_cols + if fc is None: + # Fallback to whatever the booster expects (it knows its feature names) + fc = list(self.models[mkey].feature_names or []) + + try: + X = pd.DataFrame([{col: feature_row.get(col, 0.0) for col in fc}]) + dmat = xgb.DMatrix(X) + raw = self.models[mkey].predict(dmat) + + if num_class > 2: + probs_arr = raw.reshape(-1, num_class)[0] + probs = {labels[i]: float(probs_arr[i]) for i in range(num_class)} + # Apply isotonic calibration per class + cal_total = 0.0 + for i, label in enumerate(labels): + cal_key = f"{mkey}_{i}" + if cal_key in self.calibrators: + p_cal = float(self.calibrators[cal_key].predict([probs_arr[i]])[0]) + probs[label] = max(0.01, min(0.99, p_cal)) + cal_total += probs[label] + if cal_total > 0: + probs = {k: v / cal_total for k, v in probs.items()} + else: + p = float(raw[0]) + cal_key = mkey + if cal_key in self.calibrators: + p = float(self.calibrators[cal_key].predict([p])[0]) + p = max(0.01, min(0.99, p)) + probs = {labels[0]: p, labels[1]: 1.0 - p} + + return probs + except Exception as e: + print(f"[LeagueModel] predict_market({mkey}) failed for {self.league_id}: {e}") + return None + + +class LeagueModelLoader: + """ + In-memory cache for league-specific models. + Thread-safe for single-process async servers (FastAPI/uvicorn). + """ + + def __init__(self, max_cached: int = 80): + self._cache: Dict[str, Optional[LeagueModel]] = {} + self._max_cached = max_cached + + def get(self, league_id: str) -> Optional[LeagueModel]: + """Return loaded LeagueModel for this league, or None if unavailable.""" + if league_id in self._cache: + return self._cache[league_id] + + # Evict oldest entry if cache is full + if len(self._cache) >= self._max_cached: + oldest = next(iter(self._cache)) + del self._cache[oldest] + + model = LeagueModel(league_id) + loaded = model.load() + self._cache[league_id] = model if loaded else None + if loaded: + n_models = len(model.models) + n_cals = len(model.calibrators) + print(f"[LeagueModel] Loaded {league_id}: {n_models} XGB models, {n_cals} calibrators") + return self._cache[league_id] + + def available_leagues(self) -> list: + if not os.path.isdir(LEAGUE_MODEL_DIR): + return [] + return [d for d in os.listdir(LEAGUE_MODEL_DIR) + if os.path.isdir(os.path.join(LEAGUE_MODEL_DIR, d))] + + def readiness_summary(self) -> dict: + leagues = self.available_leagues() + return { + "league_specific_dir": LEAGUE_MODEL_DIR, + "available_leagues": len(leagues), + "cached": len([v for v in self._cache.values() if v is not None]), + } + + +# ── Singleton ────────────────────────────────────────────────────── +_loader: Optional[LeagueModelLoader] = None + + +def get_league_model_loader() -> LeagueModelLoader: + global _loader + if _loader is None: + _loader = LeagueModelLoader() + return _loader diff --git a/ai-engine/models/v20_ensemble.py b/ai-engine/models/v20_ensemble.py deleted file mode 100644 index 8712a74..0000000 --- a/ai-engine/models/v20_ensemble.py +++ /dev/null @@ -1,1289 +0,0 @@ -""" -V20 Ensemble Beast - Main Predictor -Combines 4 prediction engines with surprise detection. - -This is the primary interface for V20 predictions. -""" - -import os -import sys -import math -import json -import pickle -import time -import psycopg2 -import pandas as pd -from typing import Dict, List, Optional, Tuple, Any -from dataclasses import dataclass, field - -# Add paths -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from core.engines.team_predictor import get_team_predictor -from core.engines.player_predictor import get_player_predictor -from core.engines.odds_predictor import get_odds_predictor -from core.engines.referee_predictor import get_referee_predictor -from features.upset_engine import get_upset_engine -from features.upset_engine_v2 import get_upset_engine_v2 -from features.feature_adapter import get_feature_adapter -from utils.top_leagues import load_top_league_ids -from data.db import get_clean_dsn -import xgboost as xgb -from models.calibration import Calibrator - -# New Config & Calculators -from config.config_loader import get_config -from core.calculators.base_calculator import CalculationContext -from core.calculators.match_result_calculator import MatchResultCalculator -from core.calculators.over_under_calculator import OverUnderCalculator -from core.calculators.half_time_calculator import HalfTimeCalculator -from core.calculators.score_calculator import ScoreCalculator, ScorePrediction -from core.calculators.other_markets_calculator import OtherMarketsCalculator -from core.calculators.risk_assessor import RiskAssessor -from core.calculators.bet_recommender import BetRecommender - - -class _BoosterModelAdapter: - """Adapter to provide predict_proba interface for raw xgboost.Booster models.""" - - def __init__(self, booster: xgb.Booster): - self._booster = booster - - def predict_proba(self, features: pd.DataFrame): - dmat = xgb.DMatrix(features) - preds = self._booster.predict(dmat) - if len(preds.shape) == 1: - # binary: return [P(class0), P(class1)] - return [[float(1.0 - p), float(p)] for p in preds] - # multiclass: already (n, k) - return preds - - -@dataclass -class MarketPrediction: - """Prediction for a single betting market.""" - market_type: str - pick: str - probability: float - confidence: float - odds: float = 0.0 - is_recommended: bool = False - is_value_bet: bool = False - edge: float = 0.0 # Expected edge over market - - def to_dict(self) -> dict: - return { - "market_type": self.market_type, - "pick": self.pick, - "probability": round(self.probability * 100, 1), - "confidence": round(self.confidence, 1), - "odds": self.odds, - "is_recommended": self.is_recommended, - "is_value_bet": self.is_value_bet, - "edge": round(self.edge, 1) - } - - -@dataclass -class FullMatchPrediction: - """Complete prediction for a match with ALL markets.""" - match_id: str - home_team: str - away_team: str - match_date: str = "" - - # === MAΓ‡ SONUCU (1X2) === - ms_home_prob: float = 0.33 - ms_draw_prob: float = 0.33 - ms_away_prob: float = 0.33 - ms_pick: str = "" - ms_confidence: float = 0.0 - - # === Γ‡Δ°FTE ŞANS === - dc_1x_prob: float = 0.66 - dc_x2_prob: float = 0.66 - dc_12_prob: float = 0.66 - dc_pick: str = "" - dc_confidence: float = 0.0 - - # === ALT/ÜST GOLLER === - # 1.5 - over_15_prob: float = 0.70 - under_15_prob: float = 0.30 - ou15_pick: str = "" - ou15_confidence: float = 0.0 - - # 2.5 - over_25_prob: float = 0.50 - under_25_prob: float = 0.50 - ou25_pick: str = "" - ou25_confidence: float = 0.0 - - # 3.5 - over_35_prob: float = 0.30 - under_35_prob: float = 0.70 - ou35_pick: str = "" - ou35_confidence: float = 0.0 - - # === KARŞILIKLI GOL (BTTS) === - btts_yes_prob: float = 0.50 - btts_no_prob: float = 0.50 - btts_pick: str = "" - btts_confidence: float = 0.0 - - # === Δ°LK YARI SONUCU === - ht_home_prob: float = 0.30 - ht_draw_prob: float = 0.40 - ht_away_prob: float = 0.30 - ht_pick: str = "" - ht_confidence: float = 0.0 - - # === SKOR TAHMΔ°NLERΔ° === - score: Optional[ScorePrediction] = None - predicted_ft_score: str = "1-1" - predicted_ht_score: str = "0-0" - ft_scores_top5: List[Dict] = field(default_factory=list) - - # === xG (Expected Goals) === - home_xg: float = 1.3 - away_xg: float = 1.1 - total_xg: float = 2.4 - - # === RISK DEĞERLENDΔ°RMESΔ° === - risk_level: str = "MEDIUM" # LOW, MEDIUM, HIGH, EXTREME - risk_score: float = 0.0 - is_surprise_risk: bool = False - surprise_type: str = "" - risk_warnings: List[str] = field(default_factory=list) - ht_ft_probs: Dict[str, float] = field(default_factory=dict) - - # === GLM-5 SÜRPRΔ°Z SKORU === - upset_score: int = 0 # 0-100 arasΔ± sΓΌrpriz skoru - upset_level: str = "LOW" # LOW, MEDIUM, HIGH, EXTREME - upset_reasons: List[str] = field(default_factory=list) - - # === SÜRPRΔ°Z PROFΔ°LΔ° === - surprise_score: float = 0.0 # 0-100 overall surprise risk score - surprise_comment: str = "" # Human-readable surprise commentary - surprise_reasons: List[str] = field(default_factory=list) # Flagged risk reasons - surprise_breakdown: List[Dict[str, Any]] = field(default_factory=list) # Per-factor {code, points, label} - - # === ENGINE KATKILARI === - team_confidence: float = 0.0 - player_confidence: float = 0.0 - odds_confidence: float = 0.0 - referee_confidence: float = 0.0 - - # === KORNER & KART & DİĞER === - total_corners_pred: float = 9.5 - corner_pick: str = "9.5 Üst" - - total_cards_pred: float = 4.5 - card_pick: str = "4.5 Alt" - cards_over_prob: float = 0.50 - cards_under_prob: float = 0.50 - cards_confidence: float = 0.0 - - handicap_pick: str = "" - handicap_home_prob: float = 0.33 - handicap_draw_prob: float = 0.34 - handicap_away_prob: float = 0.33 - handicap_confidence: float = 0.0 - - ht_over_05_prob: float = 0.65 - ht_under_05_prob: float = 0.35 - ht_over_15_prob: float = 0.30 - ht_under_15_prob: float = 0.70 - ht_ou_pick: str = "Δ°Y 0.5 Üst" - ht_ou15_pick: str = "Δ°Y 1.5 Alt" - - odd_even_pick: str = "Γ‡ift" - odd_prob: float = 0.50 # Tek olasΔ±lığı - even_prob: float = 0.50 # Γ‡ift olasΔ±lığı - - # === TAVSΔ°YELER (RECOMMENDATIONS) === - best_bet: Optional[MarketPrediction] = None - recommended_bets: List[MarketPrediction] = field(default_factory=list) - alternative_bet: Optional[MarketPrediction] = None - expert_recommendation: Dict[str, Any] = field(default_factory=dict) - - # === DETAILED ANALYSIS === - analysis_details: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict: - return { - "match_info": { - "match_id": self.match_id, - "home_team": self.home_team, - "away_team": self.away_team, - "match_date": self.match_date - }, - "predictions": { - "match_result": { - "1": round(self.ms_home_prob * 100, 1), - "X": round(self.ms_draw_prob * 100, 1), - "2": round(self.ms_away_prob * 100, 1), - "pick": self.ms_pick, - "confidence": round(self.ms_confidence, 1) - }, - "double_chance": { - "1X": round(self.dc_1x_prob * 100, 1), - "X2": round(self.dc_x2_prob * 100, 1), - "12": round(self.dc_12_prob * 100, 1), - "pick": self.dc_pick, - "confidence": round(self.dc_confidence, 1) - }, - "over_under": { - "1.5": { - "over": round(self.over_15_prob * 100, 1), - "under": round(self.under_15_prob * 100, 1), - "pick": self.ou15_pick, - "confidence": round(self.ou15_confidence, 1) - }, - "2.5": { - "over": round(self.over_25_prob * 100, 1), - "under": round(self.under_25_prob * 100, 1), - "pick": self.ou25_pick, - "confidence": round(self.ou25_confidence, 1) - }, - "3.5": { - "over": round(self.over_35_prob * 100, 1), - "under": round(self.under_35_prob * 100, 1), - "pick": self.ou35_pick, - "confidence": round(self.ou35_confidence, 1) - } - }, - "btts": { - "yes": round(self.btts_yes_prob * 100, 1), - "no": round(self.btts_no_prob * 100, 1), - "pick": self.btts_pick, - "confidence": round(self.btts_confidence, 1) - }, - "first_half": { - "1": round(self.ht_home_prob * 100, 1), - "X": round(self.ht_draw_prob * 100, 1), - "2": round(self.ht_away_prob * 100, 1), - "pick": self.ht_pick, - "confidence": round(self.ht_confidence, 1), - "over_under_05": { - "over": round(self.ht_over_05_prob * 100, 1), - "under": round(self.ht_under_05_prob * 100, 1), - "pick": self.ht_ou_pick - }, - "over_under_15": { - "over": round(self.ht_over_15_prob * 100, 1), - "under": round(self.ht_under_15_prob * 100, 1), - "pick": self.ht_ou15_pick - } - }, - "scores": { - "predicted_ft": self.predicted_ft_score, - "predicted_ht": self.predicted_ht_score, - "top_5_ft_scores": self.ft_scores_top5 - }, - "others": { - "handicap": { - "pick": self.handicap_pick, - "confidence": round(self.handicap_confidence, 1), - "home": round(self.handicap_home_prob * 100, 1), - "draw": round(self.handicap_draw_prob * 100, 1), - "away": round(self.handicap_away_prob * 100, 1) - }, - "corners": { - "total": round(self.total_corners_pred, 1), - "pick": self.corner_pick - }, - "cards": { - "total": round(self.total_cards_pred, 1), - "pick": self.card_pick, - "confidence": round(self.cards_confidence, 1), - "over": round(self.cards_over_prob * 100, 1), - "under": round(self.cards_under_prob * 100, 1) - }, - "odd_even": { - "pick": self.odd_even_pick, - "tek": round(self.odd_prob * 100, 1), - "cift": round(self.even_prob * 100, 1) - } - }, - "xg": { - "home": round(self.home_xg, 2), - "away": round(self.away_xg, 2), - "total": round(self.total_xg, 2) - } - }, - "risk": { - "level": self.risk_level, - "score": round(self.risk_score, 1), - "is_surprise_risk": self.is_surprise_risk, - "surprise_type": self.surprise_type, - "ht_ft_probs": {k: round(v * 100, 1) for k, v in self.ht_ft_probs.items()} if self.ht_ft_probs else {}, - "warnings": self.risk_warnings - }, - "upset_analysis": { - "score": self.upset_score, - "level": self.upset_level, - "reasons": self.upset_reasons - }, - "engine_breakdown": { - "team_engine": round(self.team_confidence, 1), - "player_engine": round(self.player_confidence, 1), - "odds_engine": round(self.odds_confidence, 1), - "referee_engine": round(self.referee_confidence, 1) - }, - "recommendations": { - "best_bet": self.best_bet.to_dict() if self.best_bet else None, - "all_recommended": [b.to_dict() for b in self.recommended_bets] if self.recommended_bets else [], - "alternative_bet": self.alternative_bet.to_dict() if self.alternative_bet else None - }, - "analysis_details": self.analysis_details - } - - -class V20EnsemblePredictor: - HTFT_LABELS = ("1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2") - # Neutral defaults when MS odds are missing: avoid synthetic home-favorite bias. - DEFAULT_MS_H = 2.65 - DEFAULT_MS_D = 3.20 - DEFAULT_MS_A = 2.65 - FOOTBALL_TOP_PRIOR = ( - 0.263760, - 0.051786, - 0.022942, - 0.150168, - 0.157798, - 0.106064, - 0.027622, - 0.051226, - 0.168634, - ) - FOOTBALL_NON_TOP_PRIOR = ( - 0.265113, - 0.048306, - 0.020399, - 0.147020, - 0.152383, - 0.113075, - 0.026542, - 0.046356, - 0.180805, - ) - # Top-league football priors conditioned on favorite side from MS (1X2) odds. - # Label order follows HTFT_LABELS. - FOOTBALL_TOP_PRIOR_HOME_FAV = ( - 0.321707, - 0.054165, - 0.017952, - 0.179729, - 0.161674, - 0.078991, - 0.031186, - 0.047394, - 0.107201, - ) - FOOTBALL_TOP_PRIOR_AWAY_FAV = ( - 0.130654, - 0.049139, - 0.033754, - 0.081975, - 0.156142, - 0.167164, - 0.020207, - 0.058324, - 0.302641, - ) - FOOTBALL_TOP_PRIOR_BALANCED = ( - 0.169429, - 0.052486, - 0.028545, - 0.144567, - 0.209024, - 0.116943, - 0.026703, - 0.053407, - 0.198895, - ) - - def __init__(self): - print("πŸš€ Initializing V20 Ensemble Beast...") - self.config = get_config() - - # Engines - self.team_engine = get_team_predictor() - self.player_engine = get_player_predictor() - self.odds_engine = get_odds_predictor() - self.referee_engine = get_referee_predictor() - self.upset_engine = get_upset_engine() - self.upset_engine_v2 = get_upset_engine_v2() # GLM-5 enhanced - - # Calculators - print("βš™οΈ Loading market calculators...") - cfg: Any = self.config - self.match_result_calc = MatchResultCalculator(cfg) - self.over_under_calc = OverUnderCalculator(cfg) - self.half_time_calc = HalfTimeCalculator(cfg) - self.score_calc = ScoreCalculator(cfg) - print(" βœ… Score Calculator (XGBoost FT+HT) loaded") - self.other_markets_calc = OtherMarketsCalculator(cfg) - self.risk_assessor = RiskAssessor(cfg) - self.bet_recommender = BetRecommender(cfg) - - # Expert Recommender (New Logic) - from core.calculators.expert_recommender import ExpertRecommender - self.expert_recommender = ExpertRecommender(cfg) - - # XGBoost Integration - print("πŸ€– Loading XGBoost models...") - self.feature_adapter = get_feature_adapter() - self.calibrator = Calibrator() - self.xgb_models = {} - self.top_league_ids = load_top_league_ids() - print(f"πŸ“‹ Loaded {len(self.top_league_ids)} top leagues for HT/FT tuning") - self.db_dsn = get_clean_dsn() - self.league_htft_prior_cache: Dict[Tuple[str, str], Optional[Tuple[float, ...]]] = {} - - xgb_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "xgboost") - model_files = { - "ms": "xgb_ms", - "ou25": "xgb_ou25", - "btts": "xgb_btts", - "ht_ft": "xgb_ht_ft", - "ht_result": "xgb_ht_result", - "ht_ou05": "xgb_ht_ou05", - "ht_ou15": "xgb_ht_ou15", - "odd_even": "xgb_odd_even", - "ou15": "xgb_ou15", - "ou35": "xgb_ou35", - "handicap_ms": "xgb_handicap_ms", - "cards_ou45": "xgb_cards_ou45", - } - - only_keys = os.getenv("XGB_MODEL_KEYS", "").strip() - if only_keys: - selected_keys = {k.strip().lower() for k in only_keys.split(",") if k.strip()} - model_files = {k: v for k, v in model_files.items() if k in selected_keys} - if model_files: - print(f"ℹ️ XGB_MODEL_KEYS active -> loading only: {', '.join(sorted(model_files.keys()))}") - else: - print("⚠️ XGB_MODEL_KEYS set but no valid keys matched. Loading none.") - - for key, base_name in model_files.items(): - print(f" ⏳ Loading {key} from {base_name}.pkl/.json...", flush=True) - model, src, err = self._load_xgb_model(xgb_dir, base_name) - if model is not None: - self.xgb_models[key] = model - print(f" βœ… Loaded {key} ({src})") - elif err: - print(f" ⚠️ Failed to load {base_name}: {err}") - else: - print(f" ⚠️ Model not found: {base_name}.pkl or {base_name}.json") - - print("βœ… V20 Ensemble Beast ready!") - - @staticmethod - def _load_xgb_model(xgb_dir: str, base_name: str): - pkl_path = os.path.join(xgb_dir, f"{base_name}.pkl") - json_path = os.path.join(xgb_dir, f"{base_name}.json") - - if os.path.exists(pkl_path): - started = time.perf_counter() - with open(pkl_path, "rb") as f: - model = pickle.load(f) - elapsed = time.perf_counter() - started - return model, f"pkl {elapsed:.2f}s", None - - if os.path.exists(json_path): - started = time.perf_counter() - # Preferred path: sklearn wrapper with predict_proba - try: - model = xgb.XGBClassifier() - model.load_model(json_path) - elapsed = time.perf_counter() - started - return model, f"json {elapsed:.2f}s", None - except Exception: - # Fallback: raw Booster + adapter - try: - booster = xgb.Booster() - booster.load_model(json_path) - model = _BoosterModelAdapter(booster) - elapsed = time.perf_counter() - started - return model, f"json/booster {elapsed:.2f}s", None - except Exception as e: - return None, "", e - - return None, "", None - - @staticmethod - def _safe_odd(value: Any) -> float: - try: - odd = float(value) - return odd if odd > 1.01 else 0.0 - except (TypeError, ValueError): - return 0.0 - - @staticmethod - def _align_features(features: pd.DataFrame, model) -> pd.DataFrame: - """Align DataFrame columns to the model's expected feature set. - - Supports: - - sklearn wrappers (XGBClassifier / LGBMClassifier) β†’ feature_names_in_ - - raw xgboost.Booster β†’ feature_names - - _BoosterModelAdapter β†’ _booster.feature_names - - If the model doesn't expose feature names, returns the DataFrame as-is. - """ - expected: Optional[List[str]] = None - - # 1. sklearn wrapper (XGBClassifier, LGBMClassifier, CalibratedClassifierCV) - if hasattr(model, 'feature_names_in_'): - expected = list(model.feature_names_in_) - # 2. _BoosterModelAdapter - elif hasattr(model, '_booster') and hasattr(model._booster, 'feature_names'): - expected = model._booster.feature_names - # 3. raw xgboost.Booster - elif hasattr(model, 'feature_names') and model.feature_names: - expected = list(model.feature_names) - - if expected is None: - return features - - # Only keep columns that the model expects (order preserved) - available = [col for col in expected if col in features.columns] - if len(available) < len(expected): - missing = set(expected) - set(available) - print(f"⚠️ Feature alignment: {len(missing)} missing features filled with 0: {sorted(missing)[:5]}{'...' if len(missing) > 5 else ''}") - # Add missing columns with 0 (safe neutral default) - for col in expected: - if col not in features.columns: - features = features.copy() - features[col] = 0.0 - - return features[expected] # type: ignore[return-value] - - def _favorite_profile_from_odds(self, odds_data: Dict[str, float]) -> Tuple[str, float]: - """ - Returns (favorite_side, gap_to_second_favorite). - favorite_side: H, A, D, or U (unknown) - """ - ms_h = self._safe_odd((odds_data or {}).get("ms_h")) - ms_d = self._safe_odd((odds_data or {}).get("ms_d")) - ms_a = self._safe_odd((odds_data or {}).get("ms_a")) - - candidates = [(side, odd) for side, odd in (("H", ms_h), ("D", ms_d), ("A", ms_a)) if odd > 0.0] - if len(candidates) < 2: - return "U", 0.0 - - candidates.sort(key=lambda item: item[1]) - favorite_side, favorite_odd = candidates[0] - second_odd = candidates[1][1] - return favorite_side, max(0.0, second_odd - favorite_odd) - - def _favorite_side_from_ms_odds( - self, - odds_data: Dict[str, float], - ) -> str: - """ - Returns side from MS home/away odds only: - - H: home favorite - - A: away favorite - - B: balanced (home and away near-equal) - - U: unknown - """ - ms_h = self._safe_odd((odds_data or {}).get("ms_h")) - ms_a = self._safe_odd((odds_data or {}).get("ms_a")) - if ms_h <= 0.0 or ms_a <= 0.0: - return "U" - - balance_gap = float(self.config.get("risk.htft_favorite_balance_gap", 0.20)) - if abs(ms_h - ms_a) <= balance_gap: - return "B" - return "H" if ms_h < ms_a else "A" - - def _get_top_odds_conditioned_prior( - self, - odds_data: Dict[str, float], - ) -> Optional[Tuple[float, ...]]: - side = self._favorite_side_from_ms_odds(odds_data) - if side == "H": - return self.FOOTBALL_TOP_PRIOR_HOME_FAV - if side == "A": - return self.FOOTBALL_TOP_PRIOR_AWAY_FAV - if side == "B": - return self.FOOTBALL_TOP_PRIOR_BALANCED - return None - - 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 _get_htft_league_prior( - self, - league_id: Optional[str], - sport: str, - ) -> Optional[Tuple[float, ...]]: - sport_key = (sport or "").lower().strip() - if sport_key != "football" or not league_id: - return None - - cache_key = (sport_key, str(league_id)) - if cache_key in self.league_htft_prior_cache: - return self.league_htft_prior_cache[cache_key] - - min_samples = int(self.config.get("risk.htft_prior_min_matches", 300)) - combo_counts = {label: 0 for label in self.HTFT_LABELS} - try: - with psycopg2.connect(self.db_dsn) as conn: - with conn.cursor() as cur: - cur.execute( - """ - WITH base AS ( - SELECT - CASE WHEN ht_score_home > ht_score_away THEN '1' - WHEN ht_score_home = ht_score_away THEN 'X' - ELSE '2' END AS ht, - CASE WHEN score_home > score_away THEN '1' - WHEN score_home = score_away THEN 'X' - ELSE '2' END AS ft - FROM matches - WHERE status = 'FT' - AND sport = %s - AND league_id = %s - AND ht_score_home IS NOT NULL - AND ht_score_away IS NOT NULL - AND score_home IS NOT NULL - AND score_away IS NOT NULL - ) - SELECT ht || '/' || ft AS combo, COUNT(*)::bigint AS n - FROM base - GROUP BY combo - """, - (sport_key, str(league_id)), - ) - rows = cur.fetchall() - except Exception: - self.league_htft_prior_cache[cache_key] = None - return None - - total = 0 - for combo, n in rows: - if combo in combo_counts: - combo_counts[combo] = int(n) - total += int(n) - - if total < min_samples: - self.league_htft_prior_cache[cache_key] = None - return None - - prior = tuple(combo_counts[label] / total for label in self.HTFT_LABELS) - self.league_htft_prior_cache[cache_key] = prior - return prior - - def _postprocess_htft_probs( - self, - raw_probs: List[float], - odds_data: Optional[Dict[str, float]] = None, - sport: str = "football", - is_top_league: bool = False, - league_id: Optional[str] = None, - ) -> List[float]: - """ - Stabilize HT/FT class probabilities. - - Why: - - HT/FT reversals (1/2, 2/1) are rare and can be overestimated. - - We preserve ranking signal but make absolute probabilities conservative. - """ - probs = [max(1e-9, float(p)) for p in raw_probs[:9]] - if len(probs) != 9: - return [1.0 / 9.0] * 9 - - # Global calibration pass for HT/FT market. - probs = [self.calibrator.calibrate("ht_ft", p) for p in probs] - - sport_key = (sport or "football").lower().strip() - - # Temperature > 1.0 flattens over-confident distributions. - if sport_key == "basketball": - if is_top_league: - temperature = float( - self.config.get("risk.htft_temperature_basketball_top", self.config.get("risk.htft_temperature_basketball", 1.08)), - ) - else: - temperature = float( - self.config.get("risk.htft_temperature_basketball_non_top", 1.15), - ) - else: - if is_top_league: - temperature = float( - self.config.get("risk.htft_temperature_top", self.config.get("risk.htft_temperature", 1.25)), - ) - else: - temperature = float( - self.config.get("risk.htft_temperature_non_top", 1.35), - ) - if temperature > 1.0: - inv_t = 1.0 / temperature - probs = [p**inv_t for p in probs] - - # Extra damping for reversal classes: 1/2 (idx 2), 2/1 (idx 6). - if is_top_league: - base_reversal_multiplier = float( - self.config.get("risk.htft_reversal_multiplier_top", self.config.get("risk.htft_reversal_multiplier", 0.60)), - ) - favorite_reversal_multiplier = float( - self.config.get( - "risk.htft_reversal_multiplier_favorite_top", - self.config.get("risk.htft_reversal_multiplier_favorite", 0.72), - ), - ) - underdog_reversal_multiplier = float( - self.config.get( - "risk.htft_reversal_multiplier_underdog_top", - self.config.get("risk.htft_reversal_multiplier_underdog", 0.45), - ), - ) - basketball_reversal_multiplier = float( - self.config.get( - "risk.htft_reversal_multiplier_basketball_top", - self.config.get("risk.htft_reversal_multiplier_basketball", 0.90), - ), - ) - else: - base_reversal_multiplier = float(self.config.get("risk.htft_reversal_multiplier_non_top", 0.45)) - favorite_reversal_multiplier = float( - self.config.get("risk.htft_reversal_multiplier_favorite_non_top", 0.55), - ) - underdog_reversal_multiplier = float( - self.config.get("risk.htft_reversal_multiplier_underdog_non_top", 0.30), - ) - basketball_reversal_multiplier = float( - self.config.get("risk.htft_reversal_multiplier_basketball_non_top", 0.75), - ) - gap_medium = float(self.config.get("risk.htft_reversal_gap_medium", 0.50)) - gap_strong = float(self.config.get("risk.htft_reversal_gap_strong", 1.00)) - - favorite_side, favorite_gap = self._favorite_profile_from_odds(odds_data or {}) - - def _reversal_multiplier(winner_side: str) -> float: - if sport_key == "basketball": - return basketball_reversal_multiplier - - multiplier = base_reversal_multiplier - if favorite_side in ("H", "A"): - multiplier = ( - favorite_reversal_multiplier - if winner_side == favorite_side - else underdog_reversal_multiplier - ) - - # If market heavily favors one side, penalize underdog-reversal harder. - if winner_side != favorite_side and favorite_gap >= gap_strong: - multiplier *= 0.80 - elif winner_side != favorite_side and favorite_gap >= gap_medium: - multiplier *= 0.90 - - return max(0.20, min(1.10, multiplier)) - - # 1/2 => winner is Away, 2/1 => winner is Home - probs[2] *= _reversal_multiplier("A") - probs[6] *= _reversal_multiplier("H") - - # Prior blend for football (league-specific if sufficient sample size). - if sport_key == "football": - league_prior = self._get_htft_league_prior(league_id=league_id, sport=sport_key) - if league_prior is not None: - prior = league_prior - blend = float(self.config.get("risk.htft_prior_blend_league", 0.65)) - else: - prior = self.FOOTBALL_TOP_PRIOR if is_top_league else self.FOOTBALL_NON_TOP_PRIOR - blend = float( - self.config.get( - "risk.htft_prior_blend_top" if is_top_league else "risk.htft_prior_blend_non_top", - 0.50 if is_top_league else 0.58, - ), - ) - - if is_top_league: - side_prior = self._get_top_odds_conditioned_prior(odds_data or {}) - if side_prior is not None: - if league_prior is not None: - odds_prior_blend = float( - self.config.get("risk.htft_prior_odds_blend_top_with_league", 0.22), - ) - else: - odds_prior_blend = float( - self.config.get("risk.htft_prior_odds_blend_top", 0.35), - ) - odds_prior_blend = max(0.0, min(0.80, odds_prior_blend)) - prior = tuple( - ((1.0 - odds_prior_blend) * prior[idx]) + (odds_prior_blend * side_prior[idx]) - for idx in range(9) - ) - - blend = max(0.0, min(0.95, blend)) - probs = [((1.0 - blend) * p) + (blend * prior[idx]) for idx, p in enumerate(probs)] - - # Hard cap reversal classes by prior factor to avoid unrealistic spikes. - cap_factor = float(self.config.get("risk.htft_reversal_cap_factor", 2.3)) - cap_factor = max(1.0, cap_factor) - for idx in (2, 6): - cap_val = prior[idx] * cap_factor - if probs[idx] > cap_val: - probs[idx] = cap_val - - total = sum(probs) - if total <= 0: - return [1.0 / 9.0] * 9 - - return [p / total for p in probs] - - def predict(self, - match_id: str, - home_team_id: str, - away_team_id: str, - home_team_name: str, - away_team_name: str, - match_date_ms: int, - odds_data: Optional[Dict[str, float]] = None, - home_lineup: Optional[List[str]] = None, - away_lineup: Optional[List[str]] = None, - referee_name: Optional[str] = None, - home_goals_avg: float = 1.5, - home_conceded_avg: float = 1.2, - away_goals_avg: float = 1.2, - away_conceded_avg: float = 1.4, - home_position: int = 10, - away_position: int = 10, - league_name: str = "", - league_id: Optional[str] = None, - sport: str = "football", - sidelined_data: Optional[Dict] = None) -> FullMatchPrediction: - """ - Generate complete V20 ensemble prediction. - - Returns FullMatchPrediction with ALL markets. - """ - - # Default odds if not provided - if odds_data is None: - odds_data = { - "ms_h": self.DEFAULT_MS_H, - "ms_d": self.DEFAULT_MS_D, - "ms_a": self.DEFAULT_MS_A, - } - - # === 1. COLLECT ALL ENGINE PREDICTIONS === - - team_pred = self.team_engine.predict( - home_team_id=home_team_id, - away_team_id=away_team_id, - match_date_ms=match_date_ms, - home_team_name=home_team_name, - away_team_name=away_team_name - ) - - player_pred = self.player_engine.predict( - match_id=match_id, - home_team_id=home_team_id, - away_team_id=away_team_id, - home_lineup=home_lineup, - away_lineup=away_lineup, - sidelined_data=sidelined_data - ) - - odds_pred = self.odds_engine.predict( - odds_data=odds_data, - home_goals_avg=home_goals_avg, - home_conceded_avg=home_conceded_avg, - away_goals_avg=away_goals_avg, - away_conceded_avg=away_conceded_avg - ) - - referee_pred = self.referee_engine.predict( - match_id=match_id, - referee_name=referee_name or "", - league_id=league_id or "" - ) - - upset_factors = self.upset_engine.calculate_upset_potential( - home_team_name=home_team_name, - home_team_id=home_team_id, - away_team_name=away_team_name, - league_name=league_name, - home_position=home_position, - away_position=away_position, - match_date_ms=match_date_ms - ) - - # GLM-5 Enhanced Upset Detection v2 - # Determine favorite from odds - favorite_side = "home" - favorite_odds = odds_data.get("ms_h", 2.0) if odds_data else 2.0 - if odds_data: - ms_h = odds_data.get("ms_h", 999) - ms_a = odds_data.get("ms_a", 999) - if ms_a < ms_h: - favorite_side = "away" - favorite_odds = ms_a - elif ms_h < ms_a: - favorite_side = "home" - favorite_odds = ms_h - else: - favorite_side = "draw" - favorite_odds = odds_data.get("ms_d", 3.0) - - upset_factors_v2 = self.upset_engine_v2.calculate_upset_potential( - home_team_name=home_team_name, - home_team_id=home_team_id, - away_team_name=away_team_name, - league_name=league_name, - home_position=home_position, - away_position=away_position, - match_date_ms=match_date_ms, - odds_data=odds_data, - referee_name=referee_name or "", - home_form_score=getattr(team_pred, 'home_form_score', 50.0), - away_form_score=getattr(team_pred, 'away_form_score', 50.0), - favorite_side=favorite_side, - favorite_odds=favorite_odds - ) - - # === 2. DYNAMIC ENGINE WEIGHTS === - w_team = self.config.get("engine_weights.team", 0.30) - w_player = self.config.get("engine_weights.player", 0.25) - w_odds = self.config.get("engine_weights.odds", 0.30) - w_referee = self.config.get("engine_weights.referee", 0.15) - - # Redistribution Logic - if not player_pred.lineup_available: - min_w = self.config.get("engine_weights.min_weight", 0.05) - surplus = w_player - min_w - w_player = min_w - w_team += surplus * self.config.get("weight_redistribution.player_missing_to_team", 0.5) - w_odds += surplus * self.config.get("weight_redistribution.player_missing_to_odds", 0.5) - - min_ref_matches = self.config.get("weight_redistribution.referee_min_matches", 5) - if referee_pred.matches_officiated < min_ref_matches: - min_w = self.config.get("engine_weights.min_weight", 0.05) - surplus = w_referee - min_w - w_referee = min_w - w_team += surplus * self.config.get("weight_redistribution.referee_missing_to_team", 0.4) - w_odds += surplus * self.config.get("weight_redistribution.referee_missing_to_odds", 0.6) - - # Normalize - w_total = w_team + w_player + w_odds + w_referee - weights = { - "team": w_team / w_total, - "player": w_player / w_total, - "odds": w_odds / w_total, - "referee": w_referee / w_total - } - - # Get Modifiers - player_mods = self.player_engine.get_1x2_modifier(player_pred) - referee_mods = self.referee_engine.get_modifiers(referee_pred) - - # Calculate xG (Used by multiple calculators) - home_xg = (team_pred.home_xg + odds_pred.poisson_home_xg) / 2 - away_xg = (team_pred.away_xg + odds_pred.poisson_away_xg) / 2 - - # === 3. CREATE CONTEXT === - ctx = CalculationContext( - team_pred=team_pred, - player_pred=player_pred, - odds_pred=odds_pred, - referee_pred=referee_pred, - upset_factors=upset_factors, - weights=weights, - player_mods=player_mods, - referee_mods=referee_mods, - match_id=match_id, - home_team_name=home_team_name, - away_team_name=away_team_name, - odds_data=odds_data, - home_xg=home_xg, - away_xg=away_xg, - total_xg=home_xg + away_xg, - league_id=league_id, - sport=(sport or "football").lower().strip(), - is_top_league=self._is_top_league(league_id), - ) - - # === 4. XGBOOST INFERENCE === - try: - # Prepare features (1 row DataFrame) - xgb_features = self.feature_adapter.get_features(ctx) - - # Predict β€” per-model feature alignment - for key, model in self.xgb_models.items(): - try: - model_features = self._align_features(xgb_features, model) - raw_pred = model.predict_proba(model_features) - except Exception as model_err: - print(f"⚠️ XGBoost {key} inference failed: {model_err}") - continue - - # Handle multi-class (MS, HT_RESULT, HT/FT) vs binary - if key in ("ms", "ht_result"): - # raw_pred is (1, 3) - probs = raw_pred[0] # [Home, Draw, Away] - ctx.xgboost_preds[key] = { - "home": float(probs[0]), - "draw": float(probs[1]), - "away": float(probs[2]) - } - elif key == "handicap_ms": - probs = raw_pred[0] # [H1, HX, H2] - ctx.xgboost_preds[key] = { - "h1": float(probs[0]), - "hx": float(probs[1]), - "h2": float(probs[2]) - } - elif key == "ht_ft": - # raw_pred is (1, 9) - raw_probs = [float(p) for p in raw_pred[0]] - probs = self._postprocess_htft_probs( - raw_probs, - odds_data=odds_data, - sport=sport, - is_top_league=ctx.is_top_league, - league_id=league_id, - ) - ctx.xgboost_preds[key] = { - label: float(probs[idx]) for idx, label in enumerate(self.HTFT_LABELS) - } - # Keep raw vector for optional calculators/debug consumers. - ctx.xgboost_preds["ht_ft_raw"] = raw_probs - else: - # Binary (OU/BTTS) - index 1 is the positive class probability - prob = float(raw_pred[0][1]) - ctx.xgboost_preds[key] = prob - - except Exception as e: - print(f"⚠️ XGBoost Inference Failed: {e}") - import traceback - traceback.print_exc() - - # === 5. RUN CALCULATORS === - ms_result = self.match_result_calc.calculate(ctx) - ou_result = self.over_under_calc.calculate(ctx) - ht_result = self.half_time_calc.calculate(ctx) - score_result = self.score_calc.calculate(ctx, ms_result) - other_result = self.other_markets_calc.calculate(ctx, ms_result) - risk_result = self.risk_assessor.calculate(ctx, ms_result) - - # Use Reconciled Result - final_ms = score_result.reconciled_ms if score_result.reconciled_ms else ms_result - - # Expert Recommendation (New Logic) - expert_result = self.expert_recommender.calculate(ctx, final_ms, ou_result, risk_result) - expert_data = {} - if expert_result: - expert_data = { - "main_pick": expert_result.main_pick, - "safe_alternative": expert_result.safe_alternative, - "value_picks": expert_result.value_picks, - "surprise_picks": expert_result.surprise_picks, - "market_summary": expert_result.market_summary - } - - # Update context with risk info for recommender - ctx.risk_level = risk_result.risk_level - ctx.is_surprise = risk_result.is_surprise_risk - - rec_result = self.bet_recommender.calculate(ctx, final_ms, ou_result, risk_result) - - # === 5. ASSEMBLE PREDICTION === - - # Map MarketPredictionDTO to internal MarketPrediction - def _map_dto(dto): - if not dto: return None - return MarketPrediction( - market_type=dto.market_type, - pick=dto.pick, - probability=dto.probability, - confidence=dto.confidence, - odds=dto.odds, - is_recommended=dto.is_recommended, - is_value_bet=dto.is_value_bet, - edge=dto.edge - ) - - best_bet = _map_dto(rec_result.best_bet) - alt_bet = _map_dto(rec_result.alternative_bet) - recommended = [m for m in (_map_dto(r) for r in rec_result.recommended_bets) if m is not None] - - # Analysis Details - analysis_details = { - "home_form": f"Form Score: {round(0.5 + team_pred.form_advantage/2, 2)}", - "away_form": f"Form Score: {round(0.5 - team_pred.form_advantage/2, 2)}", - "key_players_missing": self._get_missing_desc(player_pred), - "referee_notes": f"{referee_name}: {round(referee_pred.avg_yellow_cards, 1)} Yellow Cards/Avg", - "market_trend": "Market data analyzed" - } - - return FullMatchPrediction( - match_id=match_id, - home_team=home_team_name, - away_team=away_team_name, - - # Match Result (Using Reconciled Final MS) - ms_home_prob=final_ms.ms_home_prob, - ms_draw_prob=final_ms.ms_draw_prob, - ms_away_prob=final_ms.ms_away_prob, - ms_pick=final_ms.ms_pick, - ms_confidence=final_ms.ms_confidence, - - # Double Chance (Using Reconciled Final MS) - dc_1x_prob=final_ms.dc_1x_prob, - dc_x2_prob=final_ms.dc_x2_prob, - dc_12_prob=final_ms.dc_12_prob, - dc_pick=final_ms.dc_pick, - dc_confidence=final_ms.dc_confidence, - - # Over/Under - over_15_prob=ou_result.over_15_prob, - under_15_prob=ou_result.under_15_prob, - ou15_pick=ou_result.ou15_pick, - ou15_confidence=ou_result.ou15_confidence, - - over_25_prob=ou_result.over_25_prob, - under_25_prob=ou_result.under_25_prob, - ou25_pick=ou_result.ou25_pick, - ou25_confidence=ou_result.ou25_confidence, - - over_35_prob=ou_result.over_35_prob, - under_35_prob=ou_result.under_35_prob, - ou35_pick=ou_result.ou35_pick, - ou35_confidence=ou_result.ou35_confidence, - - # BTTS - btts_yes_prob=ou_result.btts_yes_prob, - btts_no_prob=ou_result.btts_no_prob, - btts_pick=ou_result.btts_pick, - btts_confidence=ou_result.btts_confidence, - - # Half Time - ht_home_prob=ht_result.ht_home_prob, - ht_draw_prob=ht_result.ht_draw_prob, - ht_away_prob=ht_result.ht_away_prob, - ht_pick=ht_result.ht_pick, - ht_confidence=ht_result.ht_confidence, - - # Score - score=score_result, - - # HT O/U - ht_over_05_prob=ht_result.ht_over_05_prob, - ht_under_05_prob=ht_result.ht_under_05_prob, - ht_over_15_prob=ht_result.ht_over_15_prob, - ht_under_15_prob=ht_result.ht_under_15_prob, - ht_ou_pick=ht_result.ht_ou_pick, - ht_ou15_pick=ht_result.ht_ou15_pick, - - # Scores (Reconciled check usually happens in ScoreCalc) - predicted_ft_score=score_result.predicted_ft_score, - predicted_ht_score=score_result.predicted_ht_score, - ft_scores_top5=score_result.ft_scores_top5, - - # xG - home_xg=home_xg, - away_xg=away_xg, - total_xg=home_xg + away_xg, - - # Others - total_corners_pred=other_result.total_corners_pred, - corner_pick=other_result.corner_pick or "", - total_cards_pred=other_result.total_cards_pred, - card_pick=other_result.card_pick or "", - cards_over_prob=other_result.cards_over_prob, - cards_under_prob=other_result.cards_under_prob, - cards_confidence=other_result.cards_confidence, - handicap_pick=other_result.handicap_pick or "", - handicap_home_prob=other_result.handicap_home_prob, - handicap_draw_prob=other_result.handicap_draw_prob, - handicap_away_prob=other_result.handicap_away_prob, - handicap_confidence=other_result.handicap_confidence, - odd_even_pick=other_result.odd_even_pick, - odd_prob=other_result.odd_prob, - even_prob=other_result.even_prob, - - # Risk - risk_level=risk_result.risk_level, - risk_score=risk_result.risk_score, - is_surprise_risk=risk_result.is_surprise_risk, - surprise_type=risk_result.surprise_type, - ht_ft_probs=ctx.xgboost_preds.get("ht_ft", {}), - analysis_details=analysis_details, - risk_warnings=risk_result.risk_warnings, - - # GLM-5 SΓΌrpriz Skoru - upset_score=upset_factors_v2.upset_score, - upset_level=upset_factors_v2.upset_level, - upset_reasons=upset_factors_v2.reasoning, - - # Engines - team_confidence=team_pred.confidence, - player_confidence=player_pred.confidence, - odds_confidence=odds_pred.confidence, - referee_confidence=referee_pred.confidence, - - # Recs - best_bet=best_bet, - recommended_bets=recommended, - alternative_bet=alt_bet, - - # Expert Recommendation (New) - expert_recommendation=expert_data - ) - - def _get_missing_desc(self, player_pred) -> List[str]: - if not player_pred.lineup_available: - return ["Lineups not confirmed"] - - missing = [] - if player_pred.home_missing_impact > 0.1: - missing.append(f"Home missing impact: {int(player_pred.home_missing_impact*100)}%") - if player_pred.away_missing_impact > 0.1: - missing.append(f"Away missing impact: {int(player_pred.away_missing_impact*100)}%") - - return missing if missing else ["No significant missing players"] - - -# Singleton -_predictor: Optional[V20EnsemblePredictor] = None - - -def get_v20_predictor() -> V20EnsemblePredictor: - global _predictor - if _predictor is None: - _predictor = V20EnsemblePredictor() - return _predictor - - -if __name__ == "__main__": - predictor = get_v20_predictor() - - print("\\nπŸ§ͺ V20 Ensemble Beast Test") - print("=" * 60) - - result = predictor.predict( - match_id="test_match", - home_team_id="test_home", - away_team_id="test_away", - home_team_name="Beşiktaş", - away_team_name="Galatasaray", - match_date_ms=1707393600000, - odds_data={ - "ms_h": 2.50, - "ms_d": 3.20, - "ms_a": 2.80, - "ou25_o": 1.85 - }, - home_position=3, - away_position=1, - league_name="SΓΌper Lig" - ) - - print(json.dumps(result.to_dict(), indent=2, ensure_ascii=False)) diff --git a/ai-engine/models/v25_ensemble.py b/ai-engine/models/v25_ensemble.py index 7a9af5d..a83aab9 100644 --- a/ai-engine/models/v25_ensemble.py +++ b/ai-engine/models/v25_ensemble.py @@ -20,6 +20,13 @@ from dataclasses import dataclass, field import xgboost as xgb import lightgbm as lgb +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +try: + from config.config_loader import get_config as _get_cfg +except ImportError: + _get_cfg = None # type: ignore[assignment] + # CatBoost is optional try: from catboost import CatBoostClassifier @@ -228,7 +235,7 @@ class V25Predictor: print(f"[V25] Using fallback feature columns ({len(V25Predictor._FALLBACK_FEATURE_COLS)} features)") return V25Predictor._FALLBACK_FEATURE_COLS - # Model weights for ensemble + # Model weights for ensemble (overridden from config in __init__) DEFAULT_WEIGHTS = { 'xgb': 0.50, 'lgb': 0.50, @@ -245,6 +252,16 @@ class V25Predictor: self.models = {} # market -> {'xgb': model, 'lgb': model} self._loaded = False self.FEATURE_COLS = self._load_feature_cols() + # Load weights from config (falls back to class default 0.50/0.50) + if _get_cfg is not None: + try: + cfg = _get_cfg() + self.DEFAULT_WEIGHTS = { + 'xgb': float(cfg.get('model_ensemble.xgb_weight', 0.50)), + 'lgb': float(cfg.get('model_ensemble.lgb_weight', 0.50)), + } + except Exception: + pass # keep class-level defaults # All trained market models available in V25 ALL_MARKETS = [ @@ -275,21 +292,34 @@ class V25Predictor: xgb_content = f.read() booster = xgb.Booster() booster.load_model(bytearray(xgb_content, 'utf-8')) - self.models[market]['xgb'] = booster - loaded_count += 1 - + # Corruption detection: verify model can run a dummy prediction + try: + _dummy = pd.DataFrame([{col: 0.0 for col in self.FEATURE_COLS}]) + booster.predict(xgb.DMatrix(_dummy)) + self.models[market]['xgb'] = booster + loaded_count += 1 + except Exception as _ce: + print(f"[V25] ⚠️ XGB model for {market} failed integrity check: {_ce} β€” skipping") + # Load LightGBM (read content in Python to avoid non-ASCII path issues) lgb_path = os.path.join(self.models_dir, f'lgb_v25_{market}.txt') if os.path.exists(lgb_path) and os.path.getsize(lgb_path) > 0: with open(lgb_path, 'r', encoding='utf-8') as f: model_str = f.read() - self.models[market]['lgb'] = lgb.Booster(model_str=model_str) - loaded_count += 1 - + lgb_model = lgb.Booster(model_str=model_str) + # Corruption detection: verify model can run a dummy prediction + try: + _dummy = pd.DataFrame([{col: 0.0 for col in self.FEATURE_COLS}]) + lgb_model.predict(_dummy) + self.models[market]['lgb'] = lgb_model + loaded_count += 1 + except Exception as _ce: + print(f"[V25] ⚠️ LGB model for {market} failed integrity check: {_ce} β€” skipping") + # Remove empty entries if not self.models[market]: del self.models[market] - + print(f"[V25] Loaded {loaded_count} model files across {len(self.models)} markets: {list(self.models.keys())}") self._loaded = loaded_count > 0 return self._loaded @@ -305,7 +335,27 @@ class V25Predictor: if not self._loaded: if not self.load_models(): raise RuntimeError("Failed to load V25 models") - + + def readiness_summary(self) -> Dict[str, Any]: + """Return per-market model status for health check endpoint.""" + if not self._loaded: + self.load_models() + market_status = {} + for market in self.ALL_MARKETS: + m = self.models.get(market, {}) + market_status[market] = { + "xgb": "xgb" in m, + "lgb": "lgb" in m, + "ready": bool(m), + } + loaded_markets = [k for k, v in market_status.items() if v["ready"]] + return { + "fully_loaded": len(loaded_markets) == len(self.ALL_MARKETS), + "loaded_markets": loaded_markets, + "missing_markets": [m for m in self.ALL_MARKETS if m not in loaded_markets], + "weights": self.DEFAULT_WEIGHTS, + } + def _prepare_features(self, features: Dict[str, float]) -> pd.DataFrame: """Prepare feature vector for prediction.""" X = pd.DataFrame([{col: features.get(col, 0.0) for col in self.FEATURE_COLS}]) @@ -563,13 +613,23 @@ class V25Predictor: ) -> List[ValueBet]: """Detect value bets based on model vs market odds.""" value_bets = [] - min_edge = 0.05 # 5% minimum edge - + # Market-specific minimum edge thresholds + # MS: higher variance β†’ require more edge + # OU/BTTS: binary markets β†’ tighter edge acceptable + EDGE_THRESHOLDS = { + 'MS': 0.06, + 'OU25': 0.04, + 'BTTS': 0.04, + } + ms_edge = EDGE_THRESHOLDS['MS'] + ou_edge = EDGE_THRESHOLDS['OU25'] + btts_edge = EDGE_THRESHOLDS['BTTS'] + # MS value bets if 'ms_h' in odds and odds['ms_h'] > 0: implied = 1 / odds['ms_h'] edge = home_prob - implied - if edge > min_edge: + if edge > ms_edge: value_bets.append(ValueBet( market_type='MS', pick='1', @@ -582,7 +642,7 @@ class V25Predictor: if 'ms_d' in odds and odds['ms_d'] > 0: implied = 1 / odds['ms_d'] edge = draw_prob - implied - if edge > min_edge: + if edge > ms_edge: value_bets.append(ValueBet( market_type='MS', pick='X', @@ -595,7 +655,7 @@ class V25Predictor: if 'ms_a' in odds and odds['ms_a'] > 0: implied = 1 / odds['ms_a'] edge = away_prob - implied - if edge > min_edge: + if edge > ms_edge: value_bets.append(ValueBet( market_type='MS', pick='2', @@ -609,7 +669,7 @@ class V25Predictor: if 'ou25_o' in odds and odds['ou25_o'] > 0: implied = 1 / odds['ou25_o'] edge = over_prob - implied - if edge > min_edge: + if edge > ou_edge: value_bets.append(ValueBet( market_type='OU25', pick='Over', @@ -622,7 +682,7 @@ class V25Predictor: if 'ou25_u' in odds and odds['ou25_u'] > 0: implied = 1 / odds['ou25_u'] edge = under_prob - implied - if edge > min_edge: + if edge > ou_edge: value_bets.append(ValueBet( market_type='OU25', pick='Under', @@ -636,7 +696,7 @@ class V25Predictor: if 'btts_y' in odds and odds['btts_y'] > 0: implied = 1 / odds['btts_y'] edge = btts_yes_prob - implied - if edge > min_edge: + if edge > btts_edge: value_bets.append(ValueBet( market_type='BTTS', pick='Yes', @@ -649,7 +709,7 @@ class V25Predictor: if 'btts_n' in odds and odds['btts_n'] > 0: implied = 1 / odds['btts_n'] edge = btts_no_prob - implied - if edge > min_edge: + if edge > btts_edge: value_bets.append(ValueBet( market_type='BTTS', pick='No', diff --git a/ai-engine/reports/backtest_consistency.json b/ai-engine/reports/backtest_consistency.json new file mode 100644 index 0000000..7704f6a --- /dev/null +++ b/ai-engine/reports/backtest_consistency.json @@ -0,0 +1,160 @@ +{ + "total_test": 23039, + "thresholds": { + "0.0": { + "n_matches": 22227, + "pct": 96.5, + "markets": { + "ms": { + "hit_rate": 0.5363, + "avg_roi": -0.0046, + "total_roi": -103.02 + }, + "ou15": { + "hit_rate": 0.7463, + "avg_roi": 0.0144, + "total_roi": 319.02 + }, + "ou25": { + "hit_rate": 0.6111, + "avg_roi": -0.006, + "total_roi": -134.41 + }, + "ou35": { + "hit_rate": 0.7302, + "avg_roi": -0.014, + "total_roi": -310.51 + }, + "btts": { + "hit_rate": 0.5848, + "avg_roi": 0.0031, + "total_roi": 69.5 + } + } + }, + "0.1": { + "n_matches": 23033, + "pct": 100.0, + "markets": { + "ms": { + "hit_rate": 0.546, + "avg_roi": -0.0045, + "total_roi": -104.38 + }, + "ou15": { + "hit_rate": 0.7533, + "avg_roi": 0.0145, + "total_roi": 335.02 + }, + "ou25": { + "hit_rate": 0.6193, + "avg_roi": -0.0042, + "total_roi": -96.97 + }, + "ou35": { + "hit_rate": 0.7277, + "avg_roi": -0.0147, + "total_roi": -338.57 + }, + "btts": { + "hit_rate": 0.5886, + "avg_roi": 0.0025, + "total_roi": 57.21 + } + } + }, + "0.2": { + "n_matches": 23034, + "pct": 100.0, + "markets": { + "ms": { + "hit_rate": 0.5459, + "avg_roi": -0.0046, + "total_roi": -105.38 + }, + "ou15": { + "hit_rate": 0.7533, + "avg_roi": 0.0146, + "total_roi": 335.26 + }, + "ou25": { + "hit_rate": 0.6193, + "avg_roi": -0.0043, + "total_roi": -97.97 + }, + "ou35": { + "hit_rate": 0.7276, + "avg_roi": -0.0147, + "total_roi": -339.57 + }, + "btts": { + "hit_rate": 0.5887, + "avg_roi": 0.0025, + "total_roi": 57.62 + } + } + }, + "0.3": { + "n_matches": 23039, + "pct": 100.0, + "markets": { + "ms": { + "hit_rate": 0.546, + "avg_roi": -0.0045, + "total_roi": -103.45 + }, + "ou15": { + "hit_rate": 0.7534, + "avg_roi": 0.0146, + "total_roi": 335.6 + }, + "ou25": { + "hit_rate": 0.6194, + "avg_roi": -0.0042, + "total_roi": -97.44 + }, + "ou35": { + "hit_rate": 0.7277, + "avg_roi": -0.0147, + "total_roi": -339.26 + }, + "btts": { + "hit_rate": 0.5887, + "avg_roi": 0.0025, + "total_roi": 58.61 + } + } + }, + "0.5": { + "n_matches": 23039, + "pct": 100.0, + "markets": { + "ms": { + "hit_rate": 0.546, + "avg_roi": -0.0045, + "total_roi": -103.45 + }, + "ou15": { + "hit_rate": 0.7534, + "avg_roi": 0.0146, + "total_roi": 335.6 + }, + "ou25": { + "hit_rate": 0.6194, + "avg_roi": -0.0042, + "total_roi": -97.44 + }, + "ou35": { + "hit_rate": 0.7277, + "avg_roi": -0.0147, + "total_roi": -339.26 + }, + "btts": { + "hit_rate": 0.5887, + "avg_roi": 0.0025, + "total_roi": 58.61 + } + } + } + } +} \ No newline at end of file diff --git a/ai-engine/reports/backtest_league_results.json b/ai-engine/reports/backtest_league_results.json new file mode 100644 index 0000000..9540899 --- /dev/null +++ b/ai-engine/reports/backtest_league_results.json @@ -0,0 +1,7209 @@ +{ + "generated_at": "2026-05-16T13:37:02.058845", + "n_test_per_league": 150, + "results": [ + { + "league_id": "117yqo02rs8dykkxpm274w3bd", + "league_name": "Lig 1", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7733, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.44, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3933, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "1b70m6qtxrp75b4vtk8hxh8c3", + "league_name": "1. HNL", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4133, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.74, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5867, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7133, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5533, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "1j4ehtrbry9depwt6oghaq3lu", + "league_name": "S\u00fcper Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5733, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8067, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5867, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6933, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5333, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "1mpjd0vbxbtu9zw89yj09xk3z", + "league_name": "S\u00fcper Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.2933, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5667, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.66, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.56, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "1q4ab2bpg5e8jl1g2udnakrju", + "league_name": "S Ligi", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6533, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.9133, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.7533, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.54, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6333, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "1qd0wvt30rlswa4g6nu4na660", + "league_name": "Ulusal Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.48, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7267, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.76, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4295, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6779, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6242, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2685, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "1r097lpxe0xn03ihb7wi98kao", + "league_name": "Serie A", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7067, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7933, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.44, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "1wwro3z1eb3fl601dju6inlc6", + "league_name": "Ulusal Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4667, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7067, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.56, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7133, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5667, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "1zp1du9n4rj36p1ss9zbxtqfb", + "league_name": "Serie C", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7933, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.4867, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4333, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.76, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2467, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "287tckirbfj9nb8ar2k9r60vn", + "league_name": "MLS", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4733, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.56, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.56, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4133, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.32, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.48, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "29actv1ohj8r10kd9hu0jnb0n", + "league_name": "S\u00fcper Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4133, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.84, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.62, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "2aso72utuctat2ecs6nahjss6", + "league_name": "1. Lig Kad\u0131nlar", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5333, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.68, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.52, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "2hj3286pqov1g1g59k2t2qcgm", + "league_name": "FA Cup", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5467, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.9067, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.84, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6733, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.72, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "2hsidwomhjsaaytdy9u5niyi4", + "league_name": "Liga MX", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4533, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7867, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5933, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.48, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3067, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.4867, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.48, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "2kwbbcootiqqgmrzs6o5inle5", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4133, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.76, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4133, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2467, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.4933, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "2mdmx668tyhy4u4z9zszwjv5v", + "league_name": "Victoria NPL", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3333, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8467, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6333, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.5467, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5867, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "2nttcoriwf5co73vmz1vr8frm", + "league_name": "Nesine 2. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.76, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3467, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "2o9svokc5s7diish3ycrzk7jm", + "league_name": "Trendyol 1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8067, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5067, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3533, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5133, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "2ty8ihceabty8yddmu31iuuej", + "league_name": "A Ligi", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7733, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4733, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.32, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5133, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "2wolc27r8z03itcvwp43e38c5", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7667, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.38, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.7667, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "2xg0qvif1rh7du6wmk2eleku3", + "league_name": "Ligat ha'Al", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.38, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7267, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5933, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7733, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5467, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "2y8bntiif3a9y6gtmauv30gt", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7267, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5608, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6824, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7162, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.4189, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.7533, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "2z7257m7hj58zuxcjrsg4erzc", + "league_name": "Bundesliga Kad\u0131nlar", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5667, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8667, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.7533, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6867, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "3428tckxcirwwh3o3jgc1m8ji", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4733, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.5467, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.52, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5333, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "34pl8szyvrbwcmfkuocjm3r6t", + "league_name": "LaLiga", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4467, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7933, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4467, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2533, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "392slbmf1kdqlr6sd1ckt71rs", + "league_name": "FA Trophy Kupas\u0131", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4267, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.86, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6933, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7867, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.64, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "3ab1uwtoyjopdj1y1fynyy9jg", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.44, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.64, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5933, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.8067, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5533, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "3e40pestup9xzagsu2o6c0i8u", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.38, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.54, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.78, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5533, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "3is4bkgf3loxv9qfg3hm8zfqb", + "league_name": "LaLiga 2", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.72, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.46, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2667, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5133, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5133, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "3iwftmprsznl6yribr11a8l9m", + "league_name": "B\u00f6lgesel Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4667, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8333, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.3733, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.8067, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.28, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.4667, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.44, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "3l29w00m506ex93t5bbh9cg2a", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4067, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.9, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.72, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.66, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.7, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "3n5046abeu3x482ds3jwda238", + "league_name": "WE Lig Kad\u0131nlar", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4867, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7267, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6267, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.76, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.56, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "3oa9e03e7w9nr8kqwqc3tlqz9", + "league_name": "S\u00fcper Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.42, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.56, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7267, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "3p81ltz6845appgkbgkzxueii", + "league_name": "2. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8333, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.42, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.8133, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.42, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.42, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "3w1hkk9k9gr8fwssyn4icvdfo", + "league_name": "Virsliga", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5733, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7533, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5933, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "3ww12jab49q8q8mk9avdwjqgk", + "league_name": "S\u00fcper Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.44, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "46b141eaqq9q7o4gz5gtdpikk", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.7267, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7933, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.56, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.7867, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "477yyajzheg2z8u7uick0e13e", + "league_name": "Erovnuli Ligi", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3933, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.74, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5333, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7067, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5733, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "47s2kt0e8m444ftqvsrqa3bvq", + "league_name": "NB I", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3133, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.76, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6867, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5533, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "482ofyysbdbeoxauk19yg7tdt", + "league_name": "Trendyol S\u00fcper Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7933, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3667, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "4c1nfi2j1m731hcay25fcgndq", + "league_name": "Avrupa Ligi", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.52, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.6867, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.64, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7133, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5733, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "4d5d3sf6805n5u6jdoa0hdlog", + "league_name": "Meistriliiga", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5267, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.84, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.68, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6067, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "4mbfidy8zum5u0aqjqo0vuqs2", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3867, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.58, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.56, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5533, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "4nidzmunvpvxk1ir9b6m8mpay", + "league_name": "Haz\u0131rl\u0131k Ma\u00e7lar\u0131 Kul\u00fcpler", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7933, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.303, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7576, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.5758, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2121, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.56, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.98, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "4oogyu6o156iphvdvphwpck10", + "league_name": "\u015eampiyonlar Ligi", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4467, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8067, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6867, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.62, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5667, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "4qehj8hfxmy6o2ohp4fxinnzo", + "league_name": "2. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4467, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.76, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5933, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6733, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6067, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "4rls982p5uzil6x30mhyhv9f3", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5733, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8267, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.66, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.5067, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "4w7x0s5gfs5abasphlha5de8k", + "league_name": "Ligue 2", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.44, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2933, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "4yngyfinzd6bb1k7anqtqs0wt", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4067, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.58, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6267, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.84, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.58, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "4yzidekywejmxxp77gqmdgopg", + "league_name": "3. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.8, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4362, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7315, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7114, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.349, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "4zwgbb66rif2spcoeeol2motx", + "league_name": "Pro Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7933, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7267, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3333, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "57nu0wygurzkp6fuy5hhrtaa2", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.8133, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4054, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.5743, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7162, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.223, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "581t4mywybx21wcpmpykhyzr3", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.42, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.8533, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4867, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.28, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "5aw6uyw4pz2bpj24t5z8aacim", + "league_name": "2. SNL", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4133, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7667, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.52, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.56, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "5c96g1zm7vo5ons9c42uy2w3r", + "league_name": "Bundesliga", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3133, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.6333, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5067, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6933, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "5cwsxtx37les6m10xj71htkgf", + "league_name": "A Ligi", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.38, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.6733, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.64, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7133, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5733, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "5dycj9wdhxh3n33qubw18ohlk", + "league_name": "K3 Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.2733, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.6333, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.58, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.8133, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5667, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "5taraea6mqjjldg9zxswo825y", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3067, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.6933, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7667, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5533, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "5y0z0l2epprzbscvzsgldw8vu", + "league_name": "Profesyonel Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7667, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7867, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3333, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "5zr0b05eyx25km7z1k03ca9jx", + "league_name": "Serie B", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4133, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.8067, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.1933, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "61fzfjogstjuukzcehighq7mu", + "league_name": "Queensland NPL", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3267, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8333, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6667, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.5133, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5933, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "65ggsqdi6drpa4m8y3gkll25k", + "league_name": "Bahar \u015eampiyonas\u0131", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.78, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.72, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5067, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3467, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5933, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "663a54fmymndjeev47qm7d3nf", + "league_name": "2. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8133, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4067, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2867, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "6by3h89i2eykc341oz7lv1ddd", + "league_name": "Bundesliga", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.84, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.46, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.78, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.4067, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "6g8hw3acenrw828la7gwx4mvs", + "league_name": "Yeni G\u00fcney Galler NPL", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "6ifaeunfdelecgticvxanikzu", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.38, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7267, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.64, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.66, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.56, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "6ihotpaocgiovlxw18e9r9prx", + "league_name": "Ykk\u00f6sliiga", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.42, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6533, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.64, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.66, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "6lkj3o21cr4g7bql6tb3fk222", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.36, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.78, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.54, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6533, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5733, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "6lwpjhktjhl9g7x2w7njmzva6", + "league_name": "Pro Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5933, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.82, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5503, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6644, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7517, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3893, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5933, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "6vq8j5p3av14nr3iuyi4okhjt", + "league_name": "S\u00fcper Lig Kad\u0131nlar", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5667, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7667, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6533, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.72, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6067, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "6wubmo7di3kdpflluf6s8c7vs", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4333, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.66, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.64, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7267, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.4933, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "722fdbecxzcq9788l6jqclzlw", + "league_name": "2. Bundesliga", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4867, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8133, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7267, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2733, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.48, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "75434tz9rc14xkkvudex742ui", + "league_name": "Premier Lig 2", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8533, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.84, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.5067, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "75i269i1ak43magshljadydrh", + "league_name": "Premiership", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8133, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "7af85xa75vozt2l4hzi6ryts7", + "league_name": "Ziraat T\u00fcrkiye Kupas\u0131", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6333, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.88, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6867, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.58, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "7cwemnr3vi40znjq451zxkus6", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3733, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.74, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5933, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.52, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "7hl0svs2hg225i2zud0g3xzp2", + "league_name": "Ekstraklasa", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4867, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7533, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7533, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.36, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "7nmz249q89qg5ezcvzlheljji", + "league_name": "1. SNL", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4333, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8467, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6667, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "7ntvbsyq31jnzoqoa8850b9b8", + "league_name": "Championship", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3933, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7933, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5067, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.42, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "7r1f93t6ddrsa5n8v1nq6qlzm", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3333, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.88, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.7333, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.5733, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "81txfenlgw75nq3u2nfdkj92o", + "league_name": "Serie A Kad\u0131nlar", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5533, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.72, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7867, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.62, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "82jkgccg7phfjpd0mltdl3pat", + "league_name": "S\u00fcper Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.78, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7267, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7667, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.48, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "89ovpy1rarewwzqvi30bfdr8b", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4267, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3533, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.4933, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "8dn0w8zh7nbn2i904603eigwf", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.82, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.72, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.48, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7067, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3667, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "8ey0ww2zsosdmwr8ehsorh6t7", + "league_name": "Serie B", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5067, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7933, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7067, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.46, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4933, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.72, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3133, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5133, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.4, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "8k1xcsyvxapl4jlsluh3eomre", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.8333, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.76, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.4133, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.7267, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "8r98daokeuzsamu5fmjtblqx5", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.76, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7867, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.56, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.72, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.4067, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "8sdpk4aerruf515yh76ezo7vi", + "league_name": "2. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4667, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.56, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.82, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5133, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3333, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.4333, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.56, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "8usjlmziv3p2re0r2wwzezki9", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4933, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7933, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6867, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.54, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "8v97rcbthsxmzqk4ufxws9mug", + "league_name": "Challenge Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.34, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.78, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.66, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.66, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "8y29fg2s85ppcb8uugm5ee8s4", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.6467, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6267, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.76, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5267, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "8yi6ejjd1zudcqtbn07haahg6", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7267, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.56, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.4, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "907l7wtxdvugdo9i2249wcmr0", + "league_name": "Nesine 3. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.72, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.3878, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.619, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6395, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2313, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "913mb508il6jzwtlj28fl892h", + "league_name": "2. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8467, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.7267, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7067, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4667, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.42, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.56, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.7933, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "9chuiarcjofld1dkj9kysehmb", + "league_name": "Superettan", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3933, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6533, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6467, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "9ikchyu9fb8bvx0s673jofj6s", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4867, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.6467, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.58, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.76, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5667, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "9nbpdi9q3ywcm4q0j5u0ekwcq", + "league_name": "Serie D", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5867, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8467, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.76, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.88, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.7667, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "9p3nnxhdjahfn8qswpzy8oyc3", + "league_name": "A-Lig Kad\u0131nlar", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4333, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7533, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.64, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6733, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.64, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "9ynnnx1qmkizq1o3qr3v0nsuk", + "league_name": "Eliteserien", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4733, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8267, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.64, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.5733, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.62, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "a9vrdkelbgif0gtu3wxsr75xo", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7067, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7267, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4899, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.698, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7315, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3087, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5067, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "ac112osli9fvox1epcg4ld3t6", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5933, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.3533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2933, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.8067, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "ae1wva3zrzcp2zd15gpvsntg6", + "league_name": "Ulusal Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.8133, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.4667, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "aho73e5udydy96iun3tkzdzsi", + "league_name": "V-Lig 1", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5067, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7333, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6467, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6067, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "ajxs0e0g6ryg5ol8qvw3evrcz", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3333, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.62, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.8133, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5067, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "akmkihra9ruad09ljapsm84b3", + "league_name": "Eredivisie", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8733, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.48, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.78, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3667, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.8067, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "alpfd99yd3lfv7bhjo0biuq7b", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.92, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.7933, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.8267, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.4933, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5933, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "avs3xposm3t9x1x2vzsoxzcbu", + "league_name": "K-Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.28, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.6267, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6267, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.76, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.54, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "b60nisd3qn427jm0hrg9kvmab", + "league_name": "Allsvenskan", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.38, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7533, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.54, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.72, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5867, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "b73zounsynk9d3u1p9nvpu7i2", + "league_name": "K-Lig 2", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.76, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.72, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7533, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3533, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5933, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "beqqnubkv05mamuwvimeum015", + "league_name": "NB II", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.8, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5933, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.72, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7533, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "bgen5kjer2ytfp7lo9949t72g", + "league_name": "2. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.48, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.72, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4933, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.56, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "bly7ema5au6j40i0grhl0pnub", + "league_name": "B Ligi", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.44, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6333, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.72, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.64, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "bu1l7ckihyr0errxw61p0m05", + "league_name": "Czech Liga", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.78, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7067, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.4867, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "bx57cmq1edfq53ckfk791supi", + "league_name": "CAF Konfederasyon Kupas\u0131", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6067, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7733, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.7667, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.8533, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.66, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "by5nibd18nkt40t0j8a0j5yzx", + "league_name": "B Ligi", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4333, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.6933, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6533, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.8133, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6533, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "byu00jvt1j6csyv4y1lkt2fm2", + "league_name": "Primera Nacional", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3933, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.5, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.7267, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.8933, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.8, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2667, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5067, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.4867, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "c0r21rtokgnbtc0o2rldjmkxu", + "league_name": "S\u00fcper Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.8067, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.5133, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.7667, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "c0yqkbilbbg70ij2473xymmqv", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5933, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7933, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3733, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "c7b8o53flg36wbuevfzy3lb10", + "league_name": "Konferans Ligi", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7667, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4467, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3267, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.4933, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "cegl2ivkc25blcatxp4jmk1ec", + "league_name": "Segunda Lig RFEF", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7067, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7667, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.4867, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5467, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.3067, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.48, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "cesdwwnxbc5fmajgroc0hqzy2", + "league_name": "Haz\u0131rl\u0131k Ma\u00e7lar\u0131 \u00dclkeler", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.66, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8467, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.84, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.8267, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.7, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "cfesxhzb83yl8b779uv3revz1", + "league_name": "Serie C", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5067, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.78, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.74, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.8267, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6733, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "civf31q1inxohs4a03y8reetf", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.74, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5933, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.56, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.4333, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.58, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "cse5oqqt2pzfcy8uz6yz3tkbj", + "league_name": "CAF \u015eampiyonlar Ligi", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6933, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.84, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "ddyrh5latwfhesgfh4w401n92", + "league_name": "FNL", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3733, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7533, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5733, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.54, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "degxm4y6gmvp011ccyrev6z5p", + "league_name": "Primer Lig RFEF", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5267, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.76, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4497, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.604, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7584, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2148, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5133, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.56, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "dkarmrybx9vx10rg7cywumth0", + "league_name": "3. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8133, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.5667, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7667, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.2667, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.54, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.4467, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.4933, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "dm5ka0os1e3dxcp3vh05kmp33", + "league_name": "Ligue 1", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5867, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.4867, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7067, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.38, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "dr2xk7muj8aqcjdz2b3li1c0k", + "league_name": "Meistaradeildin", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5933, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.84, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6867, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.5467, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.58, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "du6jsenbjql5e8f3yk880ox4g", + "league_name": "Kakkonen", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5067, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.9067, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6667, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6067, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "dvstmwnvw0mt5p38twn9yttyb", + "league_name": "Veikkausliiga", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3933, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7867, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5933, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6467, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6333, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "e0lck99w8meo9qoalfrxgo33o", + "league_name": "S\u00fcper Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4133, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8533, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6333, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.5867, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6667, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "e1kxdivp5g4cpldgpwvnzl1vv", + "league_name": "Ascenso MX", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6267, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7867, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7067, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.7667, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "e21cf135btr8t3upw0vl6n6x0", + "league_name": "Premiership", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.66, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7667, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.7267, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.5733, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.82, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.4867, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6733, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "e6vzdkz6l236s9p288mharefy", + "league_name": "AFC \u015eampiyonlar Ligi 2", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5733, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8067, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6933, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7667, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.64, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "ea0h6cf3bhl698hkxhpulh2zz", + "league_name": "Pro Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.8067, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6933, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6467, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.6, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.82, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.4533, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.62, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "eg6s9f1jj7jr6stmbosn0g6c8", + "league_name": "S\u00fcper Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.58, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7933, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6533, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6267, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6067, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "ein4fkggto3pdh5msp8huafiq", + "league_name": "Premier Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6067, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.7867, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5933, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.52, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.72, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6867, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.4133, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.68, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "ejunkmfhjz9weugd2bqrkgobb", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.54, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5867, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6667, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.58, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "esrunz7rjb0td98mx9e5cedoy", + "league_name": "1. NL", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3267, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.66, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.8333, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "f39uq10c8xhg5e6rwwcf6lhgc", + "league_name": "K\u00f6rfez Ligi", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4933, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8067, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6867, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.74, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6533, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "f4jc2cc5nq7flaoptpi5ua4k4", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.42, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.5667, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.74, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.9133, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6533, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "iu1vi94p4p28oozl1h9bvplr", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4867, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7533, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.5667, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.58, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "jznihqxle06xych9ygwiwnsa", + "league_name": "USL 1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.4533, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.76, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.7133, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.7467, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6467, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "scf9p4y91yjvqvg5jndxzhxj", + "league_name": "Serie A", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.5533, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.78, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.5, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.7333, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.4867, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.42, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.7467, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "HTFT": { + "accuracy": 0.32, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.5333, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.6133, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.5067, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "xwnjb1az11zffwty3m6vn8y6", + "league_name": "A Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.32, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.7933, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.6933, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6133, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "yv73ms6v1995b5wny16jcfi3", + "league_name": "PSL", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.6333, + "n": 150, + "source": "league_xgb" + }, + "OU15": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "OU25": { + "accuracy": 0.6667, + "n": 150, + "source": "league_xgb" + }, + "OU35": { + "accuracy": 0.84, + "n": 150, + "source": "league_xgb" + }, + "BTTS": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "HT": { + "accuracy": 0.7, + "n": 150, + "source": "league_xgb" + }, + "HT_OU05": { + "accuracy": 0.6533, + "n": 150, + "source": "league_xgb" + }, + "HT_OU15": { + "accuracy": 0.7667, + "n": 150, + "source": "league_xgb" + }, + "OE": { + "accuracy": 0.64, + "n": 150, + "source": "league_xgb" + }, + "CARDS": { + "accuracy": 0.7867, + "n": 150, + "source": "league_xgb" + }, + "HCAP": { + "accuracy": 0.7133, + "n": 150, + "source": "league_xgb" + } + } + }, + { + "league_id": "zilopfej2h0n3vpan5tcynpo", + "league_name": "Urvalsdeild", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.3867, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.8867, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6467, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.58, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.6667, + "n": 150, + "source": "general_v25" + } + } + }, + { + "league_id": "zs18qaehvhg3w1208874zvfa", + "league_name": "1. Lig", + "n_tested": 150, + "markets": { + "MS": { + "accuracy": 0.44, + "n": 150, + "source": "general_v25" + }, + "OU15": { + "accuracy": 0.86, + "n": 150, + "source": "general_v25" + }, + "OU25": { + "accuracy": 0.6333, + "n": 150, + "source": "general_v25" + }, + "OU35": { + "accuracy": 0.5933, + "n": 150, + "source": "general_v25" + }, + "BTTS": { + "accuracy": 0.5733, + "n": 150, + "source": "general_v25" + } + } + } + ] +} \ No newline at end of file diff --git a/ai-engine/reports/backtest_real_odds.json b/ai-engine/reports/backtest_real_odds.json new file mode 100644 index 0000000..f1d640f --- /dev/null +++ b/ai-engine/reports/backtest_real_odds.json @@ -0,0 +1,5 @@ +[ + { + "market": "MS-Ev", + "min_edge": 0.02, + "n": \ No newline at end of file diff --git a/ai-engine/reports/backtest_results.json b/ai-engine/reports/backtest_results.json new file mode 100644 index 0000000..7636bf0 --- /dev/null +++ b/ai-engine/reports/backtest_results.json @@ -0,0 +1,267 @@ +{ + "generated_at": "2026-05-15T21:40:57.995899", + "matches_processed": 3000, + "matches_skipped": 0, + "markets": { + "MS": { + "overall_accuracy": 54.97, + "total_matches": 3000, + "by_confidence_band": { + "<50%": { + "accuracy": 38.87, + "count": 759, + "mean_confidence": 45.58 + }, + "50-65%": { + "accuracy": 52.62, + "count": 1300, + "mean_confidence": 57.19 + }, + "65-75%": { + "accuracy": 66.99, + "count": 624, + "mean_confidence": 69.49 + }, + "75%+": { + "accuracy": 79.5, + "count": 317, + "mean_confidence": 80.69 + } + }, + "by_league": { + "Bundesliga": { + "accuracy": 46.77, + "count": 62 + }, + "Ligue 1": { + "accuracy": 58.73, + "count": 63 + }, + "Serie A": { + "accuracy": 56.25, + "count": 64 + }, + "Other": { + "accuracy": 55.03, + "count": 2811 + } + }, + "by_pick_direction": { + "1": { + "accuracy": 58.38, + "count": 1946, + "mean_confidence": 60.84 + }, + "2": { + "accuracy": 48.72, + "count": 1053, + "mean_confidence": 56.44 + }, + "X": { + "accuracy": 0.0, + "count": 1, + "mean_confidence": 56.07 + } + } + }, + "OU15": { + "overall_accuracy": 74.4, + "total_matches": 3000, + "by_confidence_band": { + "50-65%": { + "accuracy": 70.97, + "count": 62, + "mean_confidence": 59.63 + }, + "65-75%": { + "accuracy": 68.0, + "count": 275, + "mean_confidence": 71.1 + }, + "75%+": { + "accuracy": 75.14, + "count": 2663, + "mean_confidence": 89.44 + } + }, + "by_league": { + "Bundesliga": { + "accuracy": 67.74, + "count": 62 + }, + "Ligue 1": { + "accuracy": 76.19, + "count": 63 + }, + "Serie A": { + "accuracy": 70.31, + "count": 64 + }, + "Other": { + "accuracy": 74.6, + "count": 2811 + } + }, + "by_pick_direction": { + "Over": { + "accuracy": 74.4, + "count": 3000, + "mean_confidence": 87.14 + } + } + }, + "OU25": { + "overall_accuracy": 51.77, + "total_matches": 3000, + "by_confidence_band": { + "50-65%": { + "accuracy": 49.33, + "count": 1267, + "mean_confidence": 57.13 + }, + "65-75%": { + "accuracy": 54.53, + "count": 453, + "mean_confidence": 69.42 + }, + "75%+": { + "accuracy": 53.2, + "count": 1280, + "mean_confidence": 90.2 + } + }, + "by_league": { + "Bundesliga": { + "accuracy": 41.94, + "count": 62 + }, + "Ligue 1": { + "accuracy": 50.79, + "count": 63 + }, + "Serie A": { + "accuracy": 43.75, + "count": 64 + }, + "Other": { + "accuracy": 52.19, + "count": 2811 + } + }, + "by_pick_direction": { + "Over": { + "accuracy": 51.03, + "count": 2432, + "mean_confidence": 76.11 + }, + "Under": { + "accuracy": 54.93, + "count": 568, + "mean_confidence": 60.17 + } + } + }, + "BTTS": { + "overall_accuracy": 51.83, + "total_matches": 3000, + "by_confidence_band": { + "50-65%": { + "accuracy": 48.74, + "count": 2214, + "mean_confidence": 58.66 + }, + "65-75%": { + "accuracy": 60.42, + "count": 758, + "mean_confidence": 68.19 + }, + "75%+": { + "accuracy": 64.29, + "count": 28, + "mean_confidence": 77.44 + } + }, + "by_league": { + "Bundesliga": { + "accuracy": 54.84, + "count": 62 + }, + "Ligue 1": { + "accuracy": 50.79, + "count": 63 + }, + "Serie A": { + "accuracy": 57.81, + "count": 64 + }, + "Other": { + "accuracy": 51.65, + "count": 2811 + } + }, + "by_pick_direction": { + "No": { + "accuracy": 50.26, + "count": 2099, + "mean_confidence": 61.56 + }, + "Yes": { + "accuracy": 55.49, + "count": 901, + "mean_confidence": 60.51 + } + } + } + }, + "calibration": { + "ms_home": { + "brier_score": 0.2054, + "calibration_error": 0.0, + "sample_count": 3000, + "last_trained": "2026-05-15T21:40:58.026574", + "mean_predicted": 0.4942, + "mean_actual": 0.46 + }, + "ms_draw": { + "brier_score": 0.1846, + "calibration_error": 0.0, + "sample_count": 3000, + "last_trained": "2026-05-15T21:40:58.030886", + "mean_predicted": 0.149, + "mean_actual": 0.2493 + }, + "ms_away": { + "brier_score": 0.1726, + "calibration_error": 0.0, + "sample_count": 3000, + "last_trained": "2026-05-15T21:40:58.033980", + "mean_predicted": 0.3567, + "mean_actual": 0.2907 + }, + "ou15": { + "brier_score": 0.1884, + "calibration_error": 0.0, + "sample_count": 3000, + "last_trained": "2026-05-15T21:40:58.037204", + "mean_predicted": 0.8714, + "mean_actual": 0.744 + }, + "ou25": { + "brier_score": 0.247, + "calibration_error": 0.0, + "sample_count": 3000, + "last_trained": "2026-05-15T21:40:58.041152", + "mean_predicted": 0.6924, + "mean_actual": 0.499 + }, + "btts": { + "brier_score": 0.2453, + "calibration_error": 0.0, + "sample_count": 3000, + "last_trained": "2026-05-15T21:40:58.044344", + "mean_predicted": 0.4506, + "mean_actual": 0.5147 + } + }, + "runtime_seconds": 94.1 +} \ No newline at end of file diff --git a/ai-engine/reports/league_models/league_models_report.json b/ai-engine/reports/league_models/league_models_report.json new file mode 100644 index 0000000..a4b2f7c --- /dev/null +++ b/ai-engine/reports/league_models/league_models_report.json @@ -0,0 +1,10834 @@ +{ + "generated_at": "2026-05-16T13:08:23.348618", + "total_leagues": 152, + "elapsed_minutes": 6.1, + "results": [ + { + "league_id": "117yqo02rs8dykkxpm274w3bd", + "league_name": "Lig 1", + "n_matches": 572, + "markets": { + "MS": { + "n_train": 457, + "n_test": 115, + "accuracy": 0.4522, + "logloss": 1.0406, + "model": "xgb" + }, + "OU15": { + "n_train": 457, + "n_test": 115, + "accuracy": 0.7826, + "logloss": 0.5498, + "model": "xgb" + }, + "OU25": { + "n_train": 457, + "n_test": 115, + "accuracy": 0.4609, + "logloss": 0.7266, + "model": "xgb" + }, + "OU35": { + "n_train": 457, + "n_test": 115, + "accuracy": 0.7043, + "logloss": 0.6126, + "model": "xgb" + }, + "BTTS": { + "n_train": 457, + "n_test": 115, + "accuracy": 0.5391, + "logloss": 0.7006, + "model": "xgb" + }, + "HT": { + "n_train": 457, + "n_test": 115, + "accuracy": 0.3304, + "logloss": 1.1026, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 457, + "n_test": 115, + "accuracy": 0.7391, + "logloss": 0.5816, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 457, + "n_test": 115, + "accuracy": 0.6609, + "logloss": 0.669, + "model": "xgb" + }, + "HTFT": { + "n_train": 457, + "n_test": 115, + "accuracy": 0.287, + "logloss": 1.9889, + "model": "xgb" + }, + "OE": { + "n_train": 457, + "n_test": 115, + "accuracy": 0.4522, + "logloss": 0.7388, + "model": "xgb" + }, + "CARDS": { + "n_train": 457, + "n_test": 115, + "accuracy": 0.487, + "logloss": 0.7061, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 457, + "n_test": 115, + "accuracy": 0.5304, + "logloss": 0.971, + "model": "xgb" + } + } + }, + { + "league_id": "1qd0wvt30rlswa4g6nu4na660", + "league_name": "Ulusal Lig", + "n_matches": 602, + "markets": { + "MS": { + "n_train": 481, + "n_test": 121, + "accuracy": 0.3554, + "logloss": 1.1033, + "model": "xgb" + }, + "OU15": { + "n_train": 481, + "n_test": 121, + "accuracy": 0.7107, + "logloss": 0.6153, + "model": "xgb" + }, + "OU25": { + "n_train": 481, + "n_test": 121, + "accuracy": 0.6446, + "logloss": 0.6711, + "model": "xgb" + }, + "OU35": { + "n_train": 481, + "n_test": 121, + "accuracy": 0.7686, + "logloss": 0.543, + "model": "xgb" + }, + "BTTS": { + "n_train": 481, + "n_test": 121, + "accuracy": 0.5372, + "logloss": 0.6992, + "model": "xgb" + }, + "HT": { + "n_train": 480, + "n_test": 121, + "accuracy": 0.3719, + "logloss": 1.0818, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 480, + "n_test": 121, + "accuracy": 0.6694, + "logloss": 0.6732, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 480, + "n_test": 121, + "accuracy": 0.6694, + "logloss": 0.6358, + "model": "xgb" + }, + "HTFT": { + "n_train": 480, + "n_test": 121, + "accuracy": 0.2231, + "logloss": 1.961, + "model": "xgb" + }, + "OE": { + "n_train": 481, + "n_test": 121, + "accuracy": 0.5207, + "logloss": 0.7203, + "model": "xgb" + }, + "CARDS": { + "n_train": 481, + "n_test": 121, + "accuracy": 0.5124, + "logloss": 0.6902, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 481, + "n_test": 121, + "accuracy": 0.6198, + "logloss": 0.9158, + "model": "xgb" + } + } + }, + { + "league_id": "1r097lpxe0xn03ihb7wi98kao", + "league_name": "Serie A", + "n_matches": 757, + "markets": { + "MS": { + "n_train": 605, + "n_test": 152, + "accuracy": 0.5461, + "logloss": 1.0069, + "model": "xgb" + }, + "OU15": { + "n_train": 605, + "n_test": 152, + "accuracy": 0.7039, + "logloss": 0.6203, + "model": "xgb" + }, + "OU25": { + "n_train": 605, + "n_test": 152, + "accuracy": 0.5132, + "logloss": 0.6994, + "model": "xgb" + }, + "OU35": { + "n_train": 605, + "n_test": 152, + "accuracy": 0.7895, + "logloss": 0.5268, + "model": "xgb" + }, + "BTTS": { + "n_train": 605, + "n_test": 152, + "accuracy": 0.5461, + "logloss": 0.7067, + "model": "xgb" + }, + "HT": { + "n_train": 605, + "n_test": 152, + "accuracy": 0.4539, + "logloss": 1.0279, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 605, + "n_test": 152, + "accuracy": 0.6579, + "logloss": 0.6485, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 605, + "n_test": 152, + "accuracy": 0.6776, + "logloss": 0.6299, + "model": "xgb" + }, + "HTFT": { + "n_train": 605, + "n_test": 152, + "accuracy": 0.2961, + "logloss": 1.8993, + "model": "xgb" + }, + "OE": { + "n_train": 605, + "n_test": 152, + "accuracy": 0.4342, + "logloss": 0.7187, + "model": "xgb" + }, + "CARDS": { + "n_train": 605, + "n_test": 152, + "accuracy": 0.6382, + "logloss": 0.6147, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 605, + "n_test": 152, + "accuracy": 0.5724, + "logloss": 0.9149, + "model": "xgb" + } + } + }, + { + "league_id": "1zp1du9n4rj36p1ss9zbxtqfb", + "league_name": "Serie C", + "n_matches": 2439, + "markets": { + "MS": { + "n_train": 1951, + "n_test": 488, + "accuracy": 0.5082, + "logloss": 1.03, + "model": "xgb" + }, + "OU15": { + "n_train": 1951, + "n_test": 488, + "accuracy": 0.6824, + "logloss": 0.6167, + "model": "xgb" + }, + "OU25": { + "n_train": 1951, + "n_test": 488, + "accuracy": 0.5492, + "logloss": 0.6844, + "model": "xgb" + }, + "OU35": { + "n_train": 1951, + "n_test": 488, + "accuracy": 0.7766, + "logloss": 0.5324, + "model": "xgb" + }, + "BTTS": { + "n_train": 1951, + "n_test": 488, + "accuracy": 0.4918, + "logloss": 0.7004, + "model": "xgb" + }, + "HT": { + "n_train": 1905, + "n_test": 488, + "accuracy": 0.4283, + "logloss": 1.0645, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 1905, + "n_test": 488, + "accuracy": 0.6619, + "logloss": 0.6492, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 1905, + "n_test": 488, + "accuracy": 0.7561, + "logloss": 0.5606, + "model": "xgb" + }, + "HTFT": { + "n_train": 1905, + "n_test": 488, + "accuracy": 0.2684, + "logloss": 1.9334, + "model": "xgb" + }, + "OE": { + "n_train": 1951, + "n_test": 488, + "accuracy": 0.5492, + "logloss": 0.694, + "model": "xgb" + }, + "CARDS": { + "n_train": 1951, + "n_test": 488, + "accuracy": 0.498, + "logloss": 0.6946, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 1951, + "n_test": 488, + "accuracy": 0.5717, + "logloss": 0.9345, + "model": "xgb" + } + } + }, + { + "league_id": "287tckirbfj9nb8ar2k9r60vn", + "league_name": "MLS", + "n_matches": 1038, + "markets": { + "MS": { + "n_train": 830, + "n_test": 208, + "accuracy": 0.5144, + "logloss": 1.0004, + "model": "xgb" + }, + "OU15": { + "n_train": 830, + "n_test": 208, + "accuracy": 0.7981, + "logloss": 0.5092, + "model": "xgb" + }, + "OU25": { + "n_train": 830, + "n_test": 208, + "accuracy": 0.5962, + "logloss": 0.6588, + "model": "xgb" + }, + "OU35": { + "n_train": 830, + "n_test": 208, + "accuracy": 0.5769, + "logloss": 0.6931, + "model": "xgb" + }, + "BTTS": { + "n_train": 830, + "n_test": 208, + "accuracy": 0.5673, + "logloss": 0.6938, + "model": "xgb" + }, + "HT": { + "n_train": 830, + "n_test": 208, + "accuracy": 0.4519, + "logloss": 1.044, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 830, + "n_test": 208, + "accuracy": 0.7596, + "logloss": 0.5434, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 830, + "n_test": 208, + "accuracy": 0.5962, + "logloss": 0.6689, + "model": "xgb" + }, + "HTFT": { + "n_train": 830, + "n_test": 208, + "accuracy": 0.3558, + "logloss": 1.9112, + "model": "xgb" + }, + "OE": { + "n_train": 830, + "n_test": 208, + "accuracy": 0.5385, + "logloss": 0.7081, + "model": "xgb" + }, + "CARDS": { + "n_train": 830, + "n_test": 208, + "accuracy": 0.5481, + "logloss": 0.6937, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 830, + "n_test": 208, + "accuracy": 0.5, + "logloss": 1.0401, + "model": "xgb" + } + } + }, + { + "league_id": "2hsidwomhjsaaytdy9u5niyi4", + "league_name": "Liga MX", + "n_matches": 728, + "markets": { + "MS": { + "n_train": 582, + "n_test": 146, + "accuracy": 0.4521, + "logloss": 1.0692, + "model": "xgb" + }, + "OU15": { + "n_train": 582, + "n_test": 146, + "accuracy": 0.7808, + "logloss": 0.5284, + "model": "xgb" + }, + "OU25": { + "n_train": 582, + "n_test": 146, + "accuracy": 0.589, + "logloss": 0.6765, + "model": "xgb" + }, + "OU35": { + "n_train": 582, + "n_test": 146, + "accuracy": 0.6918, + "logloss": 0.5992, + "model": "xgb" + }, + "BTTS": { + "n_train": 582, + "n_test": 146, + "accuracy": 0.5959, + "logloss": 0.6812, + "model": "xgb" + }, + "HT": { + "n_train": 582, + "n_test": 146, + "accuracy": 0.4658, + "logloss": 1.0575, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 582, + "n_test": 146, + "accuracy": 0.6918, + "logloss": 0.6085, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 582, + "n_test": 146, + "accuracy": 0.5959, + "logloss": 0.6729, + "model": "xgb" + }, + "HTFT": { + "n_train": 582, + "n_test": 146, + "accuracy": 0.2877, + "logloss": 1.988, + "model": "xgb" + }, + "OE": { + "n_train": 582, + "n_test": 146, + "accuracy": 0.4795, + "logloss": 0.7106, + "model": "xgb" + }, + "CARDS": { + "n_train": 582, + "n_test": 146, + "accuracy": 0.4658, + "logloss": 0.7035, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 582, + "n_test": 146, + "accuracy": 0.5753, + "logloss": 0.9511, + "model": "xgb" + } + } + }, + { + "league_id": "2kwbbcootiqqgmrzs6o5inle5", + "league_name": "Premier Lig", + "n_matches": 754, + "markets": { + "MS": { + "n_train": 603, + "n_test": 151, + "accuracy": 0.4172, + "logloss": 1.0707, + "model": "xgb" + }, + "OU15": { + "n_train": 603, + "n_test": 151, + "accuracy": 0.7616, + "logloss": 0.5596, + "model": "xgb" + }, + "OU25": { + "n_train": 603, + "n_test": 151, + "accuracy": 0.5298, + "logloss": 0.7142, + "model": "xgb" + }, + "OU35": { + "n_train": 603, + "n_test": 151, + "accuracy": 0.702, + "logloss": 0.6117, + "model": "xgb" + }, + "BTTS": { + "n_train": 603, + "n_test": 151, + "accuracy": 0.5364, + "logloss": 0.7264, + "model": "xgb" + }, + "HT": { + "n_train": 603, + "n_test": 151, + "accuracy": 0.4106, + "logloss": 1.0857, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 603, + "n_test": 151, + "accuracy": 0.702, + "logloss": 0.6358, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 603, + "n_test": 151, + "accuracy": 0.6358, + "logloss": 0.6514, + "model": "xgb" + }, + "HTFT": { + "n_train": 603, + "n_test": 151, + "accuracy": 0.245, + "logloss": 2.0767, + "model": "xgb" + }, + "OE": { + "n_train": 603, + "n_test": 151, + "accuracy": 0.4967, + "logloss": 0.7042, + "model": "xgb" + }, + "CARDS": { + "n_train": 603, + "n_test": 151, + "accuracy": 0.5828, + "logloss": 0.6894, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 603, + "n_test": 151, + "accuracy": 0.6093, + "logloss": 0.8859, + "model": "xgb" + } + } + }, + { + "league_id": "2nttcoriwf5co73vmz1vr8frm", + "league_name": "Nesine 2. Lig", + "n_matches": 1059, + "markets": { + "MS": { + "n_train": 847, + "n_test": 212, + "accuracy": 0.5991, + "logloss": 0.8669, + "model": "xgb" + }, + "OU15": { + "n_train": 847, + "n_test": 212, + "accuracy": 0.783, + "logloss": 0.5206, + "model": "xgb" + }, + "OU25": { + "n_train": 847, + "n_test": 212, + "accuracy": 0.5896, + "logloss": 0.6709, + "model": "xgb" + }, + "OU35": { + "n_train": 847, + "n_test": 212, + "accuracy": 0.7264, + "logloss": 0.5865, + "model": "xgb" + }, + "BTTS": { + "n_train": 847, + "n_test": 212, + "accuracy": 0.5283, + "logloss": 0.6928, + "model": "xgb" + }, + "HT": { + "n_train": 845, + "n_test": 211, + "accuracy": 0.4028, + "logloss": 1.0724, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 845, + "n_test": 211, + "accuracy": 0.673, + "logloss": 0.6394, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 845, + "n_test": 211, + "accuracy": 0.654, + "logloss": 0.6201, + "model": "xgb" + }, + "HTFT": { + "n_train": 845, + "n_test": 211, + "accuracy": 0.3365, + "logloss": 1.9089, + "model": "xgb" + }, + "OE": { + "n_train": 847, + "n_test": 212, + "accuracy": 0.566, + "logloss": 0.6887, + "model": "xgb" + }, + "CARDS": { + "n_train": 847, + "n_test": 212, + "accuracy": 0.566, + "logloss": 0.7029, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 847, + "n_test": 212, + "accuracy": 0.6745, + "logloss": 0.8146, + "model": "xgb" + } + } + }, + { + "league_id": "2o9svokc5s7diish3ycrzk7jm", + "league_name": "Trendyol 1. Lig", + "n_matches": 682, + "markets": { + "MS": { + "n_train": 545, + "n_test": 137, + "accuracy": 0.5839, + "logloss": 0.9199, + "model": "xgb" + }, + "OU15": { + "n_train": 545, + "n_test": 137, + "accuracy": 0.7883, + "logloss": 0.4964, + "model": "xgb" + }, + "OU25": { + "n_train": 545, + "n_test": 137, + "accuracy": 0.6496, + "logloss": 0.6305, + "model": "xgb" + }, + "OU35": { + "n_train": 545, + "n_test": 137, + "accuracy": 0.6715, + "logloss": 0.6108, + "model": "xgb" + }, + "BTTS": { + "n_train": 545, + "n_test": 137, + "accuracy": 0.5474, + "logloss": 0.7012, + "model": "xgb" + }, + "HT": { + "n_train": 545, + "n_test": 137, + "accuracy": 0.4599, + "logloss": 1.0423, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 545, + "n_test": 137, + "accuracy": 0.7153, + "logloss": 0.617, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 545, + "n_test": 137, + "accuracy": 0.6569, + "logloss": 0.6549, + "model": "xgb" + }, + "HTFT": { + "n_train": 545, + "n_test": 137, + "accuracy": 0.292, + "logloss": 1.9481, + "model": "xgb" + }, + "OE": { + "n_train": 545, + "n_test": 137, + "accuracy": 0.4818, + "logloss": 0.7131, + "model": "xgb" + }, + "CARDS": { + "n_train": 545, + "n_test": 137, + "accuracy": 0.5547, + "logloss": 0.6893, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 545, + "n_test": 137, + "accuracy": 0.6496, + "logloss": 0.8644, + "model": "xgb" + } + } + }, + { + "league_id": "2ty8ihceabty8yddmu31iuuej", + "league_name": "A Ligi", + "n_matches": 934, + "markets": { + "MS": { + "n_train": 747, + "n_test": 187, + "accuracy": 0.5187, + "logloss": 0.9854, + "model": "xgb" + }, + "OU15": { + "n_train": 747, + "n_test": 187, + "accuracy": 0.7433, + "logloss": 0.5663, + "model": "xgb" + }, + "OU25": { + "n_train": 747, + "n_test": 187, + "accuracy": 0.5508, + "logloss": 0.7081, + "model": "xgb" + }, + "OU35": { + "n_train": 747, + "n_test": 187, + "accuracy": 0.7807, + "logloss": 0.5394, + "model": "xgb" + }, + "BTTS": { + "n_train": 747, + "n_test": 187, + "accuracy": 0.5187, + "logloss": 0.7072, + "model": "xgb" + }, + "HT": { + "n_train": 746, + "n_test": 187, + "accuracy": 0.4599, + "logloss": 1.0464, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 746, + "n_test": 187, + "accuracy": 0.6898, + "logloss": 0.6205, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 746, + "n_test": 187, + "accuracy": 0.6845, + "logloss": 0.6344, + "model": "xgb" + }, + "HTFT": { + "n_train": 746, + "n_test": 187, + "accuracy": 0.2888, + "logloss": 1.9055, + "model": "xgb" + }, + "OE": { + "n_train": 747, + "n_test": 187, + "accuracy": 0.4973, + "logloss": 0.7165, + "model": "xgb" + }, + "CARDS": { + "n_train": 747, + "n_test": 187, + "accuracy": 0.6738, + "logloss": 0.6089, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 747, + "n_test": 187, + "accuracy": 0.5134, + "logloss": 0.977, + "model": "xgb" + } + } + }, + { + "league_id": "2wolc27r8z03itcvwp43e38c5", + "league_name": "Premier Lig", + "n_matches": 586, + "markets": { + "MS": { + "n_train": 468, + "n_test": 118, + "accuracy": 0.4492, + "logloss": 1.0842, + "model": "xgb" + }, + "OU15": { + "n_train": 468, + "n_test": 118, + "accuracy": 0.6356, + "logloss": 0.6159, + "model": "xgb" + }, + "OU25": { + "n_train": 468, + "n_test": 118, + "accuracy": 0.5508, + "logloss": 0.7072, + "model": "xgb" + }, + "OU35": { + "n_train": 468, + "n_test": 118, + "accuracy": 0.7627, + "logloss": 0.5685, + "model": "xgb" + }, + "BTTS": { + "n_train": 468, + "n_test": 118, + "accuracy": 0.4492, + "logloss": 0.7144, + "model": "xgb" + }, + "HT": { + "n_train": 468, + "n_test": 118, + "accuracy": 0.4322, + "logloss": 1.0813, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 468, + "n_test": 118, + "accuracy": 0.6695, + "logloss": 0.6506, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 468, + "n_test": 118, + "accuracy": 0.6864, + "logloss": 0.6544, + "model": "xgb" + }, + "HTFT": { + "n_train": 468, + "n_test": 118, + "accuracy": 0.2966, + "logloss": 1.9976, + "model": "xgb" + }, + "OE": { + "n_train": 468, + "n_test": 118, + "accuracy": 0.5, + "logloss": 0.7022, + "model": "xgb" + }, + "CARDS": { + "n_train": 468, + "n_test": 118, + "accuracy": 0.7034, + "logloss": 0.6252, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 468, + "n_test": 118, + "accuracy": 0.5847, + "logloss": 0.9297, + "model": "xgb" + } + } + }, + { + "league_id": "2y8bntiif3a9y6gtmauv30gt", + "league_name": "Premier Lig", + "n_matches": 502, + "markets": { + "MS": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.5347, + "logloss": 0.9935, + "model": "xgb" + }, + "OU15": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.6535, + "logloss": 0.6816, + "model": "xgb" + }, + "OU25": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.505, + "logloss": 0.6886, + "model": "xgb" + }, + "OU35": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.6832, + "logloss": 0.6202, + "model": "xgb" + }, + "BTTS": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.4851, + "logloss": 0.7186, + "model": "xgb" + }, + "HT": { + "n_train": 400, + "n_test": 99, + "accuracy": 0.3838, + "logloss": 1.1002, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 400, + "n_test": 99, + "accuracy": 0.6869, + "logloss": 0.6145, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 400, + "n_test": 99, + "accuracy": 0.6869, + "logloss": 0.6419, + "model": "xgb" + }, + "HTFT": { + "n_train": 400, + "n_test": 99, + "accuracy": 0.2323, + "logloss": 1.9887, + "model": "xgb" + }, + "OE": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.6238, + "logloss": 0.6723, + "model": "xgb" + }, + "CARDS": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.6832, + "logloss": 0.6237, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.4653, + "logloss": 1.0213, + "model": "xgb" + } + } + }, + { + "league_id": "34pl8szyvrbwcmfkuocjm3r6t", + "league_name": "LaLiga", + "n_matches": 731, + "markets": { + "MS": { + "n_train": 584, + "n_test": 147, + "accuracy": 0.4422, + "logloss": 1.0188, + "model": "xgb" + }, + "OU15": { + "n_train": 584, + "n_test": 147, + "accuracy": 0.7891, + "logloss": 0.5446, + "model": "xgb" + }, + "OU25": { + "n_train": 584, + "n_test": 147, + "accuracy": 0.551, + "logloss": 0.6905, + "model": "xgb" + }, + "OU35": { + "n_train": 584, + "n_test": 147, + "accuracy": 0.7211, + "logloss": 0.5898, + "model": "xgb" + }, + "BTTS": { + "n_train": 584, + "n_test": 147, + "accuracy": 0.6395, + "logloss": 0.6589, + "model": "xgb" + }, + "HT": { + "n_train": 584, + "n_test": 147, + "accuracy": 0.4354, + "logloss": 1.0878, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 584, + "n_test": 147, + "accuracy": 0.6871, + "logloss": 0.6231, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 584, + "n_test": 147, + "accuracy": 0.6395, + "logloss": 0.631, + "model": "xgb" + }, + "HTFT": { + "n_train": 584, + "n_test": 147, + "accuracy": 0.2381, + "logloss": 2.0133, + "model": "xgb" + }, + "OE": { + "n_train": 584, + "n_test": 147, + "accuracy": 0.5442, + "logloss": 0.7051, + "model": "xgb" + }, + "CARDS": { + "n_train": 584, + "n_test": 147, + "accuracy": 0.5102, + "logloss": 0.6893, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 584, + "n_test": 147, + "accuracy": 0.5306, + "logloss": 1.0562, + "model": "xgb" + } + } + }, + { + "league_id": "3is4bkgf3loxv9qfg3hm8zfqb", + "league_name": "LaLiga 2", + "n_matches": 968, + "markets": { + "MS": { + "n_train": 774, + "n_test": 194, + "accuracy": 0.4948, + "logloss": 1.0279, + "model": "xgb" + }, + "OU15": { + "n_train": 774, + "n_test": 194, + "accuracy": 0.7216, + "logloss": 0.5778, + "model": "xgb" + }, + "OU25": { + "n_train": 774, + "n_test": 194, + "accuracy": 0.5412, + "logloss": 0.7001, + "model": "xgb" + }, + "OU35": { + "n_train": 774, + "n_test": 194, + "accuracy": 0.7423, + "logloss": 0.5437, + "model": "xgb" + }, + "BTTS": { + "n_train": 774, + "n_test": 194, + "accuracy": 0.5206, + "logloss": 0.7093, + "model": "xgb" + }, + "HT": { + "n_train": 774, + "n_test": 194, + "accuracy": 0.4691, + "logloss": 1.0576, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 774, + "n_test": 194, + "accuracy": 0.6959, + "logloss": 0.6179, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 774, + "n_test": 194, + "accuracy": 0.5979, + "logloss": 0.6929, + "model": "xgb" + }, + "HTFT": { + "n_train": 774, + "n_test": 194, + "accuracy": 0.2887, + "logloss": 1.8832, + "model": "xgb" + }, + "OE": { + "n_train": 774, + "n_test": 194, + "accuracy": 0.5361, + "logloss": 0.6954, + "model": "xgb" + }, + "CARDS": { + "n_train": 774, + "n_test": 194, + "accuracy": 0.5567, + "logloss": 0.6837, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 774, + "n_test": 194, + "accuracy": 0.5103, + "logloss": 1.0108, + "model": "xgb" + } + } + }, + { + "league_id": "3iwftmprsznl6yribr11a8l9m", + "league_name": "B\u00f6lgesel Lig", + "n_matches": 3143, + "markets": { + "MS": { + "n_train": 2514, + "n_test": 629, + "accuracy": 0.5135, + "logloss": 1.0139, + "model": "xgb" + }, + "OU15": { + "n_train": 2514, + "n_test": 629, + "accuracy": 0.7965, + "logloss": 0.4961, + "model": "xgb" + }, + "OU25": { + "n_train": 2514, + "n_test": 629, + "accuracy": 0.6216, + "logloss": 0.653, + "model": "xgb" + }, + "OU35": { + "n_train": 2514, + "n_test": 629, + "accuracy": 0.6439, + "logloss": 0.6311, + "model": "xgb" + }, + "BTTS": { + "n_train": 2514, + "n_test": 629, + "accuracy": 0.5882, + "logloss": 0.6824, + "model": "xgb" + }, + "HT": { + "n_train": 2481, + "n_test": 629, + "accuracy": 0.3911, + "logloss": 1.0637, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 2481, + "n_test": 629, + "accuracy": 0.7568, + "logloss": 0.556, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 2481, + "n_test": 629, + "accuracy": 0.5866, + "logloss": 0.676, + "model": "xgb" + }, + "HTFT": { + "n_train": 2481, + "n_test": 629, + "accuracy": 0.3323, + "logloss": 1.9085, + "model": "xgb" + }, + "OE": { + "n_train": 2514, + "n_test": 629, + "accuracy": 0.5103, + "logloss": 0.694, + "model": "xgb" + }, + "CARDS": { + "n_train": 2514, + "n_test": 629, + "accuracy": 0.4928, + "logloss": 0.7088, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 2514, + "n_test": 629, + "accuracy": 0.5835, + "logloss": 0.9073, + "model": "xgb" + } + } + }, + { + "league_id": "3p81ltz6845appgkbgkzxueii", + "league_name": "2. Lig", + "n_matches": 727, + "markets": { + "MS": { + "n_train": 581, + "n_test": 146, + "accuracy": 0.6096, + "logloss": 0.9216, + "model": "xgb" + }, + "OU15": { + "n_train": 581, + "n_test": 146, + "accuracy": 0.8288, + "logloss": 0.4706, + "model": "xgb" + }, + "OU25": { + "n_train": 581, + "n_test": 146, + "accuracy": 0.6575, + "logloss": 0.6376, + "model": "xgb" + }, + "OU35": { + "n_train": 581, + "n_test": 146, + "accuracy": 0.6164, + "logloss": 0.6661, + "model": "xgb" + }, + "BTTS": { + "n_train": 581, + "n_test": 146, + "accuracy": 0.5959, + "logloss": 0.6633, + "model": "xgb" + }, + "HT": { + "n_train": 581, + "n_test": 146, + "accuracy": 0.4041, + "logloss": 1.0768, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 581, + "n_test": 146, + "accuracy": 0.8082, + "logloss": 0.4803, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 581, + "n_test": 146, + "accuracy": 0.6027, + "logloss": 0.6743, + "model": "xgb" + }, + "HTFT": { + "n_train": 581, + "n_test": 146, + "accuracy": 0.4041, + "logloss": 1.8091, + "model": "xgb" + }, + "OE": { + "n_train": 581, + "n_test": 146, + "accuracy": 0.4041, + "logloss": 0.7202, + "model": "xgb" + }, + "CARDS": { + "n_train": 581, + "n_test": 146, + "accuracy": 0.6438, + "logloss": 0.6682, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 581, + "n_test": 146, + "accuracy": 0.5753, + "logloss": 0.9445, + "model": "xgb" + } + } + }, + { + "league_id": "3ww12jab49q8q8mk9avdwjqgk", + "league_name": "S\u00fcper Lig", + "n_matches": 632, + "markets": { + "MS": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.5433, + "logloss": 1.0025, + "model": "xgb" + }, + "OU15": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.7244, + "logloss": 0.5964, + "model": "xgb" + }, + "OU25": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.6299, + "logloss": 0.6466, + "model": "xgb" + }, + "OU35": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.6929, + "logloss": 0.6058, + "model": "xgb" + }, + "BTTS": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.5433, + "logloss": 0.6941, + "model": "xgb" + }, + "HT": { + "n_train": 504, + "n_test": 127, + "accuracy": 0.4882, + "logloss": 0.9837, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 504, + "n_test": 127, + "accuracy": 0.6614, + "logloss": 0.6303, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 504, + "n_test": 127, + "accuracy": 0.6614, + "logloss": 0.6472, + "model": "xgb" + }, + "HTFT": { + "n_train": 504, + "n_test": 127, + "accuracy": 0.3701, + "logloss": 1.8011, + "model": "xgb" + }, + "OE": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.6142, + "logloss": 0.6905, + "model": "xgb" + }, + "CARDS": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.5118, + "logloss": 0.7093, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.6535, + "logloss": 0.8764, + "model": "xgb" + } + } + }, + { + "league_id": "46b141eaqq9q7o4gz5gtdpikk", + "league_name": "Premier Lig", + "n_matches": 503, + "markets": { + "MS": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.5248, + "logloss": 0.9904, + "model": "xgb" + }, + "OU15": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.7228, + "logloss": 0.5969, + "model": "xgb" + }, + "OU25": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.604, + "logloss": 0.6839, + "model": "xgb" + }, + "OU35": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.7624, + "logloss": 0.5214, + "model": "xgb" + }, + "BTTS": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.5644, + "logloss": 0.7004, + "model": "xgb" + }, + "HT": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.4752, + "logloss": 0.9832, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.6931, + "logloss": 0.6581, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.6535, + "logloss": 0.6478, + "model": "xgb" + }, + "HTFT": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.4158, + "logloss": 1.8088, + "model": "xgb" + }, + "OE": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.5545, + "logloss": 0.6912, + "model": "xgb" + }, + "CARDS": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.7723, + "logloss": 0.5221, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.6139, + "logloss": 0.9128, + "model": "xgb" + } + } + }, + { + "league_id": "482ofyysbdbeoxauk19yg7tdt", + "league_name": "Trendyol S\u00fcper Lig", + "n_matches": 645, + "markets": { + "MS": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.5116, + "logloss": 0.9997, + "model": "xgb" + }, + "OU15": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.7752, + "logloss": 0.4931, + "model": "xgb" + }, + "OU25": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.5969, + "logloss": 0.6635, + "model": "xgb" + }, + "OU35": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.6899, + "logloss": 0.5918, + "model": "xgb" + }, + "BTTS": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.5426, + "logloss": 0.6802, + "model": "xgb" + }, + "HT": { + "n_train": 515, + "n_test": 129, + "accuracy": 0.4496, + "logloss": 1.0294, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 515, + "n_test": 129, + "accuracy": 0.6744, + "logloss": 0.6077, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 515, + "n_test": 129, + "accuracy": 0.6047, + "logloss": 0.6904, + "model": "xgb" + }, + "HTFT": { + "n_train": 515, + "n_test": 129, + "accuracy": 0.3101, + "logloss": 1.8562, + "model": "xgb" + }, + "OE": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.4806, + "logloss": 0.7099, + "model": "xgb" + }, + "CARDS": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.5194, + "logloss": 0.7067, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.5039, + "logloss": 1.0299, + "model": "xgb" + } + } + }, + { + "league_id": "4nidzmunvpvxk1ir9b6m8mpay", + "league_name": "Haz\u0131rl\u0131k Ma\u00e7lar\u0131 Kul\u00fcpler", + "n_matches": 1839, + "markets": { + "MS": { + "n_train": 1471, + "n_test": 368, + "accuracy": 0.5245, + "logloss": 0.9973, + "model": "xgb" + }, + "OU15": { + "n_train": 1471, + "n_test": 368, + "accuracy": 0.7717, + "logloss": 0.5354, + "model": "xgb" + }, + "OU25": { + "n_train": 1471, + "n_test": 368, + "accuracy": 0.5462, + "logloss": 0.6768, + "model": "xgb" + }, + "OU35": { + "n_train": 1471, + "n_test": 368, + "accuracy": 0.712, + "logloss": 0.5917, + "model": "xgb" + }, + "BTTS": { + "n_train": 1471, + "n_test": 368, + "accuracy": 0.6033, + "logloss": 0.6608, + "model": "xgb" + }, + "HT": { + "n_train": 304, + "n_test": 92, + "accuracy": 0.4348, + "logloss": 1.0553, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 304, + "n_test": 92, + "accuracy": 0.8043, + "logloss": 0.5096, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 304, + "n_test": 92, + "accuracy": 0.5652, + "logloss": 0.6859, + "model": "xgb" + }, + "HTFT": { + "n_train": 304, + "n_test": 92, + "accuracy": 0.4457, + "logloss": 1.7208, + "model": "xgb" + }, + "OE": { + "n_train": 1471, + "n_test": 368, + "accuracy": 0.5136, + "logloss": 0.6951, + "model": "xgb" + }, + "CARDS": { + "n_train": 1471, + "n_test": 368, + "accuracy": 0.9837, + "logloss": 0.0913, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 1471, + "n_test": 368, + "accuracy": 0.6087, + "logloss": 0.8875, + "model": "xgb" + } + } + }, + { + "league_id": "4w7x0s5gfs5abasphlha5de8k", + "league_name": "Ligue 2", + "n_matches": 670, + "markets": { + "MS": { + "n_train": 536, + "n_test": 134, + "accuracy": 0.3806, + "logloss": 1.1043, + "model": "xgb" + }, + "OU15": { + "n_train": 536, + "n_test": 134, + "accuracy": 0.6791, + "logloss": 0.647, + "model": "xgb" + }, + "OU25": { + "n_train": 536, + "n_test": 134, + "accuracy": 0.5672, + "logloss": 0.6912, + "model": "xgb" + }, + "OU35": { + "n_train": 536, + "n_test": 134, + "accuracy": 0.7388, + "logloss": 0.5786, + "model": "xgb" + }, + "BTTS": { + "n_train": 536, + "n_test": 134, + "accuracy": 0.4552, + "logloss": 0.7346, + "model": "xgb" + }, + "HT": { + "n_train": 535, + "n_test": 134, + "accuracy": 0.5, + "logloss": 1.0508, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 535, + "n_test": 134, + "accuracy": 0.6418, + "logloss": 0.6392, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 535, + "n_test": 134, + "accuracy": 0.6418, + "logloss": 0.6318, + "model": "xgb" + }, + "HTFT": { + "n_train": 535, + "n_test": 134, + "accuracy": 0.2313, + "logloss": 2.0894, + "model": "xgb" + }, + "OE": { + "n_train": 536, + "n_test": 134, + "accuracy": 0.4776, + "logloss": 0.7162, + "model": "xgb" + }, + "CARDS": { + "n_train": 536, + "n_test": 134, + "accuracy": 0.5, + "logloss": 0.7029, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 536, + "n_test": 134, + "accuracy": 0.6194, + "logloss": 0.8838, + "model": "xgb" + } + } + }, + { + "league_id": "4yzidekywejmxxp77gqmdgopg", + "league_name": "3. Lig", + "n_matches": 632, + "markets": { + "MS": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.5039, + "logloss": 1.0544, + "model": "xgb" + }, + "OU15": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.7008, + "logloss": 0.6448, + "model": "xgb" + }, + "OU25": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.5669, + "logloss": 0.709, + "model": "xgb" + }, + "OU35": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.8268, + "logloss": 0.4536, + "model": "xgb" + }, + "BTTS": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.5197, + "logloss": 0.7054, + "model": "xgb" + }, + "HT": { + "n_train": 504, + "n_test": 126, + "accuracy": 0.3889, + "logloss": 1.0986, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 504, + "n_test": 126, + "accuracy": 0.6825, + "logloss": 0.6204, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 504, + "n_test": 126, + "accuracy": 0.7143, + "logloss": 0.5937, + "model": "xgb" + }, + "HTFT": { + "n_train": 504, + "n_test": 126, + "accuracy": 0.254, + "logloss": 1.9513, + "model": "xgb" + }, + "OE": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.4724, + "logloss": 0.7367, + "model": "xgb" + }, + "CARDS": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.5354, + "logloss": 0.6809, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 505, + "n_test": 127, + "accuracy": 0.6142, + "logloss": 0.8679, + "model": "xgb" + } + } + }, + { + "league_id": "4zwgbb66rif2spcoeeol2motx", + "league_name": "Pro Lig", + "n_matches": 650, + "markets": { + "MS": { + "n_train": 520, + "n_test": 130, + "accuracy": 0.4769, + "logloss": 1.0452, + "model": "xgb" + }, + "OU15": { + "n_train": 520, + "n_test": 130, + "accuracy": 0.7769, + "logloss": 0.5472, + "model": "xgb" + }, + "OU25": { + "n_train": 520, + "n_test": 130, + "accuracy": 0.5077, + "logloss": 0.707, + "model": "xgb" + }, + "OU35": { + "n_train": 520, + "n_test": 130, + "accuracy": 0.7154, + "logloss": 0.6138, + "model": "xgb" + }, + "BTTS": { + "n_train": 520, + "n_test": 130, + "accuracy": 0.4846, + "logloss": 0.7126, + "model": "xgb" + }, + "HT": { + "n_train": 519, + "n_test": 130, + "accuracy": 0.3923, + "logloss": 1.0977, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 519, + "n_test": 130, + "accuracy": 0.6923, + "logloss": 0.6376, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 519, + "n_test": 130, + "accuracy": 0.6769, + "logloss": 0.6258, + "model": "xgb" + }, + "HTFT": { + "n_train": 519, + "n_test": 130, + "accuracy": 0.2538, + "logloss": 2.0185, + "model": "xgb" + }, + "OE": { + "n_train": 520, + "n_test": 130, + "accuracy": 0.5385, + "logloss": 0.6987, + "model": "xgb" + }, + "CARDS": { + "n_train": 520, + "n_test": 130, + "accuracy": 0.6, + "logloss": 0.6771, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 520, + "n_test": 130, + "accuracy": 0.5385, + "logloss": 0.9437, + "model": "xgb" + } + } + }, + { + "league_id": "57nu0wygurzkp6fuy5hhrtaa2", + "league_name": "1. Lig", + "n_matches": 503, + "markets": { + "MS": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.5248, + "logloss": 1.0055, + "model": "xgb" + }, + "OU15": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.6337, + "logloss": 0.6697, + "model": "xgb" + }, + "OU25": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.5545, + "logloss": 0.6851, + "model": "xgb" + }, + "OU35": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.8119, + "logloss": 0.5368, + "model": "xgb" + }, + "BTTS": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.5545, + "logloss": 0.706, + "model": "xgb" + }, + "HT": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.54, + "logloss": 0.9983, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.6, + "logloss": 0.6666, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.69, + "logloss": 0.63, + "model": "xgb" + }, + "HTFT": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.31, + "logloss": 1.8337, + "model": "xgb" + }, + "OE": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.5644, + "logloss": 0.7034, + "model": "xgb" + }, + "CARDS": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.4455, + "logloss": 0.7367, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 402, + "n_test": 101, + "accuracy": 0.505, + "logloss": 0.9939, + "model": "xgb" + } + } + }, + { + "league_id": "581t4mywybx21wcpmpykhyzr3", + "league_name": "Premier Lig", + "n_matches": 1172, + "markets": { + "MS": { + "n_train": 937, + "n_test": 235, + "accuracy": 0.4085, + "logloss": 1.0591, + "model": "xgb" + }, + "OU15": { + "n_train": 937, + "n_test": 235, + "accuracy": 0.6, + "logloss": 0.6575, + "model": "xgb" + }, + "OU25": { + "n_train": 937, + "n_test": 235, + "accuracy": 0.6553, + "logloss": 0.638, + "model": "xgb" + }, + "OU35": { + "n_train": 937, + "n_test": 235, + "accuracy": 0.8681, + "logloss": 0.4039, + "model": "xgb" + }, + "BTTS": { + "n_train": 937, + "n_test": 235, + "accuracy": 0.5617, + "logloss": 0.6901, + "model": "xgb" + }, + "HT": { + "n_train": 937, + "n_test": 235, + "accuracy": 0.4979, + "logloss": 1.0496, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 937, + "n_test": 235, + "accuracy": 0.5957, + "logloss": 0.6862, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 937, + "n_test": 235, + "accuracy": 0.7745, + "logloss": 0.5357, + "model": "xgb" + }, + "HTFT": { + "n_train": 937, + "n_test": 235, + "accuracy": 0.2596, + "logloss": 1.9236, + "model": "xgb" + }, + "OE": { + "n_train": 937, + "n_test": 235, + "accuracy": 0.5021, + "logloss": 0.6992, + "model": "xgb" + }, + "CARDS": { + "n_train": 937, + "n_test": 235, + "accuracy": 0.5489, + "logloss": 0.6779, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 937, + "n_test": 235, + "accuracy": 0.6, + "logloss": 0.9473, + "model": "xgb" + } + } + }, + { + "league_id": "5y0z0l2epprzbscvzsgldw8vu", + "league_name": "Profesyonel Lig", + "n_matches": 574, + "markets": { + "MS": { + "n_train": 459, + "n_test": 115, + "accuracy": 0.4696, + "logloss": 1.0365, + "model": "xgb" + }, + "OU15": { + "n_train": 459, + "n_test": 115, + "accuracy": 0.713, + "logloss": 0.6028, + "model": "xgb" + }, + "OU25": { + "n_train": 459, + "n_test": 115, + "accuracy": 0.5652, + "logloss": 0.6789, + "model": "xgb" + }, + "OU35": { + "n_train": 459, + "n_test": 115, + "accuracy": 0.7913, + "logloss": 0.5249, + "model": "xgb" + }, + "BTTS": { + "n_train": 459, + "n_test": 115, + "accuracy": 0.5043, + "logloss": 0.7016, + "model": "xgb" + }, + "HT": { + "n_train": 458, + "n_test": 115, + "accuracy": 0.4957, + "logloss": 1.0325, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 458, + "n_test": 115, + "accuracy": 0.5739, + "logloss": 0.6955, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 458, + "n_test": 115, + "accuracy": 0.7478, + "logloss": 0.5963, + "model": "xgb" + }, + "HTFT": { + "n_train": 458, + "n_test": 115, + "accuracy": 0.2435, + "logloss": 2.0158, + "model": "xgb" + }, + "OE": { + "n_train": 459, + "n_test": 115, + "accuracy": 0.487, + "logloss": 0.7009, + "model": "xgb" + }, + "CARDS": { + "n_train": 459, + "n_test": 115, + "accuracy": 0.5391, + "logloss": 0.7021, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 459, + "n_test": 115, + "accuracy": 0.5652, + "logloss": 0.918, + "model": "xgb" + } + } + }, + { + "league_id": "5zr0b05eyx25km7z1k03ca9jx", + "league_name": "Serie B", + "n_matches": 773, + "markets": { + "MS": { + "n_train": 618, + "n_test": 155, + "accuracy": 0.4258, + "logloss": 1.0957, + "model": "xgb" + }, + "OU15": { + "n_train": 618, + "n_test": 155, + "accuracy": 0.6387, + "logloss": 0.6345, + "model": "xgb" + }, + "OU25": { + "n_train": 618, + "n_test": 155, + "accuracy": 0.6129, + "logloss": 0.684, + "model": "xgb" + }, + "OU35": { + "n_train": 618, + "n_test": 155, + "accuracy": 0.8065, + "logloss": 0.5007, + "model": "xgb" + }, + "BTTS": { + "n_train": 618, + "n_test": 155, + "accuracy": 0.5032, + "logloss": 0.7172, + "model": "xgb" + }, + "HT": { + "n_train": 618, + "n_test": 155, + "accuracy": 0.5226, + "logloss": 0.9814, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 618, + "n_test": 155, + "accuracy": 0.6065, + "logloss": 0.6651, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 618, + "n_test": 155, + "accuracy": 0.7419, + "logloss": 0.569, + "model": "xgb" + }, + "HTFT": { + "n_train": 618, + "n_test": 155, + "accuracy": 0.2, + "logloss": 2.0786, + "model": "xgb" + }, + "OE": { + "n_train": 618, + "n_test": 155, + "accuracy": 0.5677, + "logloss": 0.69, + "model": "xgb" + }, + "CARDS": { + "n_train": 618, + "n_test": 155, + "accuracy": 0.5032, + "logloss": 0.7075, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 618, + "n_test": 155, + "accuracy": 0.5226, + "logloss": 0.9378, + "model": "xgb" + } + } + }, + { + "league_id": "65ggsqdi6drpa4m8y3gkll25k", + "league_name": "Bahar \u015eampiyonas\u0131", + "n_matches": 571, + "markets": { + "MS": { + "n_train": 456, + "n_test": 115, + "accuracy": 0.4783, + "logloss": 1.0254, + "model": "xgb" + }, + "OU15": { + "n_train": 456, + "n_test": 115, + "accuracy": 0.7565, + "logloss": 0.5247, + "model": "xgb" + }, + "OU25": { + "n_train": 456, + "n_test": 115, + "accuracy": 0.513, + "logloss": 0.7089, + "model": "xgb" + }, + "OU35": { + "n_train": 456, + "n_test": 115, + "accuracy": 0.687, + "logloss": 0.6195, + "model": "xgb" + }, + "BTTS": { + "n_train": 456, + "n_test": 115, + "accuracy": 0.6174, + "logloss": 0.68, + "model": "xgb" + }, + "HT": { + "n_train": 455, + "n_test": 115, + "accuracy": 0.3913, + "logloss": 1.1072, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 455, + "n_test": 115, + "accuracy": 0.7043, + "logloss": 0.5747, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 455, + "n_test": 115, + "accuracy": 0.6174, + "logloss": 0.6762, + "model": "xgb" + }, + "HTFT": { + "n_train": 455, + "n_test": 115, + "accuracy": 0.2087, + "logloss": 1.9534, + "model": "xgb" + }, + "OE": { + "n_train": 456, + "n_test": 115, + "accuracy": 0.5304, + "logloss": 0.7099, + "model": "xgb" + }, + "CARDS": { + "n_train": 456, + "n_test": 115, + "accuracy": 0.7217, + "logloss": 0.596, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 456, + "n_test": 115, + "accuracy": 0.5217, + "logloss": 1.0138, + "model": "xgb" + } + } + }, + { + "league_id": "663a54fmymndjeev47qm7d3nf", + "league_name": "2. Lig", + "n_matches": 630, + "markets": { + "MS": { + "n_train": 504, + "n_test": 126, + "accuracy": 0.4365, + "logloss": 1.0752, + "model": "xgb" + }, + "OU15": { + "n_train": 504, + "n_test": 126, + "accuracy": 0.8095, + "logloss": 0.497, + "model": "xgb" + }, + "OU25": { + "n_train": 504, + "n_test": 126, + "accuracy": 0.5317, + "logloss": 0.6899, + "model": "xgb" + }, + "OU35": { + "n_train": 504, + "n_test": 126, + "accuracy": 0.6429, + "logloss": 0.6421, + "model": "xgb" + }, + "BTTS": { + "n_train": 504, + "n_test": 126, + "accuracy": 0.5476, + "logloss": 0.7322, + "model": "xgb" + }, + "HT": { + "n_train": 503, + "n_test": 126, + "accuracy": 0.3492, + "logloss": 1.1503, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 503, + "n_test": 126, + "accuracy": 0.746, + "logloss": 0.554, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 503, + "n_test": 126, + "accuracy": 0.5952, + "logloss": 0.6681, + "model": "xgb" + }, + "HTFT": { + "n_train": 503, + "n_test": 126, + "accuracy": 0.2143, + "logloss": 2.0703, + "model": "xgb" + }, + "OE": { + "n_train": 504, + "n_test": 126, + "accuracy": 0.5317, + "logloss": 0.704, + "model": "xgb" + }, + "CARDS": { + "n_train": 504, + "n_test": 126, + "accuracy": 0.6032, + "logloss": 0.6152, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 504, + "n_test": 126, + "accuracy": 0.5952, + "logloss": 0.9425, + "model": "xgb" + } + } + }, + { + "league_id": "6by3h89i2eykc341oz7lv1ddd", + "league_name": "Bundesliga", + "n_matches": 684, + "markets": { + "MS": { + "n_train": 547, + "n_test": 137, + "accuracy": 0.5474, + "logloss": 0.9917, + "model": "xgb" + }, + "OU15": { + "n_train": 547, + "n_test": 137, + "accuracy": 0.8321, + "logloss": 0.4512, + "model": "xgb" + }, + "OU25": { + "n_train": 547, + "n_test": 137, + "accuracy": 0.6642, + "logloss": 0.6139, + "model": "xgb" + }, + "OU35": { + "n_train": 547, + "n_test": 137, + "accuracy": 0.6277, + "logloss": 0.6568, + "model": "xgb" + }, + "BTTS": { + "n_train": 547, + "n_test": 137, + "accuracy": 0.635, + "logloss": 0.6543, + "model": "xgb" + }, + "HT": { + "n_train": 547, + "n_test": 137, + "accuracy": 0.4234, + "logloss": 1.0592, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 547, + "n_test": 137, + "accuracy": 0.7737, + "logloss": 0.548, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 547, + "n_test": 137, + "accuracy": 0.5547, + "logloss": 0.6679, + "model": "xgb" + }, + "HTFT": { + "n_train": 547, + "n_test": 137, + "accuracy": 0.3577, + "logloss": 1.9905, + "model": "xgb" + }, + "OE": { + "n_train": 547, + "n_test": 137, + "accuracy": 0.4964, + "logloss": 0.7171, + "model": "xgb" + }, + "CARDS": { + "n_train": 547, + "n_test": 137, + "accuracy": 0.6277, + "logloss": 0.652, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 547, + "n_test": 137, + "accuracy": 0.5912, + "logloss": 0.9058, + "model": "xgb" + } + } + }, + { + "league_id": "6g8hw3acenrw828la7gwx4mvs", + "league_name": "Yeni G\u00fcney Galler NPL", + "n_matches": 504, + "markets": { + "MS": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.5347, + "logloss": 0.9972, + "model": "xgb" + }, + "OU15": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.7228, + "logloss": 0.6092, + "model": "xgb" + }, + "OU25": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.5644, + "logloss": 0.6971, + "model": "xgb" + }, + "OU35": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.6535, + "logloss": 0.6549, + "model": "xgb" + }, + "BTTS": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.5743, + "logloss": 0.684, + "model": "xgb" + }, + "OE": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.5149, + "logloss": 0.7038, + "model": "xgb" + }, + "CARDS": { + "n_train": 403, + "n_test": 101, + "error": "y_true contains only one label (0). Please provide the list of all expected class labels explicitly through the labels argument." + }, + "HANDICAP": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.505, + "logloss": 0.995, + "model": "xgb" + } + } + }, + { + "league_id": "6lwpjhktjhl9g7x2w7njmzva6", + "league_name": "Pro Lig", + "n_matches": 575, + "markets": { + "MS": { + "n_train": 460, + "n_test": 115, + "accuracy": 0.4696, + "logloss": 1.0362, + "model": "xgb" + }, + "OU15": { + "n_train": 460, + "n_test": 115, + "accuracy": 0.6348, + "logloss": 0.6797, + "model": "xgb" + }, + "OU25": { + "n_train": 460, + "n_test": 115, + "accuracy": 0.5565, + "logloss": 0.6872, + "model": "xgb" + }, + "OU35": { + "n_train": 460, + "n_test": 115, + "accuracy": 0.8348, + "logloss": 0.4844, + "model": "xgb" + }, + "BTTS": { + "n_train": 460, + "n_test": 115, + "accuracy": 0.4783, + "logloss": 0.7065, + "model": "xgb" + }, + "HT": { + "n_train": 460, + "n_test": 114, + "accuracy": 0.3596, + "logloss": 1.0288, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 460, + "n_test": 114, + "accuracy": 0.6228, + "logloss": 0.7193, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 460, + "n_test": 114, + "accuracy": 0.7632, + "logloss": 0.5855, + "model": "xgb" + }, + "HTFT": { + "n_train": 460, + "n_test": 114, + "accuracy": 0.2544, + "logloss": 1.916, + "model": "xgb" + }, + "OE": { + "n_train": 460, + "n_test": 115, + "accuracy": 0.5478, + "logloss": 0.6991, + "model": "xgb" + }, + "CARDS": { + "n_train": 460, + "n_test": 115, + "accuracy": 0.4957, + "logloss": 0.703, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 460, + "n_test": 115, + "accuracy": 0.5043, + "logloss": 1.0229, + "model": "xgb" + } + } + }, + { + "league_id": "722fdbecxzcq9788l6jqclzlw", + "league_name": "2. Bundesliga", + "n_matches": 653, + "markets": { + "MS": { + "n_train": 522, + "n_test": 131, + "accuracy": 0.4275, + "logloss": 1.0574, + "model": "xgb" + }, + "OU15": { + "n_train": 522, + "n_test": 131, + "accuracy": 0.8321, + "logloss": 0.4576, + "model": "xgb" + }, + "OU25": { + "n_train": 522, + "n_test": 131, + "accuracy": 0.5725, + "logloss": 0.7121, + "model": "xgb" + }, + "OU35": { + "n_train": 522, + "n_test": 131, + "accuracy": 0.626, + "logloss": 0.6691, + "model": "xgb" + }, + "BTTS": { + "n_train": 522, + "n_test": 131, + "accuracy": 0.6183, + "logloss": 0.6527, + "model": "xgb" + }, + "HT": { + "n_train": 522, + "n_test": 131, + "accuracy": 0.4122, + "logloss": 1.0958, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 522, + "n_test": 131, + "accuracy": 0.7557, + "logloss": 0.5779, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 522, + "n_test": 131, + "accuracy": 0.6031, + "logloss": 0.6711, + "model": "xgb" + }, + "HTFT": { + "n_train": 522, + "n_test": 131, + "accuracy": 0.1908, + "logloss": 2.0765, + "model": "xgb" + }, + "OE": { + "n_train": 522, + "n_test": 131, + "accuracy": 0.5267, + "logloss": 0.6822, + "model": "xgb" + }, + "CARDS": { + "n_train": 522, + "n_test": 131, + "accuracy": 0.4427, + "logloss": 0.7286, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 522, + "n_test": 131, + "accuracy": 0.5267, + "logloss": 0.9929, + "model": "xgb" + } + } + }, + { + "league_id": "75434tz9rc14xkkvudex742ui", + "league_name": "Premier Lig 2", + "n_matches": 537, + "markets": { + "MS": { + "n_train": 429, + "n_test": 108, + "accuracy": 0.5741, + "logloss": 0.9116, + "model": "xgb" + }, + "OU15": { + "n_train": 429, + "n_test": 108, + "accuracy": 0.8519, + "logloss": 0.4452, + "model": "xgb" + }, + "OU25": { + "n_train": 429, + "n_test": 108, + "accuracy": 0.6574, + "logloss": 0.6418, + "model": "xgb" + }, + "OU35": { + "n_train": 429, + "n_test": 108, + "accuracy": 0.5741, + "logloss": 0.6876, + "model": "xgb" + }, + "BTTS": { + "n_train": 429, + "n_test": 108, + "accuracy": 0.5833, + "logloss": 0.6995, + "model": "xgb" + }, + "HT": { + "n_train": 429, + "n_test": 108, + "accuracy": 0.3981, + "logloss": 1.0789, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 429, + "n_test": 108, + "accuracy": 0.8519, + "logloss": 0.4254, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 429, + "n_test": 108, + "accuracy": 0.4907, + "logloss": 0.7065, + "model": "xgb" + }, + "HTFT": { + "n_train": 429, + "n_test": 108, + "accuracy": 0.4444, + "logloss": 1.8799, + "model": "xgb" + }, + "OE": { + "n_train": 429, + "n_test": 108, + "accuracy": 0.5463, + "logloss": 0.7055, + "model": "xgb" + }, + "CARDS": { + "n_train": 429, + "n_test": 108, + "accuracy": 0.5741, + "logloss": 0.7046, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 429, + "n_test": 108, + "accuracy": 0.5833, + "logloss": 0.9468, + "model": "xgb" + } + } + }, + { + "league_id": "75i269i1ak43magshljadydrh", + "league_name": "Premiership", + "n_matches": 504, + "markets": { + "MS": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.5149, + "logloss": 0.981, + "model": "xgb" + }, + "OU15": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.8317, + "logloss": 0.4969, + "model": "xgb" + }, + "OU25": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.5545, + "logloss": 0.6912, + "model": "xgb" + }, + "OU35": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.6139, + "logloss": 0.6777, + "model": "xgb" + }, + "BTTS": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.495, + "logloss": 0.6878, + "model": "xgb" + }, + "HT": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.3762, + "logloss": 1.0613, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.7624, + "logloss": 0.5603, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.5248, + "logloss": 0.7984, + "model": "xgb" + }, + "HTFT": { + "n_train": 403, + "n_test": 101, + "error": "y_true and y_prob contain different number of classes: 8 vs 9. Please provide the true labels explicitly through the labels argument. Classes found in y_true: [0 1 3 4 5 6 7 8]" + }, + "OE": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.5545, + "logloss": 0.7063, + "model": "xgb" + }, + "CARDS": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.5842, + "logloss": 0.6905, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.604, + "logloss": 0.973, + "model": "xgb" + } + } + }, + { + "league_id": "7hl0svs2hg225i2zud0g3xzp2", + "league_name": "Ekstraklasa", + "n_matches": 622, + "markets": { + "MS": { + "n_train": 497, + "n_test": 125, + "accuracy": 0.44, + "logloss": 1.0941, + "model": "xgb" + }, + "OU15": { + "n_train": 497, + "n_test": 125, + "accuracy": 0.728, + "logloss": 0.581, + "model": "xgb" + }, + "OU25": { + "n_train": 497, + "n_test": 125, + "accuracy": 0.552, + "logloss": 0.6982, + "model": "xgb" + }, + "OU35": { + "n_train": 497, + "n_test": 125, + "accuracy": 0.736, + "logloss": 0.5665, + "model": "xgb" + }, + "BTTS": { + "n_train": 497, + "n_test": 125, + "accuracy": 0.552, + "logloss": 0.6936, + "model": "xgb" + }, + "HT": { + "n_train": 497, + "n_test": 125, + "accuracy": 0.448, + "logloss": 1.0759, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 497, + "n_test": 125, + "accuracy": 0.704, + "logloss": 0.5968, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 497, + "n_test": 125, + "accuracy": 0.68, + "logloss": 0.6257, + "model": "xgb" + }, + "HTFT": { + "n_train": 497, + "n_test": 125, + "accuracy": 0.272, + "logloss": 1.9114, + "model": "xgb" + }, + "OE": { + "n_train": 497, + "n_test": 125, + "accuracy": 0.448, + "logloss": 0.7232, + "model": "xgb" + }, + "CARDS": { + "n_train": 497, + "n_test": 125, + "accuracy": 0.496, + "logloss": 0.7131, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 497, + "n_test": 125, + "accuracy": 0.544, + "logloss": 0.9965, + "model": "xgb" + } + } + }, + { + "league_id": "7ntvbsyq31jnzoqoa8850b9b8", + "league_name": "Championship", + "n_matches": 1197, + "markets": { + "MS": { + "n_train": 957, + "n_test": 240, + "accuracy": 0.375, + "logloss": 1.1128, + "model": "xgb" + }, + "OU15": { + "n_train": 957, + "n_test": 240, + "accuracy": 0.775, + "logloss": 0.5382, + "model": "xgb" + }, + "OU25": { + "n_train": 957, + "n_test": 240, + "accuracy": 0.4833, + "logloss": 0.708, + "model": "xgb" + }, + "OU35": { + "n_train": 957, + "n_test": 240, + "accuracy": 0.7417, + "logloss": 0.5781, + "model": "xgb" + }, + "BTTS": { + "n_train": 957, + "n_test": 240, + "accuracy": 0.5125, + "logloss": 0.6987, + "model": "xgb" + }, + "HT": { + "n_train": 957, + "n_test": 240, + "accuracy": 0.4042, + "logloss": 1.0748, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 957, + "n_test": 240, + "accuracy": 0.7, + "logloss": 0.6143, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 957, + "n_test": 240, + "accuracy": 0.6125, + "logloss": 0.6686, + "model": "xgb" + }, + "HTFT": { + "n_train": 957, + "n_test": 240, + "accuracy": 0.2625, + "logloss": 1.9692, + "model": "xgb" + }, + "OE": { + "n_train": 957, + "n_test": 240, + "accuracy": 0.5, + "logloss": 0.7056, + "model": "xgb" + }, + "CARDS": { + "n_train": 957, + "n_test": 240, + "accuracy": 0.6458, + "logloss": 0.6627, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 957, + "n_test": 240, + "accuracy": 0.5958, + "logloss": 0.946, + "model": "xgb" + } + } + }, + { + "league_id": "82jkgccg7phfjpd0mltdl3pat", + "league_name": "S\u00fcper Lig", + "n_matches": 502, + "markets": { + "MS": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.5149, + "logloss": 0.9981, + "model": "xgb" + }, + "OU15": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.7624, + "logloss": 0.5783, + "model": "xgb" + }, + "OU25": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.604, + "logloss": 0.6942, + "model": "xgb" + }, + "OU35": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.6238, + "logloss": 0.6547, + "model": "xgb" + }, + "BTTS": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.495, + "logloss": 0.7056, + "model": "xgb" + }, + "HT": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.4059, + "logloss": 1.1145, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.7327, + "logloss": 0.5896, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.4851, + "logloss": 0.7081, + "model": "xgb" + }, + "HTFT": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.3168, + "logloss": 1.9613, + "model": "xgb" + }, + "OE": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.4356, + "logloss": 0.7354, + "model": "xgb" + }, + "CARDS": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.5446, + "logloss": 0.7165, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 401, + "n_test": 101, + "accuracy": 0.5545, + "logloss": 0.9749, + "model": "xgb" + } + } + }, + { + "league_id": "89ovpy1rarewwzqvi30bfdr8b", + "league_name": "1. Lig", + "n_matches": 671, + "markets": { + "MS": { + "n_train": 536, + "n_test": 135, + "accuracy": 0.4667, + "logloss": 1.0501, + "model": "xgb" + }, + "OU15": { + "n_train": 536, + "n_test": 135, + "accuracy": 0.6444, + "logloss": 0.7078, + "model": "xgb" + }, + "OU25": { + "n_train": 536, + "n_test": 135, + "accuracy": 0.5333, + "logloss": 0.7187, + "model": "xgb" + }, + "OU35": { + "n_train": 536, + "n_test": 135, + "accuracy": 0.7111, + "logloss": 0.6283, + "model": "xgb" + }, + "BTTS": { + "n_train": 536, + "n_test": 135, + "accuracy": 0.5259, + "logloss": 0.7059, + "model": "xgb" + }, + "HT": { + "n_train": 536, + "n_test": 135, + "accuracy": 0.3778, + "logloss": 1.0801, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 536, + "n_test": 135, + "accuracy": 0.637, + "logloss": 0.68, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 536, + "n_test": 135, + "accuracy": 0.6741, + "logloss": 0.6543, + "model": "xgb" + }, + "HTFT": { + "n_train": 536, + "n_test": 135, + "accuracy": 0.2963, + "logloss": 1.9157, + "model": "xgb" + }, + "OE": { + "n_train": 536, + "n_test": 135, + "accuracy": 0.4593, + "logloss": 0.7422, + "model": "xgb" + }, + "CARDS": { + "n_train": 536, + "n_test": 135, + "accuracy": 0.5556, + "logloss": 0.7209, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 536, + "n_test": 135, + "accuracy": 0.4963, + "logloss": 1.0849, + "model": "xgb" + } + } + }, + { + "league_id": "8dn0w8zh7nbn2i904603eigwf", + "league_name": "1. Lig", + "n_matches": 645, + "markets": { + "MS": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.4961, + "logloss": 1.0782, + "model": "xgb" + }, + "OU15": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.8062, + "logloss": 0.4959, + "model": "xgb" + }, + "OU25": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.5194, + "logloss": 0.7424, + "model": "xgb" + }, + "OU35": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.7054, + "logloss": 0.622, + "model": "xgb" + }, + "BTTS": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.6124, + "logloss": 0.6778, + "model": "xgb" + }, + "HT": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.4186, + "logloss": 1.083, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.6977, + "logloss": 0.6721, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.6512, + "logloss": 0.6406, + "model": "xgb" + }, + "HTFT": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.2946, + "logloss": 2.0154, + "model": "xgb" + }, + "OE": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.4496, + "logloss": 0.7284, + "model": "xgb" + }, + "CARDS": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.5581, + "logloss": 0.6993, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 516, + "n_test": 129, + "accuracy": 0.5891, + "logloss": 0.8853, + "model": "xgb" + } + } + }, + { + "league_id": "8ey0ww2zsosdmwr8ehsorh6t7", + "league_name": "Serie B", + "n_matches": 882, + "markets": { + "MS": { + "n_train": 705, + "n_test": 177, + "accuracy": 0.5085, + "logloss": 0.9535, + "model": "xgb" + }, + "OU15": { + "n_train": 705, + "n_test": 177, + "accuracy": 0.7797, + "logloss": 0.5324, + "model": "xgb" + }, + "OU25": { + "n_train": 705, + "n_test": 177, + "accuracy": 0.5424, + "logloss": 0.6739, + "model": "xgb" + }, + "OU35": { + "n_train": 705, + "n_test": 177, + "accuracy": 0.7288, + "logloss": 0.5766, + "model": "xgb" + }, + "BTTS": { + "n_train": 705, + "n_test": 177, + "accuracy": 0.4576, + "logloss": 0.7072, + "model": "xgb" + }, + "HT": { + "n_train": 703, + "n_test": 177, + "accuracy": 0.5198, + "logloss": 1.0346, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 703, + "n_test": 177, + "accuracy": 0.7062, + "logloss": 0.6175, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 703, + "n_test": 177, + "accuracy": 0.6328, + "logloss": 0.6564, + "model": "xgb" + }, + "HTFT": { + "n_train": 703, + "n_test": 177, + "accuracy": 0.322, + "logloss": 1.909, + "model": "xgb" + }, + "OE": { + "n_train": 705, + "n_test": 177, + "accuracy": 0.5028, + "logloss": 0.699, + "model": "xgb" + }, + "CARDS": { + "n_train": 705, + "n_test": 177, + "accuracy": 0.4237, + "logloss": 0.7504, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 705, + "n_test": 177, + "accuracy": 0.5537, + "logloss": 0.9904, + "model": "xgb" + } + } + }, + { + "league_id": "8k1xcsyvxapl4jlsluh3eomre", + "league_name": "Premier Lig", + "n_matches": 608, + "markets": { + "MS": { + "n_train": 486, + "n_test": 122, + "accuracy": 0.4754, + "logloss": 1.0151, + "model": "xgb" + }, + "OU15": { + "n_train": 486, + "n_test": 122, + "accuracy": 0.6148, + "logloss": 0.6656, + "model": "xgb" + }, + "OU25": { + "n_train": 486, + "n_test": 122, + "accuracy": 0.6967, + "logloss": 0.5863, + "model": "xgb" + }, + "OU35": { + "n_train": 486, + "n_test": 122, + "accuracy": 0.8361, + "logloss": 0.3972, + "model": "xgb" + }, + "BTTS": { + "n_train": 486, + "n_test": 122, + "accuracy": 0.582, + "logloss": 0.6768, + "model": "xgb" + }, + "HT": { + "n_train": 485, + "n_test": 122, + "accuracy": 0.5082, + "logloss": 1.0221, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 485, + "n_test": 122, + "accuracy": 0.582, + "logloss": 0.6728, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 485, + "n_test": 122, + "accuracy": 0.8197, + "logloss": 0.4854, + "model": "xgb" + }, + "HTFT": { + "n_train": 485, + "n_test": 122, + "accuracy": 0.3033, + "logloss": 1.9283, + "model": "xgb" + }, + "OE": { + "n_train": 486, + "n_test": 122, + "accuracy": 0.582, + "logloss": 0.6966, + "model": "xgb" + }, + "CARDS": { + "n_train": 486, + "n_test": 122, + "accuracy": 0.5328, + "logloss": 0.6987, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 486, + "n_test": 122, + "accuracy": 0.6885, + "logloss": 0.7995, + "model": "xgb" + } + } + }, + { + "league_id": "8r98daokeuzsamu5fmjtblqx5", + "league_name": "1. Lig", + "n_matches": 532, + "markets": { + "MS": { + "n_train": 425, + "n_test": 107, + "accuracy": 0.5234, + "logloss": 1.0152, + "model": "xgb" + }, + "OU15": { + "n_train": 425, + "n_test": 107, + "accuracy": 0.6729, + "logloss": 0.5743, + "model": "xgb" + }, + "OU25": { + "n_train": 425, + "n_test": 107, + "accuracy": 0.5514, + "logloss": 0.6954, + "model": "xgb" + }, + "OU35": { + "n_train": 425, + "n_test": 107, + "accuracy": 0.757, + "logloss": 0.5488, + "model": "xgb" + }, + "BTTS": { + "n_train": 425, + "n_test": 107, + "accuracy": 0.5607, + "logloss": 0.681, + "model": "xgb" + }, + "HT": { + "n_train": 425, + "n_test": 107, + "accuracy": 0.4486, + "logloss": 1.0842, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 425, + "n_test": 107, + "accuracy": 0.5888, + "logloss": 0.6534, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 425, + "n_test": 107, + "accuracy": 0.6542, + "logloss": 0.6637, + "model": "xgb" + }, + "HTFT": { + "n_train": 425, + "n_test": 107, + "accuracy": 0.215, + "logloss": 1.8779, + "model": "xgb" + }, + "OE": { + "n_train": 425, + "n_test": 107, + "accuracy": 0.4673, + "logloss": 0.7199, + "model": "xgb" + }, + "CARDS": { + "n_train": 425, + "n_test": 107, + "accuracy": 0.5234, + "logloss": 0.7211, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 425, + "n_test": 107, + "accuracy": 0.5981, + "logloss": 0.9314, + "model": "xgb" + } + } + }, + { + "league_id": "8sdpk4aerruf515yh76ezo7vi", + "league_name": "2. Lig", + "n_matches": 662, + "markets": { + "MS": { + "n_train": 529, + "n_test": 133, + "accuracy": 0.4286, + "logloss": 1.0863, + "model": "xgb" + }, + "OU15": { + "n_train": 529, + "n_test": 133, + "accuracy": 0.6316, + "logloss": 0.6818, + "model": "xgb" + }, + "OU25": { + "n_train": 529, + "n_test": 133, + "accuracy": 0.5263, + "logloss": 0.697, + "model": "xgb" + }, + "OU35": { + "n_train": 529, + "n_test": 133, + "accuracy": 0.8195, + "logloss": 0.4873, + "model": "xgb" + }, + "BTTS": { + "n_train": 529, + "n_test": 133, + "accuracy": 0.4887, + "logloss": 0.714, + "model": "xgb" + }, + "HT": { + "n_train": 529, + "n_test": 133, + "accuracy": 0.4737, + "logloss": 1.049, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 529, + "n_test": 133, + "accuracy": 0.5789, + "logloss": 0.6971, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 529, + "n_test": 133, + "accuracy": 0.7444, + "logloss": 0.5864, + "model": "xgb" + }, + "HTFT": { + "n_train": 529, + "n_test": 133, + "accuracy": 0.2857, + "logloss": 1.938, + "model": "xgb" + }, + "OE": { + "n_train": 529, + "n_test": 133, + "accuracy": 0.3835, + "logloss": 0.7359, + "model": "xgb" + }, + "CARDS": { + "n_train": 529, + "n_test": 133, + "accuracy": 0.5789, + "logloss": 0.6921, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 529, + "n_test": 133, + "accuracy": 0.5338, + "logloss": 1.107, + "model": "xgb" + } + } + }, + { + "league_id": "8yi6ejjd1zudcqtbn07haahg6", + "league_name": "Premier Lig", + "n_matches": 617, + "markets": { + "MS": { + "n_train": 493, + "n_test": 124, + "accuracy": 0.5645, + "logloss": 0.9236, + "model": "xgb" + }, + "OU15": { + "n_train": 493, + "n_test": 124, + "accuracy": 0.7097, + "logloss": 0.611, + "model": "xgb" + }, + "OU25": { + "n_train": 493, + "n_test": 124, + "accuracy": 0.5968, + "logloss": 0.7057, + "model": "xgb" + }, + "OU35": { + "n_train": 493, + "n_test": 124, + "accuracy": 0.6855, + "logloss": 0.6201, + "model": "xgb" + }, + "BTTS": { + "n_train": 493, + "n_test": 124, + "accuracy": 0.5484, + "logloss": 0.7038, + "model": "xgb" + }, + "HT": { + "n_train": 493, + "n_test": 124, + "accuracy": 0.4919, + "logloss": 1.0703, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 493, + "n_test": 124, + "accuracy": 0.629, + "logloss": 0.6836, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 493, + "n_test": 124, + "accuracy": 0.5565, + "logloss": 0.673, + "model": "xgb" + }, + "HTFT": { + "n_train": 493, + "n_test": 124, + "accuracy": 0.3226, + "logloss": 1.9536, + "model": "xgb" + }, + "OE": { + "n_train": 493, + "n_test": 124, + "accuracy": 0.5565, + "logloss": 0.6956, + "model": "xgb" + }, + "CARDS": { + "n_train": 493, + "n_test": 124, + "accuracy": 0.5968, + "logloss": 0.6849, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 493, + "n_test": 124, + "accuracy": 0.621, + "logloss": 0.852, + "model": "xgb" + } + } + }, + { + "league_id": "907l7wtxdvugdo9i2249wcmr0", + "league_name": "Nesine 3. Lig", + "n_matches": 1199, + "markets": { + "MS": { + "n_train": 959, + "n_test": 240, + "accuracy": 0.5667, + "logloss": 0.9114, + "model": "xgb" + }, + "OU15": { + "n_train": 959, + "n_test": 240, + "accuracy": 0.6875, + "logloss": 0.6221, + "model": "xgb" + }, + "OU25": { + "n_train": 959, + "n_test": 240, + "accuracy": 0.5958, + "logloss": 0.6545, + "model": "xgb" + }, + "OU35": { + "n_train": 959, + "n_test": 240, + "accuracy": 0.7, + "logloss": 0.612, + "model": "xgb" + }, + "BTTS": { + "n_train": 959, + "n_test": 240, + "accuracy": 0.575, + "logloss": 0.6833, + "model": "xgb" + }, + "HT": { + "n_train": 958, + "n_test": 237, + "accuracy": 0.4473, + "logloss": 1.0055, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 958, + "n_test": 237, + "accuracy": 0.6667, + "logloss": 0.5992, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 958, + "n_test": 237, + "accuracy": 0.6667, + "logloss": 0.5963, + "model": "xgb" + }, + "HTFT": { + "n_train": 958, + "n_test": 237, + "accuracy": 0.3544, + "logloss": 1.7701, + "model": "xgb" + }, + "OE": { + "n_train": 959, + "n_test": 240, + "accuracy": 0.5042, + "logloss": 0.6969, + "model": "xgb" + }, + "CARDS": { + "n_train": 959, + "n_test": 240, + "accuracy": 0.575, + "logloss": 0.6828, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 959, + "n_test": 240, + "accuracy": 0.6042, + "logloss": 0.8927, + "model": "xgb" + } + } + }, + { + "league_id": "913mb508il6jzwtlj28fl892h", + "league_name": "2. Lig", + "n_matches": 553, + "markets": { + "MS": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.5676, + "logloss": 1.0035, + "model": "xgb" + }, + "OU15": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.8378, + "logloss": 0.4806, + "model": "xgb" + }, + "OU25": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.6667, + "logloss": 0.6331, + "model": "xgb" + }, + "OU35": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.6306, + "logloss": 0.6691, + "model": "xgb" + }, + "BTTS": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.5856, + "logloss": 0.6847, + "model": "xgb" + }, + "HT": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.3423, + "logloss": 1.1178, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.7117, + "logloss": 0.609, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.5495, + "logloss": 0.699, + "model": "xgb" + }, + "HTFT": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.3063, + "logloss": 2.0096, + "model": "xgb" + }, + "OE": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.4505, + "logloss": 0.7253, + "model": "xgb" + }, + "CARDS": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.8288, + "logloss": 0.472, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.4865, + "logloss": 1.065, + "model": "xgb" + } + } + }, + { + "league_id": "a9vrdkelbgif0gtu3wxsr75xo", + "league_name": "Premier Lig", + "n_matches": 654, + "markets": { + "MS": { + "n_train": 523, + "n_test": 131, + "accuracy": 0.542, + "logloss": 0.9673, + "model": "xgb" + }, + "OU15": { + "n_train": 523, + "n_test": 131, + "accuracy": 0.7252, + "logloss": 0.5947, + "model": "xgb" + }, + "OU25": { + "n_train": 523, + "n_test": 131, + "accuracy": 0.6031, + "logloss": 0.6988, + "model": "xgb" + }, + "OU35": { + "n_train": 523, + "n_test": 131, + "accuracy": 0.7252, + "logloss": 0.6051, + "model": "xgb" + }, + "BTTS": { + "n_train": 523, + "n_test": 131, + "accuracy": 0.542, + "logloss": 0.6985, + "model": "xgb" + }, + "HT": { + "n_train": 518, + "n_test": 130, + "accuracy": 0.4231, + "logloss": 1.0659, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 518, + "n_test": 130, + "accuracy": 0.6538, + "logloss": 0.6468, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 518, + "n_test": 130, + "accuracy": 0.6923, + "logloss": 0.6028, + "model": "xgb" + }, + "HTFT": { + "n_train": 518, + "n_test": 130, + "accuracy": 0.2769, + "logloss": 1.9797, + "model": "xgb" + }, + "OE": { + "n_train": 523, + "n_test": 131, + "accuracy": 0.5191, + "logloss": 0.6837, + "model": "xgb" + }, + "CARDS": { + "n_train": 523, + "n_test": 131, + "accuracy": 0.6031, + "logloss": 0.6841, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 523, + "n_test": 131, + "accuracy": 0.4656, + "logloss": 1.0557, + "model": "xgb" + } + } + }, + { + "league_id": "ac112osli9fvox1epcg4ld3t6", + "league_name": "1. Lig", + "n_matches": 964, + "markets": { + "MS": { + "n_train": 771, + "n_test": 193, + "accuracy": 0.5181, + "logloss": 1.0021, + "model": "xgb" + }, + "OU15": { + "n_train": 771, + "n_test": 193, + "accuracy": 0.8083, + "logloss": 0.4996, + "model": "xgb" + }, + "OU25": { + "n_train": 771, + "n_test": 193, + "accuracy": 0.5907, + "logloss": 0.6673, + "model": "xgb" + }, + "OU35": { + "n_train": 771, + "n_test": 193, + "accuracy": 0.5803, + "logloss": 0.6791, + "model": "xgb" + }, + "BTTS": { + "n_train": 771, + "n_test": 193, + "accuracy": 0.5492, + "logloss": 0.6757, + "model": "xgb" + }, + "HT": { + "n_train": 771, + "n_test": 193, + "accuracy": 0.3627, + "logloss": 1.1075, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 771, + "n_test": 193, + "accuracy": 0.7513, + "logloss": 0.5663, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 771, + "n_test": 193, + "accuracy": 0.5959, + "logloss": 0.6856, + "model": "xgb" + }, + "HTFT": { + "n_train": 771, + "n_test": 193, + "accuracy": 0.3057, + "logloss": 1.947, + "model": "xgb" + }, + "OE": { + "n_train": 771, + "n_test": 193, + "accuracy": 0.4974, + "logloss": 0.7039, + "model": "xgb" + }, + "CARDS": { + "n_train": 771, + "n_test": 193, + "accuracy": 0.7876, + "logloss": 0.5297, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 771, + "n_test": 193, + "accuracy": 0.601, + "logloss": 0.9074, + "model": "xgb" + } + } + }, + { + "league_id": "ae1wva3zrzcp2zd15gpvsntg6", + "league_name": "Ulusal Lig", + "n_matches": 555, + "markets": { + "MS": { + "n_train": 444, + "n_test": 111, + "accuracy": 0.6036, + "logloss": 0.8929, + "model": "xgb" + }, + "OU15": { + "n_train": 444, + "n_test": 111, + "accuracy": 0.6306, + "logloss": 0.6524, + "model": "xgb" + }, + "OU25": { + "n_train": 444, + "n_test": 111, + "accuracy": 0.6126, + "logloss": 0.6729, + "model": "xgb" + }, + "OU35": { + "n_train": 444, + "n_test": 111, + "accuracy": 0.7658, + "logloss": 0.5423, + "model": "xgb" + }, + "BTTS": { + "n_train": 444, + "n_test": 111, + "accuracy": 0.5225, + "logloss": 0.7152, + "model": "xgb" + }, + "HT": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.4144, + "logloss": 1.028, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.6577, + "logloss": 0.6516, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.7117, + "logloss": 0.605, + "model": "xgb" + }, + "HTFT": { + "n_train": 442, + "n_test": 111, + "accuracy": 0.3784, + "logloss": 1.9541, + "model": "xgb" + }, + "OE": { + "n_train": 444, + "n_test": 111, + "accuracy": 0.5225, + "logloss": 0.7085, + "model": "xgb" + }, + "CARDS": { + "n_train": 444, + "n_test": 111, + "accuracy": 0.5766, + "logloss": 0.7005, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 444, + "n_test": 111, + "accuracy": 0.4595, + "logloss": 1.0235, + "model": "xgb" + } + } + }, + { + "league_id": "akmkihra9ruad09ljapsm84b3", + "league_name": "Eredivisie", + "n_matches": 638, + "markets": { + "MS": { + "n_train": 510, + "n_test": 128, + "accuracy": 0.4766, + "logloss": 1.056, + "model": "xgb" + }, + "OU15": { + "n_train": 510, + "n_test": 128, + "accuracy": 0.8828, + "logloss": 0.371, + "model": "xgb" + }, + "OU25": { + "n_train": 510, + "n_test": 128, + "accuracy": 0.6406, + "logloss": 0.6316, + "model": "xgb" + }, + "OU35": { + "n_train": 510, + "n_test": 128, + "accuracy": 0.6484, + "logloss": 0.6797, + "model": "xgb" + }, + "BTTS": { + "n_train": 510, + "n_test": 128, + "accuracy": 0.6172, + "logloss": 0.6706, + "model": "xgb" + }, + "HT": { + "n_train": 510, + "n_test": 128, + "accuracy": 0.4141, + "logloss": 1.102, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 510, + "n_test": 128, + "accuracy": 0.7656, + "logloss": 0.5443, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 510, + "n_test": 128, + "accuracy": 0.5781, + "logloss": 0.7103, + "model": "xgb" + }, + "HTFT": { + "n_train": 510, + "n_test": 128, + "accuracy": 0.2812, + "logloss": 1.9418, + "model": "xgb" + }, + "OE": { + "n_train": 510, + "n_test": 128, + "accuracy": 0.6016, + "logloss": 0.6865, + "model": "xgb" + }, + "CARDS": { + "n_train": 510, + "n_test": 128, + "accuracy": 0.8203, + "logloss": 0.4768, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 510, + "n_test": 128, + "accuracy": 0.5938, + "logloss": 0.9411, + "model": "xgb" + } + } + }, + { + "league_id": "alpfd99yd3lfv7bhjo0biuq7b", + "league_name": "Premier Lig", + "n_matches": 505, + "markets": { + "MS": { + "n_train": 404, + "n_test": 101, + "accuracy": 0.5743, + "logloss": 0.8813, + "model": "xgb" + }, + "OU15": { + "n_train": 404, + "n_test": 101, + "accuracy": 0.9109, + "logloss": 0.2947, + "model": "xgb" + }, + "OU25": { + "n_train": 404, + "n_test": 101, + "accuracy": 0.7228, + "logloss": 0.5705, + "model": "xgb" + }, + "OU35": { + "n_train": 404, + "n_test": 101, + "accuracy": 0.495, + "logloss": 0.7223, + "model": "xgb" + }, + "BTTS": { + "n_train": 404, + "n_test": 101, + "accuracy": 0.6733, + "logloss": 0.6238, + "model": "xgb" + }, + "HT": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.4356, + "logloss": 1.0393, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.8119, + "logloss": 0.4988, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.5347, + "logloss": 0.6976, + "model": "xgb" + }, + "HTFT": { + "n_train": 403, + "n_test": 101, + "accuracy": 0.3663, + "logloss": 1.8562, + "model": "xgb" + }, + "OE": { + "n_train": 404, + "n_test": 101, + "accuracy": 0.4356, + "logloss": 0.7128, + "model": "xgb" + }, + "CARDS": { + "n_train": 404, + "n_test": 101, + "accuracy": 0.6535, + "logloss": 0.5992, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 404, + "n_test": 101, + "accuracy": 0.5545, + "logloss": 0.9966, + "model": "xgb" + } + } + }, + { + "league_id": "b73zounsynk9d3u1p9nvpu7i2", + "league_name": "K-Lig 2", + "n_matches": 543, + "markets": { + "MS": { + "n_train": 434, + "n_test": 109, + "accuracy": 0.3761, + "logloss": 1.0882, + "model": "xgb" + }, + "OU15": { + "n_train": 434, + "n_test": 109, + "accuracy": 0.6881, + "logloss": 0.5925, + "model": "xgb" + }, + "OU25": { + "n_train": 434, + "n_test": 109, + "accuracy": 0.5138, + "logloss": 0.7123, + "model": "xgb" + }, + "OU35": { + "n_train": 434, + "n_test": 109, + "accuracy": 0.6881, + "logloss": 0.6538, + "model": "xgb" + }, + "BTTS": { + "n_train": 434, + "n_test": 109, + "accuracy": 0.5138, + "logloss": 0.7132, + "model": "xgb" + }, + "HT": { + "n_train": 434, + "n_test": 109, + "accuracy": 0.422, + "logloss": 1.1081, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 434, + "n_test": 109, + "accuracy": 0.6606, + "logloss": 0.6207, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 434, + "n_test": 109, + "accuracy": 0.7064, + "logloss": 0.5908, + "model": "xgb" + }, + "HTFT": { + "n_train": 434, + "n_test": 109, + "accuracy": 0.2018, + "logloss": 2.0614, + "model": "xgb" + }, + "OE": { + "n_train": 434, + "n_test": 109, + "accuracy": 0.5046, + "logloss": 0.7304, + "model": "xgb" + }, + "CARDS": { + "n_train": 434, + "n_test": 109, + "accuracy": 0.6147, + "logloss": 0.6714, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 434, + "n_test": 109, + "accuracy": 0.6422, + "logloss": 0.8649, + "model": "xgb" + } + } + }, + { + "league_id": "beqqnubkv05mamuwvimeum015", + "league_name": "NB II", + "n_matches": 500, + "markets": { + "MS": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.39, + "logloss": 1.0784, + "model": "xgb" + }, + "OU15": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.73, + "logloss": 0.5945, + "model": "xgb" + }, + "OU25": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.52, + "logloss": 0.6972, + "model": "xgb" + }, + "OU35": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.8, + "logloss": 0.5076, + "model": "xgb" + }, + "BTTS": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.55, + "logloss": 0.6728, + "model": "xgb" + }, + "HT": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.39, + "logloss": 1.0505, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.67, + "logloss": 0.6212, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.68, + "logloss": 0.5836, + "model": "xgb" + }, + "HTFT": { + "n_train": 400, + "n_test": 100, + "error": "y_true and y_prob contain different number of classes: 8 vs 9. Please provide the true labels explicitly through the labels argument. Classes found in y_true: [0 1 3 4 5 6 7 8]" + }, + "OE": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.4, + "logloss": 0.7546, + "model": "xgb" + }, + "CARDS": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.45, + "logloss": 0.7252, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 400, + "n_test": 100, + "accuracy": 0.64, + "logloss": 0.9049, + "model": "xgb" + } + } + }, + { + "league_id": "bgen5kjer2ytfp7lo9949t72g", + "league_name": "2. Lig", + "n_matches": 1189, + "markets": { + "MS": { + "n_train": 951, + "n_test": 238, + "accuracy": 0.5462, + "logloss": 0.9773, + "model": "xgb" + }, + "OU15": { + "n_train": 951, + "n_test": 238, + "accuracy": 0.6933, + "logloss": 0.6256, + "model": "xgb" + }, + "OU25": { + "n_train": 951, + "n_test": 238, + "accuracy": 0.5042, + "logloss": 0.704, + "model": "xgb" + }, + "OU35": { + "n_train": 951, + "n_test": 238, + "accuracy": 0.7353, + "logloss": 0.5654, + "model": "xgb" + }, + "BTTS": { + "n_train": 951, + "n_test": 238, + "accuracy": 0.5084, + "logloss": 0.7043, + "model": "xgb" + }, + "HT": { + "n_train": 951, + "n_test": 238, + "accuracy": 0.4622, + "logloss": 1.0118, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 951, + "n_test": 238, + "accuracy": 0.6345, + "logloss": 0.6709, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 951, + "n_test": 238, + "accuracy": 0.6807, + "logloss": 0.6316, + "model": "xgb" + }, + "HTFT": { + "n_train": 951, + "n_test": 238, + "accuracy": 0.2731, + "logloss": 1.9698, + "model": "xgb" + }, + "OE": { + "n_train": 951, + "n_test": 238, + "accuracy": 0.5, + "logloss": 0.7085, + "model": "xgb" + }, + "CARDS": { + "n_train": 951, + "n_test": 238, + "accuracy": 0.6555, + "logloss": 0.6404, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 951, + "n_test": 238, + "accuracy": 0.5672, + "logloss": 0.9235, + "model": "xgb" + } + } + }, + { + "league_id": "bu1l7ckihyr0errxw61p0m05", + "league_name": "Czech Liga", + "n_matches": 599, + "markets": { + "MS": { + "n_train": 479, + "n_test": 120, + "accuracy": 0.5583, + "logloss": 0.9825, + "model": "xgb" + }, + "OU15": { + "n_train": 479, + "n_test": 120, + "accuracy": 0.7583, + "logloss": 0.5688, + "model": "xgb" + }, + "OU25": { + "n_train": 479, + "n_test": 120, + "accuracy": 0.5, + "logloss": 0.7182, + "model": "xgb" + }, + "OU35": { + "n_train": 479, + "n_test": 120, + "accuracy": 0.7333, + "logloss": 0.593, + "model": "xgb" + }, + "BTTS": { + "n_train": 479, + "n_test": 120, + "accuracy": 0.3917, + "logloss": 0.734, + "model": "xgb" + }, + "HT": { + "n_train": 479, + "n_test": 120, + "accuracy": 0.3667, + "logloss": 1.1068, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 479, + "n_test": 120, + "accuracy": 0.675, + "logloss": 0.67, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 479, + "n_test": 120, + "accuracy": 0.6333, + "logloss": 0.663, + "model": "xgb" + }, + "HTFT": { + "n_train": 479, + "n_test": 120, + "error": "y_true and y_prob contain different number of classes: 8 vs 9. Please provide the true labels explicitly through the labels argument. Classes found in y_true: [0 1 3 4 5 6 7 8]" + }, + "OE": { + "n_train": 479, + "n_test": 120, + "accuracy": 0.5, + "logloss": 0.6961, + "model": "xgb" + }, + "CARDS": { + "n_train": 479, + "n_test": 120, + "accuracy": 0.5917, + "logloss": 0.6879, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 479, + "n_test": 120, + "accuracy": 0.5583, + "logloss": 0.9817, + "model": "xgb" + } + } + }, + { + "league_id": "byu00jvt1j6csyv4y1lkt2fm2", + "league_name": "Primera Nacional", + "n_matches": 1361, + "markets": { + "MS": { + "n_train": 1088, + "n_test": 273, + "accuracy": 0.4432, + "logloss": 1.0478, + "model": "xgb" + }, + "OU15": { + "n_train": 1088, + "n_test": 273, + "accuracy": 0.5128, + "logloss": 0.6929, + "model": "xgb" + }, + "OU25": { + "n_train": 1088, + "n_test": 273, + "accuracy": 0.7106, + "logloss": 0.6096, + "model": "xgb" + }, + "OU35": { + "n_train": 1088, + "n_test": 273, + "accuracy": 0.9048, + "logloss": 0.3067, + "model": "xgb" + }, + "BTTS": { + "n_train": 1088, + "n_test": 273, + "accuracy": 0.674, + "logloss": 0.6446, + "model": "xgb" + }, + "HT": { + "n_train": 1087, + "n_test": 272, + "accuracy": 0.5147, + "logloss": 1.0182, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 1087, + "n_test": 272, + "accuracy": 0.5625, + "logloss": 0.6888, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 1087, + "n_test": 272, + "accuracy": 0.8162, + "logloss": 0.4923, + "model": "xgb" + }, + "HTFT": { + "n_train": 1087, + "n_test": 272, + "accuracy": 0.2941, + "logloss": 1.8555, + "model": "xgb" + }, + "OE": { + "n_train": 1088, + "n_test": 273, + "accuracy": 0.5092, + "logloss": 0.6951, + "model": "xgb" + }, + "CARDS": { + "n_train": 1088, + "n_test": 273, + "accuracy": 0.5201, + "logloss": 0.6957, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 1088, + "n_test": 273, + "accuracy": 0.5385, + "logloss": 0.9927, + "model": "xgb" + } + } + }, + { + "league_id": "c0r21rtokgnbtc0o2rldjmkxu", + "league_name": "S\u00fcper Lig", + "n_matches": 501, + "markets": { + "MS": { + "n_train": 400, + "n_test": 101, + "accuracy": 0.505, + "logloss": 1.029, + "model": "xgb" + }, + "OU15": { + "n_train": 400, + "n_test": 101, + "accuracy": 0.6832, + "logloss": 0.6341, + "model": "xgb" + }, + "OU25": { + "n_train": 400, + "n_test": 101, + "accuracy": 0.6337, + "logloss": 0.6559, + "model": "xgb" + }, + "OU35": { + "n_train": 400, + "n_test": 101, + "accuracy": 0.7723, + "logloss": 0.5465, + "model": "xgb" + }, + "BTTS": { + "n_train": 400, + "n_test": 101, + "accuracy": 0.5347, + "logloss": 0.6867, + "model": "xgb" + }, + "HT": { + "n_train": 398, + "n_test": 101, + "accuracy": 0.4554, + "logloss": 1.0553, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 398, + "n_test": 101, + "accuracy": 0.6931, + "logloss": 0.6094, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 398, + "n_test": 101, + "accuracy": 0.7129, + "logloss": 0.6102, + "model": "xgb" + }, + "HTFT": { + "n_train": 398, + "n_test": 101, + "accuracy": 0.3069, + "logloss": 1.8729, + "model": "xgb" + }, + "OE": { + "n_train": 400, + "n_test": 101, + "accuracy": 0.5545, + "logloss": 0.6999, + "model": "xgb" + }, + "CARDS": { + "n_train": 400, + "n_test": 101, + "accuracy": 0.5248, + "logloss": 0.7122, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 400, + "n_test": 101, + "accuracy": 0.6733, + "logloss": 0.7908, + "model": "xgb" + } + } + }, + { + "league_id": "c0yqkbilbbg70ij2473xymmqv", + "league_name": "1. Lig", + "n_matches": 588, + "markets": { + "MS": { + "n_train": 470, + "n_test": 118, + "accuracy": 0.5085, + "logloss": 0.9975, + "model": "xgb" + }, + "OU15": { + "n_train": 470, + "n_test": 118, + "accuracy": 0.6271, + "logloss": 0.6913, + "model": "xgb" + }, + "OU25": { + "n_train": 470, + "n_test": 118, + "accuracy": 0.5169, + "logloss": 0.6861, + "model": "xgb" + }, + "OU35": { + "n_train": 470, + "n_test": 118, + "accuracy": 0.7881, + "logloss": 0.5208, + "model": "xgb" + }, + "BTTS": { + "n_train": 470, + "n_test": 118, + "accuracy": 0.5593, + "logloss": 0.6976, + "model": "xgb" + }, + "HT": { + "n_train": 469, + "n_test": 118, + "accuracy": 0.4661, + "logloss": 1.0815, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 469, + "n_test": 118, + "accuracy": 0.5593, + "logloss": 0.7207, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 469, + "n_test": 118, + "accuracy": 0.7203, + "logloss": 0.6014, + "model": "xgb" + }, + "HTFT": { + "n_train": 469, + "n_test": 118, + "accuracy": 0.2373, + "logloss": 1.8977, + "model": "xgb" + }, + "OE": { + "n_train": 470, + "n_test": 118, + "accuracy": 0.5339, + "logloss": 0.6959, + "model": "xgb" + }, + "CARDS": { + "n_train": 470, + "n_test": 118, + "accuracy": 0.5508, + "logloss": 0.6986, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 470, + "n_test": 118, + "accuracy": 0.5763, + "logloss": 0.9555, + "model": "xgb" + } + } + }, + { + "league_id": "c7b8o53flg36wbuevfzy3lb10", + "league_name": "Konferans Ligi", + "n_matches": 775, + "markets": { + "MS": { + "n_train": 620, + "n_test": 155, + "accuracy": 0.5355, + "logloss": 0.8868, + "model": "xgb" + }, + "OU15": { + "n_train": 620, + "n_test": 155, + "accuracy": 0.7484, + "logloss": 0.5469, + "model": "xgb" + }, + "OU25": { + "n_train": 620, + "n_test": 155, + "accuracy": 0.6, + "logloss": 0.6549, + "model": "xgb" + }, + "OU35": { + "n_train": 620, + "n_test": 155, + "accuracy": 0.7677, + "logloss": 0.5174, + "model": "xgb" + }, + "BTTS": { + "n_train": 620, + "n_test": 155, + "accuracy": 0.6, + "logloss": 0.6572, + "model": "xgb" + }, + "HT": { + "n_train": 620, + "n_test": 155, + "accuracy": 0.4387, + "logloss": 1.0553, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 620, + "n_test": 155, + "accuracy": 0.6968, + "logloss": 0.6231, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 620, + "n_test": 155, + "accuracy": 0.6387, + "logloss": 0.6463, + "model": "xgb" + }, + "HTFT": { + "n_train": 620, + "n_test": 155, + "accuracy": 0.3161, + "logloss": 1.8587, + "model": "xgb" + }, + "OE": { + "n_train": 620, + "n_test": 155, + "accuracy": 0.5355, + "logloss": 0.6983, + "model": "xgb" + }, + "CARDS": { + "n_train": 620, + "n_test": 155, + "accuracy": 0.4903, + "logloss": 0.7129, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 620, + "n_test": 155, + "accuracy": 0.6774, + "logloss": 0.8261, + "model": "xgb" + } + } + }, + { + "league_id": "cegl2ivkc25blcatxp4jmk1ec", + "league_name": "Segunda Lig RFEF", + "n_matches": 3249, + "markets": { + "MS": { + "n_train": 2599, + "n_test": 650, + "accuracy": 0.4985, + "logloss": 1.0083, + "model": "xgb" + }, + "OU15": { + "n_train": 2599, + "n_test": 650, + "accuracy": 0.6892, + "logloss": 0.5875, + "model": "xgb" + }, + "OU25": { + "n_train": 2599, + "n_test": 650, + "accuracy": 0.5923, + "logloss": 0.675, + "model": "xgb" + }, + "OU35": { + "n_train": 2599, + "n_test": 650, + "accuracy": 0.7569, + "logloss": 0.5312, + "model": "xgb" + }, + "BTTS": { + "n_train": 2599, + "n_test": 650, + "accuracy": 0.5169, + "logloss": 0.6957, + "model": "xgb" + }, + "HT": { + "n_train": 2599, + "n_test": 650, + "accuracy": 0.5046, + "logloss": 1.0121, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 2599, + "n_test": 650, + "accuracy": 0.6154, + "logloss": 0.6505, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 2599, + "n_test": 650, + "accuracy": 0.6815, + "logloss": 0.6221, + "model": "xgb" + }, + "HTFT": { + "n_train": 2599, + "n_test": 650, + "accuracy": 0.2877, + "logloss": 1.8848, + "model": "xgb" + }, + "OE": { + "n_train": 2599, + "n_test": 650, + "accuracy": 0.52, + "logloss": 0.6949, + "model": "xgb" + }, + "CARDS": { + "n_train": 2599, + "n_test": 650, + "accuracy": 0.6646, + "logloss": 0.6216, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 2599, + "n_test": 650, + "accuracy": 0.5954, + "logloss": 0.9314, + "model": "xgb" + } + } + }, + { + "league_id": "civf31q1inxohs4a03y8reetf", + "league_name": "Premier Lig", + "n_matches": 539, + "markets": { + "MS": { + "n_train": 431, + "n_test": 108, + "accuracy": 0.5278, + "logloss": 1.0016, + "model": "xgb" + }, + "OU15": { + "n_train": 431, + "n_test": 108, + "accuracy": 0.7222, + "logloss": 0.6032, + "model": "xgb" + }, + "OU25": { + "n_train": 431, + "n_test": 108, + "accuracy": 0.537, + "logloss": 0.7131, + "model": "xgb" + }, + "OU35": { + "n_train": 431, + "n_test": 108, + "accuracy": 0.7222, + "logloss": 0.5863, + "model": "xgb" + }, + "BTTS": { + "n_train": 431, + "n_test": 108, + "accuracy": 0.5, + "logloss": 0.7234, + "model": "xgb" + }, + "HT": { + "n_train": 427, + "n_test": 108, + "accuracy": 0.4259, + "logloss": 1.0775, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 427, + "n_test": 108, + "accuracy": 0.6667, + "logloss": 0.6712, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 427, + "n_test": 108, + "accuracy": 0.6204, + "logloss": 0.7134, + "model": "xgb" + }, + "HTFT": { + "n_train": 427, + "n_test": 108, + "accuracy": 0.2593, + "logloss": 1.9426, + "model": "xgb" + }, + "OE": { + "n_train": 431, + "n_test": 108, + "accuracy": 0.4722, + "logloss": 0.7191, + "model": "xgb" + }, + "CARDS": { + "n_train": 431, + "n_test": 108, + "accuracy": 0.537, + "logloss": 0.7067, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 431, + "n_test": 108, + "accuracy": 0.5556, + "logloss": 0.9321, + "model": "xgb" + } + } + }, + { + "league_id": "degxm4y6gmvp011ccyrev6z5p", + "league_name": "Primer Lig RFEF", + "n_matches": 1642, + "markets": { + "MS": { + "n_train": 1313, + "n_test": 329, + "accuracy": 0.4681, + "logloss": 1.0491, + "model": "xgb" + }, + "OU15": { + "n_train": 1313, + "n_test": 329, + "accuracy": 0.6687, + "logloss": 0.6362, + "model": "xgb" + }, + "OU25": { + "n_train": 1313, + "n_test": 329, + "accuracy": 0.6079, + "logloss": 0.6652, + "model": "xgb" + }, + "OU35": { + "n_train": 1313, + "n_test": 329, + "accuracy": 0.766, + "logloss": 0.5481, + "model": "xgb" + }, + "BTTS": { + "n_train": 1313, + "n_test": 329, + "accuracy": 0.5289, + "logloss": 0.6836, + "model": "xgb" + }, + "HT": { + "n_train": 1309, + "n_test": 328, + "accuracy": 0.439, + "logloss": 1.0547, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 1309, + "n_test": 328, + "accuracy": 0.6402, + "logloss": 0.6414, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 1309, + "n_test": 328, + "accuracy": 0.7256, + "logloss": 0.5895, + "model": "xgb" + }, + "HTFT": { + "n_train": 1309, + "n_test": 328, + "accuracy": 0.2652, + "logloss": 1.892, + "model": "xgb" + }, + "OE": { + "n_train": 1313, + "n_test": 329, + "accuracy": 0.4985, + "logloss": 0.6987, + "model": "xgb" + }, + "CARDS": { + "n_train": 1313, + "n_test": 329, + "accuracy": 0.6383, + "logloss": 0.6296, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 1313, + "n_test": 329, + "accuracy": 0.5532, + "logloss": 0.9702, + "model": "xgb" + } + } + }, + { + "league_id": "dkarmrybx9vx10rg7cywumth0", + "league_name": "3. Lig", + "n_matches": 818, + "markets": { + "MS": { + "n_train": 654, + "n_test": 164, + "accuracy": 0.5183, + "logloss": 1.0272, + "model": "xgb" + }, + "OU15": { + "n_train": 654, + "n_test": 164, + "accuracy": 0.8293, + "logloss": 0.4588, + "model": "xgb" + }, + "OU25": { + "n_train": 654, + "n_test": 164, + "accuracy": 0.5854, + "logloss": 0.6833, + "model": "xgb" + }, + "OU35": { + "n_train": 654, + "n_test": 164, + "accuracy": 0.5671, + "logloss": 0.6862, + "model": "xgb" + }, + "BTTS": { + "n_train": 654, + "n_test": 164, + "accuracy": 0.628, + "logloss": 0.621, + "model": "xgb" + }, + "HT": { + "n_train": 654, + "n_test": 164, + "accuracy": 0.439, + "logloss": 1.0921, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 654, + "n_test": 164, + "accuracy": 0.7622, + "logloss": 0.5485, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 654, + "n_test": 164, + "accuracy": 0.5793, + "logloss": 0.6979, + "model": "xgb" + }, + "HTFT": { + "n_train": 654, + "n_test": 164, + "accuracy": 0.2683, + "logloss": 1.9435, + "model": "xgb" + }, + "OE": { + "n_train": 654, + "n_test": 164, + "accuracy": 0.5305, + "logloss": 0.6932, + "model": "xgb" + }, + "CARDS": { + "n_train": 654, + "n_test": 164, + "accuracy": 0.4573, + "logloss": 0.7215, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 654, + "n_test": 164, + "accuracy": 0.5, + "logloss": 1.0627, + "model": "xgb" + } + } + }, + { + "league_id": "dm5ka0os1e3dxcp3vh05kmp33", + "league_name": "Ligue 1", + "n_matches": 659, + "markets": { + "MS": { + "n_train": 527, + "n_test": 132, + "accuracy": 0.4848, + "logloss": 1.0159, + "model": "xgb" + }, + "OU15": { + "n_train": 527, + "n_test": 132, + "accuracy": 0.7424, + "logloss": 0.5545, + "model": "xgb" + }, + "OU25": { + "n_train": 527, + "n_test": 132, + "accuracy": 0.5682, + "logloss": 0.6768, + "model": "xgb" + }, + "OU35": { + "n_train": 527, + "n_test": 132, + "accuracy": 0.6742, + "logloss": 0.6119, + "model": "xgb" + }, + "BTTS": { + "n_train": 527, + "n_test": 132, + "accuracy": 0.5455, + "logloss": 0.6963, + "model": "xgb" + }, + "HT": { + "n_train": 527, + "n_test": 132, + "accuracy": 0.4242, + "logloss": 1.0752, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 527, + "n_test": 132, + "accuracy": 0.7121, + "logloss": 0.6236, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 527, + "n_test": 132, + "accuracy": 0.6288, + "logloss": 0.6727, + "model": "xgb" + }, + "HTFT": { + "n_train": 527, + "n_test": 132, + "accuracy": 0.303, + "logloss": 1.8881, + "model": "xgb" + }, + "OE": { + "n_train": 527, + "n_test": 132, + "accuracy": 0.4924, + "logloss": 0.7132, + "model": "xgb" + }, + "CARDS": { + "n_train": 527, + "n_test": 132, + "accuracy": 0.6364, + "logloss": 0.6419, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 527, + "n_test": 132, + "accuracy": 0.5833, + "logloss": 0.9045, + "model": "xgb" + } + } + }, + { + "league_id": "e1kxdivp5g4cpldgpwvnzl1vv", + "league_name": "Ascenso MX", + "n_matches": 516, + "markets": { + "MS": { + "n_train": 412, + "n_test": 104, + "accuracy": 0.5288, + "logloss": 0.994, + "model": "xgb" + }, + "OU15": { + "n_train": 412, + "n_test": 104, + "accuracy": 0.7885, + "logloss": 0.5278, + "model": "xgb" + }, + "OU25": { + "n_train": 412, + "n_test": 104, + "accuracy": 0.4712, + "logloss": 0.7301, + "model": "xgb" + }, + "OU35": { + "n_train": 412, + "n_test": 104, + "accuracy": 0.7115, + "logloss": 0.5988, + "model": "xgb" + }, + "BTTS": { + "n_train": 412, + "n_test": 104, + "accuracy": 0.5962, + "logloss": 0.6632, + "model": "xgb" + }, + "HT": { + "n_train": 412, + "n_test": 104, + "accuracy": 0.3654, + "logloss": 1.0785, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 412, + "n_test": 104, + "accuracy": 0.6923, + "logloss": 0.6596, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 412, + "n_test": 104, + "accuracy": 0.6058, + "logloss": 0.657, + "model": "xgb" + }, + "HTFT": { + "n_train": 412, + "n_test": 104, + "error": "y_true and y_prob contain different number of classes: 8 vs 9. Please provide the true labels explicitly through the labels argument. Classes found in y_true: [0 1 3 4 5 6 7 8]" + }, + "OE": { + "n_train": 412, + "n_test": 104, + "accuracy": 0.4327, + "logloss": 0.7262, + "model": "xgb" + }, + "CARDS": { + "n_train": 412, + "n_test": 104, + "accuracy": 0.6923, + "logloss": 0.6324, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 412, + "n_test": 104, + "accuracy": 0.5096, + "logloss": 1.069, + "model": "xgb" + } + } + }, + { + "league_id": "e21cf135btr8t3upw0vl6n6x0", + "league_name": "Premiership", + "n_matches": 508, + "markets": { + "MS": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.5392, + "logloss": 0.9746, + "model": "xgb" + }, + "OU15": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.7647, + "logloss": 0.5425, + "model": "xgb" + }, + "OU25": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.6176, + "logloss": 0.6724, + "model": "xgb" + }, + "OU35": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.6569, + "logloss": 0.6561, + "model": "xgb" + }, + "BTTS": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.5392, + "logloss": 0.6752, + "model": "xgb" + }, + "HT": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.402, + "logloss": 1.0659, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.7843, + "logloss": 0.5062, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.6569, + "logloss": 0.6482, + "model": "xgb" + }, + "HTFT": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.3529, + "logloss": 1.8293, + "model": "xgb" + }, + "OE": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.5196, + "logloss": 0.7093, + "model": "xgb" + }, + "CARDS": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.6275, + "logloss": 0.6919, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.5392, + "logloss": 1.0028, + "model": "xgb" + } + } + }, + { + "league_id": "ea0h6cf3bhl698hkxhpulh2zz", + "league_name": "Pro Lig", + "n_matches": 552, + "markets": { + "MS": { + "n_train": 441, + "n_test": 111, + "accuracy": 0.6036, + "logloss": 0.8691, + "model": "xgb" + }, + "OU15": { + "n_train": 441, + "n_test": 111, + "accuracy": 0.7838, + "logloss": 0.5238, + "model": "xgb" + }, + "OU25": { + "n_train": 441, + "n_test": 111, + "accuracy": 0.5586, + "logloss": 0.6834, + "model": "xgb" + }, + "OU35": { + "n_train": 441, + "n_test": 111, + "accuracy": 0.6306, + "logloss": 0.6333, + "model": "xgb" + }, + "BTTS": { + "n_train": 441, + "n_test": 111, + "accuracy": 0.5676, + "logloss": 0.6894, + "model": "xgb" + }, + "HT": { + "n_train": 441, + "n_test": 111, + "accuracy": 0.4685, + "logloss": 1.0085, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 441, + "n_test": 111, + "accuracy": 0.7838, + "logloss": 0.5176, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 441, + "n_test": 111, + "accuracy": 0.6216, + "logloss": 0.6616, + "model": "xgb" + }, + "HTFT": { + "n_train": 441, + "n_test": 111, + "accuracy": 0.3333, + "logloss": 1.8615, + "model": "xgb" + }, + "OE": { + "n_train": 441, + "n_test": 111, + "accuracy": 0.5315, + "logloss": 0.7198, + "model": "xgb" + }, + "CARDS": { + "n_train": 441, + "n_test": 111, + "accuracy": 0.6036, + "logloss": 0.6822, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 441, + "n_test": 111, + "accuracy": 0.6126, + "logloss": 0.8409, + "model": "xgb" + } + } + }, + { + "league_id": "ein4fkggto3pdh5msp8huafiq", + "league_name": "Premier Lig", + "n_matches": 577, + "markets": { + "MS": { + "n_train": 461, + "n_test": 116, + "accuracy": 0.5172, + "logloss": 0.9865, + "model": "xgb" + }, + "OU15": { + "n_train": 461, + "n_test": 116, + "accuracy": 0.7672, + "logloss": 0.5559, + "model": "xgb" + }, + "OU25": { + "n_train": 461, + "n_test": 116, + "accuracy": 0.5, + "logloss": 0.6982, + "model": "xgb" + }, + "OU35": { + "n_train": 461, + "n_test": 116, + "accuracy": 0.6983, + "logloss": 0.637, + "model": "xgb" + }, + "BTTS": { + "n_train": 461, + "n_test": 116, + "accuracy": 0.4138, + "logloss": 0.7538, + "model": "xgb" + }, + "HT": { + "n_train": 460, + "n_test": 116, + "accuracy": 0.431, + "logloss": 1.076, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 460, + "n_test": 116, + "accuracy": 0.6638, + "logloss": 0.6115, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 460, + "n_test": 116, + "accuracy": 0.6897, + "logloss": 0.6282, + "model": "xgb" + }, + "HTFT": { + "n_train": 460, + "n_test": 116, + "accuracy": 0.319, + "logloss": 1.9087, + "model": "xgb" + }, + "OE": { + "n_train": 461, + "n_test": 116, + "accuracy": 0.5862, + "logloss": 0.6967, + "model": "xgb" + }, + "CARDS": { + "n_train": 461, + "n_test": 116, + "accuracy": 0.6121, + "logloss": 0.6476, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 461, + "n_test": 116, + "accuracy": 0.6034, + "logloss": 0.9034, + "model": "xgb" + } + } + }, + { + "league_id": "scf9p4y91yjvqvg5jndxzhxj", + "league_name": "Serie A", + "n_matches": 867, + "markets": { + "MS": { + "n_train": 693, + "n_test": 174, + "accuracy": 0.546, + "logloss": 0.9746, + "model": "xgb" + }, + "OU15": { + "n_train": 693, + "n_test": 174, + "accuracy": 0.7874, + "logloss": 0.5237, + "model": "xgb" + }, + "OU25": { + "n_train": 693, + "n_test": 174, + "accuracy": 0.5057, + "logloss": 0.7079, + "model": "xgb" + }, + "OU35": { + "n_train": 693, + "n_test": 174, + "accuracy": 0.7241, + "logloss": 0.572, + "model": "xgb" + }, + "BTTS": { + "n_train": 693, + "n_test": 174, + "accuracy": 0.477, + "logloss": 0.7146, + "model": "xgb" + }, + "HT": { + "n_train": 693, + "n_test": 174, + "accuracy": 0.431, + "logloss": 1.0403, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 693, + "n_test": 174, + "accuracy": 0.7414, + "logloss": 0.579, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 693, + "n_test": 174, + "accuracy": 0.6552, + "logloss": 0.6726, + "model": "xgb" + }, + "HTFT": { + "n_train": 693, + "n_test": 174, + "accuracy": 0.3333, + "logloss": 1.886, + "model": "xgb" + }, + "OE": { + "n_train": 693, + "n_test": 174, + "accuracy": 0.523, + "logloss": 0.6903, + "model": "xgb" + }, + "CARDS": { + "n_train": 693, + "n_test": 174, + "accuracy": 0.6092, + "logloss": 0.6677, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 693, + "n_test": 174, + "accuracy": 0.4885, + "logloss": 0.9841, + "model": "xgb" + } + } + }, + { + "league_id": "yv73ms6v1995b5wny16jcfi3", + "league_name": "PSL", + "n_matches": 508, + "markets": { + "MS": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.5098, + "logloss": 1.0469, + "model": "xgb" + }, + "OU15": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.5686, + "logloss": 0.6413, + "model": "xgb" + }, + "OU25": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.6373, + "logloss": 0.6769, + "model": "xgb" + }, + "OU35": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.8137, + "logloss": 0.5118, + "model": "xgb" + }, + "BTTS": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.5784, + "logloss": 0.6942, + "model": "xgb" + }, + "HT": { + "n_train": 396, + "n_test": 102, + "accuracy": 0.5686, + "logloss": 1.0087, + "model": "xgb" + }, + "HT_OU05": { + "n_train": 396, + "n_test": 102, + "accuracy": 0.598, + "logloss": 0.6888, + "model": "xgb" + }, + "HT_OU15": { + "n_train": 396, + "n_test": 102, + "accuracy": 0.7157, + "logloss": 0.5946, + "model": "xgb" + }, + "HTFT": { + "n_train": 396, + "n_test": 102, + "error": "y_true and y_prob contain different number of classes: 8 vs 9. Please provide the true labels explicitly through the labels argument. Classes found in y_true: [0 1 2 3 4 5 7 8]" + }, + "OE": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.5, + "logloss": 0.6984, + "model": "xgb" + }, + "CARDS": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.7549, + "logloss": 0.5605, + "model": "xgb" + }, + "HANDICAP": { + "n_train": 406, + "n_test": 102, + "accuracy": 0.6373, + "logloss": 0.8519, + "model": "xgb" + } + } + }, + { + "league_id": "1b70m6qtxrp75b4vtk8hxh8c3", + "league_name": "1. HNL", + "n_matches": 372, + "markets": { + "MS": { + "n_train": 372, + "n_test": 75, + "model": "cal_only" + }, + "OU15": { + "n_train": 372, + "n_test": 75, + "model": "cal_only" + }, + "OU25": { + "n_train": 372, + "n_test": 75, + "model": "cal_only" + }, + "OU35": { + "n_train": 372, + "n_test": 75, + "model": "cal_only" + }, + "BTTS": { + "n_train": 372, + "n_test": 75, + "model": "cal_only" + }, + "HT": { + "n_train": 372, + "n_test": 75, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 372, + "n_test": 75, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 372, + "n_test": 75, + "model": "cal_only" + }, + "OE": { + "n_train": 372, + "n_test": 75, + "model": "cal_only" + }, + "CARDS": { + "n_train": 372, + "n_test": 75, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 372, + "n_test": 75, + "model": "cal_only" + } + } + }, + { + "league_id": "1j4ehtrbry9depwt6oghaq3lu", + "league_name": "S\u00fcper Lig", + "n_matches": 185, + "markets": {} + }, + { + "league_id": "1mpjd0vbxbtu9zw89yj09xk3z", + "league_name": "S\u00fcper Lig", + "n_matches": 400, + "markets": { + "MS": { + "n_train": 400, + "n_test": 80, + "model": "cal_only" + }, + "OU15": { + "n_train": 400, + "n_test": 80, + "model": "cal_only" + }, + "OU25": { + "n_train": 400, + "n_test": 80, + "model": "cal_only" + }, + "OU35": { + "n_train": 400, + "n_test": 80, + "model": "cal_only" + }, + "BTTS": { + "n_train": 400, + "n_test": 80, + "model": "cal_only" + }, + "HT": { + "n_train": 400, + "n_test": 80, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 400, + "n_test": 80, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 400, + "n_test": 80, + "model": "cal_only" + }, + "HTFT": { + "n_train": 400, + "n_test": 80, + "model": "cal_only" + }, + "OE": { + "n_train": 400, + "n_test": 80, + "model": "cal_only" + }, + "CARDS": { + "n_train": 400, + "n_test": 80, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 400, + "n_test": 80, + "model": "cal_only" + } + } + }, + { + "league_id": "1q4ab2bpg5e8jl1g2udnakrju", + "league_name": "S Ligi", + "n_matches": 186, + "markets": {} + }, + { + "league_id": "1wwro3z1eb3fl601dju6inlc6", + "league_name": "Ulusal Lig", + "n_matches": 412, + "markets": { + "MS": { + "n_train": 412, + "n_test": 83, + "model": "cal_only" + }, + "OU15": { + "n_train": 412, + "n_test": 83, + "model": "cal_only" + }, + "OU25": { + "n_train": 412, + "n_test": 83, + "model": "cal_only" + }, + "OU35": { + "n_train": 412, + "n_test": 83, + "model": "cal_only" + }, + "BTTS": { + "n_train": 412, + "n_test": 83, + "model": "cal_only" + }, + "HT": { + "n_train": 412, + "n_test": 83, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 412, + "n_test": 83, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 412, + "n_test": 83, + "model": "cal_only" + }, + "HTFT": { + "n_train": 412, + "n_test": 83, + "model": "cal_only" + }, + "OE": { + "n_train": 412, + "n_test": 83, + "model": "cal_only" + }, + "CARDS": { + "n_train": 412, + "n_test": 83, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 412, + "n_test": 83, + "model": "cal_only" + } + } + }, + { + "league_id": "29actv1ohj8r10kd9hu0jnb0n", + "league_name": "S\u00fcper Lig", + "n_matches": 388, + "markets": { + "MS": { + "n_train": 388, + "n_test": 78, + "model": "cal_only" + }, + "OU15": { + "n_train": 388, + "n_test": 78, + "model": "cal_only" + }, + "OU25": { + "n_train": 388, + "n_test": 78, + "model": "cal_only" + }, + "OU35": { + "n_train": 388, + "n_test": 78, + "model": "cal_only" + }, + "BTTS": { + "n_train": 388, + "n_test": 78, + "model": "cal_only" + }, + "HT": { + "n_train": 388, + "n_test": 78, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 388, + "n_test": 78, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 388, + "n_test": 78, + "model": "cal_only" + }, + "HTFT": { + "n_train": 388, + "n_test": 78, + "model": "cal_only" + }, + "OE": { + "n_train": 388, + "n_test": 78, + "model": "cal_only" + }, + "CARDS": { + "n_train": 388, + "n_test": 78, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 388, + "n_test": 78, + "model": "cal_only" + } + } + }, + { + "league_id": "2aso72utuctat2ecs6nahjss6", + "league_name": "1. Lig Kad\u0131nlar", + "n_matches": 221, + "markets": { + "OU15": { + "n_train": 221, + "n_test": 45, + "model": "cal_only" + }, + "OU25": { + "n_train": 221, + "n_test": 45, + "model": "cal_only" + }, + "OU35": { + "n_train": 221, + "n_test": 45, + "model": "cal_only" + }, + "BTTS": { + "n_train": 221, + "n_test": 45, + "model": "cal_only" + }, + "HT": { + "n_train": 218, + "n_test": 45, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 218, + "n_test": 45, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 218, + "n_test": 45, + "model": "cal_only" + }, + "OE": { + "n_train": 221, + "n_test": 45, + "model": "cal_only" + }, + "CARDS": { + "n_train": 221, + "n_test": 45, + "model": "cal_only" + } + } + }, + { + "league_id": "2hj3286pqov1g1g59k2t2qcgm", + "league_name": "FA Cup", + "n_matches": 482, + "markets": { + "MS": { + "n_train": 482, + "n_test": 97, + "model": "cal_only" + }, + "OU15": { + "n_train": 482, + "n_test": 97, + "model": "cal_only" + }, + "OU25": { + "n_train": 482, + "n_test": 97, + "model": "cal_only" + }, + "OU35": { + "n_train": 482, + "n_test": 97, + "model": "cal_only" + }, + "BTTS": { + "n_train": 482, + "n_test": 97, + "model": "cal_only" + }, + "HT": { + "n_train": 319, + "n_test": 97, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 319, + "n_test": 97, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 319, + "n_test": 97, + "model": "cal_only" + }, + "OE": { + "n_train": 482, + "n_test": 97, + "model": "cal_only" + }, + "CARDS": { + "n_train": 482, + "n_test": 97, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 482, + "n_test": 97, + "model": "cal_only" + } + } + }, + { + "league_id": "2mdmx668tyhy4u4z9zszwjv5v", + "league_name": "Victoria NPL", + "n_matches": 386, + "markets": { + "MS": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "OU15": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "OU25": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "OU35": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "BTTS": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "OE": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "CARDS": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + } + } + }, + { + "league_id": "2xg0qvif1rh7du6wmk2eleku3", + "league_name": "Ligat ha'Al", + "n_matches": 175, + "markets": {} + }, + { + "league_id": "2z7257m7hj58zuxcjrsg4erzc", + "league_name": "Bundesliga Kad\u0131nlar", + "n_matches": 258, + "markets": { + "MS": { + "n_train": 258, + "n_test": 52, + "model": "cal_only" + }, + "OU15": { + "n_train": 258, + "n_test": 52, + "model": "cal_only" + }, + "OU25": { + "n_train": 258, + "n_test": 52, + "model": "cal_only" + }, + "OU35": { + "n_train": 258, + "n_test": 52, + "model": "cal_only" + }, + "BTTS": { + "n_train": 258, + "n_test": 52, + "model": "cal_only" + }, + "HT": { + "n_train": 258, + "n_test": 52, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 258, + "n_test": 52, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 258, + "n_test": 52, + "model": "cal_only" + }, + "OE": { + "n_train": 258, + "n_test": 52, + "model": "cal_only" + }, + "CARDS": { + "n_train": 258, + "n_test": 52, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 258, + "n_test": 52, + "model": "cal_only" + } + } + }, + { + "league_id": "3428tckxcirwwh3o3jgc1m8ji", + "league_name": "Premier Lig", + "n_matches": 374, + "markets": { + "MS": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "OU15": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "OU25": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "OU35": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "BTTS": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "HT": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "OE": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "CARDS": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + } + } + }, + { + "league_id": "392slbmf1kdqlr6sd1ckt71rs", + "league_name": "FA Trophy Kupas\u0131", + "n_matches": 273, + "markets": { + "MS": { + "n_train": 273, + "n_test": 55, + "model": "cal_only" + }, + "OU15": { + "n_train": 273, + "n_test": 55, + "model": "cal_only" + }, + "OU25": { + "n_train": 273, + "n_test": 55, + "model": "cal_only" + }, + "OU35": { + "n_train": 273, + "n_test": 55, + "model": "cal_only" + }, + "BTTS": { + "n_train": 273, + "n_test": 55, + "model": "cal_only" + }, + "OE": { + "n_train": 273, + "n_test": 55, + "model": "cal_only" + }, + "CARDS": { + "n_train": 273, + "n_test": 55, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 273, + "n_test": 55, + "model": "cal_only" + } + } + }, + { + "league_id": "3ab1uwtoyjopdj1y1fynyy9jg", + "league_name": "Premier Lig", + "n_matches": 490, + "markets": { + "MS": { + "n_train": 490, + "n_test": 98, + "model": "cal_only" + }, + "OU15": { + "n_train": 490, + "n_test": 98, + "model": "cal_only" + }, + "OU25": { + "n_train": 490, + "n_test": 98, + "model": "cal_only" + }, + "OU35": { + "n_train": 490, + "n_test": 98, + "model": "cal_only" + }, + "BTTS": { + "n_train": 490, + "n_test": 98, + "model": "cal_only" + }, + "HT": { + "n_train": 490, + "n_test": 98, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 490, + "n_test": 98, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 490, + "n_test": 98, + "model": "cal_only" + }, + "HTFT": { + "n_train": 490, + "n_test": 98, + "model": "cal_only" + }, + "OE": { + "n_train": 490, + "n_test": 98, + "model": "cal_only" + }, + "CARDS": { + "n_train": 490, + "n_test": 98, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 490, + "n_test": 98, + "model": "cal_only" + } + } + }, + { + "league_id": "3e40pestup9xzagsu2o6c0i8u", + "league_name": "Premier Lig", + "n_matches": 162, + "markets": {} + }, + { + "league_id": "3l29w00m506ex93t5bbh9cg2a", + "league_name": "1. Lig", + "n_matches": 305, + "markets": { + "MS": { + "n_train": 305, + "n_test": 61, + "model": "cal_only" + }, + "OU15": { + "n_train": 305, + "n_test": 61, + "model": "cal_only" + }, + "OU25": { + "n_train": 305, + "n_test": 61, + "model": "cal_only" + }, + "OU35": { + "n_train": 305, + "n_test": 61, + "model": "cal_only" + }, + "BTTS": { + "n_train": 305, + "n_test": 61, + "model": "cal_only" + }, + "HT": { + "n_train": 305, + "n_test": 61, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 305, + "n_test": 61, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 305, + "n_test": 61, + "model": "cal_only" + }, + "OE": { + "n_train": 305, + "n_test": 61, + "model": "cal_only" + }, + "CARDS": { + "n_train": 305, + "n_test": 61, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 305, + "n_test": 61, + "model": "cal_only" + } + } + }, + { + "league_id": "3n5046abeu3x482ds3jwda238", + "league_name": "WE Lig Kad\u0131nlar", + "n_matches": 215, + "markets": { + "OU15": { + "n_train": 215, + "n_test": 43, + "model": "cal_only" + }, + "OU25": { + "n_train": 215, + "n_test": 43, + "model": "cal_only" + }, + "OU35": { + "n_train": 215, + "n_test": 43, + "model": "cal_only" + }, + "BTTS": { + "n_train": 215, + "n_test": 43, + "model": "cal_only" + }, + "HT": { + "n_train": 215, + "n_test": 43, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 215, + "n_test": 43, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 215, + "n_test": 43, + "model": "cal_only" + }, + "OE": { + "n_train": 215, + "n_test": 43, + "model": "cal_only" + }, + "CARDS": { + "n_train": 215, + "n_test": 43, + "model": "cal_only" + } + } + }, + { + "league_id": "3oa9e03e7w9nr8kqwqc3tlqz9", + "league_name": "S\u00fcper Lig", + "n_matches": 263, + "markets": { + "MS": { + "n_train": 263, + "n_test": 53, + "model": "cal_only" + }, + "OU15": { + "n_train": 263, + "n_test": 53, + "model": "cal_only" + }, + "OU25": { + "n_train": 263, + "n_test": 53, + "model": "cal_only" + }, + "OU35": { + "n_train": 263, + "n_test": 53, + "model": "cal_only" + }, + "BTTS": { + "n_train": 263, + "n_test": 53, + "model": "cal_only" + }, + "HT": { + "n_train": 263, + "n_test": 53, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 263, + "n_test": 53, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 263, + "n_test": 53, + "model": "cal_only" + }, + "OE": { + "n_train": 263, + "n_test": 53, + "model": "cal_only" + }, + "CARDS": { + "n_train": 263, + "n_test": 53, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 263, + "n_test": 53, + "model": "cal_only" + } + } + }, + { + "league_id": "3w1hkk9k9gr8fwssyn4icvdfo", + "league_name": "Virsliga", + "n_matches": 364, + "markets": { + "MS": { + "n_train": 364, + "n_test": 73, + "model": "cal_only" + }, + "OU15": { + "n_train": 364, + "n_test": 73, + "model": "cal_only" + }, + "OU25": { + "n_train": 364, + "n_test": 73, + "model": "cal_only" + }, + "OU35": { + "n_train": 364, + "n_test": 73, + "model": "cal_only" + }, + "BTTS": { + "n_train": 364, + "n_test": 73, + "model": "cal_only" + }, + "HT": { + "n_train": 364, + "n_test": 73, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 364, + "n_test": 73, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 364, + "n_test": 73, + "model": "cal_only" + }, + "OE": { + "n_train": 364, + "n_test": 73, + "model": "cal_only" + }, + "CARDS": { + "n_train": 364, + "n_test": 73, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 364, + "n_test": 73, + "model": "cal_only" + } + } + }, + { + "league_id": "477yyajzheg2z8u7uick0e13e", + "league_name": "Erovnuli Ligi", + "n_matches": 348, + "markets": { + "MS": { + "n_train": 348, + "n_test": 70, + "model": "cal_only" + }, + "OU15": { + "n_train": 348, + "n_test": 70, + "model": "cal_only" + }, + "OU25": { + "n_train": 348, + "n_test": 70, + "model": "cal_only" + }, + "OU35": { + "n_train": 348, + "n_test": 70, + "model": "cal_only" + }, + "BTTS": { + "n_train": 348, + "n_test": 70, + "model": "cal_only" + }, + "HT": { + "n_train": 348, + "n_test": 70, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 348, + "n_test": 70, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 348, + "n_test": 70, + "model": "cal_only" + }, + "OE": { + "n_train": 348, + "n_test": 70, + "model": "cal_only" + }, + "CARDS": { + "n_train": 348, + "n_test": 70, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 348, + "n_test": 70, + "model": "cal_only" + } + } + }, + { + "league_id": "47s2kt0e8m444ftqvsrqa3bvq", + "league_name": "NB I", + "n_matches": 437, + "markets": { + "MS": { + "n_train": 437, + "n_test": 88, + "model": "cal_only" + }, + "OU15": { + "n_train": 437, + "n_test": 88, + "model": "cal_only" + }, + "OU25": { + "n_train": 437, + "n_test": 88, + "model": "cal_only" + }, + "OU35": { + "n_train": 437, + "n_test": 88, + "model": "cal_only" + }, + "BTTS": { + "n_train": 437, + "n_test": 88, + "model": "cal_only" + }, + "HT": { + "n_train": 437, + "n_test": 88, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 437, + "n_test": 88, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 437, + "n_test": 88, + "model": "cal_only" + }, + "HTFT": { + "n_train": 437, + "n_test": 88, + "model": "cal_only" + }, + "OE": { + "n_train": 437, + "n_test": 88, + "model": "cal_only" + }, + "CARDS": { + "n_train": 437, + "n_test": 88, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 437, + "n_test": 88, + "model": "cal_only" + } + } + }, + { + "league_id": "4c1nfi2j1m731hcay25fcgndq", + "league_name": "Avrupa Ligi", + "n_matches": 491, + "markets": { + "MS": { + "n_train": 491, + "n_test": 99, + "model": "cal_only" + }, + "OU15": { + "n_train": 491, + "n_test": 99, + "model": "cal_only" + }, + "OU25": { + "n_train": 491, + "n_test": 99, + "model": "cal_only" + }, + "OU35": { + "n_train": 491, + "n_test": 99, + "model": "cal_only" + }, + "BTTS": { + "n_train": 491, + "n_test": 99, + "model": "cal_only" + }, + "HT": { + "n_train": 491, + "n_test": 99, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 491, + "n_test": 99, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 491, + "n_test": 99, + "model": "cal_only" + }, + "HTFT": { + "n_train": 491, + "n_test": 99, + "model": "cal_only" + }, + "OE": { + "n_train": 491, + "n_test": 99, + "model": "cal_only" + }, + "CARDS": { + "n_train": 491, + "n_test": 99, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 491, + "n_test": 99, + "model": "cal_only" + } + } + }, + { + "league_id": "4d5d3sf6805n5u6jdoa0hdlog", + "league_name": "Meistriliiga", + "n_matches": 385, + "markets": { + "MS": { + "n_train": 385, + "n_test": 77, + "model": "cal_only" + }, + "OU15": { + "n_train": 385, + "n_test": 77, + "model": "cal_only" + }, + "OU25": { + "n_train": 385, + "n_test": 77, + "model": "cal_only" + }, + "OU35": { + "n_train": 385, + "n_test": 77, + "model": "cal_only" + }, + "BTTS": { + "n_train": 385, + "n_test": 77, + "model": "cal_only" + }, + "HT": { + "n_train": 385, + "n_test": 77, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 385, + "n_test": 77, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 385, + "n_test": 77, + "model": "cal_only" + }, + "HTFT": { + "n_train": 385, + "n_test": 77, + "model": "cal_only" + }, + "OE": { + "n_train": 385, + "n_test": 77, + "model": "cal_only" + }, + "CARDS": { + "n_train": 385, + "n_test": 77, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 385, + "n_test": 77, + "model": "cal_only" + } + } + }, + { + "league_id": "4mbfidy8zum5u0aqjqo0vuqs2", + "league_name": "Premier Lig", + "n_matches": 337, + "markets": { + "MS": { + "n_train": 337, + "n_test": 68, + "model": "cal_only" + }, + "OU15": { + "n_train": 337, + "n_test": 68, + "model": "cal_only" + }, + "OU25": { + "n_train": 337, + "n_test": 68, + "model": "cal_only" + }, + "OU35": { + "n_train": 337, + "n_test": 68, + "model": "cal_only" + }, + "BTTS": { + "n_train": 337, + "n_test": 68, + "model": "cal_only" + }, + "HT": { + "n_train": 337, + "n_test": 68, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 337, + "n_test": 68, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 337, + "n_test": 68, + "model": "cal_only" + }, + "OE": { + "n_train": 337, + "n_test": 68, + "model": "cal_only" + }, + "CARDS": { + "n_train": 337, + "n_test": 68, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 337, + "n_test": 68, + "model": "cal_only" + } + } + }, + { + "league_id": "4oogyu6o156iphvdvphwpck10", + "league_name": "\u015eampiyonlar Ligi", + "n_matches": 457, + "markets": { + "MS": { + "n_train": 457, + "n_test": 92, + "model": "cal_only" + }, + "OU15": { + "n_train": 457, + "n_test": 92, + "model": "cal_only" + }, + "OU25": { + "n_train": 457, + "n_test": 92, + "model": "cal_only" + }, + "OU35": { + "n_train": 457, + "n_test": 92, + "model": "cal_only" + }, + "BTTS": { + "n_train": 457, + "n_test": 92, + "model": "cal_only" + }, + "HT": { + "n_train": 457, + "n_test": 92, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 457, + "n_test": 92, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 457, + "n_test": 92, + "model": "cal_only" + }, + "HTFT": { + "n_train": 457, + "n_test": 92, + "model": "cal_only" + }, + "OE": { + "n_train": 457, + "n_test": 92, + "model": "cal_only" + }, + "CARDS": { + "n_train": 457, + "n_test": 92, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 457, + "n_test": 92, + "model": "cal_only" + } + } + }, + { + "league_id": "4qehj8hfxmy6o2ohp4fxinnzo", + "league_name": "2. Lig", + "n_matches": 447, + "markets": { + "MS": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "OU15": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "OU25": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "OU35": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "BTTS": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "HT": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "HTFT": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "OE": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "CARDS": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + } + } + }, + { + "league_id": "4rls982p5uzil6x30mhyhv9f3", + "league_name": "Premier Lig", + "n_matches": 208, + "markets": { + "OU15": { + "n_train": 208, + "n_test": 42, + "model": "cal_only" + }, + "OU25": { + "n_train": 208, + "n_test": 42, + "model": "cal_only" + }, + "OU35": { + "n_train": 208, + "n_test": 42, + "model": "cal_only" + }, + "BTTS": { + "n_train": 208, + "n_test": 42, + "model": "cal_only" + }, + "HT": { + "n_train": 208, + "n_test": 42, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 208, + "n_test": 42, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 208, + "n_test": 42, + "model": "cal_only" + }, + "OE": { + "n_train": 208, + "n_test": 42, + "model": "cal_only" + }, + "CARDS": { + "n_train": 208, + "n_test": 42, + "model": "cal_only" + } + } + }, + { + "league_id": "4yngyfinzd6bb1k7anqtqs0wt", + "league_name": "Premier Lig", + "n_matches": 394, + "markets": { + "MS": { + "n_train": 394, + "n_test": 79, + "model": "cal_only" + }, + "OU15": { + "n_train": 394, + "n_test": 79, + "model": "cal_only" + }, + "OU25": { + "n_train": 394, + "n_test": 79, + "model": "cal_only" + }, + "OU35": { + "n_train": 394, + "n_test": 79, + "model": "cal_only" + }, + "BTTS": { + "n_train": 394, + "n_test": 79, + "model": "cal_only" + }, + "HT": { + "n_train": 388, + "n_test": 74, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 388, + "n_test": 74, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 388, + "n_test": 74, + "model": "cal_only" + }, + "HTFT": { + "n_train": 388, + "n_test": 74, + "model": "cal_only" + }, + "OE": { + "n_train": 394, + "n_test": 79, + "model": "cal_only" + }, + "CARDS": { + "n_train": 394, + "n_test": 79, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 394, + "n_test": 79, + "model": "cal_only" + } + } + }, + { + "league_id": "5aw6uyw4pz2bpj24t5z8aacim", + "league_name": "2. SNL", + "n_matches": 460, + "markets": { + "MS": { + "n_train": 460, + "n_test": 92, + "model": "cal_only" + }, + "OU15": { + "n_train": 460, + "n_test": 92, + "model": "cal_only" + }, + "OU25": { + "n_train": 460, + "n_test": 92, + "model": "cal_only" + }, + "OU35": { + "n_train": 460, + "n_test": 92, + "model": "cal_only" + }, + "BTTS": { + "n_train": 460, + "n_test": 92, + "model": "cal_only" + }, + "HT": { + "n_train": 460, + "n_test": 92, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 460, + "n_test": 92, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 460, + "n_test": 92, + "model": "cal_only" + }, + "HTFT": { + "n_train": 460, + "n_test": 92, + "model": "cal_only" + }, + "OE": { + "n_train": 460, + "n_test": 92, + "model": "cal_only" + }, + "CARDS": { + "n_train": 460, + "n_test": 92, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 460, + "n_test": 92, + "model": "cal_only" + } + } + }, + { + "league_id": "5c96g1zm7vo5ons9c42uy2w3r", + "league_name": "Bundesliga", + "n_matches": 427, + "markets": { + "MS": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "OU15": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "OU25": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "OU35": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "BTTS": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "HT": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "HTFT": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "OE": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "CARDS": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + } + } + }, + { + "league_id": "5cwsxtx37les6m10xj71htkgf", + "league_name": "A Ligi", + "n_matches": 363, + "markets": { + "MS": { + "n_train": 363, + "n_test": 73, + "model": "cal_only" + }, + "OU15": { + "n_train": 363, + "n_test": 73, + "model": "cal_only" + }, + "OU25": { + "n_train": 363, + "n_test": 73, + "model": "cal_only" + }, + "OU35": { + "n_train": 363, + "n_test": 73, + "model": "cal_only" + }, + "BTTS": { + "n_train": 363, + "n_test": 73, + "model": "cal_only" + }, + "HT": { + "n_train": 362, + "n_test": 73, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 362, + "n_test": 73, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 362, + "n_test": 73, + "model": "cal_only" + }, + "OE": { + "n_train": 363, + "n_test": 73, + "model": "cal_only" + }, + "CARDS": { + "n_train": 363, + "n_test": 73, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 363, + "n_test": 73, + "model": "cal_only" + } + } + }, + { + "league_id": "5dycj9wdhxh3n33qubw18ohlk", + "league_name": "K3 Lig", + "n_matches": 447, + "markets": { + "MS": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "OU15": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "OU25": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "OU35": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "BTTS": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "HT": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "HTFT": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "OE": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "CARDS": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 447, + "n_test": 90, + "model": "cal_only" + } + } + }, + { + "league_id": "5taraea6mqjjldg9zxswo825y", + "league_name": "Premier Lig", + "n_matches": 386, + "markets": { + "MS": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "OU15": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "OU25": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "OU35": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "BTTS": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "HT": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "HTFT": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "OE": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "CARDS": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 386, + "n_test": 78, + "model": "cal_only" + } + } + }, + { + "league_id": "61fzfjogstjuukzcehighq7mu", + "league_name": "Queensland NPL", + "n_matches": 270, + "markets": { + "MS": { + "n_train": 270, + "n_test": 54, + "model": "cal_only" + }, + "OU15": { + "n_train": 270, + "n_test": 54, + "model": "cal_only" + }, + "OU25": { + "n_train": 270, + "n_test": 54, + "model": "cal_only" + }, + "OU35": { + "n_train": 270, + "n_test": 54, + "model": "cal_only" + }, + "BTTS": { + "n_train": 270, + "n_test": 54, + "model": "cal_only" + }, + "OE": { + "n_train": 270, + "n_test": 54, + "model": "cal_only" + }, + "CARDS": { + "n_train": 270, + "n_test": 54, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 270, + "n_test": 54, + "model": "cal_only" + } + } + }, + { + "league_id": "6ifaeunfdelecgticvxanikzu", + "league_name": "1. Lig", + "n_matches": 404, + "markets": { + "MS": { + "n_train": 404, + "n_test": 81, + "model": "cal_only" + }, + "OU15": { + "n_train": 404, + "n_test": 81, + "model": "cal_only" + }, + "OU25": { + "n_train": 404, + "n_test": 81, + "model": "cal_only" + }, + "OU35": { + "n_train": 404, + "n_test": 81, + "model": "cal_only" + }, + "BTTS": { + "n_train": 404, + "n_test": 81, + "model": "cal_only" + }, + "HT": { + "n_train": 404, + "n_test": 81, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 404, + "n_test": 81, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 404, + "n_test": 81, + "model": "cal_only" + }, + "HTFT": { + "n_train": 404, + "n_test": 81, + "model": "cal_only" + }, + "OE": { + "n_train": 404, + "n_test": 81, + "model": "cal_only" + }, + "CARDS": { + "n_train": 404, + "n_test": 81, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 404, + "n_test": 81, + "model": "cal_only" + } + } + }, + { + "league_id": "6ihotpaocgiovlxw18e9r9prx", + "league_name": "Ykk\u00f6sliiga", + "n_matches": 302, + "markets": { + "MS": { + "n_train": 302, + "n_test": 61, + "model": "cal_only" + }, + "OU15": { + "n_train": 302, + "n_test": 61, + "model": "cal_only" + }, + "OU25": { + "n_train": 302, + "n_test": 61, + "model": "cal_only" + }, + "OU35": { + "n_train": 302, + "n_test": 61, + "model": "cal_only" + }, + "BTTS": { + "n_train": 302, + "n_test": 61, + "model": "cal_only" + }, + "HT": { + "n_train": 302, + "n_test": 61, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 302, + "n_test": 61, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 302, + "n_test": 61, + "model": "cal_only" + }, + "OE": { + "n_train": 302, + "n_test": 61, + "model": "cal_only" + }, + "CARDS": { + "n_train": 302, + "n_test": 61, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 302, + "n_test": 61, + "model": "cal_only" + } + } + }, + { + "league_id": "6lkj3o21cr4g7bql6tb3fk222", + "league_name": "Premier Lig", + "n_matches": 232, + "markets": { + "OU15": { + "n_train": 232, + "n_test": 47, + "model": "cal_only" + }, + "OU25": { + "n_train": 232, + "n_test": 47, + "model": "cal_only" + }, + "OU35": { + "n_train": 232, + "n_test": 47, + "model": "cal_only" + }, + "BTTS": { + "n_train": 232, + "n_test": 47, + "model": "cal_only" + }, + "HT": { + "n_train": 231, + "n_test": 47, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 231, + "n_test": 47, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 231, + "n_test": 47, + "model": "cal_only" + }, + "OE": { + "n_train": 232, + "n_test": 47, + "model": "cal_only" + }, + "CARDS": { + "n_train": 232, + "n_test": 47, + "model": "cal_only" + } + } + }, + { + "league_id": "6qitd9h242qkvjenaytfdnsf2", + "league_name": "Intermedia Lig", + "n_matches": 111, + "markets": {} + }, + { + "league_id": "6vq8j5p3av14nr3iuyi4okhjt", + "league_name": "S\u00fcper Lig Kad\u0131nlar", + "n_matches": 223, + "markets": { + "OU15": { + "n_train": 223, + "n_test": 45, + "model": "cal_only" + }, + "OU25": { + "n_train": 223, + "n_test": 45, + "model": "cal_only" + }, + "OU35": { + "n_train": 223, + "n_test": 45, + "model": "cal_only" + }, + "BTTS": { + "n_train": 223, + "n_test": 45, + "model": "cal_only" + }, + "HT": { + "n_train": 223, + "n_test": 45, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 223, + "n_test": 45, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 223, + "n_test": 45, + "model": "cal_only" + }, + "OE": { + "n_train": 223, + "n_test": 45, + "model": "cal_only" + }, + "CARDS": { + "n_train": 223, + "n_test": 45, + "model": "cal_only" + } + } + }, + { + "league_id": "6wubmo7di3kdpflluf6s8c7vs", + "league_name": "Premier Lig", + "n_matches": 455, + "markets": { + "MS": { + "n_train": 455, + "n_test": 91, + "model": "cal_only" + }, + "OU15": { + "n_train": 455, + "n_test": 91, + "model": "cal_only" + }, + "OU25": { + "n_train": 455, + "n_test": 91, + "model": "cal_only" + }, + "OU35": { + "n_train": 455, + "n_test": 91, + "model": "cal_only" + }, + "BTTS": { + "n_train": 455, + "n_test": 91, + "model": "cal_only" + }, + "HT": { + "n_train": 453, + "n_test": 90, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 453, + "n_test": 90, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 453, + "n_test": 90, + "model": "cal_only" + }, + "HTFT": { + "n_train": 453, + "n_test": 90, + "model": "cal_only" + }, + "OE": { + "n_train": 455, + "n_test": 91, + "model": "cal_only" + }, + "CARDS": { + "n_train": 455, + "n_test": 91, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 455, + "n_test": 91, + "model": "cal_only" + } + } + }, + { + "league_id": "7af85xa75vozt2l4hzi6ryts7", + "league_name": "Ziraat T\u00fcrkiye Kupas\u0131", + "n_matches": 275, + "markets": { + "MS": { + "n_train": 275, + "n_test": 55, + "model": "cal_only" + }, + "OU15": { + "n_train": 275, + "n_test": 55, + "model": "cal_only" + }, + "OU25": { + "n_train": 275, + "n_test": 55, + "model": "cal_only" + }, + "OU35": { + "n_train": 275, + "n_test": 55, + "model": "cal_only" + }, + "BTTS": { + "n_train": 275, + "n_test": 55, + "model": "cal_only" + }, + "HT": { + "n_train": 272, + "n_test": 55, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 272, + "n_test": 55, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 272, + "n_test": 55, + "model": "cal_only" + }, + "OE": { + "n_train": 275, + "n_test": 55, + "model": "cal_only" + }, + "CARDS": { + "n_train": 275, + "n_test": 55, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 275, + "n_test": 55, + "model": "cal_only" + } + } + }, + { + "league_id": "7cwemnr3vi40znjq451zxkus6", + "league_name": "1. Lig", + "n_matches": 391, + "markets": { + "MS": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "OU15": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "OU25": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "OU35": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "BTTS": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "HT": { + "n_train": 390, + "n_test": 79, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 390, + "n_test": 79, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 390, + "n_test": 79, + "model": "cal_only" + }, + "HTFT": { + "n_train": 390, + "n_test": 79, + "model": "cal_only" + }, + "OE": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "CARDS": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + } + } + }, + { + "league_id": "7nmz249q89qg5ezcvzlheljji", + "league_name": "1. SNL", + "n_matches": 358, + "markets": { + "MS": { + "n_train": 358, + "n_test": 72, + "model": "cal_only" + }, + "OU15": { + "n_train": 358, + "n_test": 72, + "model": "cal_only" + }, + "OU25": { + "n_train": 358, + "n_test": 72, + "model": "cal_only" + }, + "OU35": { + "n_train": 358, + "n_test": 72, + "model": "cal_only" + }, + "BTTS": { + "n_train": 358, + "n_test": 72, + "model": "cal_only" + }, + "HT": { + "n_train": 358, + "n_test": 72, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 358, + "n_test": 72, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 358, + "n_test": 72, + "model": "cal_only" + }, + "OE": { + "n_train": 358, + "n_test": 72, + "model": "cal_only" + }, + "CARDS": { + "n_train": 358, + "n_test": 72, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 358, + "n_test": 72, + "model": "cal_only" + } + } + }, + { + "league_id": "7r1f93t6ddrsa5n8v1nq6qlzm", + "league_name": "1. Lig", + "n_matches": 498, + "markets": { + "MS": { + "n_train": 498, + "n_test": 100, + "model": "cal_only" + }, + "OU15": { + "n_train": 498, + "n_test": 100, + "model": "cal_only" + }, + "OU25": { + "n_train": 498, + "n_test": 100, + "model": "cal_only" + }, + "OU35": { + "n_train": 498, + "n_test": 100, + "model": "cal_only" + }, + "BTTS": { + "n_train": 498, + "n_test": 100, + "model": "cal_only" + }, + "HT": { + "n_train": 498, + "n_test": 100, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 498, + "n_test": 100, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 498, + "n_test": 100, + "model": "cal_only" + }, + "HTFT": { + "n_train": 498, + "n_test": 100, + "model": "cal_only" + }, + "OE": { + "n_train": 498, + "n_test": 100, + "model": "cal_only" + }, + "CARDS": { + "n_train": 498, + "n_test": 100, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 498, + "n_test": 100, + "model": "cal_only" + } + } + }, + { + "league_id": "81txfenlgw75nq3u2nfdkj92o", + "league_name": "Serie A Kad\u0131nlar", + "n_matches": 230, + "markets": { + "OU15": { + "n_train": 230, + "n_test": 46, + "model": "cal_only" + }, + "OU25": { + "n_train": 230, + "n_test": 46, + "model": "cal_only" + }, + "OU35": { + "n_train": 230, + "n_test": 46, + "model": "cal_only" + }, + "BTTS": { + "n_train": 230, + "n_test": 46, + "model": "cal_only" + }, + "HT": { + "n_train": 229, + "n_test": 46, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 229, + "n_test": 46, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 229, + "n_test": 46, + "model": "cal_only" + }, + "OE": { + "n_train": 230, + "n_test": 46, + "model": "cal_only" + }, + "CARDS": { + "n_train": 230, + "n_test": 46, + "model": "cal_only" + } + } + }, + { + "league_id": "8usjlmziv3p2re0r2wwzezki9", + "league_name": "Premier Lig", + "n_matches": 318, + "markets": { + "MS": { + "n_train": 318, + "n_test": 64, + "model": "cal_only" + }, + "OU15": { + "n_train": 318, + "n_test": 64, + "model": "cal_only" + }, + "OU25": { + "n_train": 318, + "n_test": 64, + "model": "cal_only" + }, + "OU35": { + "n_train": 318, + "n_test": 64, + "model": "cal_only" + }, + "BTTS": { + "n_train": 318, + "n_test": 64, + "model": "cal_only" + }, + "HT": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "OE": { + "n_train": 318, + "n_test": 64, + "model": "cal_only" + }, + "CARDS": { + "n_train": 318, + "n_test": 64, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 318, + "n_test": 64, + "model": "cal_only" + } + } + }, + { + "league_id": "8v97rcbthsxmzqk4ufxws9mug", + "league_name": "Challenge Lig", + "n_matches": 377, + "markets": { + "MS": { + "n_train": 377, + "n_test": 76, + "model": "cal_only" + }, + "OU15": { + "n_train": 377, + "n_test": 76, + "model": "cal_only" + }, + "OU25": { + "n_train": 377, + "n_test": 76, + "model": "cal_only" + }, + "OU35": { + "n_train": 377, + "n_test": 76, + "model": "cal_only" + }, + "BTTS": { + "n_train": 377, + "n_test": 76, + "model": "cal_only" + }, + "HT": { + "n_train": 377, + "n_test": 76, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 377, + "n_test": 76, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 377, + "n_test": 76, + "model": "cal_only" + }, + "HTFT": { + "n_train": 377, + "n_test": 76, + "model": "cal_only" + }, + "OE": { + "n_train": 377, + "n_test": 76, + "model": "cal_only" + }, + "CARDS": { + "n_train": 377, + "n_test": 76, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 377, + "n_test": 76, + "model": "cal_only" + } + } + }, + { + "league_id": "8y29fg2s85ppcb8uugm5ee8s4", + "league_name": "Premier Lig", + "n_matches": 467, + "markets": { + "MS": { + "n_train": 467, + "n_test": 94, + "model": "cal_only" + }, + "OU15": { + "n_train": 467, + "n_test": 94, + "model": "cal_only" + }, + "OU25": { + "n_train": 467, + "n_test": 94, + "model": "cal_only" + }, + "OU35": { + "n_train": 467, + "n_test": 94, + "model": "cal_only" + }, + "BTTS": { + "n_train": 467, + "n_test": 94, + "model": "cal_only" + }, + "HT": { + "n_train": 466, + "n_test": 94, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 466, + "n_test": 94, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 466, + "n_test": 94, + "model": "cal_only" + }, + "HTFT": { + "n_train": 466, + "n_test": 94, + "model": "cal_only" + }, + "OE": { + "n_train": 467, + "n_test": 94, + "model": "cal_only" + }, + "CARDS": { + "n_train": 467, + "n_test": 94, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 467, + "n_test": 94, + "model": "cal_only" + } + } + }, + { + "league_id": "9chuiarcjofld1dkj9kysehmb", + "league_name": "Superettan", + "n_matches": 468, + "markets": { + "MS": { + "n_train": 468, + "n_test": 94, + "model": "cal_only" + }, + "OU15": { + "n_train": 468, + "n_test": 94, + "model": "cal_only" + }, + "OU25": { + "n_train": 468, + "n_test": 94, + "model": "cal_only" + }, + "OU35": { + "n_train": 468, + "n_test": 94, + "model": "cal_only" + }, + "BTTS": { + "n_train": 468, + "n_test": 94, + "model": "cal_only" + }, + "HT": { + "n_train": 468, + "n_test": 94, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 468, + "n_test": 94, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 468, + "n_test": 94, + "model": "cal_only" + }, + "HTFT": { + "n_train": 468, + "n_test": 94, + "model": "cal_only" + }, + "OE": { + "n_train": 468, + "n_test": 94, + "model": "cal_only" + }, + "CARDS": { + "n_train": 468, + "n_test": 94, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 468, + "n_test": 94, + "model": "cal_only" + } + } + }, + { + "league_id": "9ikchyu9fb8bvx0s673jofj6s", + "league_name": "Premier Lig", + "n_matches": 373, + "markets": { + "MS": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "OU15": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "OU25": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "OU35": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "BTTS": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "HT": { + "n_train": 371, + "n_test": 75, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 371, + "n_test": 75, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 371, + "n_test": 75, + "model": "cal_only" + }, + "OE": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "CARDS": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + } + } + }, + { + "league_id": "9nbpdi9q3ywcm4q0j5u0ekwcq", + "league_name": "Serie D", + "n_matches": 179, + "markets": {} + }, + { + "league_id": "9p3nnxhdjahfn8qswpzy8oyc3", + "league_name": "A-Lig Kad\u0131nlar", + "n_matches": 244, + "markets": { + "OU15": { + "n_train": 244, + "n_test": 49, + "model": "cal_only" + }, + "OU25": { + "n_train": 244, + "n_test": 49, + "model": "cal_only" + }, + "OU35": { + "n_train": 244, + "n_test": 49, + "model": "cal_only" + }, + "BTTS": { + "n_train": 244, + "n_test": 49, + "model": "cal_only" + }, + "HT": { + "n_train": 244, + "n_test": 49, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 244, + "n_test": 49, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 244, + "n_test": 49, + "model": "cal_only" + }, + "OE": { + "n_train": 244, + "n_test": 49, + "model": "cal_only" + }, + "CARDS": { + "n_train": 244, + "n_test": 49, + "model": "cal_only" + } + } + }, + { + "league_id": "9ynnnx1qmkizq1o3qr3v0nsuk", + "league_name": "Eliteserien", + "n_matches": 477, + "markets": { + "MS": { + "n_train": 477, + "n_test": 96, + "model": "cal_only" + }, + "OU15": { + "n_train": 477, + "n_test": 96, + "model": "cal_only" + }, + "OU25": { + "n_train": 477, + "n_test": 96, + "model": "cal_only" + }, + "OU35": { + "n_train": 477, + "n_test": 96, + "model": "cal_only" + }, + "BTTS": { + "n_train": 477, + "n_test": 96, + "model": "cal_only" + }, + "HT": { + "n_train": 477, + "n_test": 96, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 477, + "n_test": 96, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 477, + "n_test": 96, + "model": "cal_only" + }, + "HTFT": { + "n_train": 477, + "n_test": 96, + "model": "cal_only" + }, + "OE": { + "n_train": 477, + "n_test": 96, + "model": "cal_only" + }, + "CARDS": { + "n_train": 477, + "n_test": 96, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 477, + "n_test": 96, + "model": "cal_only" + } + } + }, + { + "league_id": "aho73e5udydy96iun3tkzdzsi", + "league_name": "V-Lig 1", + "n_matches": 399, + "markets": { + "MS": { + "n_train": 399, + "n_test": 80, + "model": "cal_only" + }, + "OU15": { + "n_train": 399, + "n_test": 80, + "model": "cal_only" + }, + "OU25": { + "n_train": 399, + "n_test": 80, + "model": "cal_only" + }, + "OU35": { + "n_train": 399, + "n_test": 80, + "model": "cal_only" + }, + "BTTS": { + "n_train": 399, + "n_test": 80, + "model": "cal_only" + }, + "HT": { + "n_train": 399, + "n_test": 80, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 399, + "n_test": 80, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 399, + "n_test": 80, + "model": "cal_only" + }, + "HTFT": { + "n_train": 399, + "n_test": 80, + "model": "cal_only" + }, + "OE": { + "n_train": 399, + "n_test": 80, + "model": "cal_only" + }, + "CARDS": { + "n_train": 399, + "n_test": 80, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 399, + "n_test": 80, + "model": "cal_only" + } + } + }, + { + "league_id": "ajxs0e0g6ryg5ol8qvw3evrcz", + "league_name": "1. Lig", + "n_matches": 350, + "markets": { + "MS": { + "n_train": 350, + "n_test": 70, + "model": "cal_only" + }, + "OU15": { + "n_train": 350, + "n_test": 70, + "model": "cal_only" + }, + "OU25": { + "n_train": 350, + "n_test": 70, + "model": "cal_only" + }, + "OU35": { + "n_train": 350, + "n_test": 70, + "model": "cal_only" + }, + "BTTS": { + "n_train": 350, + "n_test": 70, + "model": "cal_only" + }, + "HT": { + "n_train": 350, + "n_test": 70, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 350, + "n_test": 70, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 350, + "n_test": 70, + "model": "cal_only" + }, + "OE": { + "n_train": 350, + "n_test": 70, + "model": "cal_only" + }, + "CARDS": { + "n_train": 350, + "n_test": 70, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 350, + "n_test": 70, + "model": "cal_only" + } + } + }, + { + "league_id": "avs3xposm3t9x1x2vzsoxzcbu", + "league_name": "K-Lig", + "n_matches": 458, + "markets": { + "MS": { + "n_train": 458, + "n_test": 92, + "model": "cal_only" + }, + "OU15": { + "n_train": 458, + "n_test": 92, + "model": "cal_only" + }, + "OU25": { + "n_train": 458, + "n_test": 92, + "model": "cal_only" + }, + "OU35": { + "n_train": 458, + "n_test": 92, + "model": "cal_only" + }, + "BTTS": { + "n_train": 458, + "n_test": 92, + "model": "cal_only" + }, + "HT": { + "n_train": 458, + "n_test": 92, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 458, + "n_test": 92, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 458, + "n_test": 92, + "model": "cal_only" + }, + "HTFT": { + "n_train": 458, + "n_test": 92, + "model": "cal_only" + }, + "OE": { + "n_train": 458, + "n_test": 92, + "model": "cal_only" + }, + "CARDS": { + "n_train": 458, + "n_test": 92, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 458, + "n_test": 92, + "model": "cal_only" + } + } + }, + { + "league_id": "b60nisd3qn427jm0hrg9kvmab", + "league_name": "Allsvenskan", + "n_matches": 456, + "markets": { + "MS": { + "n_train": 456, + "n_test": 92, + "model": "cal_only" + }, + "OU15": { + "n_train": 456, + "n_test": 92, + "model": "cal_only" + }, + "OU25": { + "n_train": 456, + "n_test": 92, + "model": "cal_only" + }, + "OU35": { + "n_train": 456, + "n_test": 92, + "model": "cal_only" + }, + "BTTS": { + "n_train": 456, + "n_test": 92, + "model": "cal_only" + }, + "HT": { + "n_train": 456, + "n_test": 92, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 456, + "n_test": 92, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 456, + "n_test": 92, + "model": "cal_only" + }, + "HTFT": { + "n_train": 456, + "n_test": 92, + "model": "cal_only" + }, + "OE": { + "n_train": 456, + "n_test": 92, + "model": "cal_only" + }, + "CARDS": { + "n_train": 456, + "n_test": 92, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 456, + "n_test": 92, + "model": "cal_only" + } + } + }, + { + "league_id": "bly7ema5au6j40i0grhl0pnub", + "league_name": "B Ligi", + "n_matches": 158, + "markets": {} + }, + { + "league_id": "bx57cmq1edfq53ckfk791supi", + "league_name": "CAF Konfederasyon Kupas\u0131", + "n_matches": 167, + "markets": {} + }, + { + "league_id": "by5nibd18nkt40t0j8a0j5yzx", + "league_name": "B Ligi", + "n_matches": 222, + "markets": { + "OU15": { + "n_train": 222, + "n_test": 45, + "model": "cal_only" + }, + "OU25": { + "n_train": 222, + "n_test": 45, + "model": "cal_only" + }, + "OU35": { + "n_train": 222, + "n_test": 45, + "model": "cal_only" + }, + "BTTS": { + "n_train": 222, + "n_test": 45, + "model": "cal_only" + }, + "HT": { + "n_train": 222, + "n_test": 45, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 222, + "n_test": 45, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 222, + "n_test": 45, + "model": "cal_only" + }, + "OE": { + "n_train": 222, + "n_test": 45, + "model": "cal_only" + }, + "CARDS": { + "n_train": 222, + "n_test": 45, + "model": "cal_only" + } + } + }, + { + "league_id": "byhmntnl1b4lxw0zz21im3zkd", + "league_name": "Kupa", + "n_matches": 137, + "markets": {} + }, + { + "league_id": "cesdwwnxbc5fmajgroc0hqzy2", + "league_name": "Haz\u0131rl\u0131k Ma\u00e7lar\u0131 \u00dclkeler", + "n_matches": 373, + "markets": { + "MS": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "OU15": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "OU25": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "OU35": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "BTTS": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "HT": { + "n_train": 342, + "n_test": 73, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 342, + "n_test": 73, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 342, + "n_test": 73, + "model": "cal_only" + }, + "OE": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "CARDS": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 373, + "n_test": 75, + "model": "cal_only" + } + } + }, + { + "league_id": "cfesxhzb83yl8b779uv3revz1", + "league_name": "Serie C", + "n_matches": 316, + "markets": { + "MS": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "OU15": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "OU25": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "OU35": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "BTTS": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "HT": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "OE": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "CARDS": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 316, + "n_test": 64, + "model": "cal_only" + } + } + }, + { + "league_id": "cse5oqqt2pzfcy8uz6yz3tkbj", + "league_name": "CAF \u015eampiyonlar Ligi", + "n_matches": 180, + "markets": {} + }, + { + "league_id": "ddyrh5latwfhesgfh4w401n92", + "league_name": "FNL", + "n_matches": 487, + "markets": { + "MS": { + "n_train": 487, + "n_test": 98, + "model": "cal_only" + }, + "OU15": { + "n_train": 487, + "n_test": 98, + "model": "cal_only" + }, + "OU25": { + "n_train": 487, + "n_test": 98, + "model": "cal_only" + }, + "OU35": { + "n_train": 487, + "n_test": 98, + "model": "cal_only" + }, + "BTTS": { + "n_train": 487, + "n_test": 98, + "model": "cal_only" + }, + "HT": { + "n_train": 487, + "n_test": 98, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 487, + "n_test": 98, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 487, + "n_test": 98, + "model": "cal_only" + }, + "HTFT": { + "n_train": 487, + "n_test": 98, + "model": "cal_only" + }, + "OE": { + "n_train": 487, + "n_test": 98, + "model": "cal_only" + }, + "CARDS": { + "n_train": 487, + "n_test": 98, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 487, + "n_test": 98, + "model": "cal_only" + } + } + }, + { + "league_id": "dr2xk7muj8aqcjdz2b3li1c0k", + "league_name": "Meistaradeildin", + "n_matches": 271, + "markets": { + "MS": { + "n_train": 271, + "n_test": 55, + "model": "cal_only" + }, + "OU15": { + "n_train": 271, + "n_test": 55, + "model": "cal_only" + }, + "OU25": { + "n_train": 271, + "n_test": 55, + "model": "cal_only" + }, + "OU35": { + "n_train": 271, + "n_test": 55, + "model": "cal_only" + }, + "BTTS": { + "n_train": 271, + "n_test": 55, + "model": "cal_only" + }, + "HT": { + "n_train": 271, + "n_test": 55, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 271, + "n_test": 55, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 271, + "n_test": 55, + "model": "cal_only" + }, + "OE": { + "n_train": 271, + "n_test": 55, + "model": "cal_only" + }, + "CARDS": { + "n_train": 271, + "n_test": 55, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 271, + "n_test": 55, + "model": "cal_only" + } + } + }, + { + "league_id": "du6jsenbjql5e8f3yk880ox4g", + "league_name": "Kakkonen", + "n_matches": 233, + "markets": { + "OU15": { + "n_train": 233, + "n_test": 47, + "model": "cal_only" + }, + "OU25": { + "n_train": 233, + "n_test": 47, + "model": "cal_only" + }, + "OU35": { + "n_train": 233, + "n_test": 47, + "model": "cal_only" + }, + "BTTS": { + "n_train": 233, + "n_test": 47, + "model": "cal_only" + }, + "HT": { + "n_train": 231, + "n_test": 47, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 231, + "n_test": 47, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 231, + "n_test": 47, + "model": "cal_only" + }, + "OE": { + "n_train": 233, + "n_test": 47, + "model": "cal_only" + }, + "CARDS": { + "n_train": 233, + "n_test": 47, + "model": "cal_only" + } + } + }, + { + "league_id": "dvstmwnvw0mt5p38twn9yttyb", + "league_name": "Veikkausliiga", + "n_matches": 343, + "markets": { + "MS": { + "n_train": 343, + "n_test": 69, + "model": "cal_only" + }, + "OU15": { + "n_train": 343, + "n_test": 69, + "model": "cal_only" + }, + "OU25": { + "n_train": 343, + "n_test": 69, + "model": "cal_only" + }, + "OU35": { + "n_train": 343, + "n_test": 69, + "model": "cal_only" + }, + "BTTS": { + "n_train": 343, + "n_test": 69, + "model": "cal_only" + }, + "HT": { + "n_train": 343, + "n_test": 69, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 343, + "n_test": 69, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 343, + "n_test": 69, + "model": "cal_only" + }, + "OE": { + "n_train": 343, + "n_test": 69, + "model": "cal_only" + }, + "CARDS": { + "n_train": 343, + "n_test": 69, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 343, + "n_test": 69, + "model": "cal_only" + } + } + }, + { + "league_id": "e0lck99w8meo9qoalfrxgo33o", + "league_name": "S\u00fcper Lig", + "n_matches": 485, + "markets": { + "MS": { + "n_train": 485, + "n_test": 97, + "model": "cal_only" + }, + "OU15": { + "n_train": 485, + "n_test": 97, + "model": "cal_only" + }, + "OU25": { + "n_train": 485, + "n_test": 97, + "model": "cal_only" + }, + "OU35": { + "n_train": 485, + "n_test": 97, + "model": "cal_only" + }, + "BTTS": { + "n_train": 485, + "n_test": 97, + "model": "cal_only" + }, + "HT": { + "n_train": 485, + "n_test": 97, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 485, + "n_test": 97, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 485, + "n_test": 97, + "model": "cal_only" + }, + "HTFT": { + "n_train": 485, + "n_test": 97, + "model": "cal_only" + }, + "OE": { + "n_train": 485, + "n_test": 97, + "model": "cal_only" + }, + "CARDS": { + "n_train": 485, + "n_test": 97, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 485, + "n_test": 97, + "model": "cal_only" + } + } + }, + { + "league_id": "e6vzdkz6l236s9p288mharefy", + "league_name": "AFC \u015eampiyonlar Ligi 2", + "n_matches": 200, + "markets": { + "OU15": { + "n_train": 200, + "n_test": 40, + "model": "cal_only" + }, + "OU25": { + "n_train": 200, + "n_test": 40, + "model": "cal_only" + }, + "OU35": { + "n_train": 200, + "n_test": 40, + "model": "cal_only" + }, + "BTTS": { + "n_train": 200, + "n_test": 40, + "model": "cal_only" + }, + "HT": { + "n_train": 198, + "n_test": 39, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 198, + "n_test": 39, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 198, + "n_test": 39, + "model": "cal_only" + }, + "OE": { + "n_train": 200, + "n_test": 40, + "model": "cal_only" + }, + "CARDS": { + "n_train": 200, + "n_test": 40, + "model": "cal_only" + } + } + }, + { + "league_id": "eg6s9f1jj7jr6stmbosn0g6c8", + "league_name": "S\u00fcper Lig", + "n_matches": 267, + "markets": { + "MS": { + "n_train": 267, + "n_test": 54, + "model": "cal_only" + }, + "OU15": { + "n_train": 267, + "n_test": 54, + "model": "cal_only" + }, + "OU25": { + "n_train": 267, + "n_test": 54, + "model": "cal_only" + }, + "OU35": { + "n_train": 267, + "n_test": 54, + "model": "cal_only" + }, + "BTTS": { + "n_train": 267, + "n_test": 54, + "model": "cal_only" + }, + "HT": { + "n_train": 267, + "n_test": 54, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 267, + "n_test": 54, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 267, + "n_test": 54, + "model": "cal_only" + }, + "OE": { + "n_train": 267, + "n_test": 54, + "model": "cal_only" + }, + "CARDS": { + "n_train": 267, + "n_test": 54, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 267, + "n_test": 54, + "model": "cal_only" + } + } + }, + { + "league_id": "ejunkmfhjz9weugd2bqrkgobb", + "league_name": "1. Lig", + "n_matches": 210, + "markets": { + "OU15": { + "n_train": 210, + "n_test": 42, + "model": "cal_only" + }, + "OU25": { + "n_train": 210, + "n_test": 42, + "model": "cal_only" + }, + "OU35": { + "n_train": 210, + "n_test": 42, + "model": "cal_only" + }, + "BTTS": { + "n_train": 210, + "n_test": 42, + "model": "cal_only" + }, + "HT": { + "n_train": 207, + "n_test": 42, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 207, + "n_test": 42, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 207, + "n_test": 42, + "model": "cal_only" + }, + "OE": { + "n_train": 210, + "n_test": 42, + "model": "cal_only" + }, + "CARDS": { + "n_train": 210, + "n_test": 42, + "model": "cal_only" + } + } + }, + { + "league_id": "er5745q30wnr8jv9nr863omzg", + "league_name": "Kupa", + "n_matches": 142, + "markets": {} + }, + { + "league_id": "esrunz7rjb0td98mx9e5cedoy", + "league_name": "1. NL", + "n_matches": 340, + "markets": { + "MS": { + "n_train": 340, + "n_test": 68, + "model": "cal_only" + }, + "OU15": { + "n_train": 340, + "n_test": 68, + "model": "cal_only" + }, + "OU25": { + "n_train": 340, + "n_test": 68, + "model": "cal_only" + }, + "OU35": { + "n_train": 340, + "n_test": 68, + "model": "cal_only" + }, + "BTTS": { + "n_train": 340, + "n_test": 68, + "model": "cal_only" + }, + "HT": { + "n_train": 340, + "n_test": 68, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 340, + "n_test": 68, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 340, + "n_test": 68, + "model": "cal_only" + }, + "OE": { + "n_train": 340, + "n_test": 68, + "model": "cal_only" + }, + "CARDS": { + "n_train": 340, + "n_test": 68, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 340, + "n_test": 68, + "model": "cal_only" + } + } + }, + { + "league_id": "f39uq10c8xhg5e6rwwcf6lhgc", + "league_name": "K\u00f6rfez Ligi", + "n_matches": 374, + "markets": { + "MS": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "OU15": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "OU25": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "OU35": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "BTTS": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "HT": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "OE": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "CARDS": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 374, + "n_test": 75, + "model": "cal_only" + } + } + }, + { + "league_id": "f4jc2cc5nq7flaoptpi5ua4k4", + "league_name": "1. Lig", + "n_matches": 459, + "markets": { + "MS": { + "n_train": 459, + "n_test": 92, + "model": "cal_only" + }, + "OU15": { + "n_train": 459, + "n_test": 92, + "model": "cal_only" + }, + "OU25": { + "n_train": 459, + "n_test": 92, + "model": "cal_only" + }, + "OU35": { + "n_train": 459, + "n_test": 92, + "model": "cal_only" + }, + "BTTS": { + "n_train": 459, + "n_test": 92, + "model": "cal_only" + }, + "HT": { + "n_train": 453, + "n_test": 90, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 453, + "n_test": 90, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 453, + "n_test": 90, + "model": "cal_only" + }, + "HTFT": { + "n_train": 453, + "n_test": 90, + "model": "cal_only" + }, + "OE": { + "n_train": 459, + "n_test": 92, + "model": "cal_only" + }, + "CARDS": { + "n_train": 459, + "n_test": 92, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 459, + "n_test": 92, + "model": "cal_only" + } + } + }, + { + "league_id": "iu1vi94p4p28oozl1h9bvplr", + "league_name": "1. Lig", + "n_matches": 441, + "markets": { + "MS": { + "n_train": 441, + "n_test": 89, + "model": "cal_only" + }, + "OU15": { + "n_train": 441, + "n_test": 89, + "model": "cal_only" + }, + "OU25": { + "n_train": 441, + "n_test": 89, + "model": "cal_only" + }, + "OU35": { + "n_train": 441, + "n_test": 89, + "model": "cal_only" + }, + "BTTS": { + "n_train": 441, + "n_test": 89, + "model": "cal_only" + }, + "HT": { + "n_train": 441, + "n_test": 89, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 441, + "n_test": 89, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 441, + "n_test": 89, + "model": "cal_only" + }, + "HTFT": { + "n_train": 441, + "n_test": 89, + "model": "cal_only" + }, + "OE": { + "n_train": 441, + "n_test": 89, + "model": "cal_only" + }, + "CARDS": { + "n_train": 441, + "n_test": 89, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 441, + "n_test": 89, + "model": "cal_only" + } + } + }, + { + "league_id": "jznihqxle06xych9ygwiwnsa", + "league_name": "USL 1. Lig", + "n_matches": 391, + "markets": { + "MS": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "OU15": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "OU25": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "OU35": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "BTTS": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "HT": { + "n_train": 390, + "n_test": 79, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 390, + "n_test": 79, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 390, + "n_test": 79, + "model": "cal_only" + }, + "HTFT": { + "n_train": 390, + "n_test": 79, + "model": "cal_only" + }, + "OE": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "CARDS": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 391, + "n_test": 79, + "model": "cal_only" + } + } + }, + { + "league_id": "xwnjb1az11zffwty3m6vn8y6", + "league_name": "A Lig", + "n_matches": 357, + "markets": { + "MS": { + "n_train": 357, + "n_test": 72, + "model": "cal_only" + }, + "OU15": { + "n_train": 357, + "n_test": 72, + "model": "cal_only" + }, + "OU25": { + "n_train": 357, + "n_test": 72, + "model": "cal_only" + }, + "OU35": { + "n_train": 357, + "n_test": 72, + "model": "cal_only" + }, + "BTTS": { + "n_train": 357, + "n_test": 72, + "model": "cal_only" + }, + "HT": { + "n_train": 357, + "n_test": 72, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 357, + "n_test": 72, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 357, + "n_test": 72, + "model": "cal_only" + }, + "OE": { + "n_train": 357, + "n_test": 72, + "model": "cal_only" + }, + "CARDS": { + "n_train": 357, + "n_test": 72, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 357, + "n_test": 72, + "model": "cal_only" + } + } + }, + { + "league_id": "zilopfej2h0n3vpan5tcynpo", + "league_name": "Urvalsdeild", + "n_matches": 321, + "markets": { + "MS": { + "n_train": 321, + "n_test": 65, + "model": "cal_only" + }, + "OU15": { + "n_train": 321, + "n_test": 65, + "model": "cal_only" + }, + "OU25": { + "n_train": 321, + "n_test": 65, + "model": "cal_only" + }, + "OU35": { + "n_train": 321, + "n_test": 65, + "model": "cal_only" + }, + "BTTS": { + "n_train": 321, + "n_test": 65, + "model": "cal_only" + }, + "HT": { + "n_train": 321, + "n_test": 65, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 321, + "n_test": 65, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 321, + "n_test": 65, + "model": "cal_only" + }, + "OE": { + "n_train": 321, + "n_test": 65, + "model": "cal_only" + }, + "CARDS": { + "n_train": 321, + "n_test": 65, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 321, + "n_test": 65, + "model": "cal_only" + } + } + }, + { + "league_id": "zs18qaehvhg3w1208874zvfa", + "league_name": "1. Lig", + "n_matches": 428, + "markets": { + "MS": { + "n_train": 428, + "n_test": 86, + "model": "cal_only" + }, + "OU15": { + "n_train": 428, + "n_test": 86, + "model": "cal_only" + }, + "OU25": { + "n_train": 428, + "n_test": 86, + "model": "cal_only" + }, + "OU35": { + "n_train": 428, + "n_test": 86, + "model": "cal_only" + }, + "BTTS": { + "n_train": 428, + "n_test": 86, + "model": "cal_only" + }, + "HT": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "HT_OU05": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "HT_OU15": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "HTFT": { + "n_train": 427, + "n_test": 86, + "model": "cal_only" + }, + "OE": { + "n_train": 428, + "n_test": 86, + "model": "cal_only" + }, + "CARDS": { + "n_train": 428, + "n_test": 86, + "model": "cal_only" + }, + "HANDICAP": { + "n_train": 428, + "n_test": 86, + "model": "cal_only" + } + } + } + ] +} \ No newline at end of file diff --git a/ai-engine/schemas/match_data.py b/ai-engine/schemas/match_data.py new file mode 100644 index 0000000..658e1df --- /dev/null +++ b/ai-engine/schemas/match_data.py @@ -0,0 +1,40 @@ +""" +MatchData dataclass β€” core data transfer object used throughout the engine. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + + +@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 + source_table: str = "matches" diff --git a/ai-engine/schemas/prediction.py b/ai-engine/schemas/prediction.py new file mode 100644 index 0000000..9067258 --- /dev/null +++ b/ai-engine/schemas/prediction.py @@ -0,0 +1,292 @@ +""" +Shared prediction dataclasses used across the AI engine. + +These were originally defined in models/v20_ensemble.py and are extracted here +so they can be used without importing the full V20 ensemble. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from core.calculators.score_calculator import ScorePrediction + + +@dataclass +class MarketPrediction: + """Prediction for a single betting market.""" + market_type: str + pick: str + probability: float + confidence: float + odds: float = 0.0 + is_recommended: bool = False + is_value_bet: bool = False + edge: float = 0.0 # Expected edge over market + + def to_dict(self) -> dict: + return { + "market_type": self.market_type, + "pick": self.pick, + "probability": round(self.probability * 100, 1), + "confidence": round(self.confidence, 1), + "odds": self.odds, + "is_recommended": self.is_recommended, + "is_value_bet": self.is_value_bet, + "edge": round(self.edge, 1) + } + + +@dataclass +class FullMatchPrediction: + """Complete prediction for a match with ALL markets.""" + match_id: str + home_team: str + away_team: str + match_date: str = "" + + # === MAΓ‡ SONUCU (1X2) === + ms_home_prob: float = 0.33 + ms_draw_prob: float = 0.33 + ms_away_prob: float = 0.33 + ms_pick: str = "" + ms_confidence: float = 0.0 + + # === Γ‡Δ°FTE ŞANS === + dc_1x_prob: float = 0.66 + dc_x2_prob: float = 0.66 + dc_12_prob: float = 0.66 + dc_pick: str = "" + dc_confidence: float = 0.0 + + # === ALT/ÜST GOLLER === + # 1.5 + over_15_prob: float = 0.70 + under_15_prob: float = 0.30 + ou15_pick: str = "" + ou15_confidence: float = 0.0 + + # 2.5 + over_25_prob: float = 0.50 + under_25_prob: float = 0.50 + ou25_pick: str = "" + ou25_confidence: float = 0.0 + + # 3.5 + over_35_prob: float = 0.30 + under_35_prob: float = 0.70 + ou35_pick: str = "" + ou35_confidence: float = 0.0 + + # === KARŞILIKLI GOL (BTTS) === + btts_yes_prob: float = 0.50 + btts_no_prob: float = 0.50 + btts_pick: str = "" + btts_confidence: float = 0.0 + + # === Δ°LK YARI SONUCU === + ht_home_prob: float = 0.30 + ht_draw_prob: float = 0.40 + ht_away_prob: float = 0.30 + ht_pick: str = "" + ht_confidence: float = 0.0 + + # === SKOR TAHMΔ°NLERΔ° === + score: Optional[ScorePrediction] = None + predicted_ft_score: str = "1-1" + predicted_ht_score: str = "0-0" + ft_scores_top5: List[Dict] = field(default_factory=list) + + # === xG (Expected Goals) === + home_xg: float = 1.3 + away_xg: float = 1.1 + total_xg: float = 2.4 + + # === RISK DEĞERLENDΔ°RMESΔ° === + risk_level: str = "MEDIUM" # LOW, MEDIUM, HIGH, EXTREME + risk_score: float = 0.0 + is_surprise_risk: bool = False + surprise_type: str = "" + risk_warnings: List[str] = field(default_factory=list) + ht_ft_probs: Dict[str, float] = field(default_factory=dict) + + # === GLM-5 SÜRPRΔ°Z SKORU === + upset_score: int = 0 # 0-100 arasΔ± sΓΌrpriz skoru + upset_level: str = "LOW" # LOW, MEDIUM, HIGH, EXTREME + upset_reasons: List[str] = field(default_factory=list) + + # === SÜRPRΔ°Z PROFΔ°LΔ° === + surprise_score: float = 0.0 # 0-100 overall surprise risk score + surprise_comment: str = "" # Human-readable surprise commentary + surprise_reasons: List[str] = field(default_factory=list) # Flagged risk reasons + surprise_breakdown: List[Dict[str, Any]] = field(default_factory=list) # Per-factor {code, points, label} + + # === ENGINE KATKILARI === + team_confidence: float = 0.0 + player_confidence: float = 0.0 + odds_confidence: float = 0.0 + referee_confidence: float = 0.0 + + # === KORNER & KART & DİĞER === + total_corners_pred: float = 9.5 + corner_pick: str = "9.5 Üst" + + total_cards_pred: float = 4.5 + card_pick: str = "4.5 Alt" + cards_over_prob: float = 0.50 + cards_under_prob: float = 0.50 + cards_confidence: float = 0.0 + + handicap_pick: str = "" + handicap_home_prob: float = 0.33 + handicap_draw_prob: float = 0.34 + handicap_away_prob: float = 0.33 + handicap_confidence: float = 0.0 + + ht_over_05_prob: float = 0.65 + ht_under_05_prob: float = 0.35 + ht_over_15_prob: float = 0.30 + ht_under_15_prob: float = 0.70 + ht_ou_pick: str = "Δ°Y 0.5 Üst" + ht_ou15_pick: str = "Δ°Y 1.5 Alt" + + odd_even_pick: str = "Γ‡ift" + odd_prob: float = 0.50 # Tek olasΔ±lığı + even_prob: float = 0.50 # Γ‡ift olasΔ±lığı + + # === TAVSΔ°YELER (RECOMMENDATIONS) === + best_bet: Optional[MarketPrediction] = None + recommended_bets: List[MarketPrediction] = field(default_factory=list) + alternative_bet: Optional[MarketPrediction] = None + expert_recommendation: Dict[str, Any] = field(default_factory=dict) + + # === DETAILED ANALYSIS === + analysis_details: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "match_info": { + "match_id": self.match_id, + "home_team": self.home_team, + "away_team": self.away_team, + "match_date": self.match_date + }, + "predictions": { + "match_result": { + "1": round(self.ms_home_prob * 100, 1), + "X": round(self.ms_draw_prob * 100, 1), + "2": round(self.ms_away_prob * 100, 1), + "pick": self.ms_pick, + "confidence": round(self.ms_confidence, 1) + }, + "double_chance": { + "1X": round(self.dc_1x_prob * 100, 1), + "X2": round(self.dc_x2_prob * 100, 1), + "12": round(self.dc_12_prob * 100, 1), + "pick": self.dc_pick, + "confidence": round(self.dc_confidence, 1) + }, + "over_under": { + "1.5": { + "over": round(self.over_15_prob * 100, 1), + "under": round(self.under_15_prob * 100, 1), + "pick": self.ou15_pick, + "confidence": round(self.ou15_confidence, 1) + }, + "2.5": { + "over": round(self.over_25_prob * 100, 1), + "under": round(self.under_25_prob * 100, 1), + "pick": self.ou25_pick, + "confidence": round(self.ou25_confidence, 1) + }, + "3.5": { + "over": round(self.over_35_prob * 100, 1), + "under": round(self.under_35_prob * 100, 1), + "pick": self.ou35_pick, + "confidence": round(self.ou35_confidence, 1) + } + }, + "btts": { + "yes": round(self.btts_yes_prob * 100, 1), + "no": round(self.btts_no_prob * 100, 1), + "pick": self.btts_pick, + "confidence": round(self.btts_confidence, 1) + }, + "first_half": { + "1": round(self.ht_home_prob * 100, 1), + "X": round(self.ht_draw_prob * 100, 1), + "2": round(self.ht_away_prob * 100, 1), + "pick": self.ht_pick, + "confidence": round(self.ht_confidence, 1), + "over_under_05": { + "over": round(self.ht_over_05_prob * 100, 1), + "under": round(self.ht_under_05_prob * 100, 1), + "pick": self.ht_ou_pick + }, + "over_under_15": { + "over": round(self.ht_over_15_prob * 100, 1), + "under": round(self.ht_under_15_prob * 100, 1), + "pick": self.ht_ou15_pick + } + }, + "scores": { + "predicted_ft": self.predicted_ft_score, + "predicted_ht": self.predicted_ht_score, + "top_5_ft_scores": self.ft_scores_top5 + }, + "others": { + "handicap": { + "pick": self.handicap_pick, + "confidence": round(self.handicap_confidence, 1), + "home": round(self.handicap_home_prob * 100, 1), + "draw": round(self.handicap_draw_prob * 100, 1), + "away": round(self.handicap_away_prob * 100, 1) + }, + "corners": { + "total": round(self.total_corners_pred, 1), + "pick": self.corner_pick + }, + "cards": { + "total": round(self.total_cards_pred, 1), + "pick": self.card_pick, + "confidence": round(self.cards_confidence, 1), + "over": round(self.cards_over_prob * 100, 1), + "under": round(self.cards_under_prob * 100, 1) + }, + "odd_even": { + "pick": self.odd_even_pick, + "tek": round(self.odd_prob * 100, 1), + "cift": round(self.even_prob * 100, 1) + } + }, + "xg": { + "home": round(self.home_xg, 2), + "away": round(self.away_xg, 2), + "total": round(self.total_xg, 2) + } + }, + "risk": { + "level": self.risk_level, + "score": round(self.risk_score, 1), + "is_surprise_risk": self.is_surprise_risk, + "surprise_type": self.surprise_type, + "ht_ft_probs": {k: round(v * 100, 1) for k, v in self.ht_ft_probs.items()} if self.ht_ft_probs else {}, + "warnings": self.risk_warnings + }, + "upset_analysis": { + "score": self.upset_score, + "level": self.upset_level, + "reasons": self.upset_reasons + }, + "engine_breakdown": { + "team_engine": round(self.team_confidence, 1), + "player_engine": round(self.player_confidence, 1), + "odds_engine": round(self.odds_confidence, 1), + "referee_engine": round(self.referee_confidence, 1) + }, + "recommendations": { + "best_bet": self.best_bet.to_dict() if self.best_bet else None, + "all_recommended": [b.to_dict() for b in self.recommended_bets] if self.recommended_bets else [], + "alternative_bet": self.alternative_bet.to_dict() if self.alternative_bet else None + }, + "analysis_details": self.analysis_details + } diff --git a/ai-engine/scripts/backfill_calibration.py b/ai-engine/scripts/backfill_calibration.py new file mode 100644 index 0000000..86fa3b8 --- /dev/null +++ b/ai-engine/scripts/backfill_calibration.py @@ -0,0 +1,510 @@ +""" +Calibration Backfill Script +============================ +Runs V25 model against historical matches (using pre-computed ai_features + odds) +to generate calibration training data, then trains isotonic calibration models. + +Usage: + python ai-engine/scripts/backfill_calibration.py + python ai-engine/scripts/backfill_calibration.py --limit 5000 + python ai-engine/scripts/backfill_calibration.py --min-samples 50 +""" + +import argparse +import json +import os +import sys +import time +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd +import psycopg2 +from psycopg2.extras import RealDictCursor +from dotenv import load_dotenv + +AI_ENGINE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, AI_ENGINE_DIR) + +from models.v25_ensemble import V25Predictor +from models.calibration import get_calibrator + +load_dotenv() + + +def _normalize_pick(pick) -> str: + return str(pick or "").strip().casefold() + + +def resolve_actual(market, pick, score_home, score_away, ht_home, ht_away): + if score_home is None or score_away is None: + return None + market = (market or "").upper() + p = _normalize_pick(pick) + total = score_home + score_away + ht_total = (ht_home or 0) + (ht_away or 0) if ht_home is not None else None + + if market == "MS": + if p == "1": return int(score_home > score_away) + if p in {"x", "0"}: return int(score_home == score_away) + if p == "2": return int(score_away > score_home) + return None + if market in {"OU15", "OU25", "OU35"}: + line = {"OU15": 1.5, "OU25": 2.5, "OU35": 3.5}[market] + if "over" in p or "ΓΌst" in p or "ust" in p: return int(total > line) + if "under" in p or "alt" in p: return int(total < line) + return None + if market == "BTTS": + both = score_home > 0 and score_away > 0 + if "yes" in p or "var" in p: return int(both) + if "no" in p or "yok" in p: return int(not both) + return None + if market == "HT": + if ht_home is None or ht_away is None: return None + if p == "1": return int(ht_home > ht_away) + if p in {"x", "0"}: return int(ht_home == ht_away) + if p == "2": return int(ht_away > ht_home) + return None + if market == "HTFT": + if ht_home is None or ht_away is None or "/" not in p: return None + ht_p, ft_p = p.split("/") + ht_actual = "1" if ht_home > ht_away else "2" if ht_away > ht_home else "x" + ft_actual = "1" if score_home > score_away else "2" if score_away > score_home else "x" + return int(ht_p.strip() == ht_actual and ft_p.strip() == ft_actual) + if market == "DC": + norm = p.replace("-", "").upper() + if norm == "1X": return int(score_home >= score_away) + if norm == "X2": return int(score_away >= score_home) + if norm == "12": return int(score_home != score_away) + return None + return None + + +def calibrator_key(market, pick): + m = (market or "").upper() + p = _normalize_pick(pick) + if m == "MS": + if p == "1": return "ms_home" + if p in {"x", "0"}: return "ms_draw" + if p == "2": return "ms_away" + return None + if m == "DC": return "dc" + if m == "OU15" and ("over" in p or "ΓΌst" in p): return "ou15" + if m == "OU25" and ("over" in p or "ΓΌst" in p): return "ou25" + if m == "OU35" and ("over" in p or "ΓΌst" in p): return "ou35" + if m == "BTTS" and ("yes" in p or "var" in p): return "btts" + if m == "HT": + if p == "1": return "ht_home" + if p in {"x", "0"}: return "ht_draw" + if p == "2": return "ht_away" + return None + if m == "HTFT": return "ht_ft" + return None + + +def get_conn(): + db_url = os.getenv("DATABASE_URL", "") + if "?schema=" in db_url: + db_url = db_url.split("?schema=")[0] + if not db_url: + raise ValueError("DATABASE_URL not set") + return psycopg2.connect(db_url, cursor_factory=RealDictCursor) + + +ODD_CAT_MAP = { + "maΓ§ sonucu": {"1": "ms_h", "0": "ms_d", "x": "ms_d", "2": "ms_a"}, + "1. yarΔ± sonucu": {"1": "ht_ms_h", "0": "ht_ms_d", "x": "ht_ms_d", "2": "ht_ms_a"}, +} + +ODD_CAT_KEYWORD_MAP = { + "karşılΔ±klΔ± gol": {"var": "btts_y", "yok": "btts_n"}, + "0,5 alt/ΓΌst": {"alt": "ou05_u", "ΓΌst": "ou05_o"}, + "1,5 alt/ΓΌst": {"alt": "ou15_u", "ΓΌst": "ou15_o"}, + "2,5 alt/ΓΌst": {"alt": "ou25_u", "ΓΌst": "ou25_o"}, + "3,5 alt/ΓΌst": {"alt": "ou35_u", "ΓΌst": "ou35_o"}, + "ilk yarΔ± 0,5 alt/ΓΌst": {"alt": "ht_ou05_u", "ΓΌst": "ht_ou05_o"}, + "ilk yarΔ± 1,5 alt/ΓΌst": {"alt": "ht_ou15_u", "ΓΌst": "ht_ou15_o"}, +} + + +def load_matches(cur, limit: int) -> List[Dict]: + cur.execute(""" + SELECT m.id, m.score_home, m.score_away, + m.ht_score_home, m.ht_score_away + FROM matches m + JOIN football_ai_features f ON f.match_id = m.id + WHERE m.status = 'FT' + AND m.sport = 'football' + AND m.score_home IS NOT NULL + AND m.score_away IS NOT NULL + ORDER BY m.mst_utc DESC + LIMIT %s + """, (limit,)) + return cur.fetchall() + + +def load_ai_features_batch(cur, match_ids: List[str]) -> Dict[str, Dict]: + if not match_ids: + return {} + ph = ",".join(["%s"] * len(match_ids)) + cur.execute(f""" + SELECT match_id, + home_elo AS home_overall_elo, + away_elo AS away_overall_elo, + elo_diff, + home_home_elo, away_away_elo, + home_form_elo, away_form_elo, + (home_form_elo - away_form_elo) AS form_elo_diff, + home_goals_avg_5 AS home_goals_avg, + home_conceded_avg_5 AS home_conceded_avg, + away_goals_avg_5 AS away_goals_avg, + away_conceded_avg_5 AS away_conceded_avg, + home_clean_sheet_rate, away_clean_sheet_rate, + home_scoring_rate, away_scoring_rate, + home_win_streak AS home_winning_streak, + away_win_streak AS away_winning_streak, + 0 AS home_unbeaten_streak, + 0 AS away_unbeaten_streak, + h2h_total AS h2h_total_matches, + h2h_home_win_rate, + (1.0 - h2h_home_win_rate - 0.33) AS h2h_draw_rate, + h2h_avg_goals, + h2h_btts_rate, h2h_over25_rate, + home_avg_possession, away_avg_possession, + home_avg_shots_on_target, away_avg_shots_on_target, + home_shot_conversion, away_shot_conversion, + 0.0 AS home_avg_corners, 0.0 AS away_avg_corners, + implied_home, implied_draw, implied_away, + league_avg_goals, + 0.0 AS league_zero_goal_rate, + 0.0 AS home_xga, 0.0 AS away_xga, + 0.0 AS upset_atmosphere, 0.0 AS upset_motivation, + 0.0 AS upset_fatigue, 0.0 AS upset_potential, + referee_home_bias, referee_avg_goals, + referee_avg_cards AS referee_cards_total, + 0.0 AS referee_avg_yellow, + 0.0 AS referee_experience, + 0.0 AS home_momentum_score, 0.0 AS away_momentum_score, + 0.0 AS momentum_diff, + 0.0 AS home_squad_quality, 0.0 AS away_squad_quality, + 0.0 AS squad_diff, + 0 AS home_key_players, 0 AS away_key_players, + missing_players_impact AS home_missing_impact, + 0.0 AS away_missing_impact, + home_goals_avg_5 AS home_goals_form, + away_goals_avg_5 AS away_goals_form + FROM football_ai_features + WHERE match_id IN ({ph}) + """, match_ids) + return {str(row["match_id"]): dict(row) for row in cur.fetchall()} + + +def load_odds_batch(cur, match_ids: List[str]) -> Dict[str, Dict[str, float]]: + if not match_ids: + return {} + ph = ",".join(["%s"] * len(match_ids)) + cur.execute(f""" + SELECT oc.match_id, oc.name AS cat_name, + os.name AS sel_name, os.odd_value + FROM odd_selections os + JOIN odd_categories oc ON os.odd_category_db_id = oc.db_id + WHERE oc.match_id IN ({ph}) + """, match_ids) + + odds: Dict[str, Dict[str, float]] = {} + for row in cur.fetchall(): + mid = str(row["match_id"]) + cat = (row["cat_name"] or "").lower().strip() + sel = (row["sel_name"] or "").strip() + val = float(row["odd_value"]) if row["odd_value"] else 0 + if val <= 0: + continue + if mid not in odds: + odds[mid] = {} + + if cat in ODD_CAT_MAP: + key = ODD_CAT_MAP[cat].get(sel.lower()) + if key: + odds[mid][key] = val + else: + for cat_pattern, kw_map in ODD_CAT_KEYWORD_MAP.items(): + if cat == cat_pattern: + for keyword, key in kw_map.items(): + if keyword in sel.lower(): + odds[mid][key] = val + break + return odds + + +MARKETS_TO_PREDICT = [ + ("MS", "1", lambda p: p[0]), + ("MS", "X", lambda p: p[1]), + ("MS", "2", lambda p: p[2]), + ("OU25", "Over 2.5", lambda p: p[0]), + ("BTTS", "Yes", lambda p: p[0]), + ("OU15", "Over 1.5", lambda p: p[0]), + ("OU35", "Over 3.5", lambda p: p[0]), + ("HT", "1", lambda p: p[0]), + ("HT", "X", lambda p: p[1]), + ("HT", "2", lambda p: p[2]), +] + + +def run_backfill(args): + print("=" * 70) + print("CALIBRATION BACKFILL") + print("=" * 70) + + conn = get_conn() + cur = conn.cursor(cursor_factory=RealDictCursor) + + t0 = time.time() + print(f"Loading matches (limit={args.limit})...") + matches = load_matches(cur, args.limit) + print(f" Found {len(matches)} finished matches with ai_features") + + match_ids = [str(m["id"]) for m in matches] + match_map = {str(m["id"]): m for m in matches} + + print("Loading ai_features...") + features_map = load_ai_features_batch(cur, match_ids) + print(f" Loaded features for {len(features_map)} matches") + + print("Loading odds...") + odds_map = load_odds_batch(cur, match_ids) + print(f" Loaded odds for {len(odds_map)} matches") + + print(f"Data loading: {time.time() - t0:.1f}s") + + print("\nLoading V25 model...") + predictor = V25Predictor() + predictor.load_models() + + feature_cols = predictor.FEATURE_COLS + + samples: List[Dict[str, Any]] = [] + skipped = 0 + processed = 0 + + print(f"\nRunning predictions on {len(match_ids)} matches...") + t1 = time.time() + + for i, mid in enumerate(match_ids): + if mid not in features_map: + skipped += 1 + continue + + feat_row = features_map[mid] + odds_row = odds_map.get(mid, {}) + match_row = match_map[mid] + + feat_dict = {} + for col in feature_cols: + if col in feat_row and feat_row[col] is not None: + feat_dict[col] = float(feat_row[col]) + elif col.startswith("odds_") and not col.endswith("_present"): + odds_key = col.replace("odds_", "") + feat_dict[col] = float(odds_row.get(odds_key, 0)) + elif col.endswith("_present"): + base = col.replace("_present", "") + odds_key = base.replace("odds_", "") + feat_dict[col] = 1.0 if odds_row.get(odds_key, 0) > 0 else 0.0 + else: + feat_dict[col] = 0.0 + + if odds_row.get("ms_h", 0) > 0: + feat_dict["odds_ms_h"] = odds_row["ms_h"] + if odds_row.get("ms_d", 0) > 0: + feat_dict["odds_ms_d"] = odds_row["ms_d"] + if odds_row.get("ms_a", 0) > 0: + feat_dict["odds_ms_a"] = odds_row["ms_a"] + + ms_h = feat_dict.get("odds_ms_h", 0) + ms_d = feat_dict.get("odds_ms_d", 0) + ms_a = feat_dict.get("odds_ms_a", 0) + if ms_h > 0 and ms_d > 0 and ms_a > 0: + raw_sum = 1/ms_h + 1/ms_d + 1/ms_a + feat_dict["implied_home"] = (1/ms_h) / raw_sum + feat_dict["implied_draw"] = (1/ms_d) / raw_sum + feat_dict["implied_away"] = (1/ms_a) / raw_sum + + sh = match_row["score_home"] + sa = match_row["score_away"] + ht_h = match_row.get("ht_score_home") + ht_a = match_row.get("ht_score_away") + + try: + X = pd.DataFrame([{c: feat_dict.get(c, 0.0) for c in feature_cols}]) + + for market_name, model_key, market_list in [ + ("ms", "ms", ["MS"]), + ("ou25", "ou25", ["OU25"]), + ("btts", "btts", ["BTTS"]), + ("ou15", "ou15", ["OU15"]), + ("ou35", "ou35", ["OU35"]), + ("ht_result", "ht_result", ["HT"]), + ]: + if model_key not in predictor.models: + continue + + probs = predictor.predict_market(model_key, feat_dict) + if probs is None: + continue + + if model_key == "ms": + for pick, prob in [("1", probs[0]), ("X", probs[1]), ("2", probs[2])]: + actual = resolve_actual("MS", pick, sh, sa, ht_h, ht_a) + key = calibrator_key("MS", pick) + if actual is not None and key: + samples.append({ + "match_id": mid, + "market": "MS", + "pick": pick, + "key": key, + "raw_prob": float(prob), + "actual": int(actual), + }) + + elif model_key == "ht_result": + if ht_h is None or ht_a is None: + continue + for pick, prob in [("1", probs[0]), ("X", probs[1]), ("2", probs[2])]: + actual = resolve_actual("HT", pick, sh, sa, ht_h, ht_a) + key = calibrator_key("HT", pick) + if actual is not None and key: + samples.append({ + "match_id": mid, + "market": "HT", + "pick": pick, + "key": key, + "raw_prob": float(prob), + "actual": int(actual), + }) + + elif model_key in ("ou25", "ou15", "ou35"): + market_upper = model_key.upper() + over_prob = float(probs[0]) if len(probs) > 0 else 0.5 + pick = f"Over" + actual = resolve_actual(market_upper, "Over", sh, sa, ht_h, ht_a) + key = calibrator_key(market_upper, "Over") + if actual is not None and key: + samples.append({ + "match_id": mid, + "market": market_upper, + "pick": pick, + "key": key, + "raw_prob": over_prob, + "actual": int(actual), + }) + + elif model_key == "btts": + yes_prob = float(probs[0]) if len(probs) > 0 else 0.5 + actual = resolve_actual("BTTS", "Yes", sh, sa, ht_h, ht_a) + key = calibrator_key("BTTS", "Yes") + if actual is not None and key: + samples.append({ + "match_id": mid, + "market": "BTTS", + "pick": "Yes", + "key": key, + "raw_prob": yes_prob, + "actual": int(actual), + }) + + processed += 1 + + except Exception as e: + skipped += 1 + if skipped <= 5: + print(f" Error on {mid}: {e}") + + if (i + 1) % 5000 == 0: + elapsed = time.time() - t1 + rate = (i + 1) / elapsed + print(f" Processed {i+1}/{len(match_ids)} ({rate:.0f} matches/s)") + + elapsed = time.time() - t1 + print(f"\nPrediction complete: {processed} matches, {skipped} skipped, {elapsed:.1f}s") + + if not samples: + print("No calibration samples generated!") + cur.close() + conn.close() + return + + df = pd.DataFrame(samples) + print(f"\nTotal calibration samples: {len(df)}") + print(f"Unique matches: {df['match_id'].nunique()}") + print(f"\nPer-key counts:") + for key, count in df["key"].value_counts().items(): + print(f" {key:<14} {count}") + + print(f"\nTraining isotonic calibration models (min_samples={args.min_samples})...") + calibrator = get_calibrator() + results: Dict[str, Any] = {} + keys = sorted(df["key"].unique()) + + for key in keys: + sub = df[df["key"] == key].copy() + sub = sub.drop_duplicates(subset=["match_id", "key"], keep="first") + sub = sub.dropna(subset=["raw_prob", "actual"]) + sub = sub[(sub["raw_prob"] > 0.0) & (sub["raw_prob"] < 1.0)] + + n = len(sub) + if n < args.min_samples: + results[key] = {"status": "skipped", "samples": n} + continue + + metrics = calibrator.train_calibration( + df=sub, + market=key, + prob_col="raw_prob", + actual_col="actual", + min_samples=args.min_samples, + save=True, + ) + results[key] = { + "status": "trained", + "samples": metrics.sample_count, + "brier": round(metrics.brier_score, 4), + "ece": round(metrics.calibration_error, 4), + "mean_predicted": round(metrics.mean_predicted, 4), + "mean_actual": round(metrics.mean_actual, 4), + } + + print("\n" + "=" * 70) + print("CALIBRATION RESULTS") + print("=" * 70) + print(f"{'market':<14} {'status':<10} {'n':<8} {'brier':<9} {'ece':<8} {'pred_avg':<9} {'actual_avg'}") + print("-" * 70) + for key, info in sorted(results.items()): + if info["status"] == "trained": + print( + f"{key:<14} {'OK':<10} {info['samples']:<8} " + f"{info['brier']:<9.4f} {info['ece']:<8.4f} " + f"{info['mean_predicted']:<9.4f} {info['mean_actual']}" + ) + else: + print(f"{key:<14} {'SKIP':<10} {info['samples']:<8}") + print("=" * 70) + + total_time = time.time() - t0 + print(f"\nTotal time: {total_time:.1f}s") + print(f"Calibration models saved to: {os.path.join(AI_ENGINE_DIR, 'models', 'calibration')}/") + + cur.close() + conn.close() + + +def main(): + parser = argparse.ArgumentParser(description="Backfill calibration from historical matches") + parser.add_argument("--limit", type=int, default=50000, + help="Max matches to process (default: 50000)") + parser.add_argument("--min-samples", type=int, default=100, + help="Min samples per market for calibration (default: 100)") + args = parser.parse_args() + run_backfill(args) + + +if __name__ == "__main__": + main() diff --git a/ai-engine/scripts/backtest_consistency.py b/ai-engine/scripts/backtest_consistency.py new file mode 100644 index 0000000..4bed8c9 --- /dev/null +++ b/ai-engine/scripts/backtest_consistency.py @@ -0,0 +1,352 @@ +""" +TutarsΔ±zlΔ±k BazlΔ± Backtest +============================ +Modeller arasΔ± tutarsΔ±zlığı ΓΆlΓ§er, tutarlΔ± maΓ§larda bahis aΓ§Δ±lsaydΔ± +ROI ne olurdu hesaplar. + +MantΔ±k: +- Her maΓ§ iΓ§in market'ler arasΔ± Γ§elişkileri tespit et +- TutarsΔ±z maΓ§larΔ± filtrele +- TutarlΔ± maΓ§larda hit rate ve ROI hesapla + +Usage: + python scripts/backtest_consistency.py +""" + +import os, sys, json +import numpy as np +import pandas as pd +import xgboost as xgb +from sklearn.metrics import accuracy_score + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +DATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'data', 'training_data.csv') +MODELS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'models', 'v25') + +SKIP_COLS = { + 'match_id','home_team_id','away_team_id','league_id','mst_utc', + 'score_home','score_away','total_goals','ht_score_home','ht_score_away','ht_total_goals', + 'label_ms','label_ou05','label_ou15','label_ou25','label_ou35','label_btts', + 'label_ht_result','label_ht_ou05','label_ht_ou15','label_ht_ft', + 'label_odd_even','label_yellow_cards','label_cards_ou45','label_handicap_ms', +} + + +def load_model(market: str): + path = os.path.join(MODELS_DIR, f'xgb_v25_{market}.json') + if not os.path.exists(path): + return None + b = xgb.Booster() + b.load_model(path) + return b + + +def predict_proba(model, X: np.ndarray, feature_cols: list, n_class: int): + dmat = xgb.DMatrix(pd.DataFrame(X, columns=feature_cols)) + raw = model.predict(dmat) + if n_class > 2: + return raw.reshape(-1, n_class) + return np.column_stack([1 - raw, raw]) + + +def consistency_score(probs: dict) -> tuple[float, list]: + """ + Market'ler arasΔ± tutarsΔ±zlığı hesapla. + 0 = tamamen tutarlΔ±, 1 = tamamen Γ§elişkili. + + Kontrol edilen Γ§elişkiler: + 1. OU15 ΓΌst yΓΌksek ama OU25 ΓΌst de yΓΌksek β†’ ok + OU15 ΓΌst yΓΌksek ama OU25 alt yΓΌksek β†’ Γ‡ELISKI (1 gol bekleniyor ama 2.5+ da bekleniyor?) + + 2. HT_OU05 ΓΌst yΓΌksek ama HT sonucu draw yΓΌksek β†’ Γ‡ELISKI + + 3. OU35 ΓΌst yΓΌksek ama BTTS düşük β†’ şüpheli + + 4. MS home yΓΌksek ama HT away yΓΌksek β†’ Γ§elişkili + """ + conflicts = [] + total_weight = 0 + total_conflict = 0 + + # OU tutarlΔ±lığı: P(OU25>0.5) <= P(OU15>0.5) matematiksel zorunluluk + ou15_over = probs.get('ou15_over', 0.5) + ou25_over = probs.get('ou25_over', 0.5) + ou35_over = probs.get('ou35_over', 0.5) + + # OU hiyerarşisi: ou35 <= ou25 <= ou15 olmalΔ± + if ou25_over > ou15_over + 0.05: + gap = ou25_over - ou15_over + conflicts.append(f'OU25>{ou25_over:.0%} > OU15>{ou15_over:.0%} (imkansΔ±z)') + total_conflict += gap * 2 + total_weight += 1 + + if ou35_over > ou25_over + 0.05: + gap = ou35_over - ou25_over + conflicts.append(f'OU35>{ou35_over:.0%} > OU25>{ou25_over:.0%} (imkansΔ±z)') + total_conflict += gap * 2 + total_weight += 1 + + # HT_OU05 ve HT sonuΓ§ tutarlΔ±lığı + ht_ou05_over = probs.get('ht_ou05_over', 0.5) + ht_draw_prob = probs.get('ht_draw', 0.34) + + # Δ°lk yarΔ±da gol bekleniyor ama beraberlik de bekleniyor (0-0 draw?) + # HT_OU05 >%70 ama HT draw >%50 β†’ Γ§elişkili (0-0 berabere Γ§ok?) + if ht_ou05_over > 0.70 and ht_draw_prob > 0.50: + conflict = min(ht_ou05_over - 0.5, ht_draw_prob - 0.4) + conflicts.append(f'HT_OU05>{ht_ou05_over:.0%} ama HT_Draw>{ht_draw_prob:.0%}') + total_conflict += conflict + total_weight += 1 + + # HT_OU05 ve HT_OU15 tutarlΔ±lığı + ht_ou15_over = probs.get('ht_ou15_over', 0.3) + if ht_ou15_over > ht_ou05_over + 0.05: + gap = ht_ou15_over - ht_ou05_over + conflicts.append(f'HT_OU15>{ht_ou15_over:.0%} > HT_OU05>{ht_ou05_over:.0%} (imkansΔ±z)') + total_conflict += gap * 2 + total_weight += 1 + + # MS ve OU tutarlΔ±lığı + ms_home = probs.get('ms_home', 0.33) + ms_away = probs.get('ms_away', 0.33) + btts_yes = probs.get('btts_yes', 0.5) + + # Tek takΔ±m galibiyeti kuvvetli ama BTTS yΓΌksek β†’ şüpheli + dominant = max(ms_home, ms_away) + if dominant > 0.65 and btts_yes > 0.65: + conflict = (dominant - 0.5) * (btts_yes - 0.5) + conflicts.append(f'MS dominant>{dominant:.0%} ama BTTS_Yes>{btts_yes:.0%}') + total_conflict += conflict * 0.5 + total_weight += 1 + + # OU25 ve BTTS tutarlΔ±lığı + # BTTS yΓΌksekse en az 2 gol β†’ OU25 ΓΌst de yΓΌksek olmalΔ± + if btts_yes > 0.65 and ou25_over < 0.45: + conflict = btts_yes - ou25_over + conflicts.append(f'BTTS_Yes>{btts_yes:.0%} ama OU25>{ou25_over:.0%} düşük') + total_conflict += conflict + total_weight += 1 + + # OU35 ΓΌst yΓΌksek ama BTTS düşük β†’ şüpheli (3+ gol ama tek takΔ±m mΔ±?) + if ou35_over > 0.45 and btts_yes < 0.40: + conflict = (ou35_over - 0.35) * (0.5 - btts_yes) + conflicts.append(f'OU35>{ou35_over:.0%} ama BTTS_Yes<{btts_yes:.0%}') + total_conflict += conflict + total_weight += 1 + + score = min(1.0, total_conflict / max(total_weight * 0.3, 0.1)) + return score, conflicts + + +def main(): + print('Loading data...') + df = pd.read_csv(DATA_PATH, low_memory=False) + + # Son %20 = test seti (kronolojik) + df = df.sort_values('mst_utc') + n_test = int(len(df) * 0.20) + df_test = df.tail(n_test).copy() + print(f'Test seti: {len(df_test):,} maΓ§') + + feature_cols = [c for c in df.columns if c not in SKIP_COLS] + + # Modelleri yΓΌkle + print('Modeller yΓΌkleniyor...') + models = { + 'ms': (load_model('ms'), 3), + 'ou15': (load_model('ou15'), 2), + 'ou25': (load_model('ou25'), 2), + 'ou35': (load_model('ou35'), 2), + 'btts': (load_model('btts'), 2), + 'ht_result':(load_model('ht_result'), 3), + 'ht_ou05': (load_model('ht_ou05'), 2), + 'ht_ou15': (load_model('ht_ou15'), 2), + } + models = {k: v for k, v in models.items() if v[0] is not None} + print(f'YΓΌklenen model: {list(models.keys())}') + + X = df_test[feature_cols].fillna(0).values + + # TΓΌm tahminleri al + print('Tahminler yapΔ±lΔ±yor...') + preds = {} + for mkey, (model, n_class) in models.items(): + p = predict_proba(model, X, feature_cols, n_class) + preds[mkey] = p + + # Her maΓ§ iΓ§in tutarsΔ±zlΔ±k skoru ve tahmin kararΔ± + results = [] + for i in range(len(df_test)): + row = df_test.iloc[i] + + # OlasΔ±lΔ±klarΔ± topla + probs = {} + if 'ms' in preds: + probs['ms_home'] = preds['ms'][i][0] + probs['ms_draw'] = preds['ms'][i][1] + probs['ms_away'] = preds['ms'][i][2] + if 'ou15' in preds: + probs['ou15_over'] = preds['ou15'][i][1] + if 'ou25' in preds: + probs['ou25_over'] = preds['ou25'][i][1] + if 'ou35' in preds: + probs['ou35_over'] = preds['ou35'][i][1] + if 'btts' in preds: + probs['btts_yes'] = preds['btts'][i][1] + if 'ht_result' in preds: + probs['ht_home'] = preds['ht_result'][i][0] + probs['ht_draw'] = preds['ht_result'][i][1] + probs['ht_away'] = preds['ht_result'][i][2] + if 'ht_ou05' in preds: + probs['ht_ou05_over'] = preds['ht_ou05'][i][1] + if 'ht_ou15' in preds: + probs['ht_ou15_over'] = preds['ht_ou15'][i][1] + + c_score, conflicts = consistency_score(probs) + + # GerΓ§ek sonuΓ§lar + actual = { + 'ms': int(row.get('label_ms', -1)), + 'ou15': int(row.get('label_ou15', -1)), + 'ou25': int(row.get('label_ou25', -1)), + 'ou35': int(row.get('label_ou35', -1)), + 'btts': int(row.get('label_btts', -1)), + } + + # Her market iΓ§in tahmin ve doğruluk + market_results = {} + for mkt, label_key in [('ms','ms'),('ou15','ou15'),('ou25','ou25'), + ('ou35','ou35'),('btts','btts')]: + if mkt not in preds or actual[label_key] < 0: + continue + pred_class = int(np.argmax(preds[mkt][i])) + correct = int(pred_class == actual[label_key]) + + # Odds (implied prob β†’ odds = 1/prob) + pred_prob = float(preds[mkt][i][pred_class]) + implied_odds = 1 / pred_prob if pred_prob > 0.01 else 10.0 + # ROI hesabΔ±: 1 birim bahis, kazanΔ±rsa (odds-1) kazanΓ§, kaybederse -1 + roi = (implied_odds - 1) * correct - (1 - correct) + + market_results[mkt] = { + 'pred': pred_class, + 'actual': actual[label_key], + 'correct': correct, + 'prob': pred_prob, + 'roi': roi, + } + + results.append({ + 'idx': i, + 'consistency_score': c_score, + 'conflicts': conflicts, + 'probs': probs, + 'market_results': market_results, + }) + + df_results = pd.DataFrame([{ + 'consistency_score': r['consistency_score'], + 'n_conflicts': len(r['conflicts']), + **{f'{m}_correct': r['market_results'].get(m, {}).get('correct', None) + for m in ['ms','ou15','ou25','ou35','btts']}, + **{f'{m}_roi': r['market_results'].get(m, {}).get('roi', None) + for m in ['ms','ou15','ou25','ou35','btts']}, + } for r in results]) + + # ── Analiz ────────────────────────────────────────────────────────── + print(f'\n{"="*70}') + print('TUTARSIZLIK ANALΔ°ZΔ°') + print(f'{"="*70}') + + thresholds = [0.0, 0.1, 0.2, 0.3, 0.5] + markets = ['ms', 'ou15', 'ou25', 'ou35', 'btts'] + + for t in thresholds: + mask = df_results['consistency_score'] <= t + n = mask.sum() + if n < 50: + continue + + print(f'\n[TutarsΔ±zlΔ±k <= {t:.1f}] β†’ {n:,} maΓ§ ({n/len(df_results)*100:.0f}%)') + print(f' {"Market":<8} {"HitRate":>8} {"ROI/bahis":>10} {"Toplam ROI":>12}') + print(f' {"-"*42}') + for m in markets: + col_c = f'{m}_correct' + col_r = f'{m}_roi' + if col_c not in df_results.columns: + continue + sub = df_results[mask][col_c].dropna() + roi_sub = df_results[mask][col_r].dropna() + if len(sub) < 20: + continue + hit = sub.mean() + avg_roi = roi_sub.mean() + total_roi = roi_sub.sum() + print(f' {m:<8} {hit:>7.1%} {avg_roi:>+9.3f} {total_roi:>+11.1f}') + + # Γ‡elişki tΓΌrlerine gΓΆre breakdown + print(f'\n{"="*70}') + print('EN SIK Γ‡ELIŞKILER') + print(f'{"="*70}') + all_conflicts = [c for r in results for c in r['conflicts']] + from collections import Counter + for conflict, cnt in Counter(all_conflicts).most_common(10): + print(f' {cnt:>5}x {conflict}') + + # TutarsΔ±zlΔ±k dağılΔ±mΔ± + print(f'\n{"="*70}') + print('TUTARSIZLIK DAĞILIMI') + print(f'{"="*70}') + for label, lo, hi in [ + ('Tamamen tutarlΔ±', 0.0, 0.05), + ('Γ‡ok tutarlΔ±', 0.05, 0.15), + ('Orta', 0.15, 0.30), + ('TutarsΔ±z', 0.30, 0.50), + ('Γ‡ok tutarsΔ±z', 0.50, 1.01), + ]: + mask = (df_results['consistency_score'] >= lo) & (df_results['consistency_score'] < hi) + n = mask.sum() + ou25_hit = df_results[mask]['ou25_correct'].mean() + ms_hit = df_results[mask]['ms_correct'].mean() + print(f' {label:<20} {n:>6,} maΓ§ ({n/len(df_results)*100:>4.0f}%) | ' + f'MS={ms_hit:.0%} OU25={ou25_hit:.0%}') + + # Raporu kaydet + report = { + 'total_test': len(df_results), + 'thresholds': {}, + } + for t in thresholds: + mask = df_results['consistency_score'] <= t + n = mask.sum() + report['thresholds'][str(t)] = { + 'n_matches': int(n), + 'pct': round(n/len(df_results)*100, 1), + 'markets': {}, + } + for m in markets: + col_c = f'{m}_correct' + col_r = f'{m}_roi' + if col_c not in df_results.columns: + continue + sub_c = df_results[mask][col_c].dropna() + sub_r = df_results[mask][col_r].dropna() + if len(sub_c) > 0: + report['thresholds'][str(t)]['markets'][m] = { + 'hit_rate': round(float(sub_c.mean()), 4), + 'avg_roi': round(float(sub_r.mean()), 4), + 'total_roi': round(float(sub_r.sum()), 2), + } + + out_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'reports', 'backtest_consistency.json') + with open(out_path, 'w') as f: + json.dump(report, f, indent=2) + print(f'\nRapor: {out_path}') + + +if __name__ == '__main__': + main() diff --git a/ai-engine/scripts/backtest_league_models.py b/ai-engine/scripts/backtest_league_models.py new file mode 100644 index 0000000..40d3963 --- /dev/null +++ b/ai-engine/scripts/backtest_league_models.py @@ -0,0 +1,310 @@ +""" +League Model Backtest β€” Son 100+ MaΓ§ +====================================== +Her lig iΓ§in en son 100-200 maΓ§Δ± (eğitim datasΔ±ndan bağımsΔ±z, test seti) +lig bazlΔ± modelle tahmin eder ve gerΓ§ek sonuΓ§la karşılaştΔ±rΔ±r. + +Usage: + python scripts/backtest_league_models.py + python scripts/backtest_league_models.py --min-matches 150 +""" + +import os, sys, json, warnings, argparse +import numpy as np +import pandas as pd +import xgboost as xgb +from sklearn.metrics import accuracy_score + +warnings.filterwarnings("ignore") +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from models.league_model import get_league_model_loader, MARKET_META, FILE_TO_SIGNAL + +AI_ENGINE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA_PATH = os.path.join(AI_ENGINE_DIR, "data", "training_data.csv") +REPORTS_DIR = os.path.join(AI_ENGINE_DIR, "reports") +QL_PATH = os.path.join(os.path.dirname(AI_ENGINE_DIR), "qualified_leagues.json") + +# GerΓ§ek label kolonlarΔ± (CSV'den) +LABEL_COLS = { + "MS": "label_ms", + "OU15": "label_ou15", + "OU25": "label_ou25", + "OU35": "label_ou35", + "BTTS": "label_btts", + "HT": "label_ht_result", + "HT_OU05": "label_ht_ou05", + "HT_OU15": "label_ht_ou15", + "HTFT": "label_ht_ft", + "OE": "label_odd_even", + "CARDS": "label_cards_ou45", + "HCAP": "label_handicap_ms", +} + +# Model dosya adΔ± β†’ signal key eşlemesi +SIGNAL_TO_FILE = {v: k for k, v in FILE_TO_SIGNAL.items()} + +SKIP_COLS = { + "match_id","home_team_id","away_team_id","league_id","mst_utc", + "score_home","score_away","total_goals","ht_score_home","ht_score_away","ht_total_goals", + "label_ms","label_ou05","label_ou15","label_ou25","label_ou35","label_btts", + "label_ht_result","label_ht_ou05","label_ht_ou15","label_ht_ft", + "label_odd_even","label_yellow_cards","label_cards_ou45","label_handicap_ms", +} + + +def backtest_league( + league_id: str, + df_league: pd.DataFrame, + feature_cols: list, + league_model, + n_test: int, +) -> dict: + """Son n_test maΓ§Δ± backtest et, her market iΓ§in doğruluk dΓΆndΓΌr.""" + df_sorted = df_league.sort_values("mst_utc") + df_test = df_sorted.tail(n_test) + + X = df_test[feature_cols].fillna(0) + results = {} + + for sig_key, mfile_key in SIGNAL_TO_FILE.items(): + label_col = LABEL_COLS.get(sig_key) + if not label_col or label_col not in df_test.columns: + continue + + y_true = df_test[label_col].dropna().values + if len(y_true) < 30: + continue + + # League-specific model varsa kullan + if league_model and league_model.has_market(mfile_key): + probs_list = [] + preds = [] + for _, row in df_test.iterrows(): + feat = row[feature_cols].fillna(0).to_dict() + probs = league_model.predict_market(mfile_key, feat) + if probs: + best = max(probs, key=probs.__getitem__) + meta = MARKET_META[mfile_key] + labels = meta[1] + pred_idx = labels.index(best) + preds.append(pred_idx) + probs_list.append(list(probs.values())) + + if not preds: + continue + + y_valid = df_test[label_col].dropna() + if len(preds) != len(y_valid): + min_len = min(len(preds), len(y_valid)) + preds = preds[:min_len] + y_valid = y_valid.values[:min_len] + else: + y_valid = y_valid.values + + acc = accuracy_score(y_valid, preds) + results[sig_key] = { + "accuracy": round(acc, 4), + "n": len(preds), + "source": "league_specific", + } + + return results + + +def backtest_with_general_v25( + df_test: pd.DataFrame, + feature_cols: list, +) -> dict: + """Genel V25 modeli ile backtest.""" + try: + from models.v25_ensemble import get_v25_predictor + v25 = get_v25_predictor() + if not v25._loaded: + v25.load_models() + except Exception as e: + return {} + + X = df_test[feature_cols].fillna(0) + results = {} + + mkey_map = { + "MS": ("ms", {"1": 0, "X": 1, "2": 2}), + "OU15": ("ou15", {"Over": 0, "Under": 1}), + "OU25": ("ou25", {"Over": 0, "Under": 1}), + "OU35": ("ou35", {"Over": 0, "Under": 1}), + "BTTS": ("btts", {"Yes": 0, "No": 1}), + } + + for sig_key, (mkey, label_to_idx) in mkey_map.items(): + label_col = LABEL_COLS.get(sig_key) + if not label_col or label_col not in df_test.columns: + continue + y_true = df_test[label_col].dropna().values + if len(y_true) < 30 or not v25.has_market(mkey): + continue + + try: + dmat = xgb.DMatrix(X.values, feature_names=feature_cols) + models_v25 = v25.models.get(mkey, {}) + if "xgb" not in models_v25: + continue + raw = models_v25["xgb"].predict(dmat) + num_class = list(MARKET_META.get(mkey, (2,)))[0] + + if num_class > 2: + raw = raw.reshape(-1, num_class) + preds = np.argmax(raw, axis=1) + else: + preds = (raw >= 0.5).astype(int) + + acc = accuracy_score(y_true, preds) + results[sig_key] = { + "accuracy": round(acc, 4), + "n": len(preds), + "source": "general_v25", + } + except Exception: + continue + + return results + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--min-matches", type=int, default=100) + parser.add_argument("--test-size", type=int, default=150, + help="Son kaΓ§ maΓ§Δ± test iΓ§in kullan (min 100)") + args = parser.parse_args() + n_test = max(args.min_matches, args.test_size) + + print(f"Loading training data ...") + df = pd.read_csv(DATA_PATH, low_memory=False) + feature_cols = [c for c in df.columns if c not in SKIP_COLS] + print(f" {len(df):,} maΓ§ | {len(feature_cols)} feature") + + qualified = json.load(open(QL_PATH)) if os.path.exists(QL_PATH) else [] + loader = get_league_model_loader() + + try: + import psycopg2 + from data.db import get_clean_dsn + conn = psycopg2.connect(get_clean_dsn()) + cur = conn.cursor() + cur.execute("SELECT id, name FROM leagues WHERE id = ANY(%s)", (qualified,)) + league_names = {r[0]: r[1] for r in cur.fetchall()} + conn.close() + except Exception: + league_names = {} + + counts = df[df["league_id"].isin(qualified)].groupby("league_id").size() + leagues_to_test = counts[counts >= n_test].index.tolist() + print(f"\nBacktest: {len(leagues_to_test)} lig (>={n_test} maΓ§) | son {n_test} maΓ§ kullanΔ±lacak\n") + + all_results = [] + markets_order = ["MS", "OU15", "OU25", "OU35", "BTTS", "HT", "HT_OU05", "HT_OU15", "HTFT", "OE", "CARDS", "HCAP"] + + header = f"{'Liga':<35} {'MaΓ§':>5} | " + " | ".join(f"{m:>7}" for m in markets_order) + print(header) + print("-" * len(header)) + + for league_id in leagues_to_test: + df_league = df[df["league_id"] == league_id].copy() + name = league_names.get(league_id, league_id[:20]) + + league_model = loader.get(league_id) + + if league_model and league_model.models: + # Batch predict from CSV features (fast) + df_test = df_league.sort_values("mst_utc").tail(n_test) + X = df_test[feature_cols].fillna(0) + mkt_results = {} + + for mfile_key in list(league_model.models.keys()): + sig_key = FILE_TO_SIGNAL.get(mfile_key) + if not sig_key: + continue + label_col = LABEL_COLS.get(sig_key) + if not label_col or label_col not in df_test.columns: + continue + y_true = df_test[label_col].dropna().values + if len(y_true) < 30: + continue + + try: + dmat = xgb.DMatrix(X.values, feature_names=feature_cols) + raw = league_model.models[mfile_key].predict(dmat) + nc = MARKET_META[mfile_key][0] + if nc > 2: + preds = np.argmax(raw.reshape(-1, nc), axis=1) + else: + preds = (raw >= 0.5).astype(int) + + acc = accuracy_score(y_true[:len(preds)], preds[:len(y_true)]) + mkt_results[sig_key] = {"accuracy": round(float(acc), 4), "n": len(preds), "source": "league_xgb"} + except Exception as e: + mkt_results[sig_key] = {"error": str(e)} + + # Fill missing markets with general V25 + missing_mkts_df = df_league.sort_values("mst_utc").tail(n_test) + gen_results = backtest_with_general_v25(missing_mkts_df, feature_cols) + for k, v in gen_results.items(): + if k not in mkt_results: + mkt_results[k] = {**v, "source": "general_v25_fallback"} + else: + # No league model β€” use general V25 + df_test = df_league.sort_values("mst_utc").tail(n_test) + mkt_results = backtest_with_general_v25(df_test, feature_cols) + for k in mkt_results: + mkt_results[k]["source"] = "general_v25" + + n_used = min(n_test, len(df_league)) + + # Print row + accs = [] + for m in markets_order: + r = mkt_results.get(m, {}) + if "accuracy" in r: + accs.append(f"{r['accuracy']*100:>6.1f}%") + else: + accs.append(f"{'β€”':>7}") + print(f"{name:<35} {n_used:>5} | " + " | ".join(accs)) + + all_results.append({ + "league_id": league_id, + "league_name": name, + "n_tested": n_used, + "markets": mkt_results, + }) + + # ── Γ–zet ────────────────────────────────────────────────────── + print("\n" + "=" * len(header)) + print("ORTALAMA DOĞRULUK (tΓΌm ligler):") + for m in markets_order: + accs = [r["markets"][m]["accuracy"] for r in all_results if m in r["markets"] and "accuracy" in r["markets"][m]] + if accs: + print(f" {m:<10}: {np.mean(accs)*100:.1f}% (min={min(accs)*100:.1f}% max={max(accs)*100:.1f}% n_leagues={len(accs)})") + + # En iyi / en kΓΆtΓΌ MS ligleri + ms_sorted = sorted( + [(r["league_name"], r["markets"].get("MS",{}).get("accuracy",0), r["n_tested"]) + for r in all_results if "MS" in r["markets"] and "accuracy" in r["markets"]["MS"]], + key=lambda x: x[1], reverse=True + ) + print("\nEN Δ°YΔ° MS (Top 10):") + for name, acc, n in ms_sorted[:10]: + print(f" {name:<35} {acc*100:.1f}% ({n} maΓ§)") + print("\nEN KΓ–TÜ MS (Bottom 10):") + for name, acc, n in ms_sorted[-10:]: + print(f" {name:<35} {acc*100:.1f}% ({n} maΓ§)") + + # Save + report = {"generated_at": pd.Timestamp.now().isoformat(), "n_test_per_league": n_test, "results": all_results} + out_path = os.path.join(REPORTS_DIR, "backtest_league_results.json") + with open(out_path, "w") as f: + json.dump(report, f, indent=2) + print(f"\nRapor: {out_path}") + + +if __name__ == "__main__": + main() diff --git a/ai-engine/scripts/backtest_real.py b/ai-engine/scripts/backtest_real.py index a62d349..51d9890 100644 --- a/ai-engine/scripts/backtest_real.py +++ b/ai-engine/scripts/backtest_real.py @@ -1,223 +1,136 @@ """ -Real AI Engine Backtest Script -============================== -Uses the ACTUAL models (V20/V25 Ensemble) to predict historical matches. - -Usage: - python ai-engine/scripts/backtest_real.py +GerΓ§ek Odds BazlΔ± Backtest +============================ +Model olasΔ±lığı vs gerΓ§ek bookmaker odds karşılaştΔ±rΔ±r. +Edge varsa bahis aΓ§Δ±ldığı varsayΔ±lΔ±r, gerΓ§ek ROI hesaplanΔ±r. """ -import os -import sys -import json -import time -import psycopg2 -from psycopg2.extras import RealDictCursor -from datetime import datetime +import os, sys, json +import numpy as np +import pandas as pd +import xgboost as xgb -# Add paths -AI_DIR = os.path.dirname(os.path.abspath(__file__)) -ROOT_DIR = os.path.dirname(AI_DIR) -sys.path.insert(0, ROOT_DIR) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -# Fix for Windows path issues in scripts -if "scripts" in os.path.basename(AI_DIR): - ROOT_DIR = os.path.dirname(ROOT_DIR) # One level up if inside scripts folder +DATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data', 'training_data.csv') +MODELS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'models', 'v25') +REPORT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'reports') -from services.single_match_orchestrator import get_single_match_orchestrator, MatchData +SKIP_COLS = { + 'match_id','home_team_id','away_team_id','league_id','mst_utc', + 'score_home','score_away','total_goals','ht_score_home','ht_score_away','ht_total_goals', + 'label_ms','label_ou05','label_ou15','label_ou25','label_ou35','label_btts', + 'label_ht_result','label_ht_ou05','label_ht_ou15','label_ht_ft', + 'label_odd_even','label_yellow_cards','label_cards_ou45','label_handicap_ms', +} -def get_clean_dsn() -> str: - return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" +# (model_key, n_class, pred_class, label_col, odds_col, isim) +MARKETS = [ + ('ms', 3, 0, 'label_ms', 'odds_ms_h', 'MS-Ev'), + ('ms', 3, 1, 'label_ms', 'odds_ms_d', 'MS-Ber'), + ('ms', 3, 2, 'label_ms', 'odds_ms_a', 'MS-Dep'), + ('ou15', 2, 1, 'label_ou15', 'odds_ou15_o', 'OU15-Ust'), + ('ou15', 2, 0, 'label_ou15', 'odds_ou15_u', 'OU15-Alt'), + ('ou25', 2, 1, 'label_ou25', 'odds_ou25_o', 'OU25-Ust'), + ('ou25', 2, 0, 'label_ou25', 'odds_ou25_u', 'OU25-Alt'), + ('ou35', 2, 1, 'label_ou35', 'odds_ou35_o', 'OU35-Ust'), + ('ou35', 2, 0, 'label_ou35', 'odds_ou35_u', 'OU35-Alt'), + ('btts', 2, 1, 'label_btts', 'odds_btts_y', 'BTTS-Var'), + ('btts', 2, 0, 'label_btts', 'odds_btts_n', 'BTTS-Yok'), +] -def run_backtest(): - print("πŸš€ REAL AI BACKTEST: Sept 13, 2024 - Top Leagues") - print("🧠 Engine: V30 Ensemble (V20+V25)") - print("="*60) +MIN_ODDS = 1.10 +MAX_ODDS = 10.0 - # Load Top Leagues - leagues_path = os.path.join(ROOT_DIR, "top_leagues.json") - try: - with open(leagues_path, 'r') as f: - top_leagues = json.load(f) - league_ids = tuple(str(lid) for lid in top_leagues) - print(f"πŸ“‹ Loaded {len(top_leagues)} top leagues.") - except Exception as e: - print(f"❌ Error loading top_leagues.json: {e}") - return - # Date Range (Sept 13, 2024) - start_dt = datetime(2024, 9, 13, 0, 0, 0) - end_dt = datetime(2024, 9, 13, 23, 59, 59) - start_ts = int(start_dt.timestamp() * 1000) - end_ts = int(end_dt.timestamp() * 1000) +def load_model(market): + path = os.path.join(MODELS_DIR, f'xgb_v25_{market}.json') + if not os.path.exists(path): + return None + b = xgb.Booster() + b.load_model(path) + return b - dsn = get_clean_dsn() - conn = psycopg2.connect(dsn) - cur = conn.cursor(cursor_factory=RealDictCursor) - # Fetch Matches - cur.execute(""" - SELECT m.id, m.match_name, m.home_team_id, m.away_team_id, - m.mst_utc, m.league_id, m.status, m.score_home, m.score_away, - t1.name as home_team, t2.name as away_team, - l.name as league_name - FROM matches m - LEFT JOIN teams t1 ON m.home_team_id = t1.id - LEFT JOIN teams t2 ON m.away_team_id = t2.id - LEFT JOIN leagues l ON m.league_id = l.id - WHERE m.mst_utc BETWEEN %s AND %s - AND m.league_id IN %s - AND m.status = 'FT' - ORDER BY m.mst_utc ASC - LIMIT 20 -- Limit to 20 matches to avoid running for hours on a single backtest - """, (start_ts, end_ts, league_ids)) - - rows = cur.fetchall() - print(f"πŸ“Š Found {len(rows)} finished matches. Starting AI Analysis...") +def main(): + print('Veri yukleniyor...') + df = pd.read_csv(DATA_PATH, low_memory=False) + df = df.sort_values('mst_utc') + n_test = int(len(df) * 0.20) + df_test = df.tail(n_test).copy().reset_index(drop=True) + print(f'Test seti: {len(df_test):,} mac') - if not rows: - print("⚠️ No matches found for this date.") - cur.close() - conn.close() - return + feature_cols = [c for c in df.columns if c not in SKIP_COLS] + X = df_test[feature_cols].fillna(0).values - # Initialize AI Engine - try: - orchestrator = get_single_match_orchestrator() - print("βœ… AI Engine (SingleMatchOrchestrator) Loaded.") - except Exception as e: - print(f"❌ Failed to load AI Engine: {e}") - print("πŸ’‘ Make sure models are trained/present in ai-engine/models/") - cur.close() - conn.close() - return + # Modelleri yukle + loaded = {} + for mkey, n_class, *_ in MARKETS: + if mkey not in loaded: + m = load_model(mkey) + if m: + loaded[mkey] = (m, n_class) + print(f'Modeller: {list(loaded.keys())}') - # ─── Backtest Loop ─── - total_matches_analyzed = 0 - bets_skipped = 0 - bets_played = 0 - bets_won = 0 - total_profit = 0.0 - - # Thresholds matching the NEW Skip Logic - MIN_CONF = 45.0 + # Toplu tahmin + raw_preds = {} + for mkey, (model, n_class) in loaded.items(): + dmat = xgb.DMatrix(pd.DataFrame(X, columns=feature_cols)) + raw = model.predict(dmat) + raw_preds[mkey] = raw.reshape(-1, n_class) if n_class > 2 else np.column_stack([1-raw, raw]) - start_time = time.time() + # Backtest + all_results = [] + print(f'\n{"Market":<12} {"Edge>=":>7} {"Bahis":>7} {"Hit%":>7} {"AvgOdds":>9} {"ROI/b":>8} {"Toplam":>10}') + print('-' * 65) - for i, row in enumerate(rows): - match_id = str(row['id']) - home_team = row['home_team'] - away_team = row['away_team'] - home_score = row['score_home'] - away_score = row['score_away'] - - print(f"\n[{i+1}/{len(rows)}] Analyzing: {home_team} vs {away_team} ...") + for mkey, n_class, pred_cls, label_col, odds_col, isim in MARKETS: + if mkey not in raw_preds or label_col not in df_test.columns or odds_col not in df_test.columns: + continue - try: - # 1. AI PREDICTION (Actual Model Call) - prediction = orchestrator.analyze_match(match_id) - - if not prediction: - print(f" ⚠️ AI returned no prediction.") + mp = raw_preds[mkey][:, pred_cls] + act = pd.to_numeric(df_test[label_col], errors='coerce').values + bko = pd.to_numeric(df_test[odds_col], errors='coerce').values + + valid = (~np.isnan(act) & ~np.isnan(bko) & + (bko >= MIN_ODDS) & (bko <= MAX_ODDS)) + mp, act, bko = mp[valid], act[valid].astype(int), bko[valid] + implied = 1.0 / bko + edge = mp - implied + + print(f'\n{isim}:') + for min_e in [0.02, 0.03, 0.05, 0.07, 0.10]: + mask = edge >= min_e + n = mask.sum() + if n < 20: continue + won = (act[mask] == pred_cls).astype(int) + roi = (bko[mask] - 1) * won - (1 - won) + hit = won.mean() + avg_roi = roi.mean() + total = roi.sum() + avg_odds = bko[mask].mean() + sign = '+' if total > 0 else '' + print(f' edge>={min_e:+.0%} n={n:>5,} hit={hit:.1%} odds={avg_odds:.2f} roi/b={avg_roi:+.3f} toplam={sign}{total:.1f}') + all_results.append({'market': isim, 'min_edge': min_e, 'n': n, + 'hit': round(hit, 4), 'avg_odds': round(avg_odds, 3), + 'avg_roi': round(avg_roi, 4), 'total_roi': round(total, 2)}) - total_matches_analyzed += 1 - - # 2. Extract Main Pick - main_pick = prediction.get("main_pick") or {} - pick_name = main_pick.get("pick") - confidence = main_pick.get("confidence", 0) - odds = main_pick.get("odds", 0) + # En iyi + winners = sorted([r for r in all_results if r['total_roi'] > 0], + key=lambda x: x['avg_roi'], reverse=True) + print(f'\n{"="*65}') + print('KAZANCLI KOMBINASYONLAR (total_roi > 0):') + print(f'{"="*65}') + for r in winners[:20]: + print(f' {r["market"]:<12} edge>={r["min_edge"]:+.0%} | n={r["n"]:>5,} | ' + f'hit={r["hit"]:.0%} | roi/b={r["avg_roi"]:+.3f} | toplam={r["total_roi"]:+.1f}') - if not pick_name or not confidence: - print(f" ⚠️ No main pick found in prediction.") - continue + os.makedirs(REPORT_DIR, exist_ok=True) + with open(os.path.join(REPORT_DIR, 'backtest_real_odds.json'), 'w') as f: + json.dump(all_results, f, indent=2) + print(f'\nRapor kaydedildi.') - print(f" πŸ€– Pick: {pick_name} | Conf: {confidence}% | Odds: {odds}") - # 3. Apply Skip Logic (New Backtest Logic) - if confidence < MIN_CONF: - print(f" 🚫 SKIPPED (Confidence {confidence}% < {MIN_CONF}%)") - bets_skipped += 1 - continue - - if odds > 0: - implied_prob = 1.0 / odds - my_prob = confidence / 100.0 - if my_prob - implied_prob < -0.03: # Negative edge - print(f" 🚫 SKIPPED (Negative Edge)") - bets_skipped += 1 - continue - - # 4. Bet Played - bets_played += 1 - print(f" 🎲 BET PLAYED: {pick_name} @ {odds}") - - # 5. Resolve Bet - won = False - # Basic resolution logic (Need to parse pick_name like "1", "X", "2", "2.5 Üst", etc.) - pick_clean = str(pick_name).upper() - - # MS - if pick_clean in ["1", "MS 1"] and home_score > away_score: won = True - elif pick_clean in ["X", "MS X"] and home_score == away_score: won = True - elif pick_clean in ["2", "MS 2"] and away_score > home_score: won = True - - # OU25 - elif "ÜST" in pick_clean or "OVER" in pick_clean: - if (home_score + away_score) > 2.5: won = True - elif "ALT" in pick_clean or "UNDER" in pick_clean: - if (home_score + away_score) < 2.5: won = True - - # BTTS - elif "VAR" in pick_clean and home_score > 0 and away_score > 0: won = True - elif "YOK" in pick_clean and (home_score == 0 or away_score == 0): won = True - - if won: - bets_won += 1 - profit = odds - 1.0 - print(f" βœ… WON! (+{profit:.2f} units)") - else: - profit = -1.0 - print(f" ❌ LOST! (-1.00 units)") - - total_profit += profit - - except Exception as e: - print(f" πŸ’₯ Error during analysis: {e}") - - elapsed = time.time() - start_time - - # ─── FINAL REPORT ─── - print("\n" + "="*60) - print("πŸ“ˆ REAL AI BACKTEST RESULTS") - print(f"πŸ•’ Time taken: {elapsed:.1f} seconds") - print("="*60) - print(f"πŸ“Š Matches Analyzed: {total_matches_analyzed}") - print(f"🚫 Bets SKIPPED: {bets_skipped}") - print(f"βœ… Bets PLAYED: {bets_played}") - - if bets_played > 0: - win_rate = (bets_won / bets_played) * 100 - roi = (total_profit / bets_played) * 100 - yield_val = total_profit # Net Units - - print(f"πŸ† Bets Won: {bets_won}") - print(f"πŸ’€ Bets Lost: {bets_played - bets_won}") - print("-" * 40) - print(f" Win Rate: {win_rate:.2f}%") - print(f"πŸ’° Total Profit (Units): {total_profit:.2f}") - print(f"πŸ“Š ROI: {roi:.2f}%") - - if roi > 0: - print("🟒 STRATEGY IS PROFITABLE!") - else: - print("πŸ”΄ STRATEGY IS LOSING") - else: - print("⚠️ No bets were played. All were skipped or failed.") - - cur.close() - conn.close() - -if __name__ == "__main__": - run_backtest() +if __name__ == '__main__': + main() diff --git a/ai-engine/scripts/extract_training_data.py b/ai-engine/scripts/extract_training_data.py index 8226a21..25513d9 100755 --- a/ai-engine/scripts/extract_training_data.py +++ b/ai-engine/scripts/extract_training_data.py @@ -128,7 +128,40 @@ FEATURE_COLS = [ "home_top_scorer_form", "away_top_scorer_form", "home_avg_player_exp", "away_avg_player_exp", "home_goals_diversity", "away_goals_diversity", - + + # V27 H2H Expanded (4) + "h2h_home_goals_avg", "h2h_away_goals_avg", + "h2h_recent_trend", "h2h_venue_advantage", + + # V27 Rolling Stats (13) + "home_rolling5_goals", "home_rolling5_conceded", + "home_rolling10_goals", "home_rolling10_conceded", + "home_rolling20_goals", "home_rolling20_conceded", + "away_rolling5_goals", "away_rolling5_conceded", + "away_rolling10_goals", "away_rolling10_conceded", + "home_rolling5_cs", "away_rolling5_cs", + + # V27 Venue Stats (4) + "home_venue_goals", "home_venue_conceded", + "away_venue_goals", "away_venue_conceded", + + # V27 Goal Trend (2) + "home_goal_trend", "away_goal_trend", + + # V27 Calendar (5) + "home_days_rest", "away_days_rest", + "match_month", "is_season_start", "is_season_end", + + # V27 Interaction (6) + "attack_vs_defense_home", "attack_vs_defense_away", + "xg_diff", "form_momentum_interaction", + "elo_form_consistency", "upset_x_elo_gap", + + # V27 League Expanded (5) + "league_home_win_rate", "league_draw_rate", + "league_btts_rate", "league_ou25_rate", + "league_reliability_score", + # Labels "score_home", "score_away", "total_goals", "ht_score_home", "ht_score_away", "ht_total_goals", @@ -296,6 +329,10 @@ class BatchDataLoader: SELECT league_id, AVG(score_home + score_away) as avg_goals, AVG(CASE WHEN score_home = 0 AND score_away = 0 THEN 1.0 ELSE 0.0 END) as zero_rate, + AVG(CASE WHEN score_home > score_away THEN 1.0 ELSE 0.0 END) as home_win_rate, + AVG(CASE WHEN score_home = score_away THEN 1.0 ELSE 0.0 END) as draw_rate, + AVG(CASE WHEN score_home > 0 AND score_away > 0 THEN 1.0 ELSE 0.0 END) as btts_rate, + AVG(CASE WHEN score_home + score_away > 2.5 THEN 1.0 ELSE 0.0 END) as ou25_rate, COUNT(*) as match_count FROM matches WHERE status = 'FT' @@ -304,12 +341,17 @@ class BatchDataLoader: AND league_id IN ({ph}) GROUP BY league_id """, self.top_league_ids) - - for league_id, avg_goals, zero_rate, cnt in self.cur.fetchall(): + + for row in self.cur.fetchall(): + league_id, avg_goals, zero_rate, home_win_rate, draw_rate, btts_rate, ou25_rate, cnt = row self.league_stats_cache[league_id] = { "avg_goals": float(avg_goals) if avg_goals else 2.5, "zero_rate": float(zero_rate) if zero_rate else 0.07, - "match_count": cnt + "home_win_rate": float(home_win_rate) if home_win_rate else 0.45, + "draw_rate": float(draw_rate) if draw_rate else 0.25, + "btts_rate": float(btts_rate) if btts_rate else 0.50, + "ou25_rate": float(ou25_rate) if ou25_rate else 0.50, + "match_count": cnt, } def _load_team_history(self): @@ -666,6 +708,9 @@ class FeatureExtractor: print(f"\nπŸ”„ Extracting features for {total} matches...", flush=True) + _last_print = t_start + _PRINT_INTERVAL = 60 # her dakika bir ilerleme + # Process chronologically β€” ELO grows as we go for i, m in enumerate(matches): ( @@ -683,17 +728,25 @@ class FeatureExtractor: league_name, ) = m - if i % 100 == 0 and i > 0: - elapsed = time.time() - t_start - rate = i / elapsed # matches per second + now = time.time() + if now - _last_print >= _PRINT_INTERVAL and i > 0: + elapsed = now - t_start + rate = i / elapsed remaining = (total - i) / rate if rate > 0 else 0 - pct = i / total * 100 + pct = i / total * 100 + eta_h = int(remaining // 3600) + eta_m = int((remaining % 3600) // 60) + eta_s = int(remaining % 60) + eta_str = (f"{eta_h}s {eta_m}dk" if eta_h else f"{eta_m}dk {eta_s}s") print( - f" [{i}/{total}] ({pct:.0f}%) | {rate:.1f} maΓ§/s | " - f"ETA: {remaining/60:.1f} dk | skipped: {skipped} | " - f"dq_rejected: {dq_rejected}", + f" ⏱ [{i:>6}/{total}] %{pct:>4.1f} | " + f"{rate:.1f} maΓ§/s | " + f"bitti: {len(rows):,} | " + f"atlanan: {skipped+dq_rejected} | " + f"ETA: {eta_str}", flush=True, ) + _last_print = now row = self._extract_one( mid, hid, aid, sh, sa, hth, hta, mst, lid, @@ -882,7 +935,10 @@ class FeatureExtractor: } # === LEAGUE FEATURES === - league = self.loader.league_stats_cache.get(lid, {"avg_goals": 2.5, "zero_rate": 0.07}) + league = self.loader.league_stats_cache.get(lid, { + "avg_goals": 2.5, "zero_rate": 0.07, "home_win_rate": 0.45, + "draw_rate": 0.25, "btts_rate": 0.50, "ou25_rate": 0.50, "match_count": 0, + }) league_features = { "league_avg_goals": league["avg_goals"], "league_zero_goal_rate": league["zero_rate"], @@ -953,6 +1009,11 @@ class FeatureExtractor: home_goals_form = home_sq.get('goals_form', 0) away_goals_form = away_sq.get('goals_form', 0) + # === V27 ROLLING / VENUE / CALENDAR FEATURES === + v27 = self._compute_v27_features(hid, aid, mst, elo_features, form_features, + home_momentum_score, away_momentum_score, + upset_feats, h2h_features, league) + # === ASSEMBLE ROW === row = { "match_id": mid, @@ -960,13 +1021,13 @@ class FeatureExtractor: "away_team_id": aid, "league_id": lid, "mst_utc": mst, - + **elo_features, **form_features, **h2h_features, **stats_features, **odds_features, - + "home_xga": form_features["home_conceded_avg"], "away_xga": form_features["away_conceded_avg"], **league_features, @@ -1007,7 +1068,10 @@ class FeatureExtractor: "away_avg_player_exp": away_sq.get('avg_player_exp', 0.0), "home_goals_diversity": home_sq.get('goals_diversity', 0.0), "away_goals_diversity": away_sq.get('goals_diversity', 0.0), - + + # V27 Features + **v27, + # Labels "score_home": sh, "score_away": sa, @@ -1033,6 +1097,103 @@ class FeatureExtractor: return row + def _compute_v27_features(self, hid, aid, mst, elo_features, form_features, + home_momentum, away_momentum, upset_feats, h2h_features, league): + """Compute V27 rolling, venue, calendar, interaction features from pre-loaded data.""" + home_history = self.loader.team_matches.get(hid, []) + away_history = self.loader.team_matches.get(aid, []) + + def _rolling(history, n): + recent = [m for m in history if m[0] < mst][-n:] + if not recent: + return 1.3, 1.1, 0.0 + goals = sum(m[2] for m in recent) / len(recent) + conceded = sum(m[3] for m in recent) / len(recent) + cs = sum(1 for m in recent if m[3] == 0) / len(recent) + return round(goals, 3), round(conceded, 3), round(cs, 3) + + def _venue(history, is_home): + recent = [m for m in history if m[0] < mst and m[1] == is_home][-10:] + if not recent: + return 1.3, 1.1 + goals = sum(m[2] for m in recent) / len(recent) + conceded = sum(m[3] for m in recent) / len(recent) + return round(goals, 3), round(conceded, 3) + + def _days_rest(history): + prior = [m[0] for m in history if m[0] < mst] + if not prior: + return 7.0 + last = prior[-1] + return round(min((mst - last) / 86400000.0, 30.0), 1) + + h5g, h5c, h5cs = _rolling(home_history, 5) + h10g, h10c, _ = _rolling(home_history, 10) + h20g, h20c, _ = _rolling(home_history, 20) + a5g, a5c, a5cs = _rolling(away_history, 5) + a10g, a10c, _ = _rolling(away_history, 10) + + hvg, hvc = _venue(home_history, True) + avg, avc = _venue(away_history, False) + + home_rest = _days_rest(home_history) + away_rest = _days_rest(away_history) + + import datetime + match_dt = datetime.datetime.utcfromtimestamp(mst / 1000) + match_month = match_dt.month + + elo_diff = elo_features["elo_diff"] + form_elo_diff = elo_features["form_elo_diff"] + mom_diff = home_momentum - away_momentum + home_conceded = form_features["home_conceded_avg"] + away_conceded = form_features["away_conceded_avg"] + home_goals = form_features["home_goals_avg"] + away_goals = form_features["away_goals_avg"] + upset_potential = upset_feats.get("upset_potential", 0.0) + + h2h_prior = [m for m in home_history if m[0] < mst and m[4] == aid] + h2h_home_goals_avg = sum(m[2] for m in h2h_prior) / len(h2h_prior) if h2h_prior else 1.3 + h2h_away_goals_avg = sum(m[3] for m in h2h_prior) / len(h2h_prior) if h2h_prior else 1.1 + recent_h2h = h2h_prior[-3:] + h2h_recent_trend = sum(1 if m[2] > m[3] else -1 if m[2] < m[3] else 0 for m in recent_h2h) / max(len(recent_h2h), 1) + venue_h2h = [m for m in h2h_prior if m[1]] + h2h_venue_advantage = sum(1 if m[2] > m[3] else 0 for m in venue_h2h) / max(len(venue_h2h), 1) if venue_h2h else 0.5 + + league_count = league.get("match_count", 0) + + return { + "h2h_home_goals_avg": round(h2h_home_goals_avg, 3), + "h2h_away_goals_avg": round(h2h_away_goals_avg, 3), + "h2h_recent_trend": round(h2h_recent_trend, 3), + "h2h_venue_advantage": round(h2h_venue_advantage, 3), + "home_rolling5_goals": h5g, "home_rolling5_conceded": h5c, + "home_rolling10_goals": h10g, "home_rolling10_conceded": h10c, + "home_rolling20_goals": h20g, "home_rolling20_conceded": h20c, + "away_rolling5_goals": a5g, "away_rolling5_conceded": a5c, + "away_rolling10_goals": a10g, "away_rolling10_conceded": a10c, + "home_rolling5_cs": h5cs, "away_rolling5_cs": a5cs, + "home_venue_goals": hvg, "home_venue_conceded": hvc, + "away_venue_goals": avg, "away_venue_conceded": avc, + "home_goal_trend": round(h5g - h10g, 3), + "away_goal_trend": round(a5g - a10g, 3), + "home_days_rest": home_rest, "away_days_rest": away_rest, + "match_month": float(match_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, + "attack_vs_defense_home": round(home_goals - away_conceded, 3), + "attack_vs_defense_away": round(away_goals - home_conceded, 3), + "xg_diff": round(home_conceded - away_conceded, 3), + "form_momentum_interaction": round(mom_diff * form_elo_diff / 1000.0, 4), + "elo_form_consistency": round(1.0 - abs(elo_diff - form_elo_diff) / max(abs(elo_diff), 100.0), 4), + "upset_x_elo_gap": round(upset_potential * abs(elo_diff) / 500.0, 4), + "league_home_win_rate": league.get("home_win_rate", 0.45), + "league_draw_rate": league.get("draw_rate", 0.25), + "league_btts_rate": league.get("btts_rate", 0.50), + "league_ou25_rate": league.get("ou25_rate", 0.50), + "league_reliability_score": min(1.0, league_count / 500.0) if league_count else 0.3, + } + def _validate_row_quality( self, row: dict, diff --git a/ai-engine/scripts/extract_training_data_colab.ipynb b/ai-engine/scripts/extract_training_data_colab.ipynb new file mode 100644 index 0000000..414cd51 --- /dev/null +++ b/ai-engine/scripts/extract_training_data_colab.ipynb @@ -0,0 +1,166 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": ["# Training Data Extraction β€” Google Colab\n", "SSH tunnel ile sunucuya bağlanΔ±r, DB'den 270K+ maΓ§ Γ§eker, Drive'a kaydeder.\n"] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 1. Gerekli paketler\n", + "!pip install sshtunnel psycopg2-binary pandas numpy -q\n", + "print('Paketler hazΔ±r')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 2. Drive bağla\n", + "from google.colab import drive\n", + "drive.mount('/content/drive')\n", + "import os\n", + "DRIVE_DIR = '/content/drive/MyDrive/iddaai'\n", + "os.makedirs(DRIVE_DIR, exist_ok=True)\n", + "print('Drive hazΔ±r:', DRIVE_DIR)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 3. SSH private key upload\n", + "# Mac'te terminalde şunu Γ§alıştΔ±r, Γ§Δ±ktΔ±yΔ± kopyala:\n", + "# cat ~/.ssh/id_ed25519\n", + "# Aşağıya yapıştΔ±r (BEGIN ve END satΔ±rlarΔ± dahil)\n", + "\n", + "SSH_PRIVATE_KEY = \"\"\"-----BEGIN OPENSSH PRIVATE KEY-----\n", + "BURAYA_KEY_ICERIGINI_YAPISTIR\n", + "-----END OPENSSH PRIVATE KEY-----\"\"\"\n", + "\n", + "# Key dosyasΔ±na yaz\n", + "key_path = '/root/.ssh/id_ed25519'\n", + "os.makedirs('/root/.ssh', exist_ok=True)\n", + "with open(key_path, 'w') as f:\n", + " f.write(SSH_PRIVATE_KEY.strip() + '\\n')\n", + "os.chmod(key_path, 0o600)\n", + "print('SSH key hazΔ±r')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 4. SSH Tunnel aΓ§ + DB bağlantΔ±sΔ±nΔ± test et\n", + "from sshtunnel import SSHTunnelForwarder\n", + "import psycopg2\n", + "\n", + "tunnel = SSHTunnelForwarder(\n", + " ('95.70.252.214', 2222),\n", + " ssh_username='haruncan',\n", + " ssh_pkey=key_path,\n", + " remote_bind_address=('localhost', 5432),\n", + " local_bind_address=('localhost', 15432),\n", + ")\n", + "tunnel.start()\n", + "print(f'Tunnel aΓ§Δ±k: localhost:{tunnel.local_bind_port}')\n", + "\n", + "conn = psycopg2.connect(\n", + " host='localhost',\n", + " port=15432,\n", + " dbname='iddaai_db',\n", + " user='iddaai_user',\n", + " password='IddaA1_S4crET!',\n", + ")\n", + "cur = conn.cursor()\n", + "cur.execute(\"SELECT COUNT(*) FROM matches WHERE status='FT' AND score_home IS NOT NULL\")\n", + "print(f'DB bağlantΔ±sΔ± OK β€” FT maΓ§ sayΔ±sΔ±: {cur.fetchone()[0]:,}')\n", + "conn.close()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 5. extract_training_data.py kodunu Drive'dan veya doğrudan Γ§alıştΔ±r\n", + "# Γ–nce repo'yu Drive'a kopyala (yoksa)\n", + "import subprocess\n", + "\n", + "REPO_DIR = f'{DRIVE_DIR}/ai-engine'\n", + "SCRIPT = f'{REPO_DIR}/scripts/extract_training_data.py'\n", + "\n", + "if not os.path.exists(SCRIPT):\n", + " print('Script bulunamadΔ± β€” ai-engine klasΓΆrΓΌnΓΌ Drive a yΓΌkle:')\n", + " print(' Yerel makinede: cp -r /Users/piton/Documents/GitHub/iddaai/iddaai-be/ai-engine ~/Google\\ Drive/MyDrive/iddaai/')\n", + "else:\n", + " print('Script hazΔ±r:', SCRIPT)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 6. Extraction'Δ± Γ§alıştΔ±r\n", + "import sys, os\n", + "sys.path.insert(0, REPO_DIR)\n", + "\n", + "# DB URL'i tunnel ΓΌzerinden ayarla\n", + "os.environ['DATABASE_URL'] = 'postgresql://iddaai_user:IddaA1_S4crET!@localhost:15432/iddaai_db'\n", + "\n", + "# Output CSV'yi Drive'a kaydet\n", + "OUTPUT_CSV = f'{DRIVE_DIR}/training_data_full.csv'\n", + "\n", + "# Script'i import et ve main'i Γ§alıştΔ±r\n", + "import importlib.util\n", + "spec = importlib.util.spec_from_file_location('extract', SCRIPT)\n", + "mod = importlib.util.load_from_spec(spec)\n", + "spec.loader.exec_module(mod)\n", + "\n", + "# OUTPUT_CSV'yi override et\n", + "mod.OUTPUT_CSV = OUTPUT_CSV\n", + "mod.TOP_LEAGUES_PATH = f'{DRIVE_DIR}/qualified_leagues.json'\n", + "\n", + "mod.main()\n", + "print(f'\\nKaydedildi: {OUTPUT_CSV}')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 7. Tunnel kapat\n", + "tunnel.stop()\n", + "print('Tunnel kapatΔ±ldΔ±')\n", + "\n", + "# Dosya boyutunu kontrol et\n", + "size_mb = os.path.getsize(OUTPUT_CSV) / 1024 / 1024\n", + "import pandas as pd\n", + "df = pd.read_csv(OUTPUT_CSV, nrows=5)\n", + "print(f'CSV: {size_mb:.1f} MB')\n", + "print(f'Kolonlar: {len(df.columns)}')" + ] + } + ], + "metadata": { + "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, + "language_info": {"name": "python", "version": "3.10.0"} + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ai-engine/scripts/run_backtest_and_calibrate.py b/ai-engine/scripts/run_backtest_and_calibrate.py new file mode 100644 index 0000000..5f5cfbd --- /dev/null +++ b/ai-engine/scripts/run_backtest_and_calibrate.py @@ -0,0 +1,806 @@ +""" +V25 Backtest + Calibration Training Script +========================================== +Runs a full backtest on historical football matches, measures model accuracy +by market / confidence band / league, and trains isotonic calibration models +for MS, OU15, OU25, and BTTS markets. + +Usage: + venv/bin/python scripts/run_backtest_and_calibrate.py +""" + +from __future__ import annotations + +import os +import sys +import json +import pickle +import time +from collections import defaultdict +from datetime import datetime +from typing import Dict, List, Optional, Tuple, Any + +import numpy as np +import pandas as pd +import psycopg2 +from psycopg2.extras import RealDictCursor + +# --------------------------------------------------------------------------- +# Path setup β€” works whether executed from ai-engine/ or project root +# --------------------------------------------------------------------------- +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +AI_ENGINE_DIR = os.path.dirname(SCRIPT_DIR) +sys.path.insert(0, AI_ENGINE_DIR) + +from data.db import get_clean_dsn +from models.calibration import Calibrator + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +QUALIFIED_LEAGUES_PATH = os.path.join(AI_ENGINE_DIR, "..", "qualified_leagues.json") +CALIBRATION_DIR = os.path.join(AI_ENGINE_DIR, "models", "calibration") +REPORTS_DIR = os.path.join(AI_ENGINE_DIR, "reports") +MAX_MATCHES = 3000 # target upper bound +PROGRESS_INTERVAL = 100 # print every N matches + +os.makedirs(CALIBRATION_DIR, exist_ok=True) +os.makedirs(REPORTS_DIR, exist_ok=True) + +# Mapping: Turkish category name -> internal feature key +ODDS_CATEGORY_MAP = { + "MaΓ§ Sonucu": { + "1": "odds_ms_h", + "X": "odds_ms_d", + "2": "odds_ms_a", + }, + "1,5 Alt/Üst": { + "Üst": "odds_ou15_o", + "Alt": "odds_ou15_u", + }, + "2,5 Alt/Üst": { + "Üst": "odds_ou25_o", + "Alt": "odds_ou25_u", + }, + "3,5 Alt/Üst": { + "Üst": "odds_ou35_o", + "Alt": "odds_ou35_u", + }, + "0,5 Alt/Üst": { + "Üst": "odds_ou05_o", + "Alt": "odds_ou05_u", + }, + "KarşılΔ±klΔ± Gol": { + "Var": "odds_btts_y", + "Yok": "odds_btts_n", + }, + "1. YarΔ± Sonucu": { + "1": "odds_ht_ms_h", + "X": "odds_ht_ms_d", + "2": "odds_ht_ms_a", + }, + "1. YarΔ± 0,5 Alt/Üst": { + "Üst": "odds_ht_ou05_o", + "Alt": "odds_ht_ou05_u", + }, + "1. YarΔ± 1,5 Alt/Üst": { + "Üst": "odds_ht_ou15_o", + "Alt": "odds_ht_ou15_u", + }, +} + +# Top 5 leagues by name for individual breakdown (will be matched by league_id) +TOP5_LEAGUE_NAMES = { + "Premier League", + "La Liga", + "Bundesliga", + "Serie A", + "Ligue 1", +} + +# ============================================================================ +# STEP 1 β€” Load qualified league IDs +# ============================================================================ + +def load_qualified_leagues() -> List[str]: + path = os.path.abspath(QUALIFIED_LEAGUES_PATH) + with open(path, "r") as f: + leagues = json.load(f) + print(f"[Step 1] Loaded {len(leagues)} qualified league IDs.") + return [str(lid) for lid in leagues] + + +# ============================================================================ +# STEP 1b β€” Fetch matches + pre-computed features in batch +# ============================================================================ + +def fetch_matches(conn, league_ids: List[str]) -> pd.DataFrame: + """ + Single batch query: matches + football_ai_features + league name. + Only returns matches that also have odds data (inner join on odd_categories). + Returns a DataFrame with one row per match. + """ + print("[Step 1b] Fetching matches with pre-computed features and odds ...") + cur = conn.cursor(cursor_factory=RealDictCursor) + + cur.execute( + """ + SELECT + m.id AS match_id, + m.league_id, + l.name AS league_name, + m.score_home, + m.score_away, + m.mst_utc, + -- From football_ai_features + f.home_elo AS home_overall_elo, + f.away_elo AS away_overall_elo, + f.elo_diff, + f.home_home_elo, + f.away_away_elo, + f.home_form_elo, + f.away_form_elo, + f.home_goals_avg_5 AS home_goals_avg, + f.away_goals_avg_5 AS away_goals_avg, + f.home_conceded_avg_5 AS home_conceded_avg, + f.away_conceded_avg_5 AS away_conceded_avg, + f.home_clean_sheet_rate, + f.away_clean_sheet_rate, + f.home_scoring_rate, + f.away_scoring_rate, + f.home_win_streak AS home_winning_streak, + f.away_win_streak AS away_winning_streak, + f.home_avg_possession, + f.away_avg_possession, + f.home_avg_shots_on_target, + f.away_avg_shots_on_target, + f.home_shot_conversion, + f.away_shot_conversion, + f.home_avg_corners, + f.away_avg_corners, + f.h2h_total AS h2h_total_matches, + f.h2h_home_win_rate, + f.h2h_avg_goals, + f.h2h_over25_rate, + f.h2h_btts_rate, + f.league_avg_goals, + f.league_home_win_pct AS league_home_win_rate, + f.league_over25_pct AS league_ou25_rate, + f.referee_avg_cards AS referee_cards_total, + f.referee_home_bias, + f.referee_avg_goals, + f.missing_players_impact AS home_missing_impact, + f.implied_home, + f.implied_draw, + f.implied_away + FROM matches m + JOIN football_ai_features f ON f.match_id = m.id + -- Only matches that have odds data + JOIN (SELECT DISTINCT match_id FROM odd_categories WHERE sport = 'football') oc + ON oc.match_id = m.id + LEFT JOIN leagues l ON l.id = m.league_id + WHERE m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.score_away IS NOT NULL + AND m.league_id = ANY(%s) + ORDER BY m.mst_utc DESC + LIMIT %s + """, + (league_ids, MAX_MATCHES), + ) + + rows = cur.fetchall() + cur.close() + df = pd.DataFrame([dict(r) for r in rows]) + print(f"[Step 1b] Fetched {len(df)} matches with features + odds coverage.") + return df + + +# ============================================================================ +# STEP 1c β€” Fetch all odds for the matched match IDs in one query +# ============================================================================ + +def fetch_odds_bulk(conn, match_ids: List[str]) -> Dict[str, Dict[str, float]]: + """ + Returns {match_id: {feature_key: odd_value, ...}} for all known categories. + """ + print(f"[Step 1c] Fetching odds for {len(match_ids)} matches ...") + cur = conn.cursor(cursor_factory=RealDictCursor) + + # Build a set of known category names + known_cats = tuple(ODDS_CATEGORY_MAP.keys()) + + cur.execute( + """ + SELECT oc.match_id, oc.name AS cat_name, os.name AS sel_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 = ANY(%s) + AND oc.name = ANY(%s) + AND oc.sport = 'football' + AND os.odd_value IS NOT NULL + AND os.odd_value ~ '^[0-9]+(\.[0-9]+)?$' + """, + (match_ids, list(known_cats)), + ) + + rows = cur.fetchall() + cur.close() + + # Build nested dict: match_id -> {feature_key -> value} + odds_map: Dict[str, Dict[str, float]] = defaultdict(dict) + for r in rows: + cat_name = r["cat_name"] + sel_name = r["sel_name"] + if cat_name in ODDS_CATEGORY_MAP and sel_name in ODDS_CATEGORY_MAP[cat_name]: + feat_key = ODDS_CATEGORY_MAP[cat_name][sel_name] + try: + val = float(r["odd_value"]) + if val > 1.0: + # Keep first encountered (most recent or primary bookmaker) + if feat_key not in odds_map[r["match_id"]]: + odds_map[r["match_id"]][feat_key] = val + except (TypeError, ValueError): + pass + + print(f"[Step 1c] Odds loaded for {len(odds_map)} matches.") + return dict(odds_map) + + +# ============================================================================ +# STEP 2 β€” Build 114-feature vector per match +# ============================================================================ + +def load_feature_cols() -> List[str]: + path = os.path.join(AI_ENGINE_DIR, "models", "v25", "feature_cols.json") + with open(path, "r") as f: + return json.load(f) + + +def build_feature_vector( + match_row: pd.Series, + odds: Dict[str, float], + feature_cols: List[str], +) -> Dict[str, float]: + """ + Construct the full feature dict for one match. + Falls back to 0.0 for any missing feature. + """ + feat: Dict[str, float] = {col: 0.0 for col in feature_cols} + + # ---- Direct columns from match row ---- + direct_map = { + "home_overall_elo": "home_overall_elo", + "away_overall_elo": "away_overall_elo", + "elo_diff": "elo_diff", + "home_home_elo": "home_home_elo", + "away_away_elo": "away_away_elo", + "home_form_elo": "home_form_elo", + "away_form_elo": "away_form_elo", + "home_goals_avg": "home_goals_avg", + "away_goals_avg": "away_goals_avg", + "home_conceded_avg": "home_conceded_avg", + "away_conceded_avg": "away_conceded_avg", + "home_clean_sheet_rate": "home_clean_sheet_rate", + "away_clean_sheet_rate": "away_clean_sheet_rate", + "home_scoring_rate": "home_scoring_rate", + "away_scoring_rate": "away_scoring_rate", + "home_winning_streak": "home_winning_streak", + "away_winning_streak": "away_winning_streak", + "home_avg_possession": "home_avg_possession", + "away_avg_possession": "away_avg_possession", + "home_avg_shots_on_target": "home_avg_shots_on_target", + "away_avg_shots_on_target": "away_avg_shots_on_target", + "home_shot_conversion": "home_shot_conversion", + "away_shot_conversion": "away_shot_conversion", + "home_avg_corners": "home_avg_corners", + "away_avg_corners": "away_avg_corners", + "h2h_total_matches": "h2h_total_matches", + "h2h_home_win_rate": "h2h_home_win_rate", + "h2h_avg_goals": "h2h_avg_goals", + "h2h_over25_rate": "h2h_over25_rate", + "h2h_btts_rate": "h2h_btts_rate", + "league_avg_goals": "league_avg_goals", + "league_home_win_rate": "league_home_win_rate", + "league_ou25_rate": "league_ou25_rate", + "referee_cards_total": "referee_cards_total", + "referee_home_bias": "referee_home_bias", + "referee_avg_goals": "referee_avg_goals", + "home_missing_impact": "home_missing_impact", + "implied_home": "implied_home", + "implied_draw": "implied_draw", + "implied_away": "implied_away", + } + + for src_col, feat_col in direct_map.items(): + if feat_col in feat and src_col in match_row.index: + val = match_row.get(src_col) + if val is not None and not (isinstance(val, float) and np.isnan(val)): + feat[feat_col] = float(val) + + # ---- Derived elo features ---- + if feat.get("home_form_elo", 0) and feat.get("away_form_elo", 0): + feat["form_elo_diff"] = feat["home_form_elo"] - feat["away_form_elo"] + + # ---- Odds features from relational tables ---- + odds_features = [ + "odds_ms_h", "odds_ms_d", "odds_ms_a", + "odds_ht_ms_h", "odds_ht_ms_d", "odds_ht_ms_a", + "odds_ou05_o", "odds_ou05_u", + "odds_ou15_o", "odds_ou15_u", + "odds_ou25_o", "odds_ou25_u", + "odds_ou35_o", "odds_ou35_u", + "odds_ht_ou05_o", "odds_ht_ou05_u", + "odds_ht_ou15_o", "odds_ht_ou15_u", + "odds_btts_y", "odds_btts_n", + ] + for ok in odds_features: + if ok in odds: + feat[ok] = odds[ok] + presence_key = f"{ok}_present" + if presence_key in feat: + feat[presence_key] = 1.0 + + # Recompute implied probabilities from odds if available and not already set + if feat.get("odds_ms_h", 0) > 1 and feat.get("odds_ms_d", 0) > 1 and feat.get("odds_ms_a", 0) > 1: + raw_h = 1.0 / feat["odds_ms_h"] + raw_d = 1.0 / feat["odds_ms_d"] + raw_a = 1.0 / feat["odds_ms_a"] + total = raw_h + raw_d + raw_a + if total > 0: + feat["implied_home"] = raw_h / total + feat["implied_draw"] = raw_d / total + feat["implied_away"] = raw_a / total + + # ---- Derived match metadata ---- + mst = match_row.get("mst_utc") + if mst is not None: + try: + ts_s = int(mst) / 1000 # stored as epoch ms + dt = datetime.utcfromtimestamp(ts_s) + if "match_month" in feat: + feat["match_month"] = float(dt.month) + # Season markers: Sept-Oct = start, April-May = end + if "is_season_start" in feat: + feat["is_season_start"] = 1.0 if dt.month in (8, 9, 10) else 0.0 + if "is_season_end" in feat: + feat["is_season_end"] = 1.0 if dt.month in (4, 5) else 0.0 + except Exception: + pass + + # ---- Interaction features ---- + if "attack_vs_defense_home" in feat: + feat["attack_vs_defense_home"] = feat.get("home_goals_avg", 0) - feat.get("away_conceded_avg", 0) + if "attack_vs_defense_away" in feat: + feat["attack_vs_defense_away"] = feat.get("away_goals_avg", 0) - feat.get("home_conceded_avg", 0) + if "form_momentum_interaction" in feat: + feat["form_momentum_interaction"] = ( + feat.get("home_momentum_score", 0) * feat.get("home_goals_avg", 0) + - feat.get("away_momentum_score", 0) * feat.get("away_goals_avg", 0) + ) + if "elo_form_consistency" in feat: + feat["elo_form_consistency"] = feat.get("elo_diff", 0) * feat.get("home_goals_avg", 0) + + return feat + + +# ============================================================================ +# STEP 3 β€” Run V25 predictions +# ============================================================================ + +def load_predictor(): + from models.v25_ensemble import get_v25_predictor + print("[Step 3] Loading V25 predictor ...") + pred = get_v25_predictor() + print("[Step 3] V25 predictor ready.") + return pred + + +# ============================================================================ +# STEP 4 β€” Compute actual outcomes from scores +# ============================================================================ + +def compute_actuals(score_home: int, score_away: int) -> Dict[str, Any]: + total = score_home + score_away + return { + "ms_actual": "1" if score_home > score_away else ("X" if score_home == score_away else "2"), + "ou15_actual": "Over" if total >= 2 else "Under", + "ou25_actual": "Over" if total >= 3 else "Under", + "btts_actual": "Yes" if score_home > 0 and score_away > 0 else "No", + } + + +# ============================================================================ +# STEP 5 β€” Accuracy helpers +# ============================================================================ + +def confidence_band(prob: float) -> str: + if prob < 0.50: + return "<50%" + elif prob < 0.65: + return "50-65%" + elif prob < 0.75: + return "65-75%" + else: + return "75%+" + + +def pick_from_ms(home_prob: float, draw_prob: float, away_prob: float) -> Tuple[str, float]: + picks = {"1": home_prob, "X": draw_prob, "2": away_prob} + best = max(picks, key=picks.__getitem__) + return best, picks[best] + + +def pick_from_binary(yes_prob: float, no_prob: float, yes_label: str, no_label: str) -> Tuple[str, float]: + if yes_prob >= no_prob: + return yes_label, yes_prob + return no_label, no_prob + + +# ============================================================================ +# MAIN +# ============================================================================ + +def main(): + t_start = time.time() + print("=" * 70) + print(" V25 Backtest + Calibration Training") + print(f" Run at: {datetime.utcnow().isoformat()} UTC") + print("=" * 70) + + # ------------------------------------------------------------------ + # Step 1 β€” Load qualified leagues + # ------------------------------------------------------------------ + league_ids = load_qualified_leagues() + + # ------------------------------------------------------------------ + # Step 1b β€” Fetch matches with features + # ------------------------------------------------------------------ + conn = psycopg2.connect(get_clean_dsn()) + try: + matches_df = fetch_matches(conn, league_ids) + + if matches_df.empty: + print("[ERROR] No matches found. Check DB connection and league IDs.") + return + + match_ids = matches_df["match_id"].tolist() + + # ------------------------------------------------------------------ + # Step 1c β€” Fetch odds in bulk + # ------------------------------------------------------------------ + odds_map = fetch_odds_bulk(conn, match_ids) + finally: + conn.close() + + # ------------------------------------------------------------------ + # Step 2 β€” Build feature vectors + # ------------------------------------------------------------------ + print(f"\n[Step 2] Building feature vectors for {len(matches_df)} matches ...") + feature_cols = load_feature_cols() + + # ------------------------------------------------------------------ + # Step 3 β€” Load V25 predictor + # ------------------------------------------------------------------ + predictor = load_predictor() + + # ------------------------------------------------------------------ + # Main loop β€” predict each match, collect results + # ------------------------------------------------------------------ + print(f"\n[Loop] Running predictions ...") + + # Storage for calibration training + calib_data: Dict[str, List[Tuple[float, int]]] = { + "ms_home": [], # (prob, 1 if home win) + "ms_draw": [], + "ms_away": [], + "ou15": [], + "ou25": [], + "btts": [], + } + + # Storage for accuracy reporting + records = [] + + skipped = 0 + processed = 0 + + for idx, row in matches_df.iterrows(): + match_id = row["match_id"] + score_home = row.get("score_home") + score_away = row.get("score_away") + + # Validate scores + try: + score_home = int(score_home) + score_away = int(score_away) + except (TypeError, ValueError): + skipped += 1 + continue + + # Build features + match_odds = odds_map.get(match_id, {}) + feat = build_feature_vector(row, match_odds, feature_cols) + + # Run predictions + try: + home_prob, draw_prob, away_prob = predictor.predict_ms(feat) + over25_prob, under25_prob = predictor.predict_ou25(feat) + btts_yes_prob, btts_no_prob = predictor.predict_btts(feat) + + # ou15 is loaded via predict_market (returns np.ndarray for binary) + ou15_arr = predictor.predict_market("ou15", feat) + if ou15_arr is not None and len(ou15_arr) > 0: + over15_prob = float(ou15_arr[0]) + under15_prob = 1.0 - over15_prob + else: + over15_prob = 0.5 + under15_prob = 0.5 + + except Exception as e: + skipped += 1 + continue + + # Compute actuals + actuals = compute_actuals(score_home, score_away) + + # MS picks + ms_pick, ms_conf = pick_from_ms(home_prob, draw_prob, away_prob) + ms_correct = int(ms_pick == actuals["ms_actual"]) + + # OU15 + ou15_pick, ou15_conf = pick_from_binary(over15_prob, under15_prob, "Over", "Under") + ou15_correct = int(ou15_pick == actuals["ou15_actual"]) + + # OU25 + ou25_pick, ou25_conf = pick_from_binary(over25_prob, under25_prob, "Over", "Under") + ou25_correct = int(ou25_pick == actuals["ou25_actual"]) + + # BTTS + btts_pick, btts_conf = pick_from_binary(btts_yes_prob, btts_no_prob, "Yes", "No") + btts_correct = int(btts_pick == actuals["btts_actual"]) + + # Collect calibration data + calib_data["ms_home"].append((home_prob, int(actuals["ms_actual"] == "1"))) + calib_data["ms_draw"].append((draw_prob, int(actuals["ms_actual"] == "X"))) + calib_data["ms_away"].append((away_prob, int(actuals["ms_actual"] == "2"))) + calib_data["ou15"].append((over15_prob, int(actuals["ou15_actual"] == "Over"))) + calib_data["ou25"].append((over25_prob, int(actuals["ou25_actual"] == "Over"))) + calib_data["btts"].append((btts_yes_prob, int(actuals["btts_actual"] == "Yes"))) + + # Determine league group + league_name = str(row.get("league_name", "Other") or "Other") + league_group = league_name if league_name in TOP5_LEAGUE_NAMES else "Other" + + records.append({ + "match_id": match_id, + "league_name": league_name, + "league_group": league_group, + "score_home": score_home, + "score_away": score_away, + # MS + "ms_pick": ms_pick, + "ms_actual": actuals["ms_actual"], + "ms_conf": ms_conf, + "ms_conf_band": confidence_band(ms_conf), + "ms_correct": ms_correct, + "ms_home_prob": home_prob, + "ms_draw_prob": draw_prob, + "ms_away_prob": away_prob, + # OU15 + "ou15_pick": ou15_pick, + "ou15_actual": actuals["ou15_actual"], + "ou15_conf": ou15_conf, + "ou15_conf_band": confidence_band(ou15_conf), + "ou15_correct": ou15_correct, + "ou15_over_prob": over15_prob, + # OU25 + "ou25_pick": ou25_pick, + "ou25_actual": actuals["ou25_actual"], + "ou25_conf": ou25_conf, + "ou25_conf_band": confidence_band(ou25_conf), + "ou25_correct": ou25_correct, + "ou25_over_prob": over25_prob, + # BTTS + "btts_pick": btts_pick, + "btts_actual": actuals["btts_actual"], + "btts_conf": btts_conf, + "btts_conf_band": confidence_band(btts_conf), + "btts_correct": btts_correct, + "btts_yes_prob": btts_yes_prob, + }) + + processed += 1 + if processed % PROGRESS_INTERVAL == 0: + elapsed = time.time() - t_start + print(f" [Progress] {processed}/{len(matches_df)} matches | " + f"skipped={skipped} | elapsed={elapsed:.1f}s") + + print(f"\n[Loop] Done. Processed={processed}, Skipped={skipped}") + + if not records: + print("[ERROR] No records to analyze. Exiting.") + return + + results_df = pd.DataFrame(records) + + # ------------------------------------------------------------------ + # Step 5 β€” Accuracy report + # ------------------------------------------------------------------ + print("\n" + "=" * 70) + print(" ACCURACY REPORT") + print("=" * 70) + + markets = [ + ("MS", "ms_correct", "ms_conf", "ms_conf_band", "ms_pick"), + ("OU15", "ou15_correct", "ou15_conf", "ou15_conf_band", "ou15_pick"), + ("OU25", "ou25_correct", "ou25_conf", "ou25_conf_band", "ou25_pick"), + ("BTTS", "btts_correct", "btts_conf", "btts_conf_band", "btts_pick"), + ] + + summary: Dict[str, Any] = { + "generated_at": datetime.utcnow().isoformat(), + "matches_processed": processed, + "matches_skipped": skipped, + "markets": {}, + } + + for market_label, correct_col, conf_col, band_col, pick_col in markets: + print(f"\n--- {market_label} ---") + sub = results_df[[correct_col, conf_col, band_col, pick_col, "league_group"]].copy() + total = len(sub) + overall_acc = sub[correct_col].mean() * 100 + print(f" Overall accuracy: {overall_acc:.1f}% ({sub[correct_col].sum()}/{total})") + + market_summary = { + "overall_accuracy": round(overall_acc, 2), + "total_matches": total, + "by_confidence_band": {}, + "by_league": {}, + "by_pick_direction": {}, + } + + # By confidence band + print(f" By confidence band:") + bands = ["<50%", "50-65%", "65-75%", "75%+"] + for band in bands: + mask = sub[band_col] == band + n = mask.sum() + if n > 0: + acc = sub.loc[mask, correct_col].mean() * 100 + mean_conf = sub.loc[mask, conf_col].mean() * 100 + print(f" {band:8s}: {acc:5.1f}% acc | {n:4d} matches | " + f"mean_conf={mean_conf:.1f}%") + market_summary["by_confidence_band"][band] = { + "accuracy": round(acc, 2), + "count": int(n), + "mean_confidence": round(mean_conf, 2), + } + + # By league group + print(f" By league:") + league_groups = list(results_df["league_group"].unique()) + # Sort: named leagues first, then Other + named = sorted([g for g in league_groups if g != "Other"]) + ordered = named + (["Other"] if "Other" in league_groups else []) + for lg in ordered: + mask = sub["league_group"] == lg + n = mask.sum() + if n > 0: + acc = sub.loc[mask, correct_col].mean() * 100 + print(f" {lg[:20]:20s}: {acc:5.1f}% ({n} matches)") + market_summary["by_league"][lg] = { + "accuracy": round(acc, 2), + "count": int(n), + } + + # By pick direction + print(f" By pick direction:") + for pick_val in sorted(sub[pick_col].unique()): + mask = sub[pick_col] == pick_val + n = mask.sum() + if n > 0: + acc = sub.loc[mask, correct_col].mean() * 100 + mean_conf = sub.loc[mask, conf_col].mean() * 100 + print(f" {pick_val:8s}: {acc:5.1f}% acc | {n:4d} matches | " + f"mean_conf={mean_conf:.1f}%") + market_summary["by_pick_direction"][pick_val] = { + "accuracy": round(acc, 2), + "count": int(n), + "mean_confidence": round(mean_conf, 2), + } + + summary["markets"][market_label] = market_summary + + # ------------------------------------------------------------------ + # Step 6 β€” Train calibration models + # ------------------------------------------------------------------ + print("\n" + "=" * 70) + print(" CALIBRATION TRAINING") + print("=" * 70) + + calibrator = Calibrator() + + # Market config: market_key -> (label for prob, label for actual binary) + calib_market_map = { + "ms_home": "ms_home", + "ms_draw": "ms_draw", + "ms_away": "ms_away", + "ou15": "ou15", + "ou25": "ou25", + "btts": "btts", + } + + calibration_results: Dict[str, Dict] = {} + + for market_key in calib_market_map: + pairs = calib_data[market_key] + if len(pairs) < 100: + print(f"[Calib] {market_key}: only {len(pairs)} samples β€” skipping.") + continue + + probs = np.array([p for p, _ in pairs]) + actuals_bin = np.array([a for _, a in pairs]) + + # Build a tiny DataFrame to use Calibrator.train_calibration + calib_df = pd.DataFrame({ + "prob": probs, + "actual": actuals_bin, + }) + + metrics = calibrator.train_calibration( + df=calib_df, + market=market_key, + prob_col="prob", + actual_col="actual", + min_samples=100, + save=True, + ) + calibration_results[market_key] = metrics.to_dict() + print(f" [Calib] {market_key}: Brier={metrics.brier_score:.4f} | " + f"ECE={metrics.calibration_error:.4f} | n={metrics.sample_count}") + + # ------------------------------------------------------------------ + # Step 7 β€” Save results + # ------------------------------------------------------------------ + output_path = os.path.join(REPORTS_DIR, "backtest_results.json") + full_report = { + **summary, + "calibration": calibration_results, + "runtime_seconds": round(time.time() - t_start, 1), + } + + with open(output_path, "w") as f: + json.dump(full_report, f, indent=2) + print(f"\n[Step 7] Report saved to {output_path}") + + # ------------------------------------------------------------------ + # Final summary table + # ------------------------------------------------------------------ + print("\n" + "=" * 70) + print(" FINAL SUMMARY TABLE") + print("=" * 70) + print(f"{'Market':<8} {'Overall Acc':>12} {'Matches':>8} " + f"{'Best Band (acc)':>18}") + print("-" * 70) + for market_label, _, _, _, _ in markets: + ms = summary["markets"].get(market_label, {}) + overall = ms.get("overall_accuracy", 0) + total_m = ms.get("total_matches", 0) + bands_d = ms.get("by_confidence_band", {}) + # Find best accuracy band with >= 50 matches + best_band = "-" + best_acc = 0.0 + for band, bdata in bands_d.items(): + if bdata["count"] >= 50 and bdata["accuracy"] > best_acc: + best_acc = bdata["accuracy"] + best_band = f"{band} ({best_acc:.1f}%)" + print(f"{market_label:<8} {overall:>11.1f}% {total_m:>8d} {best_band:>18s}") + + elapsed_total = time.time() - t_start + print(f"\nTotal runtime: {elapsed_total:.1f}s") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/ai-engine/scripts/train_league_models.py b/ai-engine/scripts/train_league_models.py new file mode 100644 index 0000000..da44678 --- /dev/null +++ b/ai-engine/scripts/train_league_models.py @@ -0,0 +1,459 @@ +""" +League-Specific Model Trainer +============================== +Trains dedicated XGBoost models + isotonic calibration for each qualified league. + +Tiers: + - >=500 FT matches β†’ full XGBoost (12 markets) + calibration + - 100-499 matches β†’ isotonic calibration only (over general V25 predictions) + - <100 matches β†’ skipped + +Usage: + python scripts/train_league_models.py + python scripts/train_league_models.py --min-samples 300 # stricter threshold + python scripts/train_league_models.py --colab # Colab-friendly output +""" + +import os +import sys +import json +import pickle +import argparse +import time +import warnings +from datetime import datetime + +import numpy as np +import pandas as pd +import xgboost as xgb +from sklearn.isotonic import IsotonicRegression +from sklearn.metrics import accuracy_score, log_loss + +warnings.filterwarnings("ignore") +optuna_available = False +try: + import optuna + optuna.logging.set_verbosity(optuna.logging.WARNING) + optuna_available = True +except ImportError: + pass + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +AI_ENGINE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA_PATH = os.path.join(AI_ENGINE_DIR, "data", "training_data.csv") +MODELS_DIR = os.path.join(AI_ENGINE_DIR, "models", "league_specific") +REPORTS_DIR = os.path.join(AI_ENGINE_DIR, "reports", "league_models") +QUALIFIED_LEAGUES_PATH = os.path.join(os.path.dirname(AI_ENGINE_DIR), "qualified_leagues.json") + +os.makedirs(MODELS_DIR, exist_ok=True) +os.makedirs(REPORTS_DIR, exist_ok=True) + +# ─── Markets ──────────────────────────────────────────────────────── +MARKETS = { + "MS": {"label": "label_ms", "num_class": 3, "min_samples": 200}, + "OU15": {"label": "label_ou15", "num_class": 2, "min_samples": 150}, + "OU25": {"label": "label_ou25", "num_class": 2, "min_samples": 150}, + "OU35": {"label": "label_ou35", "num_class": 2, "min_samples": 150}, + "BTTS": {"label": "label_btts", "num_class": 2, "min_samples": 150}, + "HT": {"label": "label_ht_result", "num_class": 3, "min_samples": 150}, + "HT_OU05": {"label": "label_ht_ou05", "num_class": 2, "min_samples": 150}, + "HT_OU15": {"label": "label_ht_ou15", "num_class": 2, "min_samples": 150}, + "HTFT": {"label": "label_ht_ft", "num_class": 9, "min_samples": 300}, + "OE": {"label": "label_odd_even", "num_class": 2, "min_samples": 150}, + "CARDS": {"label": "label_cards_ou45", "num_class": 2, "min_samples": 150}, + "HANDICAP": {"label": "label_handicap_ms", "num_class": 3, "min_samples": 200}, +} + +# Feature columns (from training_data.csv, excluding metadata + labels) +SKIP_COLS = { + "match_id", "home_team_id", "away_team_id", "league_id", "mst_utc", + "score_home", "score_away", "total_goals", "ht_score_home", "ht_score_away", + "ht_total_goals", + "label_ms", "label_ou05", "label_ou15", "label_ou25", "label_ou35", + "label_btts", "label_ht_result", "label_ht_ou05", "label_ht_ou15", + "label_ht_ft", "label_odd_even", "label_yellow_cards", "label_cards_ou45", + "label_handicap_ms", +} + +# XGBoost defaults β€” fast, no Optuna +XGB_PARAMS_BINARY = { + "objective": "binary:logistic", + "eval_metric": "logloss", + "max_depth": 4, + "eta": 0.05, + "subsample": 0.8, + "colsample_bytree": 0.8, + "min_child_weight": 5, + "gamma": 0.1, + "reg_lambda": 1.0, + "verbosity": 0, + "seed": 42, + "nthread": -1, +} + +XGB_PARAMS_MULTI = { + **XGB_PARAMS_BINARY, + "objective": "multi:softprob", + "eval_metric": "mlogloss", +} + + +def load_data() -> pd.DataFrame: + print(f"Loading training data from {DATA_PATH} ...") + df = pd.read_csv(DATA_PATH, low_memory=False) + print(f" {len(df):,} rows, {len(df.columns)} columns") + return df + + +def get_feature_cols(df: pd.DataFrame) -> list: + return [c for c in df.columns if c not in SKIP_COLS] + + +def load_qualified_leagues() -> list: + if os.path.exists(QUALIFIED_LEAGUES_PATH): + with open(QUALIFIED_LEAGUES_PATH) as f: + return json.load(f) + # fallback: all leagues in CSV + return [] + + +def train_xgb_market( + X_train: np.ndarray, + y_train: np.ndarray, + X_test: np.ndarray, + y_test: np.ndarray, + num_class: int, + feature_cols: list, +) -> tuple: + """Train XGBoost for one market. Returns (model, accuracy, logloss).""" + params = dict(XGB_PARAMS_MULTI if num_class > 2 else XGB_PARAMS_BINARY) + if num_class > 2: + params["num_class"] = num_class + + dtrain = xgb.DMatrix(X_train, label=y_train, feature_names=feature_cols) + dtest = xgb.DMatrix(X_test, label=y_test, feature_names=feature_cols) + + model = xgb.train( + params, + dtrain, + num_boost_round=300, + evals=[(dtest, "val")], + early_stopping_rounds=30, + verbose_eval=False, + ) + + raw = model.predict(dtest) + if num_class > 2: + probs = raw.reshape(-1, num_class) + preds = np.argmax(probs, axis=1) + ll = log_loss(y_test, probs) + else: + preds = (raw >= 0.5).astype(int) + ll = log_loss(y_test, raw) + + acc = accuracy_score(y_test, preds) + return model, acc, ll + + +def train_isotonic(raw_probs: np.ndarray, y_true: np.ndarray) -> IsotonicRegression: + iso = IsotonicRegression(out_of_bounds="clip") + iso.fit(raw_probs, y_true) + return iso + + +def get_general_v25_probs(df_league: pd.DataFrame, feature_cols: list, market: str, num_class: int): + """Use general V25 model to get predictions on this league's matches (for cal-only leagues).""" + try: + from models.v25_ensemble import get_v25_predictor + v25 = get_v25_predictor() + if not v25._loaded: + v25.load_models() + + label_col = MARKETS[market]["label"] + valid = df_league[feature_cols + [label_col]].dropna() + if len(valid) < 50: + return None, None + + market_key_map = { + "MS": "ms", "OU15": "ou15", "OU25": "ou25", "OU35": "ou35", + "BTTS": "btts", "HT": "ht_result", "HT_OU05": "ht_ou05", + "HT_OU15": "ht_ou15", "HTFT": "htft", "OE": "odd_even", + "CARDS": "cards_ou45", "HANDICAP": "handicap_ms", + } + mkey = market_key_map.get(market) + if not mkey or not v25.has_market(mkey): + return None, None + + X = valid[feature_cols].fillna(0).values + y = valid[label_col].values + + all_probs = [] + for i in range(0, len(X), 500): + batch = X[i:i+500] + feat_dict = {col: float(batch[j, k]) for j, row in enumerate(batch) for k, col in enumerate(feature_cols)} + # batch predict + df_batch = pd.DataFrame(batch, columns=feature_cols) + dmat = xgb.DMatrix(df_batch) + models = v25.models.get(mkey, {}) + batch_probs = [] + if "xgb" in models: + p = models["xgb"].predict(dmat) + if num_class > 2: + p = p.reshape(-1, num_class) + batch_probs.append(p) + if batch_probs: + all_probs.append(np.mean(batch_probs, axis=0)) + + if not all_probs: + return None, None + + probs = np.vstack(all_probs) if num_class > 2 else np.concatenate(all_probs) + return probs, y + except Exception as e: + return None, None + + +def process_league( + league_id: str, + df_league: pd.DataFrame, + feature_cols: list, + full_model: bool, + league_name: str, +) -> dict: + """Train models for one league. Returns metrics dict.""" + n = len(df_league) + out_dir = os.path.join(MODELS_DIR, league_id) + os.makedirs(out_dir, exist_ok=True) + + metrics = {"league_id": league_id, "league_name": league_name, "n_matches": n, "markets": {}} + + # Time-based split: last 20% as test + split_idx = int(n * 0.80) + df_sorted = df_league.sort_values("mst_utc") + df_train = df_sorted.iloc[:split_idx] + df_test = df_sorted.iloc[split_idx:] + + saved_feature_cols = False + + for market, cfg in MARKETS.items(): + label_col = cfg["label"] + num_class = cfg["num_class"] + min_samp = cfg["min_samples"] + + if label_col not in df_league.columns: + continue + + valid_train = df_train[feature_cols + [label_col]].dropna() + valid_test = df_test[feature_cols + [label_col]].dropna() + + if len(valid_train) < min_samp or len(valid_test) < 30: + continue + + X_train = valid_train[feature_cols].fillna(0).values + y_train = valid_train[label_col].values.astype(int) + X_test = valid_test[feature_cols].fillna(0).values + y_test = valid_test[label_col].values.astype(int) + + mkt_metrics = {"n_train": len(X_train), "n_test": len(X_test)} + + if full_model: + try: + model, acc, ll = train_xgb_market(X_train, y_train, X_test, y_test, num_class, feature_cols) + model_path = os.path.join(out_dir, f"xgb_{market.lower()}.json") + model.save_model(model_path) + mkt_metrics.update({"accuracy": round(acc, 4), "logloss": round(ll, 4), "model": "xgb"}) + + if not saved_feature_cols: + with open(os.path.join(out_dir, "feature_cols.json"), "w") as f: + json.dump(feature_cols, f) + saved_feature_cols = True + + # Isotonic calibration from own model predictions + dtest_xgb = xgb.DMatrix(X_test, feature_names=feature_cols) + raw = model.predict(dtest_xgb) + if num_class > 2: + raw = raw.reshape(-1, num_class) + for cls_idx in range(num_class): + iso = train_isotonic(raw[:, cls_idx], (y_test == cls_idx).astype(int)) + with open(os.path.join(out_dir, f"cal_{market.lower()}_{cls_idx}.pkl"), "wb") as f: + pickle.dump(iso, f) + else: + iso = train_isotonic(raw, y_test) + with open(os.path.join(out_dir, f"cal_{market.lower()}.pkl"), "wb") as f: + pickle.dump(iso, f) + + except Exception as e: + mkt_metrics["error"] = str(e) + else: + # Calibration only: use general V25 model + try: + all_valid = df_league[feature_cols + [label_col]].dropna() + if len(all_valid) < min_samp: + continue + + X_all = all_valid[feature_cols].fillna(0).values + y_all = all_valid[label_col].values.astype(int) + + # Use V25 general model + from models.v25_ensemble import get_v25_predictor + v25 = get_v25_predictor() + if not v25._loaded: + v25.load_models() + + market_key_map = { + "MS": "ms", "OU15": "ou15", "OU25": "ou25", "OU35": "ou35", + "BTTS": "btts", "HT": "ht_result", "HT_OU05": "ht_ou05", + "HT_OU15": "ht_ou15", "HTFT": "htft", "OE": "odd_even", + "CARDS": "cards_ou45", "HANDICAP": "handicap_ms", + } + mkey = market_key_map.get(market) + if not mkey or not v25.has_market(mkey): + continue + + df_feat = pd.DataFrame(X_all, columns=feature_cols) + dmat = xgb.DMatrix(df_feat) + models_v25 = v25.models.get(mkey, {}) + if "xgb" not in models_v25: + continue + raw = models_v25["xgb"].predict(dmat) + + if num_class > 2: + raw = raw.reshape(-1, num_class) + for cls_idx in range(num_class): + iso = train_isotonic(raw[:, cls_idx], (y_all == cls_idx).astype(int)) + with open(os.path.join(out_dir, f"cal_{market.lower()}_{cls_idx}.pkl"), "wb") as f: + pickle.dump(iso, f) + else: + iso = train_isotonic(raw, y_all) + with open(os.path.join(out_dir, f"cal_{market.lower()}.pkl"), "wb") as f: + pickle.dump(iso, f) + + mkt_metrics.update({"n_train": len(X_all), "model": "cal_only"}) + except Exception as e: + mkt_metrics["error"] = str(e) + + metrics["markets"][market] = mkt_metrics + + # Save metrics + with open(os.path.join(out_dir, "metrics.json"), "w") as f: + json.dump(metrics, f, indent=2) + + return metrics + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--min-samples", type=int, default=500, help="Min matches for full model") + parser.add_argument("--cal-min", type=int, default=100, help="Min matches for calibration") + parser.add_argument("--colab", action="store_true", help="Colab-friendly verbose output") + args = parser.parse_args() + + start_total = time.time() + + df = load_data() + feature_cols = get_feature_cols(df) + print(f"Feature columns: {len(feature_cols)}") + + qualified = load_qualified_leagues() + if not qualified: + qualified = df["league_id"].unique().tolist() + print(f"Qualified leagues: {len(qualified)}") + + # Get league names + league_names = {} + try: + import psycopg2 + from data.db import get_clean_dsn + conn = psycopg2.connect(get_clean_dsn()) + cur = conn.cursor() + cur.execute("SELECT id, name FROM leagues WHERE id = ANY(%s)", (qualified,)) + league_names = {r[0]: r[1] for r in cur.fetchall()} + conn.close() + except Exception: + pass + + # Filter to qualified leagues with enough data + counts = df[df["league_id"].isin(qualified)].groupby("league_id").size() + full_model_ids = counts[counts >= args.min_samples].index.tolist() + cal_only_ids = counts[(counts >= args.cal_min) & (counts < args.min_samples)].index.tolist() + + print(f"\nTam model ({args.min_samples}+ maΓ§): {len(full_model_ids)} lig") + print(f"Kalibrasyon ({args.cal_min}-{args.min_samples-1} maΓ§): {len(cal_only_ids)} lig") + print(f"AtlandΔ± (<{args.cal_min} maΓ§): {len([l for l in qualified if l not in full_model_ids and l not in cal_only_ids])} lig") + print() + + all_results = [] + total = len(full_model_ids) + len(cal_only_ids) + done = 0 + + for league_id, full_model in ( + [(lid, True) for lid in full_model_ids] + + [(lid, False) for lid in cal_only_ids] + ): + t0 = time.time() + df_league = df[df["league_id"] == league_id].copy() + n = len(df_league) + name = league_names.get(league_id, league_id[:12]) + tier = "FULL" if full_model else "CAL" + + try: + result = process_league(league_id, df_league, feature_cols, full_model, name) + done += 1 + elapsed = time.time() - t0 + + # Build accuracy string for key markets + acc_parts = [] + for mkt in ["MS", "OU15", "OU25", "BTTS"]: + m = result["markets"].get(mkt, {}) + if "accuracy" in m: + acc_parts.append(f"{mkt}={m['accuracy']*100:.1f}%") + acc_str = " | ".join(acc_parts) if acc_parts else "(cal only)" + + print(f"[{done:>3}/{total}] [{tier}] {name:<35} {n:>6,} maΓ§ | {acc_str} | {elapsed:.1f}s") + all_results.append(result) + + except Exception as e: + done += 1 + print(f"[{done:>3}/{total}] [{tier}] {name:<35} ERROR: {e}") + + if done % 10 == 0: + elapsed_total = time.time() - start_total + remaining = (elapsed_total / done) * (total - done) + print(f" ── {done}/{total} tamamlandΔ± | geΓ§en: {elapsed_total/60:.1f}dk | kalan tahmini: {remaining/60:.1f}dk ──") + + # Final report + total_elapsed = time.time() - start_total + print(f"\n{'='*70}") + print(f"TAMAMLANDI: {len(all_results)}/{total} lig | SΓΌre: {total_elapsed/60:.1f} dakika") + print(f"{'='*70}") + + # Top 20 by accuracy + printable = [(r["league_name"], r["n_matches"], r["markets"]) for r in all_results + if "MS" in r["markets"] and "accuracy" in r["markets"]["MS"]] + printable.sort(key=lambda x: x[2]["MS"].get("accuracy", 0), reverse=True) + + print(f"\n{'Liga':<35} {'MaΓ§':>6} {'MS':>7} {'OU15':>7} {'OU25':>7} {'BTTS':>7}") + print("-" * 70) + for name, n, mkts in printable[:30]: + ms = mkts.get("MS", {}).get("accuracy", 0) * 100 + ou15 = mkts.get("OU15", {}).get("accuracy", 0) * 100 + ou25 = mkts.get("OU25", {}).get("accuracy", 0) * 100 + btts = mkts.get("BTTS", {}).get("accuracy", 0) * 100 + print(f"{name:<35} {n:>6,} {ms:>6.1f}% {ou15:>6.1f}% {ou25:>6.1f}% {btts:>6.1f}%") + + # Save master report + report = { + "generated_at": datetime.now().isoformat(), + "total_leagues": len(all_results), + "elapsed_minutes": round(total_elapsed / 60, 1), + "results": all_results, + } + report_path = os.path.join(REPORTS_DIR, "league_models_report.json") + with open(report_path, "w") as f: + json.dump(report, f, indent=2) + print(f"\nRapor kaydedildi: {report_path}") + + +if __name__ == "__main__": + main() diff --git a/ai-engine/scripts/train_league_models_colab.ipynb b/ai-engine/scripts/train_league_models_colab.ipynb new file mode 100644 index 0000000..be4ea71 --- /dev/null +++ b/ai-engine/scripts/train_league_models_colab.ipynb @@ -0,0 +1,259 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# League-Specific Model Trainer \u2014 Google Colab\n", + "164 lig i\u00e7in XGBoost + isotonic kalibrasyon. 12 market.\n", + "Modeller Drive'a kaydedilir, `models/league_specific/` klas\u00f6r\u00fcne kopyalan\u0131r.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Mount Drive\n", + "from google.colab import drive\n", + "drive.mount('/content/drive')\n", + "\n", + "DRIVE_DIR = '/content/drive/MyDrive/iddaai'\n", + "import os\n", + "os.makedirs(DRIVE_DIR, exist_ok=True)\n", + "print('Drive mounted:', DRIVE_DIR)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# training_data.csv zaten Drive da: /content/drive/MyDrive/iddaai/training_data.csv\n", + "# Sadece qualified_leagues.json upload et (iddaai-be/ klas\u00f6r\u00fcnden)\n", + "from google.colab import files\n", + "import shutil\n", + "print(\"qualified_leagues.json dosyasini upload edin\")\n", + "uploaded = files.upload()\n", + "for fname in uploaded:\n", + " shutil.copy(fname, f\"{DRIVE_DIR}/{fname}\")\n", + " print(f\"Kaydedildi: {DRIVE_DIR}/{fname}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Upload training_data.csv and qualified_leagues.json from local machine\n", + "from google.colab import files\n", + "print('training_data.csv upload edin (ai-engine/data/training_data.csv)')\n", + "uploaded = files.upload()\n", + "import shutil\n", + "for fname in uploaded:\n", + " shutil.copy(fname, f'{DRIVE_DIR}/{fname}')\n", + " print(f'Saved: {DRIVE_DIR}/{fname}')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, json, pickle, time, warnings\n", + "import numpy as np\n", + "import pandas as pd\n", + "import xgboost as xgb\n", + "from sklearn.isotonic import IsotonicRegression\n", + "from sklearn.metrics import accuracy_score, log_loss\n", + "warnings.filterwarnings('ignore')\n", + "\n", + "DRIVE_DIR = '/content/drive/MyDrive/iddaai'\n", + "DATA_PATH = f'{DRIVE_DIR}/training_data.csv'\n", + "QL_PATH = f'{DRIVE_DIR}/qualified_leagues.json'\n", + "MODELS_DIR = f'{DRIVE_DIR}/league_specific'\n", + "os.makedirs(MODELS_DIR, exist_ok=True)\n", + "\n", + "MARKETS = {\n", + " 'MS': {'label': 'label_ms', 'num_class': 3, 'min_samples': 200},\n", + " 'OU15': {'label': 'label_ou15', 'num_class': 2, 'min_samples': 150},\n", + " 'OU25': {'label': 'label_ou25', 'num_class': 2, 'min_samples': 150},\n", + " 'OU35': {'label': 'label_ou35', 'num_class': 2, 'min_samples': 150},\n", + " 'BTTS': {'label': 'label_btts', 'num_class': 2, 'min_samples': 150},\n", + " 'HT': {'label': 'label_ht_result', 'num_class': 3, 'min_samples': 150},\n", + " 'HT_OU05': {'label': 'label_ht_ou05', 'num_class': 2, 'min_samples': 150},\n", + " 'HT_OU15': {'label': 'label_ht_ou15', 'num_class': 2, 'min_samples': 150},\n", + " 'HTFT': {'label': 'label_ht_ft', 'num_class': 9, 'min_samples': 300},\n", + " 'OE': {'label': 'label_odd_even', 'num_class': 2, 'min_samples': 150},\n", + " 'CARDS': {'label': 'label_cards_ou45', 'num_class': 2, 'min_samples': 150},\n", + " 'HANDICAP': {'label': 'label_handicap_ms', 'num_class': 3, 'min_samples': 200},\n", + "}\n", + "\n", + "SKIP_COLS = {\n", + " 'match_id','home_team_id','away_team_id','league_id','mst_utc',\n", + " 'score_home','score_away','total_goals','ht_score_home','ht_score_away','ht_total_goals',\n", + " 'label_ms','label_ou05','label_ou15','label_ou25','label_ou35','label_btts',\n", + " 'label_ht_result','label_ht_ou05','label_ht_ou15','label_ht_ft',\n", + " 'label_odd_even','label_yellow_cards','label_cards_ou45','label_handicap_ms',\n", + "}\n", + "\n", + "XGB_BASE = {\n", + " 'max_depth': 4, 'eta': 0.05, 'subsample': 0.8,\n", + " 'colsample_bytree': 0.8, 'min_child_weight': 5,\n", + " 'gamma': 0.1, 'reg_lambda': 1.0, 'verbosity': 0, 'seed': 42,\n", + " 'nthread': -1,\n", + "}\n", + "\n", + "df = pd.read_csv(DATA_PATH, low_memory=False)\n", + "feature_cols = [c for c in df.columns if c not in SKIP_COLS]\n", + "print(f'Y\u00fcklendi: {len(df):,} sat\u0131r | {len(feature_cols)} feature')\n", + "\n", + "qualified = json.load(open(QL_PATH)) if os.path.exists(QL_PATH) else df['league_id'].unique().tolist()\n", + "counts = df[df['league_id'].isin(qualified)].groupby('league_id').size()\n", + "full_ids = counts[counts >= 500].index.tolist()\n", + "cal_ids = counts[(counts >= 100) & (counts < 500)].index.tolist()\n", + "print(f'Tam model: {len(full_ids)} | Kalibrasyon: {len(cal_ids)} | Toplam: {len(full_ids)+len(cal_ids)}')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def train_one_league(league_id, df_league, feature_cols, full_model):\n", + " n = len(df_league)\n", + " out_dir = f'{MODELS_DIR}/{league_id}'\n", + " os.makedirs(out_dir, exist_ok=True)\n", + " metrics = {}\n", + "\n", + " df_sorted = df_league.sort_values('mst_utc')\n", + " split = int(n * 0.80)\n", + " df_tr, df_te = df_sorted.iloc[:split], df_sorted.iloc[split:]\n", + "\n", + " saved_fc = False\n", + "\n", + " for market, cfg in MARKETS.items():\n", + " lbl, nc, ms = cfg['label'], cfg['num_class'], cfg['min_samples']\n", + " if lbl not in df_league.columns:\n", + " continue\n", + "\n", + " if full_model:\n", + " vtr = df_tr[feature_cols + [lbl]].dropna()\n", + " vte = df_te[feature_cols + [lbl]].dropna()\n", + " if len(vtr) < ms or len(vte) < 30:\n", + " continue\n", + " Xtr, ytr = vtr[feature_cols].fillna(0).values, vtr[lbl].values.astype(int)\n", + " Xte, yte = vte[feature_cols].fillna(0).values, vte[lbl].values.astype(int)\n", + "\n", + " params = {**XGB_BASE, 'objective': 'multi:softprob' if nc > 2 else 'binary:logistic',\n", + " 'eval_metric': 'mlogloss' if nc > 2 else 'logloss'}\n", + " if nc > 2: params['num_class'] = nc\n", + "\n", + " dtr = xgb.DMatrix(Xtr, label=ytr, feature_names=feature_cols)\n", + " dte = xgb.DMatrix(Xte, label=yte, feature_names=feature_cols)\n", + " model = xgb.train(params, dtr, 300, [(dte,'v')], early_stopping_rounds=30, verbose_eval=False)\n", + " model.save_model(f'{out_dir}/xgb_{market.lower()}.json')\n", + "\n", + " if not saved_fc:\n", + " json.dump(feature_cols, open(f'{out_dir}/feature_cols.json','w'))\n", + " saved_fc = True\n", + "\n", + " raw = model.predict(dte)\n", + " if nc > 2:\n", + " raw = raw.reshape(-1, nc)\n", + " acc = accuracy_score(yte, np.argmax(raw, axis=1))\n", + " for ci in range(nc):\n", + " iso = IsotonicRegression(out_of_bounds='clip').fit(raw[:,ci], (yte==ci).astype(int))\n", + " pickle.dump(iso, open(f'{out_dir}/cal_{market.lower()}_{ci}.pkl','wb'))\n", + " else:\n", + " acc = accuracy_score(yte, (raw>=0.5).astype(int))\n", + " iso = IsotonicRegression(out_of_bounds='clip').fit(raw, yte)\n", + " pickle.dump(iso, open(f'{out_dir}/cal_{market.lower()}.pkl','wb'))\n", + "\n", + " metrics[market] = {'accuracy': round(float(acc),4), 'n_train': len(Xtr)}\n", + " else:\n", + " # Cal only \u2014 store empty placeholder so prediction knows to use general V25\n", + " metrics[market] = {'model': 'cal_only', 'n': n}\n", + "\n", + " json.dump({'league_id': league_id, 'n': n, 'markets': metrics},\n", + " open(f'{out_dir}/metrics.json','w'), indent=2)\n", + " return metrics\n", + "\n", + "start = time.time()\n", + "all_ids = [(lid, True) for lid in full_ids] + [(lid, False) for lid in cal_ids]\n", + "results = []\n", + "\n", + "for i, (lid, full) in enumerate(all_ids, 1):\n", + " dfl = df[df['league_id'] == lid].copy()\n", + " t0 = time.time()\n", + " try:\n", + " mkt_res = train_one_league(lid, dfl, feature_cols, full)\n", + " ms_acc = mkt_res.get('MS', {}).get('accuracy', '-')\n", + " results.append((lid, len(dfl), mkt_res))\n", + " print(f'[{i:>3}/{len(all_ids)}] {lid[:20]:<20} n={len(dfl):>5,} MS={ms_acc} {time.time()-t0:.1f}s')\n", + " except Exception as e:\n", + " print(f'[{i:>3}/{len(all_ids)}] {lid[:20]:<20} ERROR: {e}')\n", + "\n", + " if i % 20 == 0:\n", + " el = time.time()-start\n", + " print(f' \u2500\u2500 {i}/{len(all_ids)} done | {el/60:.1f}min elapsed | ~{el/i*(len(all_ids)-i)/60:.1f}min left \u2500\u2500')\n", + "\n", + "print(f'\\nBitti! {len(results)} lig | {(time.time()-start)/60:.1f} dakika')\n", + "print(f'Modeller: {MODELS_DIR}')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Sonu\u00e7lar\u0131 g\u00f6ster \u2014 MS accuracy s\u0131ralamas\u0131\n", + "printable = [(lid, n, m) for lid, n, m in results if 'MS' in m and 'accuracy' in m['MS']]\n", + "printable.sort(key=lambda x: x[2]['MS']['accuracy'], reverse=True)\n", + "print(f'{\"Liga ID\":<30} {\"Ma\u00e7\":>6} {\"MS\":>7} {\"OU15\":>7} {\"OU25\":>7} {\"BTTS\":>7}')\n", + "print('-'*70)\n", + "for lid, n, m in printable[:30]:\n", + " ms = m.get('MS', {}).get('accuracy', 0)*100\n", + " ou15 = m.get('OU15',{}).get('accuracy', 0)*100\n", + " ou25 = m.get('OU25',{}).get('accuracy', 0)*100\n", + " btts = m.get('BTTS',{}).get('accuracy', 0)*100\n", + " print(f'{lid:<30} {n:>6,} {ms:>6.1f}% {ou15:>6.1f}% {ou25:>6.1f}% {btts:>6.1f}%')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Zip ve indir\n", + "import shutil\n", + "zip_path = f'{DRIVE_DIR}/league_specific_models.zip'\n", + "shutil.make_archive(zip_path.replace('.zip',''), 'zip', MODELS_DIR)\n", + "print(f'Zip: {zip_path}')\n", + "# \u0130ndirmek i\u00e7in:\n", + "# from google.colab import files\n", + "# files.download(zip_path)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/ai-engine/scripts/train_v25_colab.ipynb b/ai-engine/scripts/train_v25_colab.ipynb new file mode 100644 index 0000000..446b127 --- /dev/null +++ b/ai-engine/scripts/train_v25_colab.ipynb @@ -0,0 +1,108 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# HÜCRE 1 β€” Paketler\n", + "!pip install xgboost lightgbm optuna scikit-learn pandas numpy -q\n", + "print('HazΔ±r')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# HÜCRE 2 β€” Drive bağla + CSV Γ§ek\n", + "from google.colab import drive\n", + "import os, shutil\n", + "drive.mount('/content/drive')\n", + "\n", + "# training_data.csv'yi Drive'Δ±n iddaai klasΓΆrΓΌnden kopyala\n", + "shutil.copy('/content/drive/MyDrive/iddaai/training_data.csv', '/content/training_data.csv')\n", + "print('CSV hazΔ±r:', os.path.getsize('/content/training_data.csv') // 1024 // 1024, 'MB')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# HÜCRE 3 β€” iddaai_colab3.zip upload et (ai-engine kodlarΔ±)\n", + "from google.colab import files\n", + "import zipfile\n", + "print('iddaai_colab3.zip dosyasΔ±nΔ± seΓ§:')\n", + "uploaded = files.upload()\n", + "with zipfile.ZipFile('iddaai_colab3.zip') as z:\n", + " z.extractall('/content')\n", + "print('Kod hazΔ±r')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# HÜCRE 4 β€” training_data.csv'yi script'in beklediği yere koy\n", + "import os, shutil\n", + "os.makedirs('/content/ai-engine/data', exist_ok=True)\n", + "shutil.copy('/content/training_data.csv', '/content/ai-engine/data/training_data.csv')\n", + "print('Yerleştirildi')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# HÜCRE 5 β€” Eğitimi başlat (her 5 trial'da bir ilerleme gΓΆsterir)\n", + "import subprocess, os\n", + "\n", + "proc = subprocess.Popen(\n", + " ['python', 'scripts/train_v25_pro.py'],\n", + " stdout=subprocess.PIPE,\n", + " stderr=subprocess.STDOUT,\n", + " text=True,\n", + " cwd='/content/ai-engine',\n", + " env={**os.environ, 'PYTHONPATH': '/content/ai-engine'}\n", + ")\n", + "\n", + "for line in proc.stdout:\n", + " print(line, end='', flush=True)\n", + "\n", + "proc.wait()\n", + "print('\\nEĞİTΔ°M BΔ°TTΔ°!')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# HÜCRE 6 β€” Modelleri Drive'a kaydet\n", + "import shutil, os\n", + "os.makedirs('/content/drive/MyDrive/iddaai/models_v25', exist_ok=True)\n", + "shutil.copytree(\n", + " '/content/ai-engine/models/v25',\n", + " '/content/drive/MyDrive/iddaai/models_v25',\n", + " dirs_exist_ok=True\n", + ")\n", + "print('Modeller Drive a kaydedildi: MyDrive/iddaai/models_v25/')" + ] + } + ], + "metadata": { + "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, + "language_info": {"name": "python", "version": "3.10.0"} + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ai-engine/scripts/train_v25_pro.py b/ai-engine/scripts/train_v25_pro.py index 0e360d0..d95d50e 100644 --- a/ai-engine/scripts/train_v25_pro.py +++ b/ai-engine/scripts/train_v25_pro.py @@ -101,6 +101,32 @@ FEATURES = [ "home_top_scorer_form", "away_top_scorer_form", "home_avg_player_exp", "away_avg_player_exp", "home_goals_diversity", "away_goals_diversity", + # V27 H2H Expanded (4) + "h2h_home_goals_avg", "h2h_away_goals_avg", + "h2h_recent_trend", "h2h_venue_advantage", + # V27 Rolling Stats (13) + "home_rolling5_goals", "home_rolling5_conceded", + "home_rolling10_goals", "home_rolling10_conceded", + "home_rolling20_goals", "home_rolling20_conceded", + "away_rolling5_goals", "away_rolling5_conceded", + "away_rolling10_goals", "away_rolling10_conceded", + "home_rolling5_cs", "away_rolling5_cs", + # V27 Venue Stats (4) + "home_venue_goals", "home_venue_conceded", + "away_venue_goals", "away_venue_conceded", + # V27 Goal Trend (2) + "home_goal_trend", "away_goal_trend", + # V27 Calendar (5) + "home_days_rest", "away_days_rest", + "match_month", "is_season_start", "is_season_end", + # V27 Interaction (6) + "attack_vs_defense_home", "attack_vs_defense_away", + "xg_diff", "form_momentum_interaction", + "elo_form_consistency", "upset_x_elo_gap", + # V27 League Expanded (5) + "league_home_win_rate", "league_draw_rate", + "league_btts_rate", "league_ou25_rate", + "league_reliability_score", ] MARKET_CONFIGS = [ @@ -295,12 +321,18 @@ def train_market(df, target_col, market_name, num_class, n_trials): print(f"[INFO] Split: train={len(X_train)} val={len(X_val)} cal={len(X_cal)} test={len(X_test)}") + def _cb(study, trial): + if trial.number % 5 == 0 or trial.number == n_trials - 1: + best = study.best_value if study.best_trial else float('inf') + print(f" [{trial.number+1:>3}/{n_trials}] loss={trial.value:.4f} | best={best:.4f}", flush=True) + # ── Phase 1: Optuna XGBoost ────────────────────────────────── print(f"\n[OPTUNA] XGBoost tuning ({n_trials} trials)...") xgb_study = optuna.create_study(direction="minimize", sampler=TPESampler(seed=42)) xgb_study.optimize( lambda trial: xgb_objective(trial, X_train, y_train, X_val, y_val, num_class), n_trials=n_trials, + callbacks=[_cb], ) xgb_best = xgb_study.best_params print(f"[OK] XGB best logloss: {xgb_study.best_value:.4f}") @@ -311,6 +343,7 @@ def train_market(df, target_col, market_name, num_class, n_trials): lgb_study.optimize( lambda trial: lgb_objective(trial, X_train, y_train, X_val, y_val, num_class), n_trials=n_trials, + callbacks=[_cb], ) lgb_best = lgb_study.best_params print(f"[OK] LGB best logloss: {lgb_study.best_value:.4f}") diff --git a/ai-engine/scripts/train_v25_pro_colab.ipynb b/ai-engine/scripts/train_v25_pro_colab.ipynb new file mode 100644 index 0000000..577185c --- /dev/null +++ b/ai-engine/scripts/train_v25_pro_colab.ipynb @@ -0,0 +1,343 @@ +{ + "nbformat": 4, + "nbformat_minor": 0, + "metadata": { + "colab": { + "provenance": [], + "gpuType": "T4" + }, + "kernelspec": { + "name": "python3", + "display_name": "Python 3" + }, + "accelerator": "GPU" + }, + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# V25 Pro Model Trainer β€” Colab Edition\n", + "**152 feature + Optuna + Isotonic Calibration + GPU**\n", + "\n", + "### KullanΔ±m:\n", + "1. Runtime β†’ Change runtime type β†’ **T4 GPU** seΓ§\n", + "2. `training_data.csv` dosyasΔ±nΔ± Google Drive'a yΓΌkle\n", + "3. HΓΌcreleri sΔ±rayla Γ§alıştΔ±r\n", + "4. Eğitim bitince `v25_models.zip` indir ve sunucuya yΓΌkle" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 1) KΓΌtΓΌphaneleri kur\n", + "!pip install -q xgboost lightgbm optuna" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 2) Google Drive bağla ve CSV'yi yΓΌkle\n", + "from google.colab import drive\n", + "drive.mount('/content/drive')\n", + "\n", + "import os\n", + "\n", + "# CSV'nin Drive'daki yolunu ayarla\n", + "DRIVE_CSV = '/content/drive/MyDrive/iddaai/training_data.csv'\n", + "\n", + "if not os.path.exists(DRIVE_CSV):\n", + " print(f'HATA: {DRIVE_CSV} bulunamadΔ±!')\n", + " print('Drive\\'a training_data.csv yΓΌkle veya yolu dΓΌzelt.')\n", + " print()\n", + " print('Alternatif: DosyayΔ± doğrudan upload et β†’')\n", + " from google.colab import files\n", + " uploaded = files.upload()\n", + " DRIVE_CSV = list(uploaded.keys())[0]\n", + " print(f'Uploaded: {DRIVE_CSV}')\n", + "else:\n", + " print(f'CSV bulundu: {DRIVE_CSV}')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 3) GPU kontrolΓΌ\n", + "import subprocess\n", + "try:\n", + " gpu_info = subprocess.check_output(['nvidia-smi'], text=True)\n", + " print(gpu_info)\n", + " USE_GPU = True\n", + "except:\n", + " print('GPU bulunamadΔ±, CPU ile devam edilecek.')\n", + " USE_GPU = False" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 4) Imports ve Config\n", + "import json\n", + "import pickle\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import xgboost as xgb\n", + "import lightgbm as lgb\n", + "import optuna\n", + "from optuna.samplers import TPESampler\n", + "from datetime import datetime\n", + "from sklearn.metrics import accuracy_score, log_loss, classification_report\n", + "from sklearn.isotonic import IsotonicRegression\n", + "from IPython.display import clear_output\n", + "\n", + "optuna.logging.set_verbosity(optuna.logging.WARNING)\n", + "\n", + "MODELS_DIR = '/content/v25_models'\n", + "REPORTS_DIR = '/content/v25_reports'\n", + "os.makedirs(MODELS_DIR, exist_ok=True)\n", + "os.makedirs(REPORTS_DIR, exist_ok=True)\n", + "\n", + "N_TRIALS = 50 # Optuna deneme sayΔ±sΔ± (market başına XGB + LGB)\n", + "\n", + "print(f'Optuna trials: {N_TRIALS}')\n", + "print(f'GPU: {USE_GPU}')\n", + "print(f'Models dir: {MODELS_DIR}')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 5) Feature ve Market tanΔ±mlarΔ±\n", + "\n", + "FEATURES = [\n", + " # ELO (8)\n", + " \"home_overall_elo\", \"away_overall_elo\", \"elo_diff\",\n", + " \"home_home_elo\", \"away_away_elo\",\n", + " \"home_form_elo\", \"away_form_elo\", \"form_elo_diff\",\n", + " # Form (12)\n", + " \"home_goals_avg\", \"home_conceded_avg\",\n", + " \"away_goals_avg\", \"away_conceded_avg\",\n", + " \"home_clean_sheet_rate\", \"away_clean_sheet_rate\",\n", + " \"home_scoring_rate\", \"away_scoring_rate\",\n", + " \"home_winning_streak\", \"away_winning_streak\",\n", + " \"home_unbeaten_streak\", \"away_unbeaten_streak\",\n", + " # H2H (6)\n", + " \"h2h_total_matches\", \"h2h_home_win_rate\", \"h2h_draw_rate\",\n", + " \"h2h_avg_goals\", \"h2h_btts_rate\", \"h2h_over25_rate\",\n", + " # Team Stats (8)\n", + " \"home_avg_possession\", \"away_avg_possession\",\n", + " \"home_avg_shots_on_target\", \"away_avg_shots_on_target\",\n", + " \"home_shot_conversion\", \"away_shot_conversion\",\n", + " \"home_avg_corners\", \"away_avg_corners\",\n", + " # Odds (24 + 20 presence flags)\n", + " \"odds_ms_h\", \"odds_ms_d\", \"odds_ms_a\",\n", + " \"implied_home\", \"implied_draw\", \"implied_away\",\n", + " \"odds_ht_ms_h\", \"odds_ht_ms_d\", \"odds_ht_ms_a\",\n", + " \"odds_ou05_o\", \"odds_ou05_u\",\n", + " \"odds_ou15_o\", \"odds_ou15_u\",\n", + " \"odds_ou25_o\", \"odds_ou25_u\",\n", + " \"odds_ou35_o\", \"odds_ou35_u\",\n", + " \"odds_ht_ou05_o\", \"odds_ht_ou05_u\",\n", + " \"odds_ht_ou15_o\", \"odds_ht_ou15_u\",\n", + " \"odds_btts_y\", \"odds_btts_n\",\n", + " \"odds_ms_h_present\", \"odds_ms_d_present\", \"odds_ms_a_present\",\n", + " \"odds_ht_ms_h_present\", \"odds_ht_ms_d_present\", \"odds_ht_ms_a_present\",\n", + " \"odds_ou05_o_present\", \"odds_ou05_u_present\",\n", + " \"odds_ou15_o_present\", \"odds_ou15_u_present\",\n", + " \"odds_ou25_o_present\", \"odds_ou25_u_present\",\n", + " \"odds_ou35_o_present\", \"odds_ou35_u_present\",\n", + " \"odds_ht_ou05_o_present\", \"odds_ht_ou05_u_present\",\n", + " \"odds_ht_ou15_o_present\", \"odds_ht_ou15_u_present\",\n", + " \"odds_btts_y_present\", \"odds_btts_n_present\",\n", + " # League (4)\n", + " \"home_xga\", \"away_xga\",\n", + " \"league_avg_goals\", \"league_zero_goal_rate\",\n", + " # Upset Engine (4)\n", + " \"upset_atmosphere\", \"upset_motivation\", \"upset_fatigue\", \"upset_potential\",\n", + " # Referee Engine (5)\n", + " \"referee_home_bias\", \"referee_avg_goals\", \"referee_cards_total\",\n", + " \"referee_avg_yellow\", \"referee_experience\",\n", + " # Momentum (3)\n", + " \"home_momentum_score\", \"away_momentum_score\", \"momentum_diff\",\n", + " # Squad (9)\n", + " \"home_squad_quality\", \"away_squad_quality\", \"squad_diff\",\n", + " \"home_key_players\", \"away_key_players\",\n", + " \"home_missing_impact\", \"away_missing_impact\",\n", + " \"home_goals_form\", \"away_goals_form\",\n", + " # Player-Level Features (12)\n", + " \"home_lineup_goals_per90\", \"away_lineup_goals_per90\",\n", + " \"home_lineup_assists_per90\", \"away_lineup_assists_per90\",\n", + " \"home_squad_continuity\", \"away_squad_continuity\",\n", + " \"home_top_scorer_form\", \"away_top_scorer_form\",\n", + " \"home_avg_player_exp\", \"away_avg_player_exp\",\n", + " \"home_goals_diversity\", \"away_goals_diversity\",\n", + " # V27 H2H Expanded (4)\n", + " \"h2h_home_goals_avg\", \"h2h_away_goals_avg\",\n", + " \"h2h_recent_trend\", \"h2h_venue_advantage\",\n", + " # V27 Rolling Stats (13)\n", + " \"home_rolling5_goals\", \"home_rolling5_conceded\",\n", + " \"home_rolling10_goals\", \"home_rolling10_conceded\",\n", + " \"home_rolling20_goals\", \"home_rolling20_conceded\",\n", + " \"away_rolling5_goals\", \"away_rolling5_conceded\",\n", + " \"away_rolling10_goals\", \"away_rolling10_conceded\",\n", + " \"home_rolling5_cs\", \"away_rolling5_cs\",\n", + " # V27 Venue Stats (4)\n", + " \"home_venue_goals\", \"home_venue_conceded\",\n", + " \"away_venue_goals\", \"away_venue_conceded\",\n", + " # V27 Goal Trend (2)\n", + " \"home_goal_trend\", \"away_goal_trend\",\n", + " # V27 Calendar (5)\n", + " \"home_days_rest\", \"away_days_rest\",\n", + " \"match_month\", \"is_season_start\", \"is_season_end\",\n", + " # V27 Interaction (6)\n", + " \"attack_vs_defense_home\", \"attack_vs_defense_away\",\n", + " \"xg_diff\", \"form_momentum_interaction\",\n", + " \"elo_form_consistency\", \"upset_x_elo_gap\",\n", + " # V27 League Expanded (5)\n", + " \"league_home_win_rate\", \"league_draw_rate\",\n", + " \"league_btts_rate\", \"league_ou25_rate\",\n", + " \"league_reliability_score\",\n", + "]\n", + "\n", + "MARKET_CONFIGS = [\n", + " {\"target\": \"label_ms\", \"name\": \"MS\", \"num_class\": 3},\n", + " {\"target\": \"label_ou15\", \"name\": \"OU15\", \"num_class\": 2},\n", + " {\"target\": \"label_ou25\", \"name\": \"OU25\", \"num_class\": 2},\n", + " {\"target\": \"label_ou35\", \"name\": \"OU35\", \"num_class\": 2},\n", + " {\"target\": \"label_btts\", \"name\": \"BTTS\", \"num_class\": 2},\n", + " {\"target\": \"label_ht_result\", \"name\": \"HT_RESULT\", \"num_class\": 3},\n", + " {\"target\": \"label_ht_ou05\", \"name\": \"HT_OU05\", \"num_class\": 2},\n", + " {\"target\": \"label_ht_ou15\", \"name\": \"HT_OU15\", \"num_class\": 2},\n", + " {\"target\": \"label_ht_ft\", \"name\": \"HTFT\", \"num_class\": 9},\n", + " {\"target\": \"label_odd_even\", \"name\": \"ODD_EVEN\", \"num_class\": 2},\n", + " {\"target\": \"label_cards_ou45\", \"name\": \"CARDS_OU45\", \"num_class\": 2},\n", + " {\"target\": \"label_handicap_ms\", \"name\": \"HANDICAP_MS\", \"num_class\": 3},\n", + "]\n", + "\n", + "print(f'Features: {len(FEATURES)}')\n", + "print(f'Markets: {len(MARKET_CONFIGS)}')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 6) Veriyi yΓΌkle\n", + "print(f'Loading {DRIVE_CSV}...')\n", + "df = pd.read_csv(DRIVE_CSV)\n", + "\n", + "for col in FEATURES:\n", + " if col in df.columns:\n", + " df[col] = df[col].fillna(0)\n", + "\n", + "# Odds presence flags\n", + "odds_flag_sources = {\n", + " \"odds_ms_h_present\": \"odds_ms_h\", \"odds_ms_d_present\": \"odds_ms_d\",\n", + " \"odds_ms_a_present\": \"odds_ms_a\", \"odds_ht_ms_h_present\": \"odds_ht_ms_h\",\n", + " \"odds_ht_ms_d_present\": \"odds_ht_ms_d\", \"odds_ht_ms_a_present\": \"odds_ht_ms_a\",\n", + " \"odds_ou05_o_present\": \"odds_ou05_o\", \"odds_ou05_u_present\": \"odds_ou05_u\",\n", + " \"odds_ou15_o_present\": \"odds_ou15_o\", \"odds_ou15_u_present\": \"odds_ou15_u\",\n", + " \"odds_ou25_o_present\": \"odds_ou25_o\", \"odds_ou25_u_present\": \"odds_ou25_u\",\n", + " \"odds_ou35_o_present\": \"odds_ou35_o\", \"odds_ou35_u_present\": \"odds_ou35_u\",\n", + " \"odds_ht_ou05_o_present\": \"odds_ht_ou05_o\", \"odds_ht_ou05_u_present\": \"odds_ht_ou05_u\",\n", + " \"odds_ht_ou15_o_present\": \"odds_ht_ou15_o\", \"odds_ht_ou15_u_present\": \"odds_ht_ou15_u\",\n", + " \"odds_btts_y_present\": \"odds_btts_y\", \"odds_btts_n_present\": \"odds_btts_n\",\n", + "}\n", + "for flag_col, odds_col in odds_flag_sources.items():\n", + " if flag_col not in df.columns:\n", + " df[flag_col] = (\n", + " pd.to_numeric(df.get(odds_col, 0), errors='coerce').fillna(0) > 1.01\n", + " ).astype(float)\n", + "\n", + "available = [f for f in FEATURES if f in df.columns]\n", + "missing = [f for f in FEATURES if f not in df.columns]\n", + "\n", + "print(f'Shape: {df.shape}')\n", + "print(f'Features: {len(available)}/{len(FEATURES)}')\n", + "if missing:\n", + " print(f'Missing features: {missing}')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": "# 7) YardΔ±mcΔ± fonksiyonlar\n\ndef temporal_split_4way(valid_df):\n ordered = valid_df.sort_values('mst_utc').reset_index(drop=True)\n n = len(ordered)\n i1 = int(n * 0.60)\n i2 = int(n * 0.75)\n i3 = int(n * 0.85)\n return ordered.iloc[:i1].copy(), ordered.iloc[i1:i2].copy(), ordered.iloc[i2:i3].copy(), ordered.iloc[i3:].copy()\n\n\ndef xgb_objective(trial, X_train, y_train, X_val, y_val, num_class):\n params = {\n 'objective': 'multi:softprob' if num_class > 2 else 'binary:logistic',\n 'eval_metric': 'mlogloss' if num_class > 2 else 'logloss',\n 'tree_method': 'hist',\n 'device': 'cuda' if USE_GPU else 'cpu',\n 'max_depth': trial.suggest_int('max_depth', 3, 8),\n 'eta': trial.suggest_float('eta', 0.01, 0.15, log=True),\n 'subsample': trial.suggest_float('subsample', 0.6, 1.0),\n 'colsample_bytree': trial.suggest_float('colsample_bytree', 0.5, 1.0),\n 'min_child_weight': trial.suggest_int('min_child_weight', 1, 10),\n 'gamma': trial.suggest_float('gamma', 1e-8, 1.0, log=True),\n 'reg_lambda': trial.suggest_float('reg_lambda', 1e-8, 10.0, log=True),\n 'reg_alpha': trial.suggest_float('reg_alpha', 1e-8, 1.0, log=True),\n 'random_state': 42,\n }\n if num_class > 2:\n params['num_class'] = num_class\n\n dtrain = xgb.DMatrix(X_train, label=y_train)\n dval = xgb.DMatrix(X_val, label=y_val)\n model = xgb.train(params, dtrain, num_boost_round=1000,\n evals=[(dval, 'val')], early_stopping_rounds=50, verbose_eval=False)\n preds = model.predict(dval)\n if len(preds.shape) == 1:\n preds = np.column_stack([1 - preds, preds])\n return log_loss(y_val, preds)\n\n\ndef lgb_objective(trial, X_train, y_train, X_val, y_val, num_class):\n params = {\n 'objective': 'multiclass' if num_class > 2 else 'binary',\n 'metric': 'multi_logloss' if num_class > 2 else 'binary_logloss',\n 'device': 'gpu' if USE_GPU else 'cpu',\n 'max_depth': trial.suggest_int('max_depth', 3, 8),\n 'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.15, log=True),\n 'feature_fraction': trial.suggest_float('feature_fraction', 0.5, 1.0),\n 'bagging_fraction': trial.suggest_float('bagging_fraction', 0.6, 1.0),\n 'bagging_freq': trial.suggest_int('bagging_freq', 1, 7),\n 'min_child_samples': trial.suggest_int('min_child_samples', 5, 50),\n 'lambda_l1': trial.suggest_float('lambda_l1', 1e-8, 1.0, log=True),\n 'lambda_l2': trial.suggest_float('lambda_l2', 1e-8, 10.0, log=True),\n 'random_state': 42, 'verbose': -1,\n }\n if num_class > 2:\n params['num_class'] = num_class\n\n train_data = lgb.Dataset(X_train, label=y_train)\n val_data = lgb.Dataset(X_val, label=y_val, reference=train_data)\n model = lgb.train(params, train_data, num_boost_round=1000,\n valid_sets=[val_data], valid_names=['val'],\n callbacks=[lgb.early_stopping(50), lgb.log_evaluation(0)])\n preds = model.predict(X_val, num_iteration=model.best_iteration)\n if len(preds.shape) == 1:\n preds = np.column_stack([1 - preds, preds])\n return log_loss(y_val, preds)\n\n\nprint('Fonksiyonlar hazΔ±r.')", + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": "# 8) Ana Eğitim DΓΆngΓΌsΓΌ\n\nall_metrics = {\n 'trained_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),\n 'trainer': 'v25_pro_colab',\n 'optuna_trials': N_TRIALS,\n 'total_features': len(FEATURES),\n 'gpu': USE_GPU,\n 'markets': {},\n}\n\navailable_features = [f for f in FEATURES if f in df.columns]\ntotal_markets = len(MARKET_CONFIGS)\nstart_time = time.time()\n\nfor mi, config in enumerate(MARKET_CONFIGS):\n target = config['target']\n market_name = config['name']\n num_class = config['num_class']\n market_start = time.time()\n\n print(f\"\\n{'='*60}\")\n print(f\"[{mi+1}/{total_markets}] {market_name} (classes={num_class})\")\n print(f\"{'='*60}\")\n\n if target not in df.columns:\n print(f' SKIP: {target} not in data')\n continue\n\n valid_df = df[df[target].notna()].copy()\n valid_df = valid_df[valid_df[target].astype(str) != ''].copy()\n\n if len(valid_df) < 500:\n print(f' SKIP: only {len(valid_df)} samples')\n continue\n\n train_df, val_df, cal_df, test_df = temporal_split_4way(valid_df)\n X_train = train_df[available_features].values\n X_val = val_df[available_features].values\n X_cal = cal_df[available_features].values\n X_test = test_df[available_features].values\n y_train = train_df[target].astype(int).values\n y_val = val_df[target].astype(int).values\n y_cal = cal_df[target].astype(int).values\n y_test = test_df[target].astype(int).values\n\n print(f' Samples: {len(valid_df)} | Split: {len(X_train)}/{len(X_val)}/{len(X_cal)}/{len(X_test)}')\n print(f' Features: {len(available_features)}')\n\n # ── Optuna XGBoost ──\n print(f' XGBoost Optuna ({N_TRIALS} trials)...', end=' ', flush=True)\n t0 = time.time()\n xgb_study = optuna.create_study(direction='minimize', sampler=TPESampler(seed=42))\n xgb_study.optimize(\n lambda trial: xgb_objective(trial, X_train, y_train, X_val, y_val, num_class),\n n_trials=N_TRIALS)\n xgb_best = xgb_study.best_params\n print(f'done ({time.time()-t0:.0f}s) best={xgb_study.best_value:.4f}')\n\n # ── Optuna LightGBM ──\n print(f' LightGBM Optuna ({N_TRIALS} trials)...', end=' ', flush=True)\n t0 = time.time()\n lgb_study = optuna.create_study(direction='minimize', sampler=TPESampler(seed=42))\n lgb_study.optimize(\n lambda trial: lgb_objective(trial, X_train, y_train, X_val, y_val, num_class),\n n_trials=N_TRIALS)\n lgb_best = lgb_study.best_params\n print(f'done ({time.time()-t0:.0f}s) best={lgb_study.best_value:.4f}')\n\n # ── Final XGBoost ──\n print(f' Training final XGBoost...', end=' ', flush=True)\n xgb_params = {\n 'objective': 'multi:softprob' if num_class > 2 else 'binary:logistic',\n 'eval_metric': 'mlogloss' if num_class > 2 else 'logloss',\n 'tree_method': 'hist',\n 'device': 'cuda' if USE_GPU else 'cpu',\n 'random_state': 42,\n **xgb_best,\n }\n if num_class > 2:\n xgb_params['num_class'] = num_class\n\n dtrain = xgb.DMatrix(X_train, label=y_train)\n dval = xgb.DMatrix(X_val, label=y_val)\n xgb_model = xgb.train(\n xgb_params, dtrain, num_boost_round=1500,\n evals=[(dtrain, 'train'), (dval, 'val')],\n early_stopping_rounds=80, verbose_eval=False)\n print(f'iter={xgb_model.best_iteration} score={xgb_model.best_score:.4f}')\n\n # ── Final LightGBM ──\n print(f' Training final LightGBM...', end=' ', flush=True)\n lgb_params = {\n 'objective': 'multiclass' if num_class > 2 else 'binary',\n 'metric': 'multi_logloss' if num_class > 2 else 'binary_logloss',\n 'device': 'gpu' if USE_GPU else 'cpu',\n 'random_state': 42, 'verbose': -1,\n **lgb_best,\n }\n if num_class > 2:\n lgb_params['num_class'] = num_class\n\n lgb_train_data = lgb.Dataset(X_train, label=y_train)\n lgb_val_data = lgb.Dataset(X_val, label=y_val, reference=lgb_train_data)\n lgb_model = lgb.train(\n lgb_params, lgb_train_data, num_boost_round=1500,\n valid_sets=[lgb_train_data, lgb_val_data],\n valid_names=['train', 'val'],\n callbacks=[lgb.early_stopping(80), lgb.log_evaluation(0)])\n print(f'iter={lgb_model.best_iteration}')\n\n # ── Isotonic Calibration ──\n print(f' Isotonic calibration...', end=' ', flush=True)\n dcal = xgb.DMatrix(X_cal)\n xgb_cal_raw = xgb_model.predict(dcal)\n if len(xgb_cal_raw.shape) == 1:\n xgb_cal_raw = np.column_stack([1 - xgb_cal_raw, xgb_cal_raw])\n\n xgb_iso = []\n for cls_idx in range(num_class):\n ir = IsotonicRegression(out_of_bounds='clip')\n ir.fit(xgb_cal_raw[:, cls_idx], (y_cal == cls_idx).astype(float))\n xgb_iso.append(ir)\n\n lgb_cal_raw = lgb_model.predict(X_cal, num_iteration=lgb_model.best_iteration)\n if len(lgb_cal_raw.shape) == 1:\n lgb_cal_raw = np.column_stack([1 - lgb_cal_raw, lgb_cal_raw])\n\n lgb_iso = []\n for cls_idx in range(num_class):\n ir = IsotonicRegression(out_of_bounds='clip')\n ir.fit(lgb_cal_raw[:, cls_idx], (y_cal == cls_idx).astype(float))\n lgb_iso.append(ir)\n print(f'{num_class} classes done')\n\n # ── Test Evaluation ──\n dtest = xgb.DMatrix(X_test)\n xgb_raw = xgb_model.predict(dtest)\n if len(xgb_raw.shape) == 1:\n xgb_raw = np.column_stack([1 - xgb_raw, xgb_raw])\n\n xgb_cal_p = np.column_stack([xgb_iso[i].predict(xgb_raw[:, i]) for i in range(num_class)])\n xgb_cal_p = xgb_cal_p / xgb_cal_p.sum(axis=1, keepdims=True)\n\n lgb_raw = lgb_model.predict(X_test, num_iteration=lgb_model.best_iteration)\n if len(lgb_raw.shape) == 1:\n lgb_raw = np.column_stack([1 - lgb_raw, lgb_raw])\n\n lgb_cal_p = np.column_stack([lgb_iso[i].predict(lgb_raw[:, i]) for i in range(num_class)])\n lgb_cal_p = lgb_cal_p / lgb_cal_p.sum(axis=1, keepdims=True)\n\n raw_ens = (xgb_raw + lgb_raw) / 2\n cal_ens = (xgb_cal_p + lgb_cal_p) / 2\n\n def _eval(probs, label):\n preds = np.argmax(probs, axis=1)\n acc = accuracy_score(y_test, preds)\n ll = log_loss(y_test, probs)\n return {'accuracy': round(float(acc), 4), 'logloss': round(float(ll), 4)}\n\n m_xgb_raw = _eval(xgb_raw, 'XGB Raw')\n m_xgb_cal = _eval(xgb_cal_p, 'XGB Cal')\n m_lgb_raw = _eval(lgb_raw, 'LGB Raw')\n m_lgb_cal = _eval(lgb_cal_p, 'LGB Cal')\n m_ens_raw = _eval(raw_ens, 'Ens Raw')\n m_ens_cal = _eval(cal_ens, 'Ens Cal')\n\n print(f' ── Test Results ──')\n print(f' XGB Raw: Acc={m_xgb_raw[\"accuracy\"]:.4f} LL={m_xgb_raw[\"logloss\"]:.4f}')\n print(f' XGB Cal: Acc={m_xgb_cal[\"accuracy\"]:.4f} LL={m_xgb_cal[\"logloss\"]:.4f}')\n print(f' LGB Raw: Acc={m_lgb_raw[\"accuracy\"]:.4f} LL={m_lgb_raw[\"logloss\"]:.4f}')\n print(f' LGB Cal: Acc={m_lgb_cal[\"accuracy\"]:.4f} LL={m_lgb_cal[\"logloss\"]:.4f}')\n print(f' Ens Raw: Acc={m_ens_raw[\"accuracy\"]:.4f} LL={m_ens_raw[\"logloss\"]:.4f}')\n print(f' Ens Cal: Acc={m_ens_cal[\"accuracy\"]:.4f} LL={m_ens_cal[\"logloss\"]:.4f}')\n\n ens_preds = np.argmax(raw_ens, axis=1)\n print(f'\\n Classification Report:')\n print(classification_report(y_test, ens_preds))\n\n # ── Save Models ──\n mn = market_name.lower()\n xgb_model.save_model(os.path.join(MODELS_DIR, f'xgb_v25_{mn}.json'))\n lgb_model.save_model(os.path.join(MODELS_DIR, f'lgb_v25_{mn}.txt'))\n with open(os.path.join(MODELS_DIR, f'iso_xgb_v25_{mn}.pkl'), 'wb') as f:\n pickle.dump(xgb_iso, f)\n with open(os.path.join(MODELS_DIR, f'iso_lgb_v25_{mn}.pkl'), 'wb') as f:\n pickle.dump(lgb_iso, f)\n\n elapsed = time.time() - market_start\n total_elapsed = time.time() - start_time\n avg_per_market = total_elapsed / (mi + 1)\n remaining = avg_per_market * (total_markets - mi - 1)\n print(f' Saved! ({elapsed:.0f}s) | Toplam: {total_elapsed/60:.1f}dk | Kalan: ~{remaining/60:.0f}dk')\n\n all_metrics['markets'][market_name] = {\n 'samples': int(len(valid_df)),\n 'train': int(len(X_train)),\n 'features_used': len(available_features),\n 'xgb_best_iteration': int(xgb_model.best_iteration),\n 'lgb_best_iteration': int(lgb_model.best_iteration),\n 'xgb_optuna_best': round(float(xgb_study.best_value), 4),\n 'lgb_optuna_best': round(float(lgb_study.best_value), 4),\n 'test_xgb_raw': m_xgb_raw, 'test_xgb_cal': m_xgb_cal,\n 'test_lgb_raw': m_lgb_raw, 'test_lgb_cal': m_lgb_cal,\n 'test_ensemble_raw': m_ens_raw, 'test_ensemble_calibrated': m_ens_cal,\n 'elapsed_seconds': round(elapsed, 1),\n }\n\n# Feature cols kaydet\nwith open(os.path.join(MODELS_DIR, 'feature_cols.json'), 'w') as f:\n json.dump(available_features, f, indent=2)\n\n# Rapor kaydet\nwith open(os.path.join(REPORTS_DIR, 'v25_pro_metrics.json'), 'w') as f:\n json.dump(all_metrics, f, indent=2, default=str)\n\ntotal_time = time.time() - start_time\nprint(f\"\\n{'='*60}\")\nprint(f'TAMAMLANDI! Toplam sΓΌre: {total_time/60:.1f} dakika')\nprint(f\"{'='*60}\")\nfor name, m in all_metrics['markets'].items():\n ens = m.get('test_ensemble_calibrated', m.get('test_ensemble_raw', {}))\n print(f\" {name:12s} | Acc={ens.get('accuracy','?'):.4f} | LL={ens.get('logloss','?'):.4f}\")", + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 9) Modelleri ZIP'le ve indir\n", + "import shutil\n", + "\n", + "zip_path = shutil.make_archive('/content/v25_models', 'zip', MODELS_DIR)\n", + "print(f'ZIP: {zip_path} ({os.path.getsize(zip_path)/1024/1024:.1f} MB)')\n", + "\n", + "# Drive'a da kopyala\n", + "drive_out = '/content/drive/MyDrive/iddaai/v25_models.zip'\n", + "shutil.copy2(zip_path, drive_out)\n", + "print(f'Drive kopyasΔ±: {drive_out}')\n", + "\n", + "# Raporu da kopyala\n", + "shutil.copy2(os.path.join(REPORTS_DIR, 'v25_pro_metrics.json'),\n", + " '/content/drive/MyDrive/iddaai/v25_pro_metrics.json')\n", + "\n", + "# Δ°ndir\n", + "from google.colab import files\n", + "files.download(zip_path)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Sunucuya YΓΌkleme\n", + "\n", + "ZIP indirdikten sonra sunucuya yΓΌklemek iΓ§in:\n", + "\n", + "```bash\n", + "# 1) ZIP'i sunucuya kopyala\n", + "scp -P 2222 v25_models.zip haruncan@95.70.252.214:~/\n", + "\n", + "# 2) Docker container'a kopyala\n", + "docker cp ~/v25_models.zip 85b57a7291df:/app/models/\n", + "\n", + "# 3) Container iΓ§inde aΓ§\n", + "docker exec 85b57a7291df bash -c 'cd /app/models/v25 && unzip -o /app/models/v25_models.zip'\n", + "\n", + "# 4) Container'Δ± restart et\n", + "docker restart 85b57a7291df\n", + "```" + ] + } + ] +} \ No newline at end of file diff --git a/ai-engine/services/betting_brain.py b/ai-engine/services/betting_brain.py index 737edab..e2e3a45 100644 --- a/ai-engine/services/betting_brain.py +++ b/ai-engine/services/betting_brain.py @@ -19,11 +19,26 @@ class BettingBrain: SOFT_DIVERGENCE = 0.14 EXTREME_MODEL_PROB = 0.85 EXTREME_GAP = 0.30 - # Vetoes that is_value_sniper bypasses (does NOT bypass odds_below_minimum) - SNIPER_BYPASSABLE_VETOES = {"calibrated_confidence_too_low", "play_score_too_low"} - # Trap market: market implied probability massively exceeds historical band hit rate + SNIPER_BYPASSABLE_VETOES = {"play_score_too_low"} TRAP_MARKET_GAP = 0.10 + MARKET_MIN_CONFIDENCE = { + "MS": 45.0, + "DC": 55.0, + "OU25": 48.0, + "OU15": 55.0, + "OU35": 42.0, + "BTTS": 48.0, + "HT": 55.0, + "HTFT": 65.0, + "OE": 55.0, + "CARDS": 50.0, + "HT_OU05": 55.0, + "HT_OU15": 50.0, + } + + SNIPER_BLOCKED_MARKETS = {"HT", "HTFT", "OE", "CARDS", "HT_OU05", "HT_OU15"} + MARKET_PRIORS = { "DC": 4.0, "OU15": 3.0, @@ -31,10 +46,10 @@ class BettingBrain: "BTTS": 0.0, "MS": -2.0, "OU35": -2.0, - "HT": -6.0, - "HTFT": -12.0, - "CARDS": -5.0, - "OE": -8.0, + "HT": -10.0, + "HTFT": -18.0, + "CARDS": -8.0, + "OE": -12.0, } def judge(self, package: Dict[str, Any]) -> Dict[str, Any]: @@ -182,8 +197,10 @@ class BettingBrain: issues.append("base_model_not_playable") is_value_sniper = bool(row.get("is_value_sniper")) + if market in self.SNIPER_BLOCKED_MARKETS: + is_value_sniper = False if is_value_sniper: - score += 35.0 + score += 20.0 positives.append("value_sniper_override") score += max(0.0, min(20.0, calibrated_conf * 0.22)) @@ -197,9 +214,31 @@ class BettingBrain: risk = str((package.get("risk") or {}).get("level") or "MEDIUM").upper() score += {"LOW": 5.0, "MEDIUM": 0.0, "HIGH": -12.0, "EXTREME": -22.0}.get(risk, -4.0) + # League reliability penalty: weak leagues produce unreliable raw probabilities. + # odds_reliability is pre-computed per-league from historical Brier score analysis. + odds_rel = self._safe_float(row.get("odds_reliability"), 0.35) or 0.35 + if odds_rel < 0.30: + score -= 22.0 + issues.append("very_low_reliability_league") + if market in {"MS", "DC", "OU25", "BTTS"} and not is_value_sniper: + vetoes.append("low_reliability_league_hard_block") + elif odds_rel < 0.45: + score -= 12.0 + issues.append("low_reliability_league") + elif odds_rel < 0.55: + score -= 5.0 + + # Inferred features penalty: when ELO/form/H2H come from live enrichment + # (not pre-computed table), statistical quality is unknown β€” penalise hard. + dq_flags = list(data_quality.get("flags") or []) + if "ai_features_inferred_from_history" in dq_flags: + score -= 18.0 + issues.append("inferred_statistical_features") + if odds < self.MIN_ODDS: vetoes.append("odds_below_minimum") - if calibrated_conf < 38.0 and not is_value_sniper: + min_conf = self.MARKET_MIN_CONFIDENCE.get(market, 45.0) + if calibrated_conf < min_conf: vetoes.append("calibrated_confidence_too_low") if play_score < 50.0 and not is_value_sniper: vetoes.append("play_score_too_low") @@ -270,7 +309,7 @@ class BettingBrain: score -= 24.0 vetoes.append("extreme_probability_without_evidence") - if market in {"HT", "HTFT", "OE"} and score < 86.0 and not is_value_sniper: + if market in {"HT", "HTFT", "OE"} and score < 86.0: vetoes.append("volatile_market_requires_exceptional_evidence") # Sniper override: bypass eligible vetoes when value sniper triggered diff --git a/ai-engine/services/match_commentary.py b/ai-engine/services/match_commentary.py index 4d7c311..4fea68b 100644 --- a/ai-engine/services/match_commentary.py +++ b/ai-engine/services/match_commentary.py @@ -62,7 +62,7 @@ def generate_match_commentary(package: Dict[str, Any]) -> Dict[str, Any]: ) # ── Quick notes ─────────────────────────────────────────────── - notes = _build_notes(market_board, v27_engine, score_pred, risk, home, away) + notes = _build_notes(market_board, v27_engine, score_pred, risk, home, away, league_name=match_info.get("league", "")) # ── Contradiction detection ─────────────────────────────────── contradictions = _detect_contradictions(market_board, v27_engine, package) @@ -206,11 +206,17 @@ def _build_notes( risk: Dict[str, Any], home: str, away: str, + league_name: str = "", ) -> List[str]: notes: List[str] = [] triple_value = v27_engine.get("triple_value") or {} odds_band = v27_engine.get("odds_band") or {} + # Cup game note β€” model uses league statistics; cup dynamics differ + _cup_kws = ("kupa", "cup", "coupe", "copa", "pokal", "ziraat", "trophy", "shield", "super cup", "sΓΌper kupa") + if any(kw in (league_name or "").lower() for kw in _cup_kws): + notes.append("⚠️ Kupa maΓ§Δ±: ev avantajΔ± zayΔ±f, rotasyon ve düşük motivasyon riski var") + # MS note ms = market_board.get("MS") or {} ms_conf = float(ms.get("confidence", 0) or 0) diff --git a/ai-engine/services/orchestrator/__init__.py b/ai-engine/services/orchestrator/__init__.py new file mode 100644 index 0000000..23f3a73 --- /dev/null +++ b/ai-engine/services/orchestrator/__init__.py @@ -0,0 +1,28 @@ +"""Orchestrator package β€” mixin modules split from the original 5786-line +monolithic SingleMatchOrchestrator. Behaviour is identical to the pre-refactor +version; only file layout has changed. +""" + +from services.orchestrator.data_loader import DataLoaderMixin +from services.orchestrator.feature_builder import FeatureBuilderMixin +from services.orchestrator.prediction import PredictionMixin +from services.orchestrator.basketball import BasketballMixin +from services.orchestrator.upper_brain import UpperBrainMixin +from services.orchestrator.htms import HtmsMixin +from services.orchestrator.coupon import CouponMixin +from services.orchestrator.reversal import ReversalMixin +from services.orchestrator.market_board import MarketBoardMixin +from services.orchestrator.utils import UtilsMixin + +__all__ = [ + "DataLoaderMixin", + "FeatureBuilderMixin", + "PredictionMixin", + "BasketballMixin", + "UpperBrainMixin", + "HtmsMixin", + "CouponMixin", + "ReversalMixin", + "MarketBoardMixin", + "UtilsMixin", +] diff --git a/ai-engine/services/orchestrator/basketball.py b/ai-engine/services/orchestrator/basketball.py new file mode 100644 index 0000000..131160f --- /dev/null +++ b/ai-engine/services/orchestrator/basketball.py @@ -0,0 +1,538 @@ +"""Basketball Mixin β€” basketball-specific market construction. + +Auto-extracted mixin module β€” split from services/single_match_orchestrator.py. +All methods here are composed into SingleMatchOrchestrator via inheritance. +`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are +initialised in the main __init__. +""" + +from __future__ import annotations + +import json +import re +import time +import math +import os +import pickle +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set, Tuple, overload + +import pandas as pd +import numpy as np + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from data.db import get_clean_dsn +from schemas.prediction import FullMatchPrediction +from schemas.match_data import MatchData +from models.v25_ensemble import V25Predictor, get_v25_predictor +try: + from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +except ImportError: + class V27Predictor: # type: ignore[no-redef] + def __init__(self): self.models = {} + def load_models(self): return False + def predict_all(self, features): return {} + def compute_divergence(*args, **kwargs): + return {} + def compute_value_edge(*args, **kwargs): + return {} +from features.odds_band_analyzer import OddsBandAnalyzer +try: + from models.basketball_v25 import ( + BasketballMatchPrediction, + get_basketball_v25_predictor, + ) +except ImportError: + BasketballMatchPrediction = Any # type: ignore[misc] + def get_basketball_v25_predictor() -> Any: + 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 services.match_commentary import generate_match_commentary +from utils.top_leagues import load_top_league_ids +from utils.league_reliability import load_league_reliability +from config.config_loader import build_threshold_dict, get_threshold_default +from models.calibration import get_calibrator + + +class BasketballMixin: + 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")] + + MIN_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("ev_edge", 0.0)), + float(r.get("play_score", 0.0)), + ), + reverse=True, + ) + main_pick = playable_with_odds[0] + main_pick["is_guaranteed"] = False + main_pick["pick_reason"] = "positive_ev_pick" + else: + fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0] + fallback_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) + main_pick = 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["playable"] = False + main_pick["stake_units"] = 0.0 + main_pick["bet_grade"] = "PASS" + main_pick["pick_reason"] = "no_playable_value_found" + + 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 _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, + } diff --git a/ai-engine/services/orchestrator/coupon.py b/ai-engine/services/orchestrator/coupon.py new file mode 100644 index 0000000..18c7390 --- /dev/null +++ b/ai-engine/services/orchestrator/coupon.py @@ -0,0 +1,444 @@ +"""Coupon Mixin β€” multi-match coupon builder + daily bankers. + +Auto-extracted mixin module β€” split from services/single_match_orchestrator.py. +All methods here are composed into SingleMatchOrchestrator via inheritance. +`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are +initialised in the main __init__. +""" + +from __future__ import annotations + +import json +import re +import time +import math +import os +import pickle +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set, Tuple, overload + +import pandas as pd +import numpy as np + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from data.db import get_clean_dsn +from schemas.prediction import FullMatchPrediction +from schemas.match_data import MatchData +from models.v25_ensemble import V25Predictor, get_v25_predictor +try: + from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +except ImportError: + class V27Predictor: # type: ignore[no-redef] + def __init__(self): self.models = {} + def load_models(self): return False + def predict_all(self, features): return {} + def compute_divergence(*args, **kwargs): + return {} + def compute_value_edge(*args, **kwargs): + return {} +from features.odds_band_analyzer import OddsBandAnalyzer +try: + from models.basketball_v25 import ( + BasketballMatchPrediction, + get_basketball_v25_predictor, + ) +except ImportError: + BasketballMatchPrediction = Any # type: ignore[misc] + def get_basketball_v25_predictor() -> Any: + 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 services.match_commentary import generate_match_commentary +from utils.top_leagues import load_top_league_ids +from utils.league_reliability import load_league_reliability +from config.config_loader import build_threshold_dict, get_threshold_default +from models.calibration import get_calibrator + + +class CouponMixin: + 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_live(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_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] diff --git a/ai-engine/services/orchestrator/data_loader.py b/ai-engine/services/orchestrator/data_loader.py new file mode 100644 index 0000000..fa34a28 --- /dev/null +++ b/ai-engine/services/orchestrator/data_loader.py @@ -0,0 +1,1111 @@ +"""Data Loader Mixin β€” DB fetching, lineup/odds parsing. + +Auto-extracted mixin module β€” split from services/single_match_orchestrator.py. +All methods here are composed into SingleMatchOrchestrator via inheritance. +`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are +initialised in the main __init__. +""" + +from __future__ import annotations + +import json +import re +import time +import math +import os +import pickle +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set, Tuple, overload + +import pandas as pd +import numpy as np + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from data.db import get_clean_dsn +from schemas.prediction import FullMatchPrediction +from schemas.match_data import MatchData +from models.v25_ensemble import V25Predictor, get_v25_predictor +try: + from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +except ImportError: + class V27Predictor: # type: ignore[no-redef] + def __init__(self): self.models = {} + def load_models(self): return False + def predict_all(self, features): return {} + def compute_divergence(*args, **kwargs): + return {} + def compute_value_edge(*args, **kwargs): + return {} +from features.odds_band_analyzer import OddsBandAnalyzer +try: + from models.basketball_v25 import ( + BasketballMatchPrediction, + get_basketball_v25_predictor, + ) +except ImportError: + BasketballMatchPrediction = Any # type: ignore[misc] + def get_basketball_v25_predictor() -> Any: + 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 services.match_commentary import generate_match_commentary +from utils.top_leagues import load_top_league_ids +from utils.league_reliability import load_league_reliability +from config.config_loader import build_threshold_dict, get_threshold_default +from models.calibration import get_calibrator + + +class DataLoaderMixin: + 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 + + status, state, substate = self._normalize_match_status( + row.get("status"), + row.get("state"), + row.get("substate"), + row.get("score_home"), + row.get("score_away"), + ) + 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=status, + state=state, + substate=substate, + lineup_confidence=lineup_confidence, + source_table=str(row.get("source_table") or "matches"), + current_score_home=( + int(str(row.get("score_home"))) + if row.get("score_home") is not None + else None + ), + current_score_away=( + int(str(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, + 'live_matches'::text as source_table + 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() + + @staticmethod + def _normalize_match_status( + status: Any, + state: Any, + substate: Any, + score_home: Any, + score_away: Any, + ) -> Tuple[str, Optional[str], Optional[str]]: + state_text = str(state or "").strip() + status_text = str(status or "").strip() + substate_text = str(substate or "").strip() + + state_key = state_text.lower().replace("_", "").replace(" ", "") + status_key = status_text.lower().replace("_", "").replace(" ", "") + substate_key = substate_text.lower().replace("_", "").replace(" ", "") + + live_tokens = {"live", "livegame", "firsthalf", "secondhalf", "halftime", "1h", "2h", "ht", "1q", "2q", "3q", "4q"} + finished_tokens = {"post", "postgame", "finished", "played", "ft", "ended", "aet", "pen", "penalties", "afterpenalties"} + pre_tokens = {"pre", "pregame", "scheduled", "ns", "notstarted", "timestamp"} + + if state_key in live_tokens or status_key in live_tokens or substate_key in live_tokens: + return "LIVE", state_text or "live", substate_text or None + if state_key in finished_tokens or status_key in finished_tokens or substate_key in finished_tokens: + return "FT", state_text or "post", substate_text or None + if score_home is not None and score_away is not None and status_key not in pre_tokens: + return "FT", state_text or "post", substate_text or None + if state_key in pre_tokens or status_key in pre_tokens or substate_key in pre_tokens: + return "NS", state_text or "pre", substate_text or None + + return status_text or "NS", state_text or None, substate_text or None + + 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, + 'matches'::text as source_table + 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([dict(r) for r in relational_rows]) + if rel_odds: + for key, value in rel_odds.items(): + odds_data.setdefault(key, value) + + # Odds staleness check: warn if odds haven't been updated within 48h of match + # Uses a savepoint to avoid aborting the transaction if the column doesn't exist + try: + cur.execute("SAVEPOINT odds_staleness_check") + match_ts_ms = int(row.get("match_date_ms") or 0) + if match_ts_ms > 0: + cur.execute( + """ + SELECT EXTRACT(EPOCH FROM (NOW() - MAX(oc.updated_at))) / 3600 AS hours_stale + FROM odd_categories oc + WHERE oc.match_id = %s AND oc.updated_at IS NOT NULL + """, + (row["match_id"],), + ) + stale_row = cur.fetchone() + if stale_row and stale_row.get("hours_stale") is not None: + hours_stale = float(stale_row["hours_stale"]) + if hours_stale > 48: + print(f"⚠️ [DataLoader] Odds for {row['match_id']} are {hours_stale:.0f}h stale (threshold: 48h)") + odds_data["_odds_stale"] = True + cur.execute("RELEASE SAVEPOINT odds_staleness_check") + except Exception: + cur.execute("ROLLBACK TO SAVEPOINT odds_staleness_check") # restore transaction + + 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") diff --git a/ai-engine/services/orchestrator/feature_builder.py b/ai-engine/services/orchestrator/feature_builder.py new file mode 100644 index 0000000..d1c5d4f --- /dev/null +++ b/ai-engine/services/orchestrator/feature_builder.py @@ -0,0 +1,498 @@ +"""Feature Builder Mixin β€” V25/V28 feature vector assembly. + +Auto-extracted mixin module β€” split from services/single_match_orchestrator.py. +All methods here are composed into SingleMatchOrchestrator via inheritance. +`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are +initialised in the main __init__. +""" + +from __future__ import annotations + +import json +import re +import time +import math +import os +import pickle +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set, Tuple, overload + +import pandas as pd +import numpy as np + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from data.db import get_clean_dsn +from schemas.prediction import FullMatchPrediction +from schemas.match_data import MatchData +from models.v25_ensemble import V25Predictor, get_v25_predictor +try: + from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +except ImportError: + class V27Predictor: # type: ignore[no-redef] + def __init__(self): self.models = {} + def load_models(self): return False + def predict_all(self, features): return {} + def compute_divergence(*args, **kwargs): + return {} + def compute_value_edge(*args, **kwargs): + return {} +from features.odds_band_analyzer import OddsBandAnalyzer +try: + from models.basketball_v25 import ( + BasketballMatchPrediction, + get_basketball_v25_predictor, + ) +except ImportError: + BasketballMatchPrediction = Any # type: ignore[misc] + def get_basketball_v25_predictor() -> Any: + raise ImportError("Basketball predictor is not available") +from core.engines.player_predictor import PlayerPrediction, get_player_predictor +from features.upset_engine import get_upset_engine +from services.feature_enrichment import FeatureEnrichmentService +from services.betting_brain import BettingBrain +from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine +from services.match_commentary import generate_match_commentary +from utils.top_leagues import load_top_league_ids +from utils.league_reliability import load_league_reliability +from config.config_loader import build_threshold_dict, get_threshold_default +from models.calibration import get_calibrator + + +class FeatureBuilderMixin: + 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') or 0) + ms_d = float(odds.get('ms_d') or 0) + ms_a = float(odds.get('ms_a') or 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 + # Defaults β€” overridden by successful queries + 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, away_momentum = 0.0, 0.0 + 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, away_rest = 7.0, 7.0 + odds_band_features = {} + enrichment_failures = [] + + try: + with psycopg2.connect(self.dsn) as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + # ELO + try: + 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) + else: + cur.execute( + "SELECT team_id, overall_elo, home_elo, away_elo, form_elo " + "FROM team_elo_ratings WHERE team_id IN (%s, %s)", + (data.home_team_id, data.away_team_id), + ) + by_team = {str(r.get("team_id")): r for r in cur.fetchall()} + home_row = by_team.get(str(data.home_team_id)) + away_row = by_team.get(str(data.away_team_id)) + if home_row: + home_elo = float(home_row.get("overall_elo") or 1500.0) + home_venue_elo = float(home_row.get("home_elo") or home_elo) + home_form_elo_val = float(home_row.get("form_elo") or home_elo) + if away_row: + away_elo = float(away_row.get("overall_elo") or 1500.0) + away_venue_elo = float(away_row.get("away_elo") or away_elo) + away_form_elo_val = float(away_row.get("form_elo") or away_elo) + setattr(data, "feature_source", "football_ai_features" if elo_row else "live_prematch_enrichment") + # Staleness check: both teams at exact 1500 β†’ ELO was never computed + if home_elo == 1500.0 and away_elo == 1500.0: + enrichment_failures.append("elo_stale:both_teams_at_default_1500") + except Exception as e: + enrichment_failures.append(f"elo:{e}") + setattr(data, "feature_source", "fallback_defaults") + + # Team stats + try: + 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) + except Exception as e: + enrichment_failures.append(f"team_stats:{e}") + + # H2H + try: + h2h = enr.compute_h2h(cur, data.home_team_id, data.away_team_id, data.match_date_ms) + except Exception as e: + enrichment_failures.append(f"h2h:{e}") + + # Form + try: + 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) + except Exception as e: + enrichment_failures.append(f"form:{e}") + + # Referee + try: + ref = enr.compute_referee_stats(cur, data.referee_name, data.match_date_ms) + except Exception as e: + enrichment_failures.append(f"referee:{e}") + + # League + try: + league = enr.compute_league_averages(cur, data.league_id, data.match_date_ms) + except Exception as e: + enrichment_failures.append(f"league:{e}") + + # Momentum + try: + 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) + except Exception as e: + enrichment_failures.append(f"momentum:{e}") + + # V27 Rolling + Venue + Rest + try: + 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) + except Exception as e: + enrichment_failures.append(f"rolling/venue:{e}") + + # V28 Odds-Band + try: + 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 as e: + enrichment_failures.append(f"odds_band:{e}") + + except Exception as e: + enrichment_failures.append(f"db_connection:{e}") + setattr(data, "feature_source", "fallback_defaults") + + setattr(data, "odds_band_features", odds_band_features) + if enrichment_failures: + print(f"⚠️ Enrichment partial failures for {data.match_id}: {', '.join(enrichment_failures)}") + + # Upset engine features + upset_atmosphere, upset_motivation, upset_fatigue = 0.0, 0.0, 0.0 + try: + upset_engine = get_upset_engine() + upset_feats = upset_engine.get_features( + home_team_name=getattr(data, 'home_team_name', '') or '', + home_team_id=data.home_team_id, + away_team_name=getattr(data, 'away_team_name', '') or '', + league_name=getattr(data, 'league_name', '') or '', + home_position=10, + away_position=10, + match_date_ms=data.match_date_ms, + home_days_rest=int(home_rest), + away_days_rest=int(away_rest), + ) + upset_atmosphere = upset_feats.get('upset_atmosphere', 0.0) + upset_motivation = upset_feats.get('upset_motivation', 0.0) + upset_fatigue = upset_feats.get('upset_fatigue', 0.0) + except Exception as e: + print(f"⚠️ Upset engine failed: {e}") + + 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') or 0) > 1.01 else 0.0, + 'odds_ht_ms_d_present': 1.0 if float(odds.get('ht_d') or 0) > 1.01 else 0.0, + 'odds_ht_ms_a_present': 1.0 if float(odds.get('ht_a') or 0) > 1.01 else 0.0, + 'odds_ou05_o_present': 1.0 if float(odds.get('ou05_o') or 0) > 1.01 else 0.0, + 'odds_ou05_u_present': 1.0 if float(odds.get('ou05_u') or 0) > 1.01 else 0.0, + 'odds_ou15_o_present': 1.0 if float(odds.get('ou15_o') or 0) > 1.01 else 0.0, + 'odds_ou15_u_present': 1.0 if float(odds.get('ou15_u') or 0) > 1.01 else 0.0, + 'odds_ou25_o_present': 1.0 if float(odds.get('ou25_o') or 0) > 1.01 else 0.0, + 'odds_ou25_u_present': 1.0 if float(odds.get('ou25_u') or 0) > 1.01 else 0.0, + 'odds_ou35_o_present': 1.0 if float(odds.get('ou35_o') or 0) > 1.01 else 0.0, + 'odds_ou35_u_present': 1.0 if float(odds.get('ou35_u') or 0) > 1.01 else 0.0, + 'odds_ht_ou05_o_present': 1.0 if float(odds.get('ht_ou05_o') or 0) > 1.01 else 0.0, + 'odds_ht_ou05_u_present': 1.0 if float(odds.get('ht_ou05_u') or 0) > 1.01 else 0.0, + 'odds_ht_ou15_o_present': 1.0 if float(odds.get('ht_ou15_o') or 0) > 1.01 else 0.0, + 'odds_ht_ou15_u_present': 1.0 if float(odds.get('ht_ou15_u') or 0) > 1.01 else 0.0, + 'odds_btts_y_present': 1.0 if float(odds.get('btts_y') or 0) > 1.01 else 0.0, + 'odds_btts_n_present': 1.0 if float(odds.get('btts_n') or 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 + + # ── Cup game detection: dampen home advantage in feature space ── + _league_name = (getattr(data, 'league_name', '') or '').lower() + _cup_keywords = ("kupa", "cup", "coupe", "copa", "coppa", "pokal", + "trophy", "shield", "ziraat", "sΓΌper kupa", "super cup") + _is_cup = any(kw in _league_name for kw in _cup_keywords) + + # ── Derived / Interaction features (V27) ── + # Cup games: home ELO advantage is ~30% weaker (rotation, lower motivation) + elo_diff = (home_elo - away_elo) * (0.70 if _is_cup else 1.0) + 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') or 0), + 'odds_ht_ms_d': float(odds.get('ht_d') or 0), + 'odds_ht_ms_a': float(odds.get('ht_a') or 0), + 'odds_ou05_o': float(odds.get('ou05_o') or 0), + 'odds_ou05_u': float(odds.get('ou05_u') or 0), + 'odds_ou15_o': float(odds.get('ou15_o') or 0), + 'odds_ou15_u': float(odds.get('ou15_u') or 0), + 'odds_ou25_o': float(odds.get('ou25_o') or 0), + 'odds_ou25_u': float(odds.get('ou25_u') or 0), + 'odds_ou35_o': float(odds.get('ou35_o') or 0), + 'odds_ou35_u': float(odds.get('ou35_u') or 0), + 'odds_ht_ou05_o': float(odds.get('ht_ou05_o') or 0), + 'odds_ht_ou05_u': float(odds.get('ht_ou05_u') or 0), + 'odds_ht_ou15_o': float(odds.get('ht_ou15_o') or 0), + 'odds_ht_ou15_u': float(odds.get('ht_ou15_u') or 0), + 'odds_btts_y': float(odds.get('btts_y') or 0), + 'odds_btts_n': float(odds.get('btts_n') or 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': upset_atmosphere, + 'upset_motivation': upset_motivation, + 'upset_fatigue': upset_fatigue, + '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 with 12 player-level features.""" + defaults = { + 'home_squad_quality': 12.0, 'away_squad_quality': 12.0, '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, + 'home_lineup_goals_per90': 0.0, 'away_lineup_goals_per90': 0.0, + 'home_lineup_assists_per90': 0.0, 'away_lineup_assists_per90': 0.0, + 'home_squad_continuity': 0.5, 'away_squad_continuity': 0.5, + 'home_top_scorer_form': 0.0, 'away_top_scorer_form': 0.0, + 'home_avg_player_exp': 0.0, 'away_avg_player_exp': 0.0, + 'home_goals_diversity': 0.0, 'away_goals_diversity': 0.0, + } + 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, + ) + result = { + 'home_squad_quality': float(pred.home_squad_quality or 0.0), + 'away_squad_quality': float(pred.away_squad_quality or 0.0), + 'squad_diff': float(pred.squad_diff or 0.0), + 'home_key_players': float(pred.home_key_players or 0), + 'away_key_players': float(pred.away_key_players or 0), + 'home_missing_impact': float(pred.home_missing_impact or 0.0), + 'away_missing_impact': float(pred.away_missing_impact or 0.0), + 'home_goals_form': float(pred.home_goals_form or 0.0), + 'away_goals_form': float(pred.away_goals_form or 0.0), + 'home_lineup_goals_per90': float(pred.home_lineup_goals_per90 or 0.0), + 'away_lineup_goals_per90': float(pred.away_lineup_goals_per90 or 0.0), + 'home_lineup_assists_per90': float(pred.home_lineup_assists_per90 or 0.0), + 'away_lineup_assists_per90': float(pred.away_lineup_assists_per90 or 0.0), + 'home_squad_continuity': float(pred.home_squad_continuity or 0.5), + 'away_squad_continuity': float(pred.away_squad_continuity or 0.5), + 'home_top_scorer_form': float(pred.home_top_scorer_form or 0), + 'away_top_scorer_form': float(pred.away_top_scorer_form or 0), + 'home_avg_player_exp': float(pred.home_avg_player_exp or 0.0), + 'away_avg_player_exp': float(pred.away_avg_player_exp or 0.0), + 'home_goals_diversity': float(pred.home_goals_diversity or 0.0), + 'away_goals_diversity': float(pred.away_goals_diversity or 0.0), + } + for side in ('home', 'away'): + sq = result[f'{side}_squad_quality'] + if sq > 50 or sq < 0: + print(f"🚨 SCALE MISMATCH: {side}_squad_quality={sq:.1f} " + f"(expected 3-36). Check player_predictor formula!") + return result + except Exception as e: + print(f"⚠️ Squad features failed: {e}") + return defaults + + 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 diff --git a/ai-engine/services/orchestrator/htms.py b/ai-engine/services/orchestrator/htms.py new file mode 100644 index 0000000..5bcf18b --- /dev/null +++ b/ai-engine/services/orchestrator/htms.py @@ -0,0 +1,231 @@ +"""HT/MS Mixin β€” analyze_match_htms endpoint and helpers. + +Auto-extracted mixin module β€” split from services/single_match_orchestrator.py. +All methods here are composed into SingleMatchOrchestrator via inheritance. +`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are +initialised in the main __init__. +""" + +from __future__ import annotations + +import json +import re +import time +import math +import os +import pickle +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set, Tuple, overload + +import pandas as pd +import numpy as np + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from data.db import get_clean_dsn +from schemas.prediction import FullMatchPrediction +from schemas.match_data import MatchData +from models.v25_ensemble import V25Predictor, get_v25_predictor +try: + from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +except ImportError: + class V27Predictor: # type: ignore[no-redef] + def __init__(self): self.models = {} + def load_models(self): return False + def predict_all(self, features): return {} + def compute_divergence(*args, **kwargs): + return {} + def compute_value_edge(*args, **kwargs): + return {} +from features.odds_band_analyzer import OddsBandAnalyzer +try: + from models.basketball_v25 import ( + BasketballMatchPrediction, + get_basketball_v25_predictor, + ) +except ImportError: + BasketballMatchPrediction = Any # type: ignore[misc] + def get_basketball_v25_predictor() -> Any: + 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 services.match_commentary import generate_match_commentary +from utils.top_leagues import load_top_league_ids +from utils.league_reliability import load_league_reliability +from config.config_loader import build_threshold_dict, get_threshold_default +from models.calibration import get_calibrator + + +class HtmsMixin: + 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)) diff --git a/ai-engine/services/orchestrator/market_board.py b/ai-engine/services/orchestrator/market_board.py new file mode 100644 index 0000000..895122d --- /dev/null +++ b/ai-engine/services/orchestrator/market_board.py @@ -0,0 +1,1470 @@ +"""Market Board Mixin β€” market rows, decoration, surprise profile, data quality. + +Auto-extracted mixin module β€” split from services/single_match_orchestrator.py. +All methods here are composed into SingleMatchOrchestrator via inheritance. +`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are +initialised in the main __init__. +""" + +from __future__ import annotations + +import json +import re +import time +import math +import os +import pickle +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set, Tuple, overload + +import pandas as pd +import numpy as np + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from data.db import get_clean_dsn +from schemas.prediction import FullMatchPrediction +from schemas.match_data import MatchData +from models.v25_ensemble import V25Predictor, get_v25_predictor +try: + from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +except ImportError: + class V27Predictor: # type: ignore[no-redef] + def __init__(self): self.models = {} + def load_models(self): return False + def predict_all(self, features): return {} + def compute_divergence(*args, **kwargs): + return {} + def compute_value_edge(*args, **kwargs): + return {} +from features.odds_band_analyzer import OddsBandAnalyzer +try: + from models.basketball_v25 import ( + BasketballMatchPrediction, + get_basketball_v25_predictor, + ) +except ImportError: + BasketballMatchPrediction = Any # type: ignore[misc] + def get_basketball_v25_predictor() -> Any: + 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 services.match_commentary import generate_match_commentary +from utils.top_leagues import load_top_league_ids +from utils.league_reliability import load_league_reliability +from config.config_loader import build_threshold_dict, get_threshold_default +from models.calibration import get_calibrator + + +class MarketBoardMixin: + 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")] + + MIN_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("ev_edge", 0.0)), + float(r.get("play_score", 0.0)), + ), + reverse=True, + ) + main_pick = playable_with_odds[0] + main_pick["is_guaranteed"] = False + main_pick["pick_reason"] = "positive_ev_after_odds_band_gate" + else: + fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0] + fallback_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) + main_pick = 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["playable"] = False + main_pick["stake_units"] = 0.0 + main_pick["bet_grade"] = "PASS" + main_pick["pick_reason"] = "no_playable_value_after_odds_band_gate" + + 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 + # V34: Lowered min calibrated_confidence for value candidates from 40.0 to 25.0 + # to allow high-odds value bets (which naturally have lower probabilities). + and float(row.get("calibrated_confidence", 0.0)) >= 25.0 + ] + if value_candidates: + # Score them by (ev_edge) to reward actual mathematical value + value_candidates.sort(key=lambda r: float(r.get("ev_edge", 0.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, + # Live snapshot β€” match_commentary uses this to detect upset-in-progress + "status": data.status, + "state": data.state, + "is_live": self._is_live_match(data), + "current_score_home": data.current_score_home, + "current_score_away": data.current_score_away, + }, + "prediction_freshness": { + "generated_at_ms": int(time.time() * 1000), + "is_pre_match_snapshot": True, + # Stale when the match is already underway β€” UI should warn the user. + "is_stale_for_live": self._is_live_match(data), + }, + "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 []), + "surprise_breakdown": list(getattr(prediction, "surprise_breakdown", []) or []), + "warnings": prediction.risk_warnings, + }, + "engine_breakdown": self._build_engine_breakdown(prediction), + "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 _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 + + @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 + + def _odds_band_verdict( + self, + data: MatchData, + market: str, + pick: str, + implied_prob: float, + ) -> Dict[str, Any]: + features = getattr(data, "odds_band_features", {}) or {} + market_key = str(market or "").upper() + if not isinstance(features, dict) or implied_prob <= 0.0: + return { + "required": market_key in self.odds_band_min_sample, + "available": False, + "band_prob": 0.0, + "band_sample": 0.0, + "band_edge": 0.0, + "aligned": False, + "reason": "odds_band_unavailable", + } + + pick_key = self._normalize_pick_token(pick) + band_prob = 0.0 + sample = 0.0 + + if market_key == "MS": + if pick_key == "1": + band_prob = float(features.get("home_band_ms_win_rate", 0.0) or 0.0) + sample = float(features.get("home_band_ms_sample", 0.0) or 0.0) + elif pick_key == "2": + band_prob = float(features.get("away_band_ms_win_rate", 0.0) or 0.0) + sample = float(features.get("away_band_ms_sample", 0.0) or 0.0) + elif pick_key in {"X", "0"}: + home_draw = float(features.get("home_band_ms_draw_rate", 0.0) or 0.0) + away_draw = float(features.get("away_band_ms_draw_rate", 0.0) or 0.0) + band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw) + sample = max( + float(features.get("home_band_ms_sample", 0.0) or 0.0), + float(features.get("away_band_ms_sample", 0.0) or 0.0), + ) + elif market_key == "DC": + dc_key = pick_key.replace("-", "").lower() + band_prob = float(features.get(f"band_dc_{dc_key}_rate", 0.0) or 0.0) + sample = float(features.get(f"band_dc_{dc_key}_sample", 0.0) or 0.0) + elif market_key in {"OU15", "OU25", "OU35"}: + suffix = {"OU15": "ou15", "OU25": "ou25", "OU35": "ou35"}[market_key] + rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate" + band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0) + sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0) + elif market_key == "BTTS": + is_yes = "VAR" in pick_key or "YES" in pick_key or pick_key == "Y" + band_prob = float(features.get(f"band_btts_{'yes' if is_yes else 'no'}_rate", 0.0) or 0.0) + sample = float(features.get("band_btts_sample", 0.0) or 0.0) + elif market_key == "HT": + if pick_key == "1": + band_prob = float(features.get("home_band_ht_win_rate", 0.0) or 0.0) + sample = float(features.get("home_band_ht_sample", 0.0) or 0.0) + elif pick_key == "2": + band_prob = float(features.get("away_band_ht_win_rate", 0.0) or 0.0) + sample = float(features.get("away_band_ht_sample", 0.0) or 0.0) + elif pick_key in {"X", "0"}: + home_draw = float(features.get("home_band_ht_draw_rate", 0.0) or 0.0) + away_draw = float(features.get("away_band_ht_draw_rate", 0.0) or 0.0) + band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw) + sample = max( + float(features.get("home_band_ht_sample", 0.0) or 0.0), + float(features.get("away_band_ht_sample", 0.0) or 0.0), + ) + elif market_key in {"HT_OU05", "HT_OU15"}: + suffix = "ht_ou05" if market_key == "HT_OU05" else "ht_ou15" + rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate" + band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0) + sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0) + + band_edge = band_prob - implied_prob if band_prob > 0.0 else 0.0 + required_sample = float(self.odds_band_min_sample.get(market_key, 0.0)) + required_edge = float(self.odds_band_min_edge.get(market_key, 0.0)) + available = band_prob > 0.0 and sample >= required_sample + aligned = available and band_edge >= required_edge + + reason = "odds_band_confirms_value" + if required_sample > 0.0 and sample < required_sample: + reason = "odds_band_sample_too_low" + elif band_prob <= 0.0: + reason = "odds_band_missing_probability" + elif band_edge < required_edge: + reason = f"odds_band_no_value_{band_edge:+.3f}" + + return { + "required": market_key in self.odds_band_min_sample, + "available": available, + "band_prob": band_prob, + "band_sample": sample, + "band_edge": band_edge, + "aligned": aligned, + "reason": reason, + } + + @staticmethod + def _normalize_pick_token(pick: str) -> str: + return ( + str(pick or "") + .strip() + .upper() + .replace("Δ°", "I") + .replace("Ü", "U") + .replace("Ş", "S") + .replace("Ğ", "G") + .replace("Γ–", "O") + .replace("Γ‡", "C") + ) + + @staticmethod + def _pick_is_over(pick_key: str) -> bool: + return "UST" in pick_key or "OVER" in pick_key + + @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]: + """ + Produces an explainable surprise profile. + + Each factor pushes the base score and contributes: + - a human-readable Turkish reason + - a `breakdown` entry with code, points, label + """ + BASE_SCORE = 22.0 + breakdown: List[Dict[str, Any]] = [] + reasons: List[str] = [] + score = BASE_SCORE + + def add(code: str, points: float, label: str) -> None: + nonlocal score + score += points + reasons.append(label) + breakdown.append({"code": code, "points": round(points, 1), "label": label}) + + 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: + add("balanced_match_risk", 18.0, "TakΔ±mlar birbirine Γ§ok yakΔ±n β€” sonuΓ§ kΔ±rΔ±labilir") + if ms_draw >= 0.30: + add("draw_probability_elevated", 14.0, f"Beraberlik olasΔ±lığı yΓΌksek (%{ms_draw*100:.0f})") + if total_xg >= 3.25: + add("high_total_goal_volatility", 10.0, f"Toplam gol beklentisi yΓΌksek (xG {total_xg:.1f}) β€” aΓ§Δ±k skor riski") + if btts_yes >= 0.68: + add("mutual_goal_pressure", 8.0, f"KarşılΔ±klΔ± gol baskΔ±sΔ± (%{btts_yes*100:.0f})") + if over35 >= 0.52: + add("late_goal_swing_risk", 8.0, "GeΓ§ gol/skor değişimi riski") + + # Odds-based traps (favorite odds trap from UpsetEngineV2 logic) + ms_h_odd = self._safe_float((data.odds_data or {}).get("ms_h"), 0.0) + ms_a_odd = self._safe_float((data.odds_data or {}).get("ms_a"), 0.0) + ms_d_odd = self._safe_float((data.odds_data or {}).get("ms_d"), 0.0) + favorite_side = None + favorite_odd = 0.0 + if ms_h_odd > 1.01 and ms_a_odd > 1.01: + if ms_h_odd <= ms_a_odd: + favorite_side, favorite_odd = "home", ms_h_odd + else: + favorite_side, favorite_odd = "away", ms_a_odd + + # Favorite odds trap (1.40-1.60 historically %33+ surprise rate) + if 1.40 <= favorite_odd < 1.60: + add( + "favorite_odds_trap", + 12.0, + f"Favori oranΔ± tuzak aralığında ({favorite_odd:.2f}) β€” tarihsel sΓΌrpriz oranΔ± %30+", + ) + elif 1.20 <= favorite_odd < 1.30: + add( + "low_odds_trap_suspicion", + 6.0, + f"Favori oranΔ± Γ§ok düşük ({favorite_odd:.2f}) β€” piyasa aşırΔ± gΓΌveniyor olabilir", + ) + + # Bookmaker margin + if ms_h_odd > 1.01 and ms_a_odd > 1.01 and ms_d_odd > 1.01: + margin = (1 / ms_h_odd + 1 / ms_d_odd + 1 / ms_a_odd) - 1 + if margin > 0.20: + add( + "bookmaker_margin_high", + 10.0, + f"Bookmaker marjΔ± Γ§ok yΓΌksek (%{margin*100:.1f}) β€” bahisΓ§i risk gΓΆrΓΌyor", + ) + elif margin > 0.18: + add( + "bookmaker_margin_elevated", + 6.0, + f"Bookmaker marjΔ± yΓΌksek (%{margin*100:.1f})", + ) + + # Away favorite carries inherent extra risk + if favorite_side == "away" and favorite_odd > 0: + add( + "away_favorite_extra_risk", + 6.0, + "Deplasman favorisi β€” atmosfer ve seyahat ek risk yaratΔ±r", + ) + + if data.lineup_source == "probable_xi": + add("lineup_probable_not_confirmed", 8.0, "Kadrolar tahmini β€” kesinleşmemiş") + if data.lineup_source == "none": + add("lineup_unavailable", 12.0, "Kadro bilgisi yok β€” analiz gΓΌvenilirliği düştΓΌ") + if not data.referee_name: + add("missing_referee", 6.0, "Hakem atanmamış β€” disiplin/avantaj sinyali eksik") + + 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: + add("live_match_open_state", 18.0, f"MaΓ§ şu an aΓ§Δ±k skorlu ({current_goals} gol) β€” pre-match tahminler riskli") + elif current_goals >= 2: + add("live_match_active_state", 10.0, f"MaΓ§ canlΔ± ve hareketli ({current_goals} gol)") + + # Live underdog leading (pre-match favorite is losing) + cur_home = int(data.current_score_home or 0) + cur_away = int(data.current_score_away or 0) + if favorite_side == "home" and cur_away > cur_home: + add( + "live_underdog_leading", + 20.0, + "CanlΔ±: deplasman ΓΆnde, pre-match ev sahibi favorisiydi β€” sΓΌrpriz GERΓ‡EKLEŞİYOR", + ) + elif favorite_side == "away" and cur_home > cur_away: + add( + "live_underdog_leading", + 20.0, + "CanlΔ±: ev sahibi ΓΆnde, pre-match deplasman favorisiydi β€” sΓΌrpriz GERΓ‡EKLEŞİYOR", + ) + + 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." + + # Deduplicate reasons by text while preserving order + deduped_reasons = list(dict.fromkeys(reasons))[:8] + # Same dedup logic for breakdown (by code) + seen_codes: Set[str] = set() + deduped_breakdown: List[Dict[str, Any]] = [] + for entry in breakdown: + if entry["code"] in seen_codes: + continue + seen_codes.add(entry["code"]) + deduped_breakdown.append(entry) + + return { + "score": round(score, 1), + "comment": comment, + "reasons": deduped_reasons, + "breakdown": deduped_breakdown[:10], + "base_score": BASE_SCORE, + } + + @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) + pick_str = str(row.get("pick") or "") + + # Trained isotonic calibrator (preferred) β€” falls back to multiplier if not trained. + # IMPORTANT: trainer was fed (raw_confidence/100, actual). Orchestrator must feed + # the same shape β€” using `prob` (which may differ from raw_conf/100 due to upstream + # confidence boosting) would give the calibrator an out-of-distribution input. + calibrator = get_calibrator() + cal_key = self._calibrator_key(market, pick_str) + if cal_key and cal_key in calibrator.calibrators: + cal_input = max(0.001, min(0.999, raw_conf / 100.0)) + cal_prob = calibrator.calibrate(cal_key, cal_input, odds_val=odd if odd > 1.0 else None) + calibrated_conf = max(1.0, min(99.0, cal_prob * 100.0)) + else: + multiplier = self.market_calibration.get(market, 0.85) + calibrated_conf = max(1.0, min(99.0, raw_conf * multiplier)) + min_conf = self.market_min_conf.get(market, 55.0) + + implied_prob = (1.0 / odd) if odd > 1.0 else 0.0 + band_verdict = self._odds_band_verdict(data, market, pick_str, implied_prob) + + # ── 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, + ) + # V33: Removed probability deflation. Deflating probability breaks normalization + # (probs no longer sum to 1) and mathematically guarantees negative EV edge. + # Data quality and confidence penalties are already applied to play_score. + model_calibrated_prob = prob + band_prob = float(band_verdict.get("band_prob", 0.0) or 0.0) + if bool(band_verdict.get("available")): + calibrated_probability = ( + (model_calibrated_prob * 0.45) + + (band_prob * 0.35) + + (implied_prob * 0.20) + ) + elif implied_prob > 0.0: + calibrated_probability = (model_calibrated_prob * 0.65) + (implied_prob * 0.35) + else: + calibrated_probability = model_calibrated_prob + calibrated_probability = max(0.0, min(0.99, calibrated_probability)) + model_edge = model_calibrated_prob - implied_prob if implied_prob > 0 else 0.0 + ev_edge = (calibrated_probability * odd) - 1.0 if odd > 1.0 else 0.0 + simple_edge = calibrated_probability - implied_prob if implied_prob > 0 else 0.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) + + # ── 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 + + # V34: Broadened value_sniper bypass β€” odds-aware model rarely shows 3% EV edge + # Allow high-confidence predictions OR modest positive EV to bypass secondary gates + is_value_sniper = ev_edge >= 0.008 or calibrated_conf >= 55.0 + + if calibrated_conf < min_conf: + if not is_value_sniper: + 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") + # V34: Added confidence bonus β€” high raw model probability gets a boost + # This prevents over-penalization when edge is near-zero but model is confident + raw_top_prob = float(row.get("probability", 0.0)) + confidence_bonus = 0.0 + if raw_top_prob >= 0.65: + confidence_bonus = 15.0 + elif raw_top_prob >= 0.55: + confidence_bonus = 10.0 + elif raw_top_prob >= 0.45: + confidence_bonus = 5.0 + base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier) + confidence_bonus + play_score = max( + 0.0, + min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty), + ) + # V34: odds_band gate β€” only hard-block when band data is AVAILABLE and aligned=False + # When band data is sparse (available=False), skip alignment check entirely + band_available = bool(band_verdict.get("available", False)) + if band_available and bool(band_verdict.get("required")) and not bool(band_verdict.get("aligned")): + if not is_value_sniper: + playable = False + reasons.append(str(band_verdict.get("reason") or "odds_band_not_aligned")) + elif not band_available and bool(band_verdict.get("required")): + # Sparse data β€” log but don't block + reasons.append("odds_band_data_sparse_skipped") + # V34: REMOVED model_not_above_market gate entirely + # V25 model is odds-informed BY DESIGN β†’ model output β‰ˆ market-implied probability + # Requiring model > market is mathematically impossible with this architecture + # The negative_model_edge gate below still catches truly anti-value picks + # V34: negative edge threshold relaxed β€” odds-aware model's edge is naturally near zero + # Reliable league: -0.08, unreliable: up to -0.15 + # Only blocks truly anti-value picks (model significantly below market) + neg_edge_threshold = -0.08 - (1.0 - odds_rel) * 0.07 + if odd > 1.0 and simple_edge < neg_edge_threshold: + if not is_value_sniper: + playable = False + reasons.append(f"negative_model_edge_{simple_edge:+.3f}") + # V34: Added value_sniper bypass β€” was missing before, causing hard blocks + if odd > 1.0 and ev_edge < min_edge: + if not is_value_sniper: + playable = False + reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}") + if play_score < min_play_score: + if not is_value_sniper: + 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(calibrated_probability, odd) + reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A") + elif ev_edge > 0.05: + grade = "B" + stake_units = self._kelly_stake(calibrated_probability, odd) + reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B") + elif ev_edge > 0.02: + grade = "C" + stake_units = self._kelly_stake(calibrated_probability, 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), + "model_probability": round(prob, 4), + "model_edge": round(model_edge, 4), + "calibrated_probability": round(calibrated_probability, 4), + "implied_prob": round(implied_prob, 4), + "ev_edge": round(ev_edge, 4), + "is_value_sniper": is_value_sniper, + "odds_band_probability": round(float(band_verdict.get("band_prob", 0.0) or 0.0), 4), + "odds_band_sample": round(float(band_verdict.get("band_sample", 0.0) or 0.0), 1), + "odds_band_edge": round(float(band_verdict.get("band_edge", 0.0) or 0.0), 4), + "odds_band_aligned": bool(band_verdict.get("aligned")), + "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)), + "is_value_sniper": bool(row.get("is_value_sniper")), + "model_probability": row.get("model_probability", row.get("probability", 0.0)), + "model_edge": row.get("model_edge", 0.0), + "calibrated_probability": row.get("calibrated_probability", row.get("probability", 0.0)), + "implied_prob": row.get("implied_prob", 0.0), + "odds_band_probability": row.get("odds_band_probability", 0.0), + "odds_band_sample": row.get("odds_band_sample", 0.0), + "odds_band_edge": row.get("odds_band_edge", 0.0), + "odds_band_aligned": bool(row.get("odds_band_aligned")), + "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") + if data.source_table == "live_matches": + flags.append("live_match_pre_match_features") + feature_source = str(getattr(data, "feature_source", "") or "") + if feature_source == "live_prematch_enrichment": + flags.append("ai_features_inferred_from_history") + + total_score = (odds_score * 0.45) + (lineup_score * 0.45) + (ref_score * 0.10) + + # When statistical features are inferred (not from pre-computed table), + # ELO/form/H2H data reliability is unknown β€” cap quality at MEDIUM. + if feature_source == "live_prematch_enrichment": + total_score = min(total_score, 0.74) + + if total_score >= 0.8: + label = "HIGH" + elif total_score >= 0.55: + label = "MEDIUM" + else: + label = "LOW" + if label == "HIGH" and ( + data.lineup_source == "probable_xi" or not data.referee_name + or feature_source == "live_prematch_enrichment" + ): + label = "MEDIUM" + + 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), + "feature_source": feature_source or "unknown", + "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 diff --git a/ai-engine/services/orchestrator/prediction.py b/ai-engine/services/orchestrator/prediction.py new file mode 100644 index 0000000..625f155 --- /dev/null +++ b/ai-engine/services/orchestrator/prediction.py @@ -0,0 +1,662 @@ +"""Prediction Mixin β€” V25 signal extraction and prediction building. + +Auto-extracted mixin module β€” split from services/single_match_orchestrator.py. +All methods here are composed into SingleMatchOrchestrator via inheritance. +`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are +initialised in the main __init__. +""" + +from __future__ import annotations + +import json +import re +import time +import math +import os +import pickle +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set, Tuple, overload + +import pandas as pd +import numpy as np + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from data.db import get_clean_dsn +from schemas.prediction import FullMatchPrediction +from schemas.match_data import MatchData +from models.v25_ensemble import V25Predictor, get_v25_predictor +try: + from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +except ImportError: + class V27Predictor: # type: ignore[no-redef] + def __init__(self): self.models = {} + def load_models(self): return False + def predict_all(self, features): return {} + def compute_divergence(*args, **kwargs): + return {} + def compute_value_edge(*args, **kwargs): + return {} +from features.odds_band_analyzer import OddsBandAnalyzer +try: + from models.basketball_v25 import ( + BasketballMatchPrediction, + get_basketball_v25_predictor, + ) +except ImportError: + BasketballMatchPrediction = Any # type: ignore[misc] + def get_basketball_v25_predictor() -> Any: + 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 services.match_commentary import generate_match_commentary +from utils.top_leagues import load_top_league_ids +from utils.league_reliability import load_league_reliability +from config.config_loader import build_threshold_dict, get_threshold_default, get_config +from models.calibration import get_calibrator +from models.league_model import get_league_model_loader, FILE_TO_SIGNAL + + +class PredictionMixin: + 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 + + _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] = {} + + # ── League-specific model override ───────────────────────────────── + league_id = getattr(data, "league_id", None) + league_model = None + if league_id: + try: + league_model = get_league_model_loader().get(league_id) + except Exception: + league_model = None + + if league_model: + # Predict all available markets with league-specific XGBoost + for mkey, sig_key in FILE_TO_SIGNAL.items(): + probs = league_model.predict_market(mkey, feature_row) + if probs: + best_label = max(probs, key=probs.__getitem__) + signal[sig_key] = { + "probs": probs, + "raw_probs": probs, + "pick": best_label, + "probability": float(probs[best_label]), + "confidence": round(float(probs[best_label]) * 100.0, 1), + "source": "league_specific", + } + if signal: + print(f" [LEAGUE-MODEL] {league_id}: {len(signal)} markets predicted") + # Fill remaining markets from general V25 (markets not in league model) + # fall through to general prediction below for missing ones + + def _temperature_scale(probs_dict: Dict[str, float], temperature: float = 1.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). + + V34: Reduced from 2.5 to 1.5 β€” V25 model is already calibrated via + odds-aware training. Excessive flattening was destroying signal. + """ + import math + eps = 1e-7 # numerical stability + n = len(probs_dict) + + # V34: Reduced temperature β€” odds-aware model is already calibrated + # Binary markets (2-class) tend to be more overconfident in LGB + if n <= 2: + T = max(temperature, 1.5) # was 2.0 + elif n == 3: + T = max(temperature * 0.8, 1.2) # was 1.5 β€” 3-way slightly less aggressive + else: + T = max(temperature * 0.6, 1.0) # was 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 + + calibrator = get_calibrator() + _temperature = float(get_config().get('model_ensemble.temperature', 1.5)) + + # Map (market_key, label) β†’ calibrator market key + _CAL_KEY_MAP: Dict[str, str] = { + "ms_1": "ms_home", "ms_x": "ms_draw", "ms_2": "ms_away", + "ou15_over": "ou15", "ou15_under": "ou15", + "ou25_over": "ou25", "ou25_under": "ou25", + "ou35_over": "ou35", "ou35_under": "ou35", + "btts_yes": "btts", "btts_no": "btts", + "ht_1": "ht_home", "ht_x": "ht_draw", "ht_2": "ht_away", + } + + def _enrich_signal_entry(probs_dict: Dict[str, float], market_key: str = "") -> Dict[str, Any]: + """Temperature scaling + Isotonic calibration pipeline.""" + scaled_probs = _temperature_scale(probs_dict, temperature=_temperature) + + # Isotonic calibration per outcome (if trained models exist) + if market_key: + calibrated = {} + for label, prob in scaled_probs.items(): + raw_key = f"{market_key}_{label}".lower().replace(" ", "_") + cal_key = _CAL_KEY_MAP.get(raw_key, raw_key) + calibrated[label] = calibrator.calibrate(cal_key, prob) + total = sum(calibrated.values()) + if total > 0: + calibrated = {k: v / total for k, v in calibrated.items()} + scaled_probs = calibrated + + best_label = max(scaled_probs, key=scaled_probs.__getitem__) + best_prob = float(scaled_probs[best_label]) + return { + "probs": scaled_probs, + "raw_probs": probs_dict, + "pick": best_label, + "probability": best_prob, + "confidence": round(best_prob * 100.0, 1), + } + + # Core markets using dedicated methods (skip if league model already covered them) + if "MS" not in signal: + h, d, a = v25.predict_ms(feature_row) + signal["MS"] = _enrich_signal_entry({"1": h, "X": d, "2": a}, "ms") + print(f" [V25-SIGNAL] MS β†’ H={h:.4f} D={d:.4f} A={a:.4f}") + else: + print(f" [LEAGUE-MODEL] MS β†’ {signal['MS']['probs']}") + + if "OU25" not in signal: + over25, under25 = v25.predict_ou25(feature_row) + signal["OU25"] = _enrich_signal_entry({"Over": over25, "Under": under25}, "ou25") + print(f" [V25-SIGNAL] OU25 β†’ O={over25:.4f} U={under25:.4f}") + + if "BTTS" not in signal: + btts_y, btts_n = v25.predict_btts(feature_row) + signal["BTTS"] = _enrich_signal_entry({"Yes": btts_y, "No": btts_n}, "btts") + print(f" [V25-SIGNAL] BTTS β†’ Y={btts_y:.4f} N={btts_n:.4f}") + + # Additional markets via generic predict_market (skip if league model covered them) + 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 = str(self._V25_KEY_MAP.get(model_key, model_key.upper())) + if out_key in signal: + continue # already predicted by league-specific model + 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, model_key) + 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}, model_key) + 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, model_key) + + 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 _is_cup_game(league_name: str) -> bool: + """Detect cup/knockout competitions where home advantage is significantly weaker.""" + name = (league_name or "").lower() + cup_keywords = ( + "kupa", "cup", "coupe", "copa", "coppa", "pokal", + "trophy", "shield", "challenge", + "ziraat", "sΓΌper kupa", "super cup", + ) + return any(kw in name for kw in cup_keywords) + + @staticmethod + def _best_prob_pick(prob_map: Dict[str, float]) -> Tuple[str, float]: + if not prob_map: + return "", 0.0 + pick = max(prob_map, key=prob_map.__getitem__) + 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}) + + # Cup game: dampen home advantage β€” model trained on league data overestimates home edge + is_cup = self._is_cup_game(getattr(data, "league_name", "") or "") + if is_cup: + # Shift 8% of home probability toward away and draw (rotation, neutral venue effect) + cup_transfer = ms_probs["1"] * 0.08 + ms_probs = { + "1": ms_probs["1"] - cup_transfer, + "X": ms_probs["X"] + cup_transfer * 0.4, + "2": ms_probs["2"] + cup_transfer * 0.6, + } + total = sum(ms_probs.values()) + ms_probs = {k: v / total for k, v in ms_probs.items()} + + 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 ────────── + ms_edge = prediction.ms_home_prob - prediction.ms_away_prob + 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 or 1.3) + float(features.get("away_xga", data.away_conceded_avg) or 1.2)) / 2.0) + base_away_xg = max(0.25, (float(data.away_goals_avg or 1.3) + float(features.get("home_xga", data.home_conceded_avg) or 1.2)) / 2.0) + # ms_edge already computed above + total_target = max( + 1.4, + min( + 4.8, + (float(features.get("league_avg_goals", 2.7)) * 0.55) + + ((float(data.home_goals_avg or 1.3) + float(data.away_goals_avg or 1.3)) * 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) + + # Cup game: reduce xG by 20% β€” rotation + lower motivation + defensive tactics + if is_cup: + prediction.home_xg = round(prediction.home_xg * 0.80, 2) + prediction.away_xg = round(prediction.away_xg * 0.80, 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) + + # Score prediction: find the most likely scoreline consistent with the MS pick + # Instead of just rounding xG (misleading), filter Poisson top scores by result direction + ms_pick = prediction.ms_pick # "1", "X", or "2" + top5 = prediction.ft_scores_top5 + if top5 and ms_pick in ("1", "X", "2"): + def _result_of(score_str: str) -> str: + try: + h, a = map(int, score_str.split("-")) + if h > a: return "1" + if h < a: return "2" + return "X" + except Exception: + return "?" + + # Filter to scorelines matching the predicted result + matching = [s for s in top5 if _result_of(s["score"]) == ms_pick] + if matching: + best = matching[0] # already sorted by probability desc + h_str, a_str = best["score"].split("-") + prediction.predicted_ft_score = best["score"] + # Recalculate HT score proportionally from the FT pick + h_val, a_val = int(h_str), int(a_str) + prediction.predicted_ht_score = f"{int(round(h_val * 0.45))}-{int(round(a_val * 0.45))}" + + 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 + # Cup game penalty: model trained on league data has lower reliability for cup matches + cup_penalty = 10.0 if is_cup else 0.0 + # Bookmaker margin penalty: high margin signals that even the market is uncertain + bm_margin = 0.0 + odds_data = getattr(data, "odds_data", {}) or {} + _h, _d, _a = float(odds_data.get("ms_h") or 0), float(odds_data.get("ms_d") or 0), float(odds_data.get("ms_a") or 0) + if _h > 1.01 and _d > 1.01 and _a > 1.01: + bm_margin = (1 / _h + 1 / _d + 1 / _a) - 1 + bookmaker_penalty = 12.0 if bm_margin > 0.20 else 6.0 if bm_margin > 0.15 else 0.0 + prediction.risk_score = round(min(100.0, max(10.0, 100.0 - max_market_conf + lineup_penalty + referee_penalty + parity_penalty + cup_penalty + bookmaker_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 is_cup: + prediction.risk_warnings.append("cup_game_home_advantage_reduced") + if bookmaker_penalty > 0: + prediction.risk_warnings.append(f"bookmaker_margin_high_{bm_margin*100:.0f}pct") + 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.surprise_breakdown = surprise.get("breakdown", []) + # Auto-flag is_surprise_risk when score crosses 45 even if other paths didn't fire + if surprise["score"] >= 45.0: + prediction.is_surprise_risk = True + + 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, float(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 _build_engine_breakdown(self, prediction: FullMatchPrediction) -> Dict[str, Any]: + """ + Engine breakdown with backward-compatible flat scores + rich detail siblings. + + Shape: + { + team: 74.1, player: 55.7, odds: 55.2, referee: 62.0, # legacy flat scores + detail: { team: {score, label, ...}, player: {...}, ... } + } + """ + components = { + "team": ("TakΔ±m modeli", float(prediction.team_confidence)), + "player": ("Oyuncu / kadro modeli", float(prediction.player_confidence)), + "odds": ("Oran piyasasΔ±", float(prediction.odds_confidence)), + "referee": ("Hakem etkisi", float(prediction.referee_confidence)), + } + flat: Dict[str, Any] = {} + detail: Dict[str, Any] = {} + for key, (display, raw) in components.items(): + score = round(raw, 1) + label, interpretation = self._confidence_label(score) + flat[key] = score + detail[key] = { + "score": score, + "label": label, + "display_name": display, + "interpretation": interpretation, + } + flat["detail"] = detail + return flat diff --git a/ai-engine/services/orchestrator/reversal.py b/ai-engine/services/orchestrator/reversal.py new file mode 100644 index 0000000..ea63f59 --- /dev/null +++ b/ai-engine/services/orchestrator/reversal.py @@ -0,0 +1,469 @@ +"""Reversal Mixin β€” HT/FT reversal watchlist and cycle metrics. + +Auto-extracted mixin module β€” split from services/single_match_orchestrator.py. +All methods here are composed into SingleMatchOrchestrator via inheritance. +`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are +initialised in the main __init__. +""" + +from __future__ import annotations + +import json +import re +import time +import math +import os +import pickle +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set, Tuple, overload + +import pandas as pd +import numpy as np + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from data.db import get_clean_dsn +from schemas.prediction import FullMatchPrediction +from schemas.match_data import MatchData +from models.v25_ensemble import V25Predictor, get_v25_predictor +try: + from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +except ImportError: + class V27Predictor: # type: ignore[no-redef] + def __init__(self): self.models = {} + def load_models(self): return False + def predict_all(self, features): return {} + def compute_divergence(*args, **kwargs): + return {} + def compute_value_edge(*args, **kwargs): + return {} +from features.odds_band_analyzer import OddsBandAnalyzer +try: + from models.basketball_v25 import ( + BasketballMatchPrediction, + get_basketball_v25_predictor, + ) +except ImportError: + BasketballMatchPrediction = Any # type: ignore[misc] + def get_basketball_v25_predictor() -> Any: + 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 services.match_commentary import generate_match_commentary +from utils.top_leagues import load_top_league_ids +from utils.league_reliability import load_league_reliability +from config.config_loader import build_threshold_dict, get_threshold_default +from models.calibration import get_calibrator + + +class ReversalMixin: + 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) + h, a = sorted((home_id, away_id)) + pair_keys.add((h, a)) + + 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 + + h, a = sorted((data.home_team_id, data.away_team_id)) + pair_key = (h, a) + 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"]) + h, a = sorted((home_id, away_id)) + key = (h, a) + 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" diff --git a/ai-engine/services/orchestrator/upper_brain.py b/ai-engine/services/orchestrator/upper_brain.py new file mode 100644 index 0000000..84ae345 --- /dev/null +++ b/ai-engine/services/orchestrator/upper_brain.py @@ -0,0 +1,350 @@ +"""Upper Brain Mixin β€” V27 cross-check guards and assessments. + +Auto-extracted mixin module β€” split from services/single_match_orchestrator.py. +All methods here are composed into SingleMatchOrchestrator via inheritance. +`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are +initialised in the main __init__. +""" + +from __future__ import annotations + +import json +import re +import time +import math +import os +import pickle +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set, Tuple, overload + +import pandas as pd +import numpy as np + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from data.db import get_clean_dsn +from schemas.prediction import FullMatchPrediction +from schemas.match_data import MatchData +from models.v25_ensemble import V25Predictor, get_v25_predictor +try: + from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +except ImportError: + class V27Predictor: # type: ignore[no-redef] + def __init__(self): self.models = {} + def load_models(self): return False + def predict_all(self, features): return {} + def compute_divergence(*args, **kwargs): + return {} + def compute_value_edge(*args, **kwargs): + return {} +from features.odds_band_analyzer import OddsBandAnalyzer +try: + from models.basketball_v25 import ( + BasketballMatchPrediction, + get_basketball_v25_predictor, + ) +except ImportError: + BasketballMatchPrediction = Any # type: ignore[misc] + def get_basketball_v25_predictor() -> Any: + 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 services.match_commentary import generate_match_commentary +from utils.top_leagues import load_top_league_ids +from utils.league_reliability import load_league_reliability +from config.config_loader import build_threshold_dict, get_threshold_default +from models.calibration import get_calibrator + + +class UpperBrainMixin: + 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 + return self._safe_float(probs.get(prob_key)) + + 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": + ms_key = {"1": "home", "X": "draw", "2": "away"}.get(pick or "") + return self._safe_float(ms.get(ms_key), 0.0) if ms_key else 0.0 + 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), 0.0) if prob_key else 0.0 + return 0.0 + + @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 diff --git a/ai-engine/services/orchestrator/utils.py b/ai-engine/services/orchestrator/utils.py new file mode 100644 index 0000000..9f3c34c --- /dev/null +++ b/ai-engine/services/orchestrator/utils.py @@ -0,0 +1,174 @@ +"""Utility Mixin β€” generic helpers (safe_float, label normalisation, JSON parsing). + +Auto-extracted mixin module β€” split from services/single_match_orchestrator.py. +All methods here are composed into SingleMatchOrchestrator via inheritance. +`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are +initialised in the main __init__. +""" + +from __future__ import annotations + +import json +import re +import time +import math +import os +import pickle +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set, Tuple, overload + +import pandas as pd +import numpy as np + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from data.db import get_clean_dsn +from schemas.prediction import FullMatchPrediction +from schemas.match_data import MatchData +from models.v25_ensemble import V25Predictor, get_v25_predictor +try: + from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +except ImportError: + class V27Predictor: # type: ignore[no-redef] + def __init__(self): self.models = {} + def load_models(self): return False + def predict_all(self, features): return {} + def compute_divergence(*args, **kwargs): + return {} + def compute_value_edge(*args, **kwargs): + return {} +from features.odds_band_analyzer import OddsBandAnalyzer +try: + from models.basketball_v25 import ( + BasketballMatchPrediction, + get_basketball_v25_predictor, + ) +except ImportError: + BasketballMatchPrediction = Any # type: ignore[misc] + def get_basketball_v25_predictor() -> Any: + 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 services.match_commentary import generate_match_commentary +from utils.top_leagues import load_top_league_ids +from utils.league_reliability import load_league_reliability +from config.config_loader import build_threshold_dict, get_threshold_default +from models.calibration import get_calibrator + + +class UtilsMixin: + @staticmethod + @overload + def _safe_float(value: Any, default: float) -> float: ... + + @staticmethod + @overload + def _safe_float(value: Any, default: None = ...) -> Optional[float]: ... + + @staticmethod + def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]: + try: + return float(value) + except (TypeError, ValueError): + return default + + @staticmethod + def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + @staticmethod + def _calibrator_key(market: str, pick: str) -> Optional[str]: + """Map (market, pick) β†’ trained-calibrator key in models/calibration.""" + m = (market or "").upper() + p = (pick or "").strip().casefold() + if m == "MS": + if p == "1": + return "ms_home" + if p == "x" or p == "0": + return "ms_draw" + if p == "2": + return "ms_away" + return None + if m == "DC": + return "dc" + if m == "OU15" and ("over" in p or "ΓΌst" in p or "ust" in p): + return "ou15" + if m == "OU25" and ("over" in p or "ΓΌst" in p or "ust" in p): + return "ou25" + if m == "OU35" and ("over" in p or "ΓΌst" in p or "ust" in p): + return "ou35" + if m == "BTTS" and ("yes" in p or "var" in p): + return "btts" + if m == "HT": + if p == "1": + return "ht_home" + if p == "x" or p == "0": + return "ht_draw" + if p == "2": + return "ht_away" + return None + if m == "HTFT": + return "ht_ft" + return None + + @staticmethod + def _confidence_label(score: float) -> Tuple[str, str]: + """Turkish UX label + interpretation for a 0-100 confidence score.""" + if score >= 75: + return "YUKSEK", "Bu sinyal gΓΌΓ§lΓΌ ve gΓΌvenilir" + if score >= 60: + return "ORTA", "Sinyal makul, Γ§elişen veri yok" + if score >= 45: + return "DUSUK", "Sinyal zayΔ±f, dikkatli yorumla" + return "COK_DUSUK", "Veri yetersiz veya Γ§elişkili β€” bu motoru bu maΓ§ iΓ§in ihmal et" + + @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 diff --git a/ai-engine/services/single_match_orchestrator.py b/ai-engine/services/single_match_orchestrator.py index 68ea026..a1ea661 100755 --- a/ai-engine/services/single_match_orchestrator.py +++ b/ai-engine/services/single_match_orchestrator.py @@ -20,14 +20,14 @@ 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, overload import psycopg2 from psycopg2.extras import RealDictCursor from data.db import get_clean_dsn -from models.v20_ensemble import FullMatchPrediction +from schemas.prediction import FullMatchPrediction +from schemas.match_data import MatchData from models.v25_ensemble import V25Predictor, get_v25_predictor try: from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge @@ -57,47 +57,48 @@ from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine from services.match_commentary import generate_match_commentary from utils.top_leagues import load_top_league_ids from utils.league_reliability import load_league_reliability -from config.config_loader import build_threshold_dict, get_threshold_default +from config.config_loader import build_threshold_dict, get_threshold_default, get_config from models.calibration import get_calibrator - -@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 - source_table: str = "matches" +# Refactor note (post-V28): +# The original 5786-line monolith was split into focused mixin modules under +# services/orchestrator/. This file is the slim composition layer plus the +# main entry points (__init__, analyze_match, predictor lifecycle). +from services.orchestrator import ( + DataLoaderMixin, + FeatureBuilderMixin, + PredictionMixin, + BasketballMixin, + UpperBrainMixin, + HtmsMixin, + CouponMixin, + ReversalMixin, + MarketBoardMixin, + UtilsMixin, +) -class SingleMatchOrchestrator: +# MRO note: the original file contained two `_safe_float` definitions where +# the second silently overrode the first per Python class-scope rules. +# Both are preserved in UtilsMixin in their original order, so the override +# behaviour is identical. +class SingleMatchOrchestrator( + DataLoaderMixin, + FeatureBuilderMixin, + PredictionMixin, + BasketballMixin, + UpperBrainMixin, + HtmsMixin, + CouponMixin, + ReversalMixin, + MarketBoardMixin, + UtilsMixin, +): """Main V20+ application service used by API endpoints.""" - DEFAULT_MS_H = 2.65 - DEFAULT_MS_D = 3.20 - DEFAULT_MS_A = 2.65 + _cfg = get_config() + DEFAULT_MS_H: float = float(_cfg.get('model_ensemble.default_ms_odds.home', 2.65)) + DEFAULT_MS_D: float = float(_cfg.get('model_ensemble.default_ms_odds.draw', 3.20)) + DEFAULT_MS_A: float = float(_cfg.get('model_ensemble.default_ms_odds.away', 2.65)) RELATIONAL_ODDS_KEYS = ( "ms_h", "ms_d", @@ -214,805 +215,6 @@ class SingleMatchOrchestrator: 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') or 0) - ms_d = float(odds.get('ms_d') or 0) - ms_a = float(odds.get('ms_a') or 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) - else: - cur.execute( - """ - SELECT - team_id, - overall_elo, - home_elo, - away_elo, - form_elo - FROM team_elo_ratings - WHERE team_id IN (%s, %s) - """, - (data.home_team_id, data.away_team_id), - ) - elo_rows = cur.fetchall() - by_team = {str(r.get("team_id")): r for r in elo_rows} - home_row = by_team.get(str(data.home_team_id)) - away_row = by_team.get(str(data.away_team_id)) - if home_row: - home_elo = float(home_row.get("overall_elo") or 1500.0) - home_venue_elo = float(home_row.get("home_elo") or home_elo) - home_form_elo_val = float(home_row.get("form_elo") or home_elo) - if away_row: - away_elo = float(away_row.get("overall_elo") or 1500.0) - away_venue_elo = float(away_row.get("away_elo") or away_elo) - away_form_elo_val = float(away_row.get("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, - ) - setattr(data, "odds_band_features", odds_band_features) - setattr(data, "feature_source", "football_ai_features" if elo_row else "live_prematch_enrichment") - 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 - setattr(data, "odds_band_features", odds_band_features) - setattr(data, "feature_source", "fallback_defaults") - - 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') or 0) > 1.01 else 0.0, - 'odds_ht_ms_d_present': 1.0 if float(odds.get('ht_d') or 0) > 1.01 else 0.0, - 'odds_ht_ms_a_present': 1.0 if float(odds.get('ht_a') or 0) > 1.01 else 0.0, - 'odds_ou05_o_present': 1.0 if float(odds.get('ou05_o') or 0) > 1.01 else 0.0, - 'odds_ou05_u_present': 1.0 if float(odds.get('ou05_u') or 0) > 1.01 else 0.0, - 'odds_ou15_o_present': 1.0 if float(odds.get('ou15_o') or 0) > 1.01 else 0.0, - 'odds_ou15_u_present': 1.0 if float(odds.get('ou15_u') or 0) > 1.01 else 0.0, - 'odds_ou25_o_present': 1.0 if float(odds.get('ou25_o') or 0) > 1.01 else 0.0, - 'odds_ou25_u_present': 1.0 if float(odds.get('ou25_u') or 0) > 1.01 else 0.0, - 'odds_ou35_o_present': 1.0 if float(odds.get('ou35_o') or 0) > 1.01 else 0.0, - 'odds_ou35_u_present': 1.0 if float(odds.get('ou35_u') or 0) > 1.01 else 0.0, - 'odds_ht_ou05_o_present': 1.0 if float(odds.get('ht_ou05_o') or 0) > 1.01 else 0.0, - 'odds_ht_ou05_u_present': 1.0 if float(odds.get('ht_ou05_u') or 0) > 1.01 else 0.0, - 'odds_ht_ou15_o_present': 1.0 if float(odds.get('ht_ou15_o') or 0) > 1.01 else 0.0, - 'odds_ht_ou15_u_present': 1.0 if float(odds.get('ht_ou15_u') or 0) > 1.01 else 0.0, - 'odds_btts_y_present': 1.0 if float(odds.get('btts_y') or 0) > 1.01 else 0.0, - 'odds_btts_n_present': 1.0 if float(odds.get('btts_n') or 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') or 0), - 'odds_ht_ms_d': float(odds.get('ht_d') or 0), - 'odds_ht_ms_a': float(odds.get('ht_a') or 0), - 'odds_ou05_o': float(odds.get('ou05_o') or 0), - 'odds_ou05_u': float(odds.get('ou05_u') or 0), - 'odds_ou15_o': float(odds.get('ou15_o') or 0), - 'odds_ou15_u': float(odds.get('ou15_u') or 0), - 'odds_ou25_o': float(odds.get('ou25_o') or 0), - 'odds_ou25_u': float(odds.get('ou25_u') or 0), - 'odds_ou35_o': float(odds.get('ou35_o') or 0), - 'odds_ou35_u': float(odds.get('ou35_u') or 0), - 'odds_ht_ou05_o': float(odds.get('ht_ou05_o') or 0), - 'odds_ht_ou05_u': float(odds.get('ht_ou05_u') or 0), - 'odds_ht_ou15_o': float(odds.get('ht_ou15_o') or 0), - 'odds_ht_ou15_u': float(odds.get('ht_ou15_u') or 0), - 'odds_btts_y': float(odds.get('btts_y') or 0), - 'odds_btts_n': float(odds.get('btts_n') or 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': 12.0, 'away_squad_quality': 12.0, '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, - ) - result = { - 'home_squad_quality': float(pred.home_squad_quality or 0.0), - 'away_squad_quality': float(pred.away_squad_quality or 0.0), - 'squad_diff': float(pred.squad_diff or 0.0), - 'home_key_players': float(pred.home_key_players or 0), - 'away_key_players': float(pred.away_key_players or 0), - 'home_missing_impact': float(pred.home_missing_impact or 0.0), - 'away_missing_impact': float(pred.away_missing_impact or 0.0), - 'home_goals_form': float(pred.home_goals_form or 0.0), - 'away_goals_form': float(pred.away_goals_form or 0.0), - } - # Sanity check: squad_quality must be in training range (~3-36) - for side in ('home', 'away'): - sq = result[f'{side}_squad_quality'] - if sq > 50 or sq < 0: - print(f"🚨 SCALE MISMATCH: {side}_squad_quality={sq:.1f} " - f"(expected 3-36). Check player_predictor formula!") - return result - 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 = 1.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). - - V34: Reduced from 2.5 to 1.5 β€” V25 model is already calibrated via - odds-aware training. Excessive flattening was destroying signal. - """ - import math - eps = 1e-7 # numerical stability - n = len(probs_dict) - - # V34: Reduced temperature β€” odds-aware model is already calibrated - # Binary markets (2-class) tend to be more overconfident in LGB - if n <= 2: - T = max(temperature, 1.5) # was 2.0 - elif n == 3: - T = max(temperature * 0.8, 1.2) # was 1.5 β€” 3-way slightly less aggressive - else: - T = max(temperature * 0.6, 1.0) # was 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. - """ - # V34: Apply temperature scaling β€” reduced from 2.5 to 1.5 - scaled_probs = _temperature_scale(probs_dict, temperature=1.5) - - best_label = max(scaled_probs, key=scaled_probs.__getitem__) - 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 = str(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]: - if not prob_map: - return "", 0.0 - pick = max(prob_map, key=prob_map.__getitem__) - 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 ────────── - ms_edge = prediction.ms_home_prob - prediction.ms_away_prob - 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 or 1.3) + float(features.get("away_xga", data.away_conceded_avg) or 1.2)) / 2.0) - base_away_xg = max(0.25, (float(data.away_goals_avg or 1.3) + float(features.get("home_xga", data.home_conceded_avg) or 1.2)) / 2.0) - # ms_edge already computed above - total_target = max( - 1.4, - min( - 4.8, - (float(features.get("league_avg_goals", 2.7)) * 0.55) - + ((float(data.home_goals_avg or 1.3) + float(data.away_goals_avg or 1.3)) * 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.surprise_breakdown = surprise.get("breakdown", []) - # Auto-flag is_surprise_risk when score crosses 45 even if other paths didn't fire - if surprise["score"] >= 45.0: - prediction.is_surprise_risk = True - - 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, float(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() @@ -1500,4313 +702,6 @@ class SingleMatchOrchestrator: 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 - return self._safe_float(probs.get(prob_key)) - - 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": - ms_key = {"1": "home", "X": "draw", "2": "away"}.get(pick or "") - return self._safe_float(ms.get(ms_key), 0.0) if ms_key else 0.0 - 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), 0.0) if prob_key else 0.0 - return 0.0 - - @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 - @overload - def _safe_float(value: Any, default: float) -> float: ... - @staticmethod - @overload - def _safe_float(value: Any, default: None = ...) -> Optional[float]: ... - @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_live(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) - h, a = sorted((home_id, away_id)) - pair_keys.add((h, a)) - - 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 - - h, a = sorted((data.home_team_id, data.away_team_id)) - pair_key = (h, a) - 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"]) - h, a = sorted((home_id, away_id)) - key = (h, a) - 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 - - status, state, substate = self._normalize_match_status( - row.get("status"), - row.get("state"), - row.get("substate"), - row.get("score_home"), - row.get("score_away"), - ) - 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=status, - state=state, - substate=substate, - lineup_confidence=lineup_confidence, - source_table=str(row.get("source_table") or "matches"), - current_score_home=( - int(str(row.get("score_home"))) - if row.get("score_home") is not None - else None - ), - current_score_away=( - int(str(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, - 'live_matches'::text as source_table - 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() - - @staticmethod - def _normalize_match_status( - status: Any, - state: Any, - substate: Any, - score_home: Any, - score_away: Any, - ) -> Tuple[str, Optional[str], Optional[str]]: - state_text = str(state or "").strip() - status_text = str(status or "").strip() - substate_text = str(substate or "").strip() - - state_key = state_text.lower().replace("_", "").replace(" ", "") - status_key = status_text.lower().replace("_", "").replace(" ", "") - substate_key = substate_text.lower().replace("_", "").replace(" ", "") - - live_tokens = {"live", "livegame", "firsthalf", "secondhalf", "halftime", "1h", "2h", "ht", "1q", "2q", "3q", "4q"} - finished_tokens = {"post", "postgame", "finished", "played", "ft", "ended", "aet", "pen", "penalties", "afterpenalties"} - pre_tokens = {"pre", "pregame", "scheduled", "ns", "notstarted", "timestamp"} - - if state_key in live_tokens or status_key in live_tokens or substate_key in live_tokens: - return "LIVE", state_text or "live", substate_text or None - if state_key in finished_tokens or status_key in finished_tokens or substate_key in finished_tokens: - return "FT", state_text or "post", substate_text or None - if score_home is not None and score_away is not None and status_key not in pre_tokens: - return "FT", state_text or "post", substate_text or None - if state_key in pre_tokens or status_key in pre_tokens or substate_key in pre_tokens: - return "NS", state_text or "pre", substate_text or None - - return status_text or "NS", state_text or None, substate_text or None - - 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, - 'matches'::text as source_table - 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([dict(r) for r in 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")] - - MIN_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("ev_edge", 0.0)), - float(r.get("play_score", 0.0)), - ), - reverse=True, - ) - main_pick = playable_with_odds[0] - main_pick["is_guaranteed"] = False - main_pick["pick_reason"] = "positive_ev_after_odds_band_gate" - else: - fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0] - fallback_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) - main_pick = 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["playable"] = False - main_pick["stake_units"] = 0.0 - main_pick["bet_grade"] = "PASS" - main_pick["pick_reason"] = "no_playable_value_after_odds_band_gate" - - 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 - # V34: Lowered min calibrated_confidence for value candidates from 40.0 to 25.0 - # to allow high-odds value bets (which naturally have lower probabilities). - and float(row.get("calibrated_confidence", 0.0)) >= 25.0 - ] - if value_candidates: - # Score them by (ev_edge) to reward actual mathematical value - value_candidates.sort(key=lambda r: float(r.get("ev_edge", 0.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, - # Live snapshot β€” match_commentary uses this to detect upset-in-progress - "status": data.status, - "state": data.state, - "is_live": self._is_live_match(data), - "current_score_home": data.current_score_home, - "current_score_away": data.current_score_away, - }, - "prediction_freshness": { - "generated_at_ms": int(time.time() * 1000), - "is_pre_match_snapshot": True, - # Stale when the match is already underway β€” UI should warn the user. - "is_stale_for_live": self._is_live_match(data), - }, - "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 []), - "surprise_breakdown": list(getattr(prediction, "surprise_breakdown", []) or []), - "warnings": prediction.risk_warnings, - }, - "engine_breakdown": self._build_engine_breakdown(prediction), - "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")] - - MIN_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("ev_edge", 0.0)), - float(r.get("play_score", 0.0)), - ), - reverse=True, - ) - main_pick = playable_with_odds[0] - main_pick["is_guaranteed"] = False - main_pick["pick_reason"] = "positive_ev_pick" - else: - fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0] - fallback_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) - main_pick = 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["playable"] = False - main_pick["stake_units"] = 0.0 - main_pick["bet_grade"] = "PASS" - main_pick["pick_reason"] = "no_playable_value_found" - - 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 - - def _odds_band_verdict( - self, - data: MatchData, - market: str, - pick: str, - implied_prob: float, - ) -> Dict[str, Any]: - features = getattr(data, "odds_band_features", {}) or {} - market_key = str(market or "").upper() - if not isinstance(features, dict) or implied_prob <= 0.0: - return { - "required": market_key in self.odds_band_min_sample, - "available": False, - "band_prob": 0.0, - "band_sample": 0.0, - "band_edge": 0.0, - "aligned": False, - "reason": "odds_band_unavailable", - } - - pick_key = self._normalize_pick_token(pick) - band_prob = 0.0 - sample = 0.0 - - if market_key == "MS": - if pick_key == "1": - band_prob = float(features.get("home_band_ms_win_rate", 0.0) or 0.0) - sample = float(features.get("home_band_ms_sample", 0.0) or 0.0) - elif pick_key == "2": - band_prob = float(features.get("away_band_ms_win_rate", 0.0) or 0.0) - sample = float(features.get("away_band_ms_sample", 0.0) or 0.0) - elif pick_key in {"X", "0"}: - home_draw = float(features.get("home_band_ms_draw_rate", 0.0) or 0.0) - away_draw = float(features.get("away_band_ms_draw_rate", 0.0) or 0.0) - band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw) - sample = max( - float(features.get("home_band_ms_sample", 0.0) or 0.0), - float(features.get("away_band_ms_sample", 0.0) or 0.0), - ) - elif market_key == "DC": - dc_key = pick_key.replace("-", "").lower() - band_prob = float(features.get(f"band_dc_{dc_key}_rate", 0.0) or 0.0) - sample = float(features.get(f"band_dc_{dc_key}_sample", 0.0) or 0.0) - elif market_key in {"OU15", "OU25", "OU35"}: - suffix = {"OU15": "ou15", "OU25": "ou25", "OU35": "ou35"}[market_key] - rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate" - band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0) - sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0) - elif market_key == "BTTS": - is_yes = "VAR" in pick_key or "YES" in pick_key or pick_key == "Y" - band_prob = float(features.get(f"band_btts_{'yes' if is_yes else 'no'}_rate", 0.0) or 0.0) - sample = float(features.get("band_btts_sample", 0.0) or 0.0) - elif market_key == "HT": - if pick_key == "1": - band_prob = float(features.get("home_band_ht_win_rate", 0.0) or 0.0) - sample = float(features.get("home_band_ht_sample", 0.0) or 0.0) - elif pick_key == "2": - band_prob = float(features.get("away_band_ht_win_rate", 0.0) or 0.0) - sample = float(features.get("away_band_ht_sample", 0.0) or 0.0) - elif pick_key in {"X", "0"}: - home_draw = float(features.get("home_band_ht_draw_rate", 0.0) or 0.0) - away_draw = float(features.get("away_band_ht_draw_rate", 0.0) or 0.0) - band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw) - sample = max( - float(features.get("home_band_ht_sample", 0.0) or 0.0), - float(features.get("away_band_ht_sample", 0.0) or 0.0), - ) - elif market_key in {"HT_OU05", "HT_OU15"}: - suffix = "ht_ou05" if market_key == "HT_OU05" else "ht_ou15" - rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate" - band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0) - sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0) - - band_edge = band_prob - implied_prob if band_prob > 0.0 else 0.0 - required_sample = float(self.odds_band_min_sample.get(market_key, 0.0)) - required_edge = float(self.odds_band_min_edge.get(market_key, 0.0)) - available = band_prob > 0.0 and sample >= required_sample - aligned = available and band_edge >= required_edge - - reason = "odds_band_confirms_value" - if required_sample > 0.0 and sample < required_sample: - reason = "odds_band_sample_too_low" - elif band_prob <= 0.0: - reason = "odds_band_missing_probability" - elif band_edge < required_edge: - reason = f"odds_band_no_value_{band_edge:+.3f}" - - return { - "required": market_key in self.odds_band_min_sample, - "available": available, - "band_prob": band_prob, - "band_sample": sample, - "band_edge": band_edge, - "aligned": aligned, - "reason": reason, - } - - @staticmethod - def _normalize_pick_token(pick: str) -> str: - return ( - str(pick or "") - .strip() - .upper() - .replace("Δ°", "I") - .replace("Ü", "U") - .replace("Ş", "S") - .replace("Ğ", "G") - .replace("Γ–", "O") - .replace("Γ‡", "C") - ) - - @staticmethod - def _pick_is_over(pick_key: str) -> bool: - return "UST" in pick_key or "OVER" in pick_key - - @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]: - """ - Produces an explainable surprise profile. - - Each factor pushes the base score and contributes: - - a human-readable Turkish reason - - a `breakdown` entry with code, points, label - """ - BASE_SCORE = 22.0 - breakdown: List[Dict[str, Any]] = [] - reasons: List[str] = [] - score = BASE_SCORE - - def add(code: str, points: float, label: str) -> None: - nonlocal score - score += points - reasons.append(label) - breakdown.append({"code": code, "points": round(points, 1), "label": label}) - - 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: - add("balanced_match_risk", 18.0, "TakΔ±mlar birbirine Γ§ok yakΔ±n β€” sonuΓ§ kΔ±rΔ±labilir") - if ms_draw >= 0.30: - add("draw_probability_elevated", 14.0, f"Beraberlik olasΔ±lığı yΓΌksek (%{ms_draw*100:.0f})") - if total_xg >= 3.25: - add("high_total_goal_volatility", 10.0, f"Toplam gol beklentisi yΓΌksek (xG {total_xg:.1f}) β€” aΓ§Δ±k skor riski") - if btts_yes >= 0.68: - add("mutual_goal_pressure", 8.0, f"KarşılΔ±klΔ± gol baskΔ±sΔ± (%{btts_yes*100:.0f})") - if over35 >= 0.52: - add("late_goal_swing_risk", 8.0, "GeΓ§ gol/skor değişimi riski") - - # Odds-based traps (favorite odds trap from UpsetEngineV2 logic) - ms_h_odd = self._safe_float((data.odds_data or {}).get("ms_h"), 0.0) - ms_a_odd = self._safe_float((data.odds_data or {}).get("ms_a"), 0.0) - ms_d_odd = self._safe_float((data.odds_data or {}).get("ms_d"), 0.0) - favorite_side = None - favorite_odd = 0.0 - if ms_h_odd > 1.01 and ms_a_odd > 1.01: - if ms_h_odd <= ms_a_odd: - favorite_side, favorite_odd = "home", ms_h_odd - else: - favorite_side, favorite_odd = "away", ms_a_odd - - # Favorite odds trap (1.40-1.60 historically %33+ surprise rate) - if 1.40 <= favorite_odd < 1.60: - add( - "favorite_odds_trap", - 12.0, - f"Favori oranΔ± tuzak aralığında ({favorite_odd:.2f}) β€” tarihsel sΓΌrpriz oranΔ± %30+", - ) - elif 1.20 <= favorite_odd < 1.30: - add( - "low_odds_trap_suspicion", - 6.0, - f"Favori oranΔ± Γ§ok düşük ({favorite_odd:.2f}) β€” piyasa aşırΔ± gΓΌveniyor olabilir", - ) - - # Bookmaker margin - if ms_h_odd > 1.01 and ms_a_odd > 1.01 and ms_d_odd > 1.01: - margin = (1 / ms_h_odd + 1 / ms_d_odd + 1 / ms_a_odd) - 1 - if margin > 0.20: - add( - "bookmaker_margin_high", - 10.0, - f"Bookmaker marjΔ± Γ§ok yΓΌksek (%{margin*100:.1f}) β€” bahisΓ§i risk gΓΆrΓΌyor", - ) - elif margin > 0.18: - add( - "bookmaker_margin_elevated", - 6.0, - f"Bookmaker marjΔ± yΓΌksek (%{margin*100:.1f})", - ) - - # Away favorite carries inherent extra risk - if favorite_side == "away" and favorite_odd > 0: - add( - "away_favorite_extra_risk", - 6.0, - "Deplasman favorisi β€” atmosfer ve seyahat ek risk yaratΔ±r", - ) - - if data.lineup_source == "probable_xi": - add("lineup_probable_not_confirmed", 8.0, "Kadrolar tahmini β€” kesinleşmemiş") - if data.lineup_source == "none": - add("lineup_unavailable", 12.0, "Kadro bilgisi yok β€” analiz gΓΌvenilirliği düştΓΌ") - if not data.referee_name: - add("missing_referee", 6.0, "Hakem atanmamış β€” disiplin/avantaj sinyali eksik") - - 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: - add("live_match_open_state", 18.0, f"MaΓ§ şu an aΓ§Δ±k skorlu ({current_goals} gol) β€” pre-match tahminler riskli") - elif current_goals >= 2: - add("live_match_active_state", 10.0, f"MaΓ§ canlΔ± ve hareketli ({current_goals} gol)") - - # Live underdog leading (pre-match favorite is losing) - cur_home = int(data.current_score_home or 0) - cur_away = int(data.current_score_away or 0) - if favorite_side == "home" and cur_away > cur_home: - add( - "live_underdog_leading", - 20.0, - "CanlΔ±: deplasman ΓΆnde, pre-match ev sahibi favorisiydi β€” sΓΌrpriz GERΓ‡EKLEŞİYOR", - ) - elif favorite_side == "away" and cur_home > cur_away: - add( - "live_underdog_leading", - 20.0, - "CanlΔ±: ev sahibi ΓΆnde, pre-match deplasman favorisiydi β€” sΓΌrpriz GERΓ‡EKLEŞİYOR", - ) - - 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." - - # Deduplicate reasons by text while preserving order - deduped_reasons = list(dict.fromkeys(reasons))[:8] - # Same dedup logic for breakdown (by code) - seen_codes: Set[str] = set() - deduped_breakdown: List[Dict[str, Any]] = [] - for entry in breakdown: - if entry["code"] in seen_codes: - continue - seen_codes.add(entry["code"]) - deduped_breakdown.append(entry) - - return { - "score": round(score, 1), - "comment": comment, - "reasons": deduped_reasons, - "breakdown": deduped_breakdown[:10], - "base_score": BASE_SCORE, - } - - @staticmethod - def _safe_float(value: Any, default: float = 0.0) -> float: - try: - return float(value) - except (TypeError, ValueError): - return default - - @staticmethod - def _calibrator_key(market: str, pick: str) -> Optional[str]: - """Map (market, pick) β†’ trained-calibrator key in models/calibration.""" - m = (market or "").upper() - p = (pick or "").strip().casefold() - if m == "MS": - if p == "1": - return "ms_home" - if p == "x" or p == "0": - return "ms_draw" - if p == "2": - return "ms_away" - return None - if m == "DC": - return "dc" - if m == "OU15" and ("over" in p or "ΓΌst" in p or "ust" in p): - return "ou15" - if m == "OU25" and ("over" in p or "ΓΌst" in p or "ust" in p): - return "ou25" - if m == "OU35" and ("over" in p or "ΓΌst" in p or "ust" in p): - return "ou35" - if m == "BTTS" and ("yes" in p or "var" in p): - return "btts" - if m == "HT": - if p == "1": - return "ht_home" - if p == "x" or p == "0": - return "ht_draw" - if p == "2": - return "ht_away" - return None - if m == "HTFT": - return "ht_ft" - return None - - @staticmethod - def _confidence_label(score: float) -> Tuple[str, str]: - """Turkish UX label + interpretation for a 0-100 confidence score.""" - if score >= 75: - return "YUKSEK", "Bu sinyal gΓΌΓ§lΓΌ ve gΓΌvenilir" - if score >= 60: - return "ORTA", "Sinyal makul, Γ§elişen veri yok" - if score >= 45: - return "DUSUK", "Sinyal zayΔ±f, dikkatli yorumla" - return "COK_DUSUK", "Veri yetersiz veya Γ§elişkili β€” bu motoru bu maΓ§ iΓ§in ihmal et" - - def _build_engine_breakdown(self, prediction: FullMatchPrediction) -> Dict[str, Any]: - """ - Engine breakdown with backward-compatible flat scores + rich detail siblings. - - Shape: - { - team: 74.1, player: 55.7, odds: 55.2, referee: 62.0, # legacy flat scores - detail: { team: {score, label, ...}, player: {...}, ... } - } - """ - components = { - "team": ("TakΔ±m modeli", float(prediction.team_confidence)), - "player": ("Oyuncu / kadro modeli", float(prediction.player_confidence)), - "odds": ("Oran piyasasΔ±", float(prediction.odds_confidence)), - "referee": ("Hakem etkisi", float(prediction.referee_confidence)), - } - flat: Dict[str, Any] = {} - detail: Dict[str, Any] = {} - for key, (display, raw) in components.items(): - score = round(raw, 1) - label, interpretation = self._confidence_label(score) - flat[key] = score - detail[key] = { - "score": score, - "label": label, - "display_name": display, - "interpretation": interpretation, - } - flat["detail"] = detail - return flat - - @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) - pick_str = str(row.get("pick") or "") - - # Trained isotonic calibrator (preferred) β€” falls back to multiplier if not trained. - # IMPORTANT: trainer was fed (raw_confidence/100, actual). Orchestrator must feed - # the same shape β€” using `prob` (which may differ from raw_conf/100 due to upstream - # confidence boosting) would give the calibrator an out-of-distribution input. - calibrator = get_calibrator() - cal_key = self._calibrator_key(market, pick_str) - if cal_key and cal_key in calibrator.calibrators: - cal_input = max(0.001, min(0.999, raw_conf / 100.0)) - cal_prob = calibrator.calibrate(cal_key, cal_input, odds_val=odd if odd > 1.0 else None) - calibrated_conf = max(1.0, min(99.0, cal_prob * 100.0)) - else: - multiplier = self.market_calibration.get(market, 0.85) - calibrated_conf = max(1.0, min(99.0, raw_conf * multiplier)) - min_conf = self.market_min_conf.get(market, 55.0) - - implied_prob = (1.0 / odd) if odd > 1.0 else 0.0 - band_verdict = self._odds_band_verdict(data, market, pick_str, implied_prob) - - # ── 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, - ) - # V33: Removed probability deflation. Deflating probability breaks normalization - # (probs no longer sum to 1) and mathematically guarantees negative EV edge. - # Data quality and confidence penalties are already applied to play_score. - model_calibrated_prob = prob - band_prob = float(band_verdict.get("band_prob", 0.0) or 0.0) - if bool(band_verdict.get("available")): - calibrated_probability = ( - (model_calibrated_prob * 0.45) - + (band_prob * 0.35) - + (implied_prob * 0.20) - ) - elif implied_prob > 0.0: - calibrated_probability = (model_calibrated_prob * 0.65) + (implied_prob * 0.35) - else: - calibrated_probability = model_calibrated_prob - calibrated_probability = max(0.0, min(0.99, calibrated_probability)) - model_edge = model_calibrated_prob - implied_prob if implied_prob > 0 else 0.0 - ev_edge = (calibrated_probability * odd) - 1.0 if odd > 1.0 else 0.0 - simple_edge = calibrated_probability - implied_prob if implied_prob > 0 else 0.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) - - # ── 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 - - # V34: Broadened value_sniper bypass β€” odds-aware model rarely shows 3% EV edge - # Allow high-confidence predictions OR modest positive EV to bypass secondary gates - is_value_sniper = ev_edge >= 0.008 or calibrated_conf >= 55.0 - - if calibrated_conf < min_conf: - if not is_value_sniper: - 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") - # V34: Added confidence bonus β€” high raw model probability gets a boost - # This prevents over-penalization when edge is near-zero but model is confident - raw_top_prob = float(row.get("probability", 0.0)) - confidence_bonus = 0.0 - if raw_top_prob >= 0.65: - confidence_bonus = 15.0 - elif raw_top_prob >= 0.55: - confidence_bonus = 10.0 - elif raw_top_prob >= 0.45: - confidence_bonus = 5.0 - base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier) + confidence_bonus - play_score = max( - 0.0, - min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty), - ) - # V34: odds_band gate β€” only hard-block when band data is AVAILABLE and aligned=False - # When band data is sparse (available=False), skip alignment check entirely - band_available = bool(band_verdict.get("available", False)) - if band_available and bool(band_verdict.get("required")) and not bool(band_verdict.get("aligned")): - if not is_value_sniper: - playable = False - reasons.append(str(band_verdict.get("reason") or "odds_band_not_aligned")) - elif not band_available and bool(band_verdict.get("required")): - # Sparse data β€” log but don't block - reasons.append("odds_band_data_sparse_skipped") - # V34: REMOVED model_not_above_market gate entirely - # V25 model is odds-informed BY DESIGN β†’ model output β‰ˆ market-implied probability - # Requiring model > market is mathematically impossible with this architecture - # The negative_model_edge gate below still catches truly anti-value picks - # V34: negative edge threshold relaxed β€” odds-aware model's edge is naturally near zero - # Reliable league: -0.08, unreliable: up to -0.15 - # Only blocks truly anti-value picks (model significantly below market) - neg_edge_threshold = -0.08 - (1.0 - odds_rel) * 0.07 - if odd > 1.0 and simple_edge < neg_edge_threshold: - if not is_value_sniper: - playable = False - reasons.append(f"negative_model_edge_{simple_edge:+.3f}") - # V34: Added value_sniper bypass β€” was missing before, causing hard blocks - if odd > 1.0 and ev_edge < min_edge: - if not is_value_sniper: - playable = False - reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}") - if play_score < min_play_score: - if not is_value_sniper: - 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(calibrated_probability, odd) - reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A") - elif ev_edge > 0.05: - grade = "B" - stake_units = self._kelly_stake(calibrated_probability, odd) - reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B") - elif ev_edge > 0.02: - grade = "C" - stake_units = self._kelly_stake(calibrated_probability, 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), - "model_probability": round(prob, 4), - "model_edge": round(model_edge, 4), - "calibrated_probability": round(calibrated_probability, 4), - "implied_prob": round(implied_prob, 4), - "ev_edge": round(ev_edge, 4), - "is_value_sniper": is_value_sniper, - "odds_band_probability": round(float(band_verdict.get("band_prob", 0.0) or 0.0), 4), - "odds_band_sample": round(float(band_verdict.get("band_sample", 0.0) or 0.0), 1), - "odds_band_edge": round(float(band_verdict.get("band_edge", 0.0) or 0.0), 4), - "odds_band_aligned": bool(band_verdict.get("aligned")), - "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)), - "is_value_sniper": bool(row.get("is_value_sniper")), - "model_probability": row.get("model_probability", row.get("probability", 0.0)), - "model_edge": row.get("model_edge", 0.0), - "calibrated_probability": row.get("calibrated_probability", row.get("probability", 0.0)), - "implied_prob": row.get("implied_prob", 0.0), - "odds_band_probability": row.get("odds_band_probability", 0.0), - "odds_band_sample": row.get("odds_band_sample", 0.0), - "odds_band_edge": row.get("odds_band_edge", 0.0), - "odds_band_aligned": bool(row.get("odds_band_aligned")), - "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") - if data.source_table == "live_matches": - flags.append("live_match_pre_match_features") - feature_source = str(getattr(data, "feature_source", "") or "") - if feature_source == "live_prematch_enrichment": - flags.append("ai_features_inferred_from_history") - - 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" - if label == "HIGH" and ( - data.lineup_source == "probable_xi" or not data.referee_name - ): - label = "MEDIUM" - - 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), - "feature_source": feature_source or "unknown", - "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 diff --git a/ai-engine/tests/test_engine_null_safety.py b/ai-engine/tests/test_engine_null_safety.py deleted file mode 100644 index 828b122..0000000 --- a/ai-engine/tests/test_engine_null_safety.py +++ /dev/null @@ -1,75 +0,0 @@ -import sys -import unittest -from decimal import Decimal -from pathlib import Path -from unittest.mock import MagicMock - -AI_ENGINE_ROOT = Path(__file__).resolve().parents[1] -if str(AI_ENGINE_ROOT) not in sys.path: - sys.path.insert(0, str(AI_ENGINE_ROOT)) - -from core.engines.odds_predictor import OddsPredictorEngine -from features.sidelined_analyzer import SidelinedAnalyzer - - -class EngineNullSafetyTests(unittest.TestCase): - def test_odds_predictor_accepts_decimal_inputs_without_crashing(self): - engine = OddsPredictorEngine() - - prediction = engine.predict( - odds_data={ - "ms_h": Decimal("2.10"), - "ms_d": Decimal("3.25"), - "ms_a": Decimal("3.60"), - "ou25_o": Decimal("1.90"), - }, - ) - - self.assertGreater(prediction.market_home_prob, 0.0) - self.assertGreater(prediction.market_draw_prob, 0.0) - self.assertGreater(prediction.market_away_prob, 0.0) - - def test_sidelined_analyzer_handles_non_numeric_fields(self): - analyzer = SidelinedAnalyzer.__new__(SidelinedAnalyzer) - analyzer.position_weights = {"K": 0.35, "D": 0.20, "O": 0.25, "F": 0.30} - analyzer.max_rating = 10 - analyzer.adaptation_threshold = 10 - analyzer.adaptation_discount = 0.5 - analyzer.goalkeeper_penalty = 0.15 - analyzer.confidence_boost = 10 - analyzer.max_impact = 0.85 - analyzer.key_player_threshold = 3 - analyzer.recent_matches_lookback = 15 - analyzer._fetch_player_stats = MagicMock(return_value={}) - - result = analyzer.analyze( - { - "totalSidelined": 2, - "players": [ - { - "playerId": "p1", - "playerName": "Player One", - "positionShort": "O", - "matchesMissed": "N/A", - "average": "?", - "type": "injury", - }, - { - "playerId": "p2", - "playerName": "Player Two", - "positionShort": "K", - "matchesMissed": "12", - "average": "6.7", - "type": "suspension", - }, - ], - }, - ) - - self.assertEqual(result.total_sidelined, 2) - self.assertGreaterEqual(result.impact_score, 0.0) - self.assertTrue(len(result.player_details) >= 2) - - -if __name__ == "__main__": - unittest.main() diff --git a/ai-engine/tests/test_single_match_orchestrator.py b/ai-engine/tests/test_single_match_orchestrator.py index 86eadc5..6ae71c2 100644 --- a/ai-engine/tests/test_single_match_orchestrator.py +++ b/ai-engine/tests/test_single_match_orchestrator.py @@ -8,9 +8,10 @@ AI_ENGINE_ROOT = Path(__file__).resolve().parents[1] if str(AI_ENGINE_ROOT) not in sys.path: sys.path.insert(0, str(AI_ENGINE_ROOT)) -from models.v20_ensemble import FullMatchPrediction +from schemas.prediction import FullMatchPrediction +from schemas.match_data import MatchData from models.basketball_v25 import BasketballMatchPrediction -from services.single_match_orchestrator import MatchData, SingleMatchOrchestrator +from services.single_match_orchestrator import SingleMatchOrchestrator class _CursorContext: diff --git a/prisma.config.ts b/prisma.config.ts index 6214ac9..3368937 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,15 +1,15 @@ -import path from 'node:path'; -import { defineConfig, env } from '@prisma/config'; -import { config } from 'dotenv'; +import path from "node:path"; +import { defineConfig, env } from "@prisma/config"; +import { config } from "dotenv"; -config({ path: '.env' }); +config({ path: ".env.local" }); export default defineConfig({ - schema: path.join('prisma', 'schema.prisma'), + schema: path.join("prisma", "schema.prisma"), migrations: { - path: path.join('prisma', 'migrations'), + path: path.join("prisma", "migrations"), }, datasource: { - url: env('DATABASE_URL'), + url: env("DATABASE_URL"), }, }); diff --git a/prisma/migrations/20260515120000_add_opening_value_and_odds_movement/migration.sql b/prisma/migrations/20260515120000_add_opening_value_and_odds_movement/migration.sql new file mode 100644 index 0000000..e649dc4 --- /dev/null +++ b/prisma/migrations/20260515120000_add_opening_value_and_odds_movement/migration.sql @@ -0,0 +1,10 @@ +-- Add opening_value to odd_selections for tracking first-seen odds +ALTER TABLE "odd_selections" ADD COLUMN "opening_value" TEXT; + +-- Add odds movement features to football_ai_features +ALTER TABLE "football_ai_features" ADD COLUMN "odds_movement_home" DOUBLE PRECISION; +ALTER TABLE "football_ai_features" ADD COLUMN "odds_movement_draw" DOUBLE PRECISION; +ALTER TABLE "football_ai_features" ADD COLUMN "odds_movement_away" DOUBLE PRECISION; +ALTER TABLE "football_ai_features" ADD COLUMN "odds_movement_o25" DOUBLE PRECISION; +ALTER TABLE "football_ai_features" ADD COLUMN "odds_movement_btts" DOUBLE PRECISION; +ALTER TABLE "football_ai_features" ADD COLUMN "odds_sharpness" DOUBLE PRECISION; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6baef90..0059557 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -207,6 +207,12 @@ model FootballAiFeature { leagueHomeWinPct Float @default(0.0) @map("league_home_win_pct") leagueOver25Pct Float @default(0.0) @map("league_over25_pct") missingPlayersImpact Float @default(0.0) @map("missing_players_impact") + oddsMovementHome Float? @map("odds_movement_home") + oddsMovementDraw Float? @map("odds_movement_draw") + oddsMovementAway Float? @map("odds_movement_away") + oddsMovementO25 Float? @map("odds_movement_o25") + oddsMovementBtts Float? @map("odds_movement_btts") + oddsSharpness Float? @map("odds_sharpness") calculatorVer String @default("v2.0") @map("calculator_ver") updatedAt DateTime @updatedAt @map("updated_at") match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) @@ -448,18 +454,19 @@ model OddCategory { } model OddSelection { - dbId Int @id @default(autoincrement()) @map("db_id") - categoryId Int @map("odd_category_db_id") - name String? - oddValue String? @map("odd_value") - position String? - sov Float? - state String? - sport Sport? - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") - category OddCategory @relation(fields: [categoryId], references: [dbId], onDelete: Cascade) - history OddsHistory[] + dbId Int @id @default(autoincrement()) @map("db_id") + categoryId Int @map("odd_category_db_id") + name String? + oddValue String? @map("odd_value") + openingValue String? @map("opening_value") + position String? + sov Float? + state String? + sport Sport? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @map("updated_at") + category OddCategory @relation(fields: [categoryId], references: [dbId], onDelete: Cascade) + history OddsHistory[] @@unique([categoryId, name]) @@index([categoryId]) diff --git a/qualified_leagues.json b/qualified_leagues.json index 67ec5b2..d070f05 100644 --- a/qualified_leagues.json +++ b/qualified_leagues.json @@ -1,267 +1,445 @@ [ - "3iwftmprsznl6yribr11a8l9m", - "cegl2ivkc25blcatxp4jmk1ec", - "1zp1du9n4rj36p1ss9zbxtqfb", - "bockl24qpr7ryjl8b6obukga", - "byu00jvt1j6csyv4y1lkt2fm2", - "degxm4y6gmvp011ccyrev6z5p", - "c7b8o53flg36wbuevfzy3lb10", - "7ntvbsyq31jnzoqoa8850b9b8", - "581t4mywybx21wcpmpykhyzr3", - "3frp1zxrqulrlrnk503n6l4l", - "287tckirbfj9nb8ar2k9r60vn", - "bgen5kjer2ytfp7lo9949t72g", - "ac112osli9fvox1epcg4ld3t6", - "3is4bkgf3loxv9qfg3hm8zfqb", - "c1d9p6b2e9zr5tqlzx3ktjplg", - "5zr0b05eyx25km7z1k03ca9jx", - "5z8v4mj6cjs9ex6hdrpourjzh", - "scf9p4y91yjvqvg5jndxzhxj", - "3p81ltz6845appgkbgkzxueii", - "b5udgm9vakjqz8dcmy5b2g0xt", - "b1rveez5u792gess9w3e7v5le", - "2ty8ihceabty8yddmu31iuuej", - "8ey0ww2zsosdmwr8ehsorh6t7", - "2nttcoriwf5co73vmz1vr8frm", - "1r097lpxe0xn03ihb7wi98kao", - "2kwbbcootiqqgmrzs6o5inle5", - "907l7wtxdvugdo9i2249wcmr0", - "8o5tv5viv4hy1qg9jp94k7ayb", - "4nidzmunvpvxk1ir9b6m8mpay", - "dkarmrybx9vx10rg7cywumth0", - "a9vrdkelbgif0gtu3wxsr75xo", - "4w7x0s5gfs5abasphlha5de8k", - "8dn0w8zh7nbn2i904603eigwf", - "1gwajyt0pk2jm5fx5mu36v114", - "2o9svokc5s7diish3ycrzk7jm", - "7hl0svs2hg225i2zud0g3xzp2", - "89ovpy1rarewwzqvi30bfdr8b", - "2hsidwomhjsaaytdy9u5niyi4", - "34pl8szyvrbwcmfkuocjm3r6t", - "8r98daokeuzsamu5fmjtblqx5", - "akmkihra9ruad09ljapsm84b3", - "722fdbecxzcq9788l6jqclzlw", - "663a54fmymndjeev47qm7d3nf", - "4zwgbb66rif2spcoeeol2motx", - "9chuiarcjofld1dkj9kysehmb", - "5y0z0l2epprzbscvzsgldw8vu", - "2wolc27r8z03itcvwp43e38c5", - "alpfd99yd3lfv7bhjo0biuq7b", - "ea0h6cf3bhl698hkxhpulh2zz", - "8sdpk4aerruf515yh76ezo7vi", - "6by3h89i2eykc341oz7lv1ddd", - "7r1f93t6ddrsa5n8v1nq6qlzm", - "8yi6ejjd1zudcqtbn07haahg6", - "ein4fkggto3pdh5msp8huafiq", - "b60nisd3qn427jm0hrg9kvmab", - "1qd0wvt30rlswa4g6nu4na660", - "b73zounsynk9d3u1p9nvpu7i2", - "civf31q1inxohs4a03y8reetf", - "bu1l7ckihyr0errxw61p0m05", - "a7247po5qs29o3zsfmt222ydu", - "6lwpjhktjhl9g7x2w7njmzva6", - "4c1nfi2j1m731hcay25fcgndq", - "3ww12jab49q8q8mk9avdwjqgk", - "8y29fg2s85ppcb8uugm5ee8s4", - "82jkgccg7phfjpd0mltdl3pat", - "46b141eaqq9q7o4gz5gtdpikk", - "482ofyysbdbeoxauk19yg7tdt", - "4oogyu6o156iphvdvphwpck10", - "2y8bntiif3a9y6gtmauv30gt", - "e21cf135btr8t3upw0vl6n6x0", - "c0yqkbilbbg70ij2473xymmqv", - "5dycj9wdhxh3n33qubw18ohlk", - "1eruend45vd20g9hbrpiggs5u", - "e1kxdivp5g4cpldgpwvnzl1vv", - "ddyrh5latwfhesgfh4w401n92", - "af79lqrc0ntom74zq13ccjslo", - "3ab1uwtoyjopdj1y1fynyy9jg", - "c0r21rtokgnbtc0o2rldjmkxu", - "e0lck99w8meo9qoalfrxgo33o", - "yv73ms6v1995b5wny16jcfi3", - "5aw6uyw4pz2bpj24t5z8aacim", - "75i269i1ak43magshljadydrh", - "8k1xcsyvxapl4jlsluh3eomre", - "jznihqxle06xych9ygwiwnsa", - "6wubmo7di3kdpflluf6s8c7vs", - "7cwemnr3vi40znjq451zxkus6", - "6ifaeunfdelecgticvxanikzu", - "913mb508il6jzwtlj28fl892h", - "29actv1ohj8r10kd9hu0jnb0n", - "3btdfgw79qiz3jmyfudovtbu2", - "5cwsxtx37les6m10xj71htkgf", - "9nbpdi9q3ywcm4q0j5u0ekwcq", - "dm5ka0os1e3dxcp3vh05kmp33", - "beqqnubkv05mamuwvimeum015", - "57nu0wygurzkp6fuy5hhrtaa2", - "du6jsenbjql5e8f3yk880ox4g", - "cesdwwnxbc5fmajgroc0hqzy2", - "3w1hkk9k9gr8fwssyn4icvdfo", - "65ggsqdi6drpa4m8y3gkll25k", - "4yzidekywejmxxp77gqmdgopg", - "avs3xposm3t9x1x2vzsoxzcbu", - "75434tz9rc14xkkvudex742ui", - "aho73e5udydy96iun3tkzdzsi", - "4qehj8hfxmy6o2ohp4fxinnzo", - "ae1wva3zrzcp2zd15gpvsntg6", - "4d5d3sf6805n5u6jdoa0hdlog", - "3l29w00m506ex93t5bbh9cg2a", - "zs18qaehvhg3w1208874zvfa", - "4mbfidy8zum5u0aqjqo0vuqs2", - "8v97rcbthsxmzqk4ufxws9mug", - "c76z5d6j7dpi1e79tm8fpm39z", - "47s2kt0e8m444ftqvsrqa3bvq", - "9ikchyu9fb8bvx0s673jofj6s", - "6ihotpaocgiovlxw18e9r9prx", - "32n2r9bl6x90psj0wa7bfs6vq", - "zilopfej2h0n3vpan5tcynpo", - "7nmz249q89qg5ezcvzlheljji", - "ajxs0e0g6ryg5ol8qvw3evrcz", - "477yyajzheg2z8u7uick0e13e", - "8t2o4huu2e48ij23dxnl9w5qx", - "1wwro3z1eb3fl601dju6inlc6", - "4yngyfinzd6bb1k7anqtqs0wt", - "1b70m6qtxrp75b4vtk8hxh8c3", - "7af85xa75vozt2l4hzi6ryts7", - "117yqo02rs8dykkxpm274w3bd", - "725gd73msyt08xm76v7gkxj7u", - "f4jc2cc5nq7flaoptpi5ua4k4", - "xwnjb1az11zffwty3m6vn8y6", - "dr2xk7muj8aqcjdz2b3li1c0k", - "1mpjd0vbxbtu9zw89yj09xk3z", - "3428tckxcirwwh3o3jgc1m8ji", - "6sxm2iln2w45ux498pty9miw8", - "6321dlqv4ziuwqte4xpohijtw", - "5c96g1zm7vo5ons9c42uy2w3r", - "ili150pwfuf39f7yfdch9lhw", - "7swf4kpu3v38i2it4h94c5s9k", - "iu1vi94p4p28oozl1h9bvplr", - "5k620c7y6dlbmcm88dt3eb7t", - "f39uq10c8xhg5e6rwwcf6lhgc", - "6lkj3o21cr4g7bql6tb3fk222", - "9ynnnx1qmkizq1o3qr3v0nsuk", - "8usjlmziv3p2re0r2wwzezki9", - "4zwjlzdszduqmxzusysvzymms", - "7mxwwunvot2pi69pj1yr1kh8i", - "5taraea6mqjjldg9zxswo825y", - "9fuwphq8kvugrlc3ckm7k8wes", - "dvstmwnvw0mt5p38twn9yttyb", - "2xg0qvif1rh7du6wmk2eleku3", - "8x3sbh85gc8qir50utw39jl04", - "59tpnfrwnvhnhzmnvfyug68hj", - "1fedahp0rws09tj451onten8r", - "esrunz7rjb0td98mx9e5cedoy", - "2hj3286pqov1g1g59k2t2qcgm", - "55hcphd1ccc6eai1ms77460on", - "40yjcbx2sq6oq736iqqqczwt1", - "eog6knrkfei68si736fpquyzc", - "f47f3717z2vtpxfxrpdd4jl1x", - "3oa9e03e7w9nr8kqwqc3tlqz9", - "apdwh753fupxheygs8seahh7x", - "486rhdgz7yc0sygziht7hje65", - "erpufio3qaujd9gkszcqvb0bf", - "cu0rmpyff5692eo06ltddjo8a", - "eg6s9f1jj7jr6stmbosn0g6c8", - "9p3nnxhdjahfn8qswpzy8oyc3", - "cse5oqqt2pzfcy8uz6yz3tkbj", - "cfesxhzb83yl8b779uv3revz1", - "4rls982p5uzil6x30mhyhv9f3", - "eitf7hulqfv1clb7toewkil24", - "byhmntnl1b4lxw0zz21im3zkd", - "gfskxsdituog2kqp9yiu7bzi", - "ejunkmfhjz9weugd2bqrkgobb", - "bdtat25m14jy85y484z3e6lf", - "ax1yf4nlzqpcji4j8epdgx3zl", - "1j4ehtrbry9depwt6oghaq3lu", - "xaouuwuk8qyhv1libkeexwjh", - "1q4ab2bpg5e8jl1g2udnakrju", - "81txfenlgw75nq3u2nfdkj92o", - "19q13y6ruzo0o84ipblcuouzs", - "3n9mk5b2mxmq831wfmv6pu86i", - "3n5046abeu3x482ds3jwda238", - "2aso72utuctat2ecs6nahjss6", - "2bmwykmdlcc2u1c40ytoc39vy", - "bx57cmq1edfq53ckfk791supi", - "bly7ema5au6j40i0grhl0pnub", - "er5745q30wnr8jv9nr863omzg", - "by5nibd18nkt40t0j8a0j5yzx", - "1ncmha8yglhyyhg6gtaujymqf", - "agpweohvn9tugnyl6ry4rhivp", - "8ztsv3pzrsyq5w1r3a0nfk1y5", - "4davonpqws4a4ejl1awu98zdg", - "6vq8j5p3av14nr3iuyi4okhjt", - "bbajzna018c79opa1kl5kmkqo", - "eu2g5j36zzxiazpd729osx0wm", - "595nsvo7ykvoe690b1e4u5n56", - "1gxlzw2ezkyeykhcaa5x8ozkk", - "2z7257m7hj58zuxcjrsg4erzc", - "392slbmf1kdqlr6sd1ckt71rs", - "6g8hw3acenrw828la7gwx4mvs", - "d9eaigzyfnfiraqc3ius757tl", - "3aa4mumjl6zyetg6o9hwd5hhx", - "6hlw7rhrpe9garwmfoxu4lebc", - "e6vzdkz6l236s9p288mharefy", - "dvtl8sf1262pd2aqgu641qa7u", - "5pq4dbinkmt8ujoepyqzih7iw", - "6qitd9h242qkvjenaytfdnsf2", - "cbdbziaqczfuyuwqsylqi26zd", - "3ymqchdzk8tt6lfphf26xfvh0", - "2rdrisk4vlglfjxwu0precyqd", - "1cnx2c8g3hhp8ssxnwwli0mjb", - "65q4uwm6ol1rkf5dp89m8omny", - "8kt53kt3mfo29gldhkl05u25b", - "5jd0k2txwnq69frs79eulba8j", - "8x62utr2uti3i7kk14isbnip6", - "b3ufcd24wfnnd5j98ped6irfu", - "61fzfjogstjuukzcehighq7mu", - "50ap4sua1xyut3mpu7ehesp63", - "6694fff47wqxl10lrd9tb91f8", - "macko16888165594668885588", - "3e40pestup9xzagsu2o6c0i8u", - "9oqeqyj7swpnl86ytafjwavvo", - "1qt9bfl6dhydf4tpano6n1p7s", - "29lni33vxqrl1tqhadrnfid6t", - "2db0aw1duj2my9l5iey5gm6nq", - "1vyghvhuy6abu4htoemdi79bd", - "4vksk0d2q4c5w0itdl52lzek6", - "193wqkyb0v5jnsblhvd2ocmyo", - "a3egqgf45jqft6y0uoyvw3mbj", - "5liafywveaf56s2nod8hg9nca", - "3a0j0giz3c3ajw9h59evv7lqt", - "2mdmx668tyhy4u4z9zszwjv5v", - "19mr0xdp7li6nkz87oxh53xed", - "8u5w0g8jimye1cu5albkcb3qs", - "2kuyfkulm5lsgjxynrgh3vz70", - "8cit3whr514nnd4zkaovsnqn", - "9mr92dlx7ryaxhi07sgt90ish", - "1dajh9qrda3enawmlt7ogt05w", - "10x5pvhifwo4y7hs3fz9hf245", - "dc4k1xh2984zbypbnunk7ncic", - "e6rl4hongahbihxd3tpudespd", - "2r1hqz453bn9ljzt53kdr2lwb", - "86wrztni4x8tnvq9cr1cetvfu", - "5em08hhvd7komnfdsb1yagpas", - "326jpj7749ojwqhu3ap27zl77", - "bqvy41un7sf86rbse9tv810x7", - "93i7thp7zi0ympyt6l8aa1r2i", - "ahl3vljaignq9ebaos4uqkrvo", - "68zplepppndhl8bfdvgy9vgu1", - "df1o8phtfy4dwhv6n7mmeedvw", - "cj30195079sdep2imeyt7y47p", - "3z6xfyd3ovi5x09orlo4rmskx", - "1n990e5dpi9xwruwf6uslknkq", - "etta63x1t7tnkn4jheisjwk4p", - "2xv6qkye2rsnwram454x8i8f1", - "8c93rclta164ypkno054nkfyt", - "89v3ukjpui1gashsz3i1vphfa", - "8tddm56zbasf57jkkay4kbf11", - "dcgbs1vkp9y3y31li7s95i51f", - "dlf90uty1axvtr1vn2aaw9vqh", - "9gvvndi7vk9fzvpe65pv5x2ir", - "7siumtnmgqfap6nalpu8xcwb6", - "7zsbjmlmhzn0y7923lw4zquud", - "8dxsd8xnjm9n1ogo37yomgl3p", - "arrfx02rdlstdfwdyikwqtwgl", - "afp674ll89oqsbbrqt17xfxlh", - "22euhl6zy56cp651ipq99rooq" -] + "cegl2ivkc25blcatxp4jmk1ec", + "3iwftmprsznl6yribr11a8l9m", + "78wml3z5wrfxe5iky50tiotgu", + "1zp1du9n4rj36p1ss9zbxtqfb", + "4nidzmunvpvxk1ir9b6m8mpay", + "bockl24qpr7ryjl8b6obukga", + "54c65mhi143utomzvvv3q2avh", + "a4fgj2rfbpf4ejo1qi624fefo", + "1owhvvge4wlx7e0e431b4vhqx", + "degxm4y6gmvp011ccyrev6z5p", + "byu00jvt1j6csyv4y1lkt2fm2", + "907l7wtxdvugdo9i2249wcmr0", + "b5udgm9vakjqz8dcmy5b2g0xt", + "3frp1zxrqulrlrnk503n6l4l", + "bgen5kjer2ytfp7lo9949t72g", + "581t4mywybx21wcpmpykhyzr3", + "7ntvbsyq31jnzoqoa8850b9b8", + "2nttcoriwf5co73vmz1vr8frm", + "287tckirbfj9nb8ar2k9r60vn", + "5vq1bl8h8dxdr34w0jaanokto", + "ac112osli9fvox1epcg4ld3t6", + "3is4bkgf3loxv9qfg3hm8zfqb", + "2ty8ihceabty8yddmu31iuuej", + "c1d9p6b2e9zr5tqlzx3ktjplg", + "2sla6rn7prvvm7miswg39fwqz", + "8ey0ww2zsosdmwr8ehsorh6t7", + "scf9p4y91yjvqvg5jndxzhxj", + "4jieyfpyom8d011dgeqj77ojm", + "c7b8o53flg36wbuevfzy3lb10", + "dkarmrybx9vx10rg7cywumth0", + "1gwajyt0pk2jm5fx5mu36v114", + "enzlj1as2raqm4ids1zyb07y1", + "5zr0b05eyx25km7z1k03ca9jx", + "3p81ltz6845appgkbgkzxueii", + "2g6lybov5xk8rlb79ek8imri7", + "1r097lpxe0xn03ihb7wi98kao", + "b1rveez5u792gess9w3e7v5le", + "2kwbbcootiqqgmrzs6o5inle5", + "34pl8szyvrbwcmfkuocjm3r6t", + "6w05j3xxw76an1jwkzr6a6m03", + "2hsidwomhjsaaytdy9u5niyi4", + "9hh6n2f84k31zmlcxyvmc1w2y", + "macko16698982162572521585", + "5z8v4mj6cjs9ex6hdrpourjzh", + "2o9svokc5s7diish3ycrzk7jm", + "4w7x0s5gfs5abasphlha5de8k", + "89ovpy1rarewwzqvi30bfdr8b", + "a9vrdkelbgif0gtu3wxsr75xo", + "6by3h89i2eykc341oz7lv1ddd", + "482ofyysbdbeoxauk19yg7tdt", + "8sdpk4aerruf515yh76ezo7vi", + "8dn0w8zh7nbn2i904603eigwf", + "8ivsfwex4dfx1tvgsiq8askcx", + "4zwgbb66rif2spcoeeol2motx", + "dm5ka0os1e3dxcp3vh05kmp33", + "663a54fmymndjeev47qm7d3nf", + "722fdbecxzcq9788l6jqclzlw", + "bfqezwfhot1l3p1cpk4oonh25", + "4yzidekywejmxxp77gqmdgopg", + "7hl0svs2hg225i2zud0g3xzp2", + "3ww12jab49q8q8mk9avdwjqgk", + "akmkihra9ruad09ljapsm84b3", + "65ggsqdi6drpa4m8y3gkll25k", + "8yi6ejjd1zudcqtbn07haahg6", + "8k1xcsyvxapl4jlsluh3eomre", + "8o5tv5viv4hy1qg9jp94k7ayb", + "1qd0wvt30rlswa4g6nu4na660", + "c0yqkbilbbg70ij2473xymmqv", + "dy8zaksw5e9nwrs1p5ss4o1nu", + "6ybvtzejh91761lqe7y1csrqo", + "bu1l7ckihyr0errxw61p0m05", + "117yqo02rs8dykkxpm274w3bd", + "2wolc27r8z03itcvwp43e38c5", + "913mb508il6jzwtlj28fl892h", + "6lwpjhktjhl9g7x2w7njmzva6", + "ea0h6cf3bhl698hkxhpulh2zz", + "ein4fkggto3pdh5msp8huafiq", + "2hj3286pqov1g1g59k2t2qcgm", + "5y0z0l2epprzbscvzsgldw8vu", + "75434tz9rc14xkkvudex742ui", + "af79lqrc0ntom74zq13ccjslo", + "ae1wva3zrzcp2zd15gpvsntg6", + "b73zounsynk9d3u1p9nvpu7i2", + "8r98daokeuzsamu5fmjtblqx5", + "b8rae0ib0frjmwlca429bq19q", + "civf31q1inxohs4a03y8reetf", + "57nu0wygurzkp6fuy5hhrtaa2", + "yv73ms6v1995b5wny16jcfi3", + "duuc1qczfnawwncru1ly6o66", + "ac42gi3penartj88fe9l6plpk", + "e1kxdivp5g4cpldgpwvnzl1vv", + "7r1f93t6ddrsa5n8v1nq6qlzm", + "7wssxdqi4xihseeam8grqa2b8", + "beqqnubkv05mamuwvimeum015", + "alpfd99yd3lfv7bhjo0biuq7b", + "6g8hw3acenrw828la7gwx4mvs", + "75i269i1ak43magshljadydrh", + "a7247po5qs29o3zsfmt222ydu", + "46b141eaqq9q7o4gz5gtdpikk", + "82jkgccg7phfjpd0mltdl3pat", + "ddyrh5latwfhesgfh4w401n92", + "3ab1uwtoyjopdj1y1fynyy9jg", + "5aw6uyw4pz2bpj24t5z8aacim", + "z3w7v4hqa7ssk5p6qkbpg9au", + "c0r21rtokgnbtc0o2rldjmkxu", + "2y8bntiif3a9y6gtmauv30gt", + "6wubmo7di3kdpflluf6s8c7vs", + "e0lck99w8meo9qoalfrxgo33o", + "e21cf135btr8t3upw0vl6n6x0", + "f4jc2cc5nq7flaoptpi5ua4k4", + "9ynnnx1qmkizq1o3qr3v0nsuk", + "7qf0jaayyxy3ruamsexv5p1kl", + "8y29fg2s85ppcb8uugm5ee8s4", + "9chuiarcjofld1dkj9kysehmb", + "4oogyu6o156iphvdvphwpck10", + "avs3xposm3t9x1x2vzsoxzcbu", + "b60nisd3qn427jm0hrg9kvmab", + "4c1nfi2j1m731hcay25fcgndq", + "5k620c7y6dlbmcm88dt3eb7t", + "23e698ls3x6vi9x8wl0mz7bsa", + "5dycj9wdhxh3n33qubw18ohlk", + "4qehj8hfxmy6o2ohp4fxinnzo", + "zs18qaehvhg3w1208874zvfa", + "iu1vi94p4p28oozl1h9bvplr", + "1eruend45vd20g9hbrpiggs5u", + "cesdwwnxbc5fmajgroc0hqzy2", + "66zod8zakjpchbuqrfewpbx24", + "cv3tuitw3ho3v0opjjxpn83b9", + "53tknno09wqihmwxrqcuwq9sa", + "47s2kt0e8m444ftqvsrqa3bvq", + "c76z5d6j7dpi1e79tm8fpm39z", + "392slbmf1kdqlr6sd1ckt71rs", + "5c96g1zm7vo5ons9c42uy2w3r", + "1wwro3z1eb3fl601dju6inlc6", + "3btdfgw79qiz3jmyfudovtbu2", + "8jh0jejuxfhrpawnoztz2jlv4", + "aho73e5udydy96iun3tkzdzsi", + "8vbck9a4mxjms783lf72779uu", + "6ifaeunfdelecgticvxanikzu", + "1klyfth8tl6lu6ra7k8zmy2n2", + "jznihqxle06xych9ygwiwnsa", + "1mpjd0vbxbtu9zw89yj09xk3z", + "29actv1ohj8r10kd9hu0jnb0n", + "2mdmx668tyhy4u4z9zszwjv5v", + "5taraea6mqjjldg9zxswo825y", + "4yngyfinzd6bb1k7anqtqs0wt", + "7cwemnr3vi40znjq451zxkus6", + "abs7n2ae3oydilk0tgmpnsj89", + "6321dlqv4ziuwqte4xpohijtw", + "4vt0ldrcl6thpxpcs8zmpdq1g", + "ajxs0e0g6ryg5ol8qvw3evrcz", + "8v97rcbthsxmzqk4ufxws9mug", + "f39uq10c8xhg5e6rwwcf6lhgc", + "8t2o4huu2e48ij23dxnl9w5qx", + "4d5d3sf6805n5u6jdoa0hdlog", + "6sxm2iln2w45ux498pty9miw8", + "9ikchyu9fb8bvx0s673jofj6s", + "7mxwwunvot2pi69pj1yr1kh8i", + "9z5643nd06afqu01ea2wt8y4g", + "3428tckxcirwwh3o3jgc1m8ji", + "1b70m6qtxrp75b4vtk8hxh8c3", + "3w1hkk9k9gr8fwssyn4icvdfo", + "5cwsxtx37les6m10xj71htkgf", + "4jg7he1n3rb5dniq6hf49xorq", + "esrunz7rjb0td98mx9e5cedoy", + "477yyajzheg2z8u7uick0e13e", + "7nmz249q89qg5ezcvzlheljji", + "4mbfidy8zum5u0aqjqo0vuqs2", + "xwnjb1az11zffwty3m6vn8y6", + "awvkxs5trlg5e5e5kg4hq8f1", + "7ut48s80k421xixh6ev5i6h62", + "8najqkluatpaxvqws78b9s17c", + "dvstmwnvw0mt5p38twn9yttyb", + "3ri6juw2w6ma0jezszdlv1uqm", + "32n2r9bl6x90psj0wa7bfs6vq", + "3ymqchdzk8tt6lfphf26xfvh0", + "32vph7vcjqgo1ksj1548di90n", + "zilopfej2h0n3vpan5tcynpo", + "8usjlmziv3p2re0r2wwzezki9", + "59tpnfrwnvhnhzmnvfyug68hj", + "cfesxhzb83yl8b779uv3revz1", + "7af85xa75vozt2l4hzi6ryts7", + "9w8ydnoft05zow8l4wu9sxfq1", + "ili150pwfuf39f7yfdch9lhw", + "7swf4kpu3v38i2it4h94c5s9k", + "alfi8igkewru9ot3mah71mjc1", + "ajm86skyzse4ym8g6fpgzncxa", + "6ihotpaocgiovlxw18e9r9prx", + "3l29w00m506ex93t5bbh9cg2a", + "1txej2dzohnydl21zc9pgx6hy", + "ppzvnquzq1gmez5snxzfxkbt", + "145hkd59i6foieuwr4mwi6wlq", + "ay4u6j7lfkcg7x21mx5q121j", + "macko16871069044304033100", + "xaouuwuk8qyhv1libkeexwjh", + "9nbpdi9q3ywcm4q0j5u0ekwcq", + "2z7257m7hj58zuxcjrsg4erzc", + "61fzfjogstjuukzcehighq7mu", + "dr2xk7muj8aqcjdz2b3li1c0k", + "eg6s9f1jj7jr6stmbosn0g6c8", + "7pk2icgodrzaih8i0pikb21s4", + "du6jsenbjql5e8f3yk880ox4g", + "9u4pm8x0lfmfq3r0pypmrls71", + "82wo38rqeizxlfjjhfjy4rx7u", + "1fedahp0rws09tj451onten8r", + "9p3nnxhdjahfn8qswpzy8oyc3", + "6nju5p3dc3gpz1wp7vpi56pzo", + "3oa9e03e7w9nr8kqwqc3tlqz9", + "9rpro4x6j1xhkyijm18gkhmy4", + "3n5046abeu3x482ds3jwda238", + "4davonpqws4a4ejl1awu98zdg", + "1n9l0ex47bu0762qg574hzjtd", + "81txfenlgw75nq3u2nfdkj92o", + "2aso72utuctat2ecs6nahjss6", + "apdwh753fupxheygs8seahh7x", + "55hcphd1ccc6eai1ms77460on", + "f47f3717z2vtpxfxrpdd4jl1x", + "6vq8j5p3av14nr3iuyi4okhjt", + "7qdv1xae7ikfe8dft3oj29yqc", + "6lkj3o21cr4g7bql6tb3fk222", + "3n9mk5b2mxmq831wfmv6pu86i", + "e6vzdkz6l236s9p288mharefy", + "by5nibd18nkt40t0j8a0j5yzx", + "agpweohvn9tugnyl6ry4rhivp", + "cse5oqqt2pzfcy8uz6yz3tkbj", + "etl8yxjt7x3cyzduc6x5eg7ti", + "ejunkmfhjz9weugd2bqrkgobb", + "5jd0k2txwnq69frs79eulba8j", + "4rls982p5uzil6x30mhyhv9f3", + "3j81qr7yc4gdnakfwnxf95ovh", + "bx57cmq1edfq53ckfk791supi", + "ahl3vljaignq9ebaos4uqkrvo", + "4zwjlzdszduqmxzusysvzymms", + "193wqkyb0v5jnsblhvd2ocmyo", + "1cnx2c8g3hhp8ssxnwwli0mjb", + "eqz64pn0qsp2y7aq4m9id3fn6", + "erpufio3qaujd9gkszcqvb0bf", + "1q4ab2bpg5e8jl1g2udnakrju", + "6p92gso8ghearz8dywojboibf", + "1snn4i06dn6n11mrfc894snzm", + "9u9j3f1oplv8p7wevlko5r8by", + "1j4ehtrbry9depwt6oghaq3lu", + "bt24epydr1s8zc2x5xb0n9noc", + "725gd73msyt08xm76v7gkxj7u", + "50ap4sua1xyut3mpu7ehesp63", + "3e40pestup9xzagsu2o6c0i8u", + "eog6knrkfei68si736fpquyzc", + "8q60vlvn3krynkob6igrncdjq", + "67uya58idol2eq18ljecsru5o", + "3gp6ls97f1m51ohtkua36lj81", + "2bmwykmdlcc2u1c40ytoc39vy", + "d9eaigzyfnfiraqc3ius757tl", + "er5745q30wnr8jv9nr863omzg", + "65q4uwm6ol1rkf5dp89m8omny", + "dvtl8sf1262pd2aqgu641qa7u", + "3aa4mumjl6zyetg6o9hwd5hhx", + "dexr6wlw3e339z88f1qr74wt6", + "macko16981466327942993115", + "87h32eza4zpxytxwtezfvzeji", + "bly7ema5au6j40i0grhl0pnub", + "2xg0qvif1rh7du6wmk2eleku3", + "2z9rz5s0wn8de1nnrvxzddf2w", + "19q13y6ruzo0o84ipblcuouzs", + "5f4vmg8dv2d0y8fuw60f0kbca", + "6qitd9h242qkvjenaytfdnsf2", + "40yjcbx2sq6oq736iqqqczwt1", + "39q1hq42hxjfylxb7xpe9bvf9", + "9fuwphq8kvugrlc3ckm7k8wes", + "ahqvf4v6ibao6885a6d7h8jmy", + "cbdbziaqczfuyuwqsylqi26zd", + "bdtat25m14jy85y484z3e6lf", + "bzg1m7arxdvqs3iwcksymu2hg", + "capnw9242hzut00ei9rgfco8n", + "byhmntnl1b4lxw0zz21im3zkd", + "cu0rmpyff5692eo06ltddjo8a", + "dugi7e4nkmsbhtaacqr27fd8z", + "8x3sbh85gc8qir50utw39jl04", + "6lrwyoy74oz0t5udrnmuyql6a", + "eu2g5j36zzxiazpd729osx0wm", + "1okgv7alq5gggbccv5r9p63v8", + "ax1yf4nlzqpcji4j8epdgx3zl", + "b3ufcd24wfnnd5j98ped6irfu", + "macko17474028438909407652", + "6c8i3dxxtqhhxw6eacylplt73", + "486rhdgz7yc0sygziht7hje65", + "8ztsv3pzrsyq5w1r3a0nfk1y5", + "6hlw7rhrpe9garwmfoxu4lebc", + "macko1692520799556438919", + "bcpgmjs508joni6hpme87qz2b", + "1gxlzw2ezkyeykhcaa5x8ozkk", + "1vyghvhuy6abu4htoemdi79bd", + "macko16947839657652675283", + "chfah95whw2m0sbdq6cvfac7q", + "8z3180hhw2pj1i65uftlk54uz", + "5em08hhvd7komnfdsb1yagpas", + "79s9qgiydh9on34565f8suktl", + "d2xx0aq6s4jxls7nycyp32gb8", + "e6rl4hongahbihxd3tpudespd", + "3umprqta6ipyann6qjjh07biz", + "macko17034314927937958755", + "gfskxsdituog2kqp9yiu7bzi", + "bjzrti3ijoyjz8xqee72rfz3e", + "4nconewk3ipq2ql9a41h40ff8", + "595nsvo7ykvoe690b1e4u5n56", + "65d7qef6gdqauyilrftxeh42x", + "1ncmha8yglhyyhg6gtaujymqf", + "dv4tmo77hzul23izx8s5do4up", + "8kt53kt3mfo29gldhkl05u25b", + "1pz0ch210cun5hthsvq0lb7x3", + "10x5pvhifwo4y7hs3fz9hf245", + "4ean4fk01vvwgq1cywi6d3t4s", + "etta63x1t7tnkn4jheisjwk4p", + "4a7o9rf7ytl8g3ejwpblc6p5n", + "bq89wbdvedtov6auzuh6rsv7s", + "eitf7hulqfv1clb7toewkil24", + "5eg2n7dfbhbc669h475d1chdg", + "18izk5zx4t85y7sbpjk3rlr4n", + "5c0q11ygqq0ipg3bl5tnkq0uz", + "bbajzna018c79opa1kl5kmkqo", + "diyn3s6fyn7w6j58hq7kkzmjt", + "ehmpyfw70d7gs99hb9gz74wve", + "cefr2jp9ukac65a0w2cvkv0us", + "a3agf15089yvptkvldhuiel86", + "1dajh9qrda3enawmlt7ogt05w", + "4azsryi40zahspm5h6d0f0pgl", + "axmk1ke9j3idwqdxurn5d33mc", + "macko17161180860119283210", + "dr6r77p0pavomlvzvdkjrj54h", + "c5551zb2twbvyv8sh55bnht09", + "9zvu736m8f9jzeyukisgey356", + "9qpanykytmn5m0r0usacdt1a0", + "macko17080064783832627620", + "5pq4dbinkmt8ujoepyqzih7iw", + "6jgwiu2gq3dllmrwt45pfdn2z", + "dh0uni5wv8biorifoox1xk3ys", + "z1jegisr1gyeusokq5sgukbu", + "9sr54lwtg5hdlgmj2spz61cli", + "4pohvulrkgzx38eoqse6b5cdg", + "d6zovb8puwgcmsg91iya6rbtm", + "5jej246l7zaj0c7cesk23wbug", + "ezdsrofi27pck8v4ifpcg24bu", + "8ifnkq96dckjtn9ab9wplpl9l", + "aql5z4osw5wmun0emnakfpwji", + "32ngqzgynsjt7miwr5bkbm7ri", + "eh23n2rqhcdg7mpo3a7ec78yx", + "8cit3whr514nnd4zkaovsnqn", + "6694fff47wqxl10lrd9tb91f8", + "eosk3mmb4aypx4j4ki1wlriyz", + "awlih5a6wxegtxcx2gtps77my", + "d88wxnb1q9o8jhw9enklfy5ww", + "1zcp6rnjil25rnsv1ttcz3dhx", + "69ruk23bbos6yc07zeogu0mzu", + "9akr1h05hescbbtybhii9hgrl", + "9c0sdh9dm7ktcvs7vojicls7o", + "1qt9bfl6dhydf4tpano6n1p7s", + "7654efzou9a3lvjrmnl2dczys", + "1n990e5dpi9xwruwf6uslknkq", + "btouq8t23agr62ij7e0ju5p6n", + "4x4vwsyz29sfgvvy1qt0l7sxm", + "3a1kagik7bnoofrhr6qqp0dn8", + "macko1751463264891966017", + "a04no7mzejf9acrxgg10eryms", + "3e79p4eyu2prq96x39nt7djro", + "c8ynqmmwxpo7jh568jxg3syui", + "1xeblbv1u1bbtoedqfuefqky8", + "macko17126607353232849951", + "1pptcuhbrktn3aia6khf9edt6", + "68zplepppndhl8bfdvgy9vgu1", + "9oqeqyj7swpnl86ytafjwavvo", + "9i6k76jrh4elmn91usdc1rlec", + "dngxpyjjb58hmaowcupa8oq8x", + "dv7yrlxxgsfl7wl2sno3adndq", + "3a0j0giz3c3ajw9h59evv7lqt", + "4oajht599asio7mn3yf5q9eqx", + "macko16870922147293180860", + "2yyjcbbryf1r10apyzl7c7jvp", + "29lni33vxqrl1tqhadrnfid6t", + "ggsjtgoapnah61wu939ni8js", + "bq86t5p0ebho32hc5etoj24z4", + "9q1lv8j58bsnrnmj6wf2inf4w", + "di6z5rb67q76r5m9y9cc8xb9z", + "eve4cbr6ybatmp2qdh0nu7lx9", + "19mr0xdp7li6nkz87oxh53xed", + "macko16916734271721720280", + "86wrztni4x8tnvq9cr1cetvfu", + "3z6xfyd3ovi5x09orlo4rmskx", + "macko16881234097632836563", + "7gkb19b2t5kq70h0xhymop44t", + "7kp7k96a1s8r8ssqyy2uhc23d", + "53bqtby283rqeils2kjnjy3g6", + "emy1ibc8fu2l0fukh4vlu5xl5", + "7zsbjmlmhzn0y7923lw4zquud", + "2rdrisk4vlglfjxwu0precyqd", + "3faz4auaxirmsh26ztmidb001", + "8n9w0n3i9kk05echhtmstn6o9", + "df1o8phtfy4dwhv6n7mmeedvw", + "326jpj7749ojwqhu3ap27zl77", + "1nwgd1b2d903fvnwgnsdadkb8", + "5ruz6576fbs10qkuwq2yqctzp", + "5hwvpu5kcvehmftkhf8m6bvje", + "macko17001408659755761281", + "9uug7ctsyjsznqfe6s6zqgbv8", + "2xkxwl03668hb7d7y63naupgq", + "t2th5fajt23vbdfzhnd6gbbe", + "4vksk0d2q4c5w0itdl52lzek6", + "macko16888165594668885588", + "89v3ukjpui1gashsz3i1vphfa", + "9r3u0jtuznun4k2ydy39m1sve", + "6thk4rltubz0yjy35ru5dlo4x", + "bowzlakqovi6ldlo84e3ttad7", + "bmmm8kjsbbl7izu0bsl2spyw9", + "5qmjkpvi92vrzdcb2knassjkk", + "awf9v9httvkfc2osvy4vw2z8", + "3jlffacre45qv9p155t1yr7je", + "3nwcjuff6go8bs35fsnyq7h79", + "7aarv28uhxre2vot3v7b6s9ek", + "2kuyfkulm5lsgjxynrgh3vz70", + "9gvvndi7vk9fzvpe65pv5x2ir", + "dl5v19e4466jn4fuufhlae7jm", + "7j72vu09q4jdtlffn618nfun", + "arrfx02rdlstdfwdyikwqtwgl", + "eu6kl6j2w2qg7dg1dlpzo20fc", + "ww2yb4rhuu26usnvfksas4rj", + "j1lh8leo8vxo6mqk0nexpv1m", + "yubueqouzeanni89laaztw16", + "aed8nciihgj74iq4tv6mrq39w", + "cydr5rltozch9qnzjurt1r7d9", + "24uts34wjguq7pj6bc9qexmej", + "3skp15xzte4ugd9b4n2ju6zth", + "cize73kxls2n25jjwb5csecfe", + "dlf90uty1axvtr1vn2aaw9vqh", + "4qtsau8rkg0g5wt58wjw19rn1", + "3akazj2cmherl0b0hot94ezb8", + "9n3kbzjhtbnfu28zh3esh8xt7", + "41hbf8hlc2hfpw0n9hnn1ow7o", + "5yrl526gvkly2827inevbyu9r", + "22euhl6zy56cp651ipq99rooq", + "41j4f3gpb4882mydgxa6nsv1i", + "d1d1wnseo0ao8ojqtpxbirh2b", + "2smaq6vx7pgwmkfkn15kp7ib", + "8x62utr2uti3i7kk14isbnip6", + "2kfd1pg7ymajwbxlfy1j2dhio", + "66nxwll8fur0rtp9q7wqaki1l" +] \ No newline at end of file diff --git a/src/modules/feeder/feeder-persistence.service.ts b/src/modules/feeder/feeder-persistence.service.ts index 4a96513..9668d4f 100755 --- a/src/modules/feeder/feeder-persistence.service.ts +++ b/src/modules/feeder/feeder-persistence.service.ts @@ -134,6 +134,7 @@ export class FeederPersistenceService { categoryId: category.dbId, name: sName, oddValue: sValue, + openingValue: sValue, position: sPos, }, }); diff --git a/src/tasks/prediction-settlement.task.ts b/src/tasks/prediction-settlement.task.ts index 958dac3..2f67c92 100644 --- a/src/tasks/prediction-settlement.task.ts +++ b/src/tasks/prediction-settlement.task.ts @@ -99,9 +99,121 @@ export class PredictionSettlementTask { this.logger.log( `Settlement finished: scanned=${scanned} updated=${updated}`, ); + + const settled = await this.computeOddsMovementAndCleanup(); + this.logger.log( + `Odds movement: computed=${settled.computed} cleaned=${settled.cleaned}`, + ); + return { scanned, updated }; } + private async computeOddsMovementAndCleanup(): Promise<{ + computed: number; + cleaned: number; + }> { + let computed = 0; + let cleaned = 0; + + const finishedMatchIds = await this.prisma.$queryRaw<{ id: string }[]>( + Prisma.sql` + SELECT DISTINCT oh.match_id AS id + FROM odds_history oh + JOIN matches m ON m.id = oh.match_id + WHERE m.status = 'FT' + LIMIT 500 + `, + ); + + if (finishedMatchIds.length === 0) return { computed, cleaned }; + + const matchIds = finishedMatchIds.map((r) => r.id); + + for (const matchId of matchIds) { + try { + await this.computeMovementForMatch(matchId); + computed++; + } catch (e) { + this.logger.warn(`Movement calc failed for ${matchId}: ${e}`); + } + + await this.prisma.oddsHistory.deleteMany({ where: { matchId } }); + cleaned++; + } + + return { computed, cleaned }; + } + + private async computeMovementForMatch(matchId: string): Promise { + const selections = await this.prisma.$queryRaw< + { + name: string; + category_name: string; + opening_value: string | null; + odd_value: string | null; + }[] + >(Prisma.sql` + SELECT os.name, oc.name AS category_name, + os.opening_value, os.odd_value + FROM odd_selections os + JOIN odd_categories oc ON oc.db_id = os.odd_category_db_id + WHERE oc.match_id = ${matchId} + AND os.opening_value IS NOT NULL + AND os.odd_value IS NOT NULL + `); + + const movements: Record = {}; + + for (const sel of selections) { + const opening = parseFloat(sel.opening_value!); + const closing = parseFloat(sel.odd_value!); + if (!Number.isFinite(opening) || !Number.isFinite(closing) || opening <= 0) + continue; + + const movement = ((closing - opening) / opening) * 100; + const cat = (sel.category_name ?? "").toLowerCase(); + const name = (sel.name ?? "").toLowerCase(); + + if (cat.includes("maΓ§ sonucu") || cat.includes("mac sonucu") || cat === "ms") { + if (name === "1") movements.home = movement; + else if (name === "x" || name === "0") movements.draw = movement; + else if (name === "2") movements.away = movement; + } else if ( + (cat.includes("2,5") || cat.includes("2.5")) && + (cat.includes("alt/ΓΌst") || cat.includes("alt/ust")) + ) { + if (name.includes("ΓΌst") || name.includes("ust") || name.includes("over")) + movements.o25 = movement; + } else if ( + cat.includes("karşılΔ±klΔ± gol") || + cat.includes("karsilikli gol") || + cat === "kg" + ) { + if (name === "var" || name.includes("yes")) movements.btts = movement; + } + } + + if (Object.keys(movements).length === 0) return; + + const vals = Object.values(movements); + const sharpness = + vals.length > 0 + ? vals.reduce((sum, v) => sum + Math.abs(v), 0) / vals.length + : 0; + + await this.prisma.footballAiFeature.updateMany({ + where: { matchId }, + data: { + oddsMovementHome: movements.home ?? null, + oddsMovementDraw: movements.draw ?? null, + oddsMovementAway: movements.away ?? null, + oddsMovementO25: movements.o25 ?? null, + oddsMovementBtts: movements.btts ?? null, + oddsSharpness: sharpness || null, + }, + }); + } + private settleRow( row: UnresolvedRow, ): { outcome: string; unitProfit: number } | null {