From b6d64b59bf5cad8f436427c37c2288c45ee19060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahri=20Can=20Se=C3=A7er?= Date: Tue, 12 May 2026 02:43:02 +0300 Subject: [PATCH] main --- ai-engine/core/calculators/base_calculator.py | 2 +- ai-engine/core/calculators/bet_recommender.py | 2 +- .../core/calculators/expert_recommender.py | 2 +- .../core/calculators/half_time_calculator.py | 2 +- .../calculators/match_result_calculator.py | 4 +- .../calculators/other_markets_calculator.py | 2 +- .../core/calculators/over_under_calculator.py | 2 +- ai-engine/core/calculators/risk_assessor.py | 69 +- .../core/calculators/score_calculator.py | 9 +- ai-engine/core/engines/odds_predictor.py | 4 +- ai-engine/core/engines/player_predictor.py | 20 +- ai-engine/core/engines/referee_predictor.py | 16 +- ai-engine/models/calibration.py | 89 ++- ai-engine/models/v20_ensemble.py | 59 +- ai-engine/models/v25_ensemble.py | 15 +- ai-engine/pyright_errors.json | Bin 0 -> 495736 bytes ai-engine/scripts/train_calibration.py | 709 +++++++++--------- ai-engine/services/betting_brain.py | 113 ++- ai-engine/services/feature_enrichment.py | 49 +- ai-engine/services/match_commentary.py | 44 +- .../services/single_match_orchestrator.py | 422 ++++++++--- ai-engine/services/v26_shadow_engine.py | 2 +- package-lock.json | 50 +- prisma.config.ts | 4 +- .../migration.sql | 3 + .../migration.sql | 4 + prisma/schema.prisma | 2 + scripts/analyze_prediction_patterns.ts | 210 ++++++ src/modules/admin/admin.controller.ts | 55 +- .../admin/dto/admin-users-query.dto.ts | 12 + .../admin/dto/update-user-subscription.dto.ts | 13 + src/modules/matches/matches.service.ts | 9 +- .../subscriptions/subscriptions.service.ts | 7 +- src/modules/users/dto/user.dto.ts | 3 + src/tasks/data-fetcher.task.ts | 22 + 35 files changed, 1400 insertions(+), 630 deletions(-) create mode 100644 ai-engine/pyright_errors.json create mode 100644 prisma/migrations/20260512000000_add_max_columns_to_usage_limits/migration.sql create mode 100644 prisma/migrations/20260512120000_add_ht_score_to_live_matches/migration.sql create mode 100644 scripts/analyze_prediction_patterns.ts create mode 100644 src/modules/admin/dto/admin-users-query.dto.ts create mode 100644 src/modules/admin/dto/update-user-subscription.dto.ts diff --git a/ai-engine/core/calculators/base_calculator.py b/ai-engine/core/calculators/base_calculator.py index 71a89bf..7547214 100755 --- a/ai-engine/core/calculators/base_calculator.py +++ b/ai-engine/core/calculators/base_calculator.py @@ -40,7 +40,7 @@ class CalculationContext: is_surprise: bool = False # XGBoost Predictions (New) - xgboost_preds: dict[str, dict[str, Any]] = field(default_factory=dict) + xgboost_preds: dict[str, Any] = field(default_factory=dict) class BaseCalculator: diff --git a/ai-engine/core/calculators/bet_recommender.py b/ai-engine/core/calculators/bet_recommender.py index 29497f4..04f3b48 100755 --- a/ai-engine/core/calculators/bet_recommender.py +++ b/ai-engine/core/calculators/bet_recommender.py @@ -28,7 +28,7 @@ class RecommendationResult: class BetRecommender(BaseCalculator): - def calculate(self, + def calculate(self, # type: ignore[override] ctx: CalculationContext, ms_res: MatchResultPrediction, ou_res: OverUnderPrediction, diff --git a/ai-engine/core/calculators/expert_recommender.py b/ai-engine/core/calculators/expert_recommender.py index 1746cf1..bbe7c7b 100644 --- a/ai-engine/core/calculators/expert_recommender.py +++ b/ai-engine/core/calculators/expert_recommender.py @@ -36,7 +36,7 @@ class ExpertResult: class ExpertRecommender(BaseCalculator): - def calculate(self, + def calculate(self, # type: ignore[override] ctx: CalculationContext, ms_res: MatchResultPrediction, ou_res: OverUnderPrediction, diff --git a/ai-engine/core/calculators/half_time_calculator.py b/ai-engine/core/calculators/half_time_calculator.py index 5049409..2828021 100755 --- a/ai-engine/core/calculators/half_time_calculator.py +++ b/ai-engine/core/calculators/half_time_calculator.py @@ -31,7 +31,7 @@ class HalfTimeCalculator(BaseCalculator): return 1.0 if k == 0 else 0.0 return (lam ** k) * math.exp(-lam) / math.factorial(k) - def calculate(self, ctx: CalculationContext) -> HalfTimePrediction: + def calculate(self, ctx: CalculationContext) -> HalfTimePrediction: # type: ignore[override] team_pred = ctx.team_pred odds_pred = ctx.odds_pred diff --git a/ai-engine/core/calculators/match_result_calculator.py b/ai-engine/core/calculators/match_result_calculator.py index 12a2a52..755f2a6 100755 --- a/ai-engine/core/calculators/match_result_calculator.py +++ b/ai-engine/core/calculators/match_result_calculator.py @@ -22,9 +22,9 @@ class MatchResultCalculator(BaseCalculator): def _get_engine_winner(self, home_prob: float, draw_prob: float, away_prob: float) -> str: """Determine which outcome an engine favors.""" probs = {"1": home_prob, "X": draw_prob, "2": away_prob} - return max(probs, key=probs.get) + return max(probs, key=probs.__getitem__) - def calculate(self, ctx: CalculationContext) -> MatchResultPrediction: + def calculate(self, ctx: CalculationContext) -> MatchResultPrediction: # type: ignore[override] # Weights w_team = ctx.weights["team"] w_player = ctx.weights["player"] diff --git a/ai-engine/core/calculators/other_markets_calculator.py b/ai-engine/core/calculators/other_markets_calculator.py index 69dabe2..dc67312 100755 --- a/ai-engine/core/calculators/other_markets_calculator.py +++ b/ai-engine/core/calculators/other_markets_calculator.py @@ -28,7 +28,7 @@ class OtherMarketsPrediction: class OtherMarketsCalculator(BaseCalculator): - def calculate( + def calculate( # type: ignore[override] self, ctx: CalculationContext, ms_result: MatchResultPrediction, diff --git a/ai-engine/core/calculators/over_under_calculator.py b/ai-engine/core/calculators/over_under_calculator.py index 6a73d85..d6efd13 100755 --- a/ai-engine/core/calculators/over_under_calculator.py +++ b/ai-engine/core/calculators/over_under_calculator.py @@ -55,7 +55,7 @@ class OverUnderCalculator(BaseCalculator): return over_15, over_25, over_35, btts_yes - def calculate(self, ctx: CalculationContext) -> OverUnderPrediction: + def calculate(self, ctx: CalculationContext) -> OverUnderPrediction: # type: ignore[override] odds_pred = ctx.odds_pred referee_mods = ctx.referee_mods diff --git a/ai-engine/core/calculators/risk_assessor.py b/ai-engine/core/calculators/risk_assessor.py index 2c21947..bb1346e 100755 --- a/ai-engine/core/calculators/risk_assessor.py +++ b/ai-engine/core/calculators/risk_assessor.py @@ -67,12 +67,14 @@ class RiskAssessor(BaseCalculator): if sport_key == "basketball": if is_top_league: - return float( - self.config.get("risk.surprise_threshold_basketball_top", self.config.get("risk.surprise_threshold_basketball", 0.30)), - ) - return float( - self.config.get("risk.surprise_threshold_basketball_non_top", 0.34), - ) + top_val = self.config.get("risk.surprise_threshold_basketball_top") + if top_val is not None: + return float(top_val) + base_val = self.config.get("risk.surprise_threshold_basketball") + return float(base_val) if base_val is not None else 0.30 + + non_top_val = self.config.get("risk.surprise_threshold_basketball_non_top") + return float(non_top_val) if non_top_val is not None else 0.34 if top_label not in ("1/2", "2/1"): return base_threshold @@ -81,27 +83,30 @@ class RiskAssessor(BaseCalculator): favorite_side, gap = self._favorite_profile_from_odds(ctx.odds_data) if is_top_league: - favorite_winner_threshold = float( - self.config.get( - "risk.surprise_threshold_favorite_reversal_top", - self.config.get("risk.surprise_threshold_favorite_reversal", 0.26), - ), - ) - underdog_winner_threshold = float( - self.config.get( - "risk.surprise_threshold_underdog_reversal_top", - self.config.get("risk.surprise_threshold_underdog_reversal", 0.20), - ), - ) + top_fav = self.config.get("risk.surprise_threshold_favorite_reversal_top") + if top_fav is not None: + favorite_winner_threshold = float(top_fav) + else: + base_fav = self.config.get("risk.surprise_threshold_favorite_reversal") + favorite_winner_threshold = float(base_fav) if base_fav is not None else 0.26 + + top_ud = self.config.get("risk.surprise_threshold_underdog_reversal_top") + if top_ud is not None: + underdog_winner_threshold = float(top_ud) + else: + base_ud = self.config.get("risk.surprise_threshold_underdog_reversal") + underdog_winner_threshold = float(base_ud) if base_ud is not None else 0.20 else: - favorite_winner_threshold = float( - self.config.get("risk.surprise_threshold_favorite_reversal_non_top", 0.30), - ) - underdog_winner_threshold = float( - self.config.get("risk.surprise_threshold_underdog_reversal_non_top", 0.24), - ) - 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)) + nt_fav = self.config.get("risk.surprise_threshold_favorite_reversal_non_top") + favorite_winner_threshold = float(nt_fav) if nt_fav is not None else 0.30 + nt_ud = self.config.get("risk.surprise_threshold_underdog_reversal_non_top") + underdog_winner_threshold = float(nt_ud) if nt_ud is not None else 0.24 + + gm = self.config.get("risk.htft_reversal_gap_medium") + gap_medium = float(gm) if gm is not None else 0.50 + + gs = self.config.get("risk.htft_reversal_gap_strong") + gap_strong = float(gs) if gs is not None else 1.00 if favorite_side in ("H", "A"): threshold = ( @@ -117,7 +122,7 @@ class RiskAssessor(BaseCalculator): return base_threshold - def calculate(self, ctx: CalculationContext, ms_result=None) -> RiskAnalysis: + def calculate(self, ctx: CalculationContext, ms_result: Any = None) -> RiskAnalysis: # type: ignore[override] """ Wrapper for assess_risk to match BaseCalculator interface but with extra arg. """ @@ -173,9 +178,15 @@ class RiskAssessor(BaseCalculator): threshold = self._dynamic_reversal_threshold(ctx, top_label) if getattr(ctx, "is_top_league", False): - min_gap = float(self.config.get("risk.surprise_min_top_gap_top", self.config.get("risk.surprise_min_top_gap", 0.02))) + top_gap_val = self.config.get("risk.surprise_min_top_gap_top") + if top_gap_val is not None: + min_gap = float(top_gap_val) + else: + base_gap_val = self.config.get("risk.surprise_min_top_gap") + min_gap = float(base_gap_val) if base_gap_val is not None else 0.02 else: - min_gap = float(self.config.get("risk.surprise_min_top_gap_non_top", 0.03)) + non_top_gap_val = self.config.get("risk.surprise_min_top_gap_non_top") + min_gap = float(non_top_gap_val) if non_top_gap_val is not None else 0.03 # Trigger surprise only when reversal class is: # - top HT/FT outcome diff --git a/ai-engine/core/calculators/score_calculator.py b/ai-engine/core/calculators/score_calculator.py index e2b089b..43ac4f5 100755 --- a/ai-engine/core/calculators/score_calculator.py +++ b/ai-engine/core/calculators/score_calculator.py @@ -3,7 +3,7 @@ import pickle import pandas as pd import xgboost as xgb from dataclasses import dataclass -from typing import List, Dict, Tuple +from typing import List, Dict, Tuple, Optional import math from .base_calculator import BaseCalculator, CalculationContext from .confidence import calc_confidence_3way, calc_confidence_dc @@ -16,7 +16,7 @@ class ScorePrediction: ft_scores_top5: List[Dict] # Reconciled MS/DC predictions (can be updated here) - reconciled_ms: MatchResultPrediction = None + reconciled_ms: Optional[MatchResultPrediction] = None class ScoreCalculator(BaseCalculator): @@ -57,7 +57,8 @@ class ScoreCalculator(BaseCalculator): return 1.0 if k == 0 else 0.0 return (lam ** k) * math.exp(-lam) / math.factorial(k) - def calculate(self, ctx: CalculationContext, ms_result: MatchResultPrediction) -> ScorePrediction: + def calculate(self, ctx: CalculationContext, ms_result: MatchResultPrediction) -> ScorePrediction: # type: ignore[override] + predicted_ht = None # Default Lambdas (fallback) lambda_home = max(0.5, ctx.home_xg) lambda_away = max(0.5, ctx.away_xg) @@ -199,7 +200,7 @@ class ScoreCalculator(BaseCalculator): predicted_ft = top_overall_score # If we didn't calculate HT via ML (exception case), do it now - if 'predicted_ht' not in locals(): + if predicted_ht is None: ft_to_ht = self.config.get("half_time.ft_to_ht_ratio", 0.42) ht_h = round(lambda_home * ft_to_ht) ht_a = round(lambda_away * ft_to_ht) diff --git a/ai-engine/core/engines/odds_predictor.py b/ai-engine/core/engines/odds_predictor.py index 7ce1231..f27ae23 100755 --- a/ai-engine/core/engines/odds_predictor.py +++ b/ai-engine/core/engines/odds_predictor.py @@ -42,7 +42,7 @@ class OddsPrediction: third_likely_score: str = "2-1" # Value bet opportunities - value_bets: list = None + value_bets: Optional[list] = None confidence: float = 0.0 @@ -84,7 +84,7 @@ class OddsPredictorEngine: try: self.value_calc = get_value_calculator() except Exception: - self.value_calc = None + 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 diff --git a/ai-engine/core/engines/player_predictor.py b/ai-engine/core/engines/player_predictor.py index fc45ba4..ba6c154 100755 --- a/ai-engine/core/engines/player_predictor.py +++ b/ai-engine/core/engines/player_predictor.py @@ -72,9 +72,9 @@ class PlayerPredictorEngine: match_id: str, home_team_id: str, away_team_id: str, - home_lineup: List[str] = None, - away_lineup: List[str] = None, - sidelined_data: Dict = None) -> PlayerPrediction: + home_lineup: Optional[List[str]] = None, + away_lineup: Optional[List[str]] = None, + sidelined_data: Optional[Dict] = None) -> PlayerPrediction: """ Generate player-based prediction. @@ -134,10 +134,10 @@ class PlayerPredictorEngine: lineup_available = False # Extract features - home_goals = features.get("home_goals_last_5", 0) - away_goals = features.get("away_goals_last_5", 0) - home_key = features.get("home_key_players", 0) - away_key = features.get("away_key_players", 0) + 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) @@ -171,8 +171,8 @@ class PlayerPredictorEngine: # Priority: sidelined data (position-weighted) > lineup count (basic) if sidelined_data: home_impact, away_impact = self.sidelined_analyzer.analyze_match(sidelined_data) - home_missing = home_impact.impact_score - away_missing = away_impact.impact_score + 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 @@ -241,7 +241,7 @@ if __name__ == "__main__": print("=" * 50) pred = engine.predict( - match_id=None, + match_id="test_match", home_team_id="test_home", away_team_id="test_away" ) diff --git a/ai-engine/core/engines/referee_predictor.py b/ai-engine/core/engines/referee_predictor.py index de25656..7dc62eb 100755 --- a/ai-engine/core/engines/referee_predictor.py +++ b/ai-engine/core/engines/referee_predictor.py @@ -78,9 +78,9 @@ class RefereePredictorEngine: print("✅ RefereePredictorEngine initialized") def predict(self, - match_id: str = None, - referee_name: str = None, - league_id: str = None) -> RefereePrediction: + match_id: Optional[str] = None, + referee_name: Optional[str] = None, + league_id: Optional[str] = None) -> RefereePrediction: """ Generate referee-based prediction. @@ -95,21 +95,21 @@ class RefereePredictorEngine: # Get referee features if match_id: - features = self.referee_engine.get_features(match_id, league_id=league_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) + 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) + 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 = features.get("referee_name", "Unknown") - matches = features.get("referee_matches", 0) + ref_name = str(features.get("referee_name", "Unknown")) + matches = int(features.get("referee_matches", 0)) if matches < 5: # Not enough data diff --git a/ai-engine/models/calibration.py b/ai-engine/models/calibration.py index cc5ff15..000e656 100644 --- a/ai-engine/models/calibration.py +++ b/ai-engine/models/calibration.py @@ -91,22 +91,26 @@ class Calibrator: def __init__(self): self.calibrators: Dict[str, IsotonicRegression] = {} self.metrics: Dict[str, CalibrationMetrics] = {} + # Less aggressive shrinkage — only meaningful overconfident bands are pulled. + # Default raised from ~0.85-0.90 to 0.95+ since the orchestrator and config + # already apply market-level multipliers; double-shrinkage was the root cause + # of 24-35pt avg calibrated-vs-raw drops in production traces. self.heuristic_fallback: Dict[str, float] = { - "ms": 0.90, - "ms_home": 0.90, - "ms_home_heavy_fav": 0.95, - "ms_home_fav": 0.90, - "ms_home_balanced": 0.85, - "ms_home_underdog": 0.80, - "ms_draw": 0.90, - "ms_away": 0.90, - "ou15": 0.90, - "ou25": 0.90, - "ou35": 0.90, - "btts": 0.90, - "ht_ft": 0.85, - "dc": 0.93, - "ht": 0.85, + "ms": 0.96, + "ms_home": 0.96, + "ms_home_heavy_fav": 0.98, + "ms_home_fav": 0.96, + "ms_home_balanced": 0.94, + "ms_home_underdog": 0.92, + "ms_draw": 0.94, + "ms_away": 0.96, + "ou15": 0.96, + "ou25": 0.96, + "ou35": 0.94, + "btts": 0.96, + "ht_ft": 0.92, + "dc": 0.97, + "ht": 0.92, } self._load_calibrators() @@ -139,21 +143,32 @@ class Calibrator: except Exception as e: print(f"[Calibrator] Warning: Failed to load metrics for {market}: {e}") + # Below this sample count, blend isotonic with raw_prob to dampen overfit jumps. + # Above this count, trust isotonic fully. + TRUSTED_SAMPLE_FLOOR = 30 + TRUSTED_SAMPLE_CEILING = 200 + # Hard cap on how far calibration can move probability in either direction. + MAX_DELTA = 0.20 + def calibrate(self, market_type: str, raw_prob: float, odds_val: Optional[float] = None) -> float: """ - Calibrate a raw probability using Isotonic Regression. - + Calibrate a raw probability using Isotonic Regression with safeguards. + Args: market_type (str): 'ms_home', 'ou25', 'btts', 'ht_ft', etc. raw_prob (float): The raw probability from XGBoost (0.0 - 1.0) odds_val (float, optional): The pre-match odds, used for context-aware bucket mapping - + Returns: float: Calibrated probability (0.0 - 1.0) + + Safeguards: + * Low-sample trained models are blended with raw_prob to dampen overfit. + * MAX_DELTA caps the per-call adjustment (prevents 40pp swings). """ # Normalize market type market_key = market_type.lower().replace("-", "_") - + # Route to bucket if ms_home and odds provided if market_key == "ms_home" and odds_val is not None and odds_val > 1.0: if odds_val <= 1.40: @@ -164,20 +179,42 @@ class Calibrator: bucket_key = "ms_home_balanced" else: bucket_key = "ms_home_underdog" - + if bucket_key in self.calibrators: market_key = bucket_key - - # If we have a trained Isotonic Regression model, use it + + # If we have a trained Isotonic Regression model, use it (with safeguards) if market_key in self.calibrators: try: - calibrated = self.calibrators[market_key].predict([raw_prob])[0] - # Ensure output is valid probability - return float(np.clip(calibrated, 0.01, 0.99)) + iso_pred = float(self.calibrators[market_key].predict([raw_prob])[0]) + + # Sample-count weighted blend with raw probability. + # Sparse models barely move probability; mature models dominate. + metrics = self.metrics.get(market_key) + n_samples = metrics.sample_count if metrics else 0 + if n_samples >= self.TRUSTED_SAMPLE_CEILING: + iso_weight = 1.0 + elif n_samples <= self.TRUSTED_SAMPLE_FLOOR: + # Very sparse: at least 30% trust to surface the signal + iso_weight = max(0.30, n_samples / self.TRUSTED_SAMPLE_CEILING) + else: + # Linearly ramp 30% → 100% between floor and ceiling + span = self.TRUSTED_SAMPLE_CEILING - self.TRUSTED_SAMPLE_FLOOR + iso_weight = 0.30 + 0.70 * (n_samples - self.TRUSTED_SAMPLE_FLOOR) / span + blended = iso_weight * iso_pred + (1.0 - iso_weight) * raw_prob + + # Cap delta to avoid huge swings on noisy calibrators + delta = blended - raw_prob + if delta > self.MAX_DELTA: + blended = raw_prob + self.MAX_DELTA + elif delta < -self.MAX_DELTA: + blended = raw_prob - self.MAX_DELTA + + return float(np.clip(blended, 0.01, 0.99)) except Exception as e: print(f"[Calibrator] Warning: Isotonic failed for {market_key}: {e}") # Fall through to heuristic - + # Fallback to heuristic calibration return self._heuristic_calibrate(market_key, raw_prob) diff --git a/ai-engine/models/v20_ensemble.py b/ai-engine/models/v20_ensemble.py index b890fc4..8712a74 100644 --- a/ai-engine/models/v20_ensemble.py +++ b/ai-engine/models/v20_ensemble.py @@ -139,7 +139,7 @@ class FullMatchPrediction: ht_confidence: float = 0.0 # === SKOR TAHMİNLERİ === - score: ScorePrediction = None + 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) @@ -161,7 +161,13 @@ class FullMatchPrediction: 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 @@ -412,18 +418,19 @@ class V20EnsemblePredictor: # Calculators print("⚙️ Loading market calculators...") - self.match_result_calc = MatchResultCalculator(self.config) - self.over_under_calc = OverUnderCalculator(self.config) - self.half_time_calc = HalfTimeCalculator(self.config) - self.score_calc = ScoreCalculator(self.config) + 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(self.config) - self.risk_assessor = RiskAssessor(self.config) - self.bet_recommender = BetRecommender(self.config) + 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(self.config) + self.expert_recommender = ExpertRecommender(cfg) # XGBoost Integration print("🤖 Loading XGBoost models...") @@ -551,7 +558,7 @@ class V20EnsemblePredictor: features = features.copy() features[col] = 0.0 - return features[expected] + return features[expected] # type: ignore[return-value] def _favorite_profile_from_odds(self, odds_data: Dict[str, float]) -> Tuple[str, float]: """ @@ -838,10 +845,10 @@ class V20EnsemblePredictor: home_team_name: str, away_team_name: str, match_date_ms: int, - odds_data: Dict[str, float] = None, - home_lineup: List[str] = None, - away_lineup: List[str] = None, - referee_name: str = None, + 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, @@ -849,9 +856,9 @@ class V20EnsemblePredictor: home_position: int = 10, away_position: int = 10, league_name: str = "", - league_id: str = None, + league_id: Optional[str] = None, sport: str = "football", - sidelined_data: Dict = None) -> FullMatchPrediction: + sidelined_data: Optional[Dict] = None) -> FullMatchPrediction: """ Generate complete V20 ensemble prediction. @@ -895,8 +902,8 @@ class V20EnsemblePredictor: referee_pred = self.referee_engine.predict( match_id=match_id, - referee_name=referee_name, - league_id=league_id + referee_name=referee_name or "", + league_id=league_id or "" ) upset_factors = self.upset_engine.calculate_upset_potential( @@ -935,9 +942,9 @@ class V20EnsemblePredictor: away_position=away_position, match_date_ms=match_date_ms, odds_data=odds_data, - referee_name=referee_name, - home_form_score=team_pred.home_form_score if hasattr(team_pred, 'home_form_score') else 50.0, - away_form_score=team_pred.away_form_score if hasattr(team_pred, 'away_form_score') else 50.0, + 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 ) @@ -1105,7 +1112,7 @@ class V20EnsemblePredictor: best_bet = _map_dto(rec_result.best_bet) alt_bet = _map_dto(rec_result.alternative_bet) - recommended = [_map_dto(r) for r in rec_result.recommended_bets] + recommended = [m for m in (_map_dto(r) for r in rec_result.recommended_bets) if m is not None] # Analysis Details analysis_details = { @@ -1187,13 +1194,13 @@ class V20EnsemblePredictor: # Others total_corners_pred=other_result.total_corners_pred, - corner_pick=other_result.corner_pick, + corner_pick=other_result.corner_pick or "", total_cards_pred=other_result.total_cards_pred, - card_pick=other_result.card_pick, + 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, + 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, diff --git a/ai-engine/models/v25_ensemble.py b/ai-engine/models/v25_ensemble.py index 57bf161..7a9af5d 100644 --- a/ai-engine/models/v25_ensemble.py +++ b/ai-engine/models/v25_ensemble.py @@ -228,15 +228,13 @@ class V25Predictor: print(f"[V25] Using fallback feature columns ({len(V25Predictor._FALLBACK_FEATURE_COLS)} features)") return V25Predictor._FALLBACK_FEATURE_COLS - FEATURE_COLS = _load_feature_cols.__func__() - # Model weights for ensemble DEFAULT_WEIGHTS = { 'xgb': 0.50, 'lgb': 0.50, } - - def __init__(self, models_dir: str = None): + + def __init__(self, models_dir: Optional[str] = None): """ Initialize V25 Predictor. @@ -246,6 +244,7 @@ class V25Predictor: self.models_dir = models_dir or MODELS_DIR self.models = {} # market -> {'xgb': model, 'lgb': model} self._loaded = False + self.FEATURE_COLS = self._load_feature_cols() # All trained market models available in V25 ALL_MARKETS = [ @@ -412,7 +411,7 @@ class V25Predictor: return float(avg_prob), float(1 - avg_prob) - def predict_market(self, market: str, features: Dict[str, float]) -> np.ndarray: + def predict_market(self, market: str, features: Dict[str, float]) -> Optional[np.ndarray]: """ Generic prediction for any loaded market. @@ -510,15 +509,15 @@ class V25Predictor: # Determine picks ms_probs = {'1': home_prob, 'X': draw_prob, '2': away_prob} - ms_pick = max(ms_probs, key=ms_probs.get) + ms_pick = max(ms_probs, key=ms_probs.__getitem__) ms_confidence = ms_probs[ms_pick] * 100 ou25_probs = {'Over': over_prob, 'Under': under_prob} - ou25_pick = max(ou25_probs, key=ou25_probs.get) + ou25_pick = max(ou25_probs, key=ou25_probs.__getitem__) ou25_confidence = ou25_probs[ou25_pick] * 100 btts_probs = {'Yes': btts_yes_prob, 'No': btts_no_prob} - btts_pick = max(btts_probs, key=btts_probs.get) + btts_pick = max(btts_probs, key=btts_probs.__getitem__) btts_confidence = btts_probs[btts_pick] * 100 # Create prediction diff --git a/ai-engine/pyright_errors.json b/ai-engine/pyright_errors.json new file mode 100644 index 0000000000000000000000000000000000000000..8bb8ca618f3e65555f8182fc3bc7f5010f25900c GIT binary patch literal 495736 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 z List[str]: - """Load top league IDs from JSON file.""" - if not os.path.exists(TOP_LEAGUES_PATH): - print(f"[Warning] top_leagues.json not found at {TOP_LEAGUES_PATH}") - return [] - - with open(TOP_LEAGUES_PATH, "r") as f: - data = json.load(f) - - # Handle both list and dict formats - if isinstance(data, dict): - return data.get("football", []) - return data +# ============================================================================= +# OUTCOME RESOLUTION +# ============================================================================= +def _normalize_pick(pick: Any) -> str: + return str(pick or "").strip().casefold() + + +def _is_over(pick: str) -> bool: + norm = _normalize_pick(pick) + return "over" in norm or "üst" in norm or "ust" in norm + + +def _is_under(pick: str) -> bool: + norm = _normalize_pick(pick) + return "under" in norm or "alt" in norm + + +def _is_yes(pick: str) -> bool: + norm = _normalize_pick(pick) + return "yes" in norm or "var" in norm + + +def resolve_actual( + market: str, + pick: str, + score_home: Optional[int], + score_away: Optional[int], + ht_home: Optional[int], + ht_away: Optional[int], +) -> Optional[int]: + """Return 1 if the (market, pick) hit, 0 if it missed, None if undetermined.""" + 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", "x/0"}: + return int(score_home == score_away) + if p == "2": + return int(score_away > score_home) + return None + + 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 + + if market in {"OU15", "OU25", "OU35"}: + line = {"OU15": 1.5, "OU25": 2.5, "OU35": 3.5}[market] + if _is_over(p): + return int(total > line) + if _is_under(p): + return int(total < line) + return None + + if market == "BTTS": + both_scored = score_home > 0 and score_away > 0 + if _is_yes(p): + return int(both_scored) + if "no" in p or "yok" in p: + return int(not both_scored) + 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 in {"HT_OU05", "HT_OU15"}: + if ht_total is None: + return None + line = 0.5 if market == "HT_OU05" else 1.5 + if _is_over(p): + return int(ht_total > line) + if _is_under(p): + return int(ht_total < line) + return None + + if market == "OE": + if "odd" in p or "tek" in p: + return int(total % 2 == 1) + if "even" in p or "çift" in p or "cift" in p: + return int(total % 2 == 0) + 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) + + return None + + +# ============================================================================= +# CALIBRATOR KEY (must mirror orchestrator._calibrator_key) +# ============================================================================= +def calibrator_key(market: str, pick: str) -> Optional[str]: + 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 _is_over(p): + return "ou15" + if m == "OU25" and _is_over(p): + return "ou25" + if m == "OU35" and _is_over(p): + return "ou35" + if m == "BTTS" and _is_yes(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 # ============================================================================= # DATA EXTRACTION # ============================================================================= -def fetch_training_data( - cur, - start_date: str, - end_date: str, - league_ids: List[str] = None, -) -> pd.DataFrame: +def fetch_predictions_with_outcomes(cur) -> List[Dict[str, Any]]: """ - Fetch match data with odds and results for calibration training. - - Returns DataFrame with columns: - - match_id - - home_team, away_team - - ms_h, ms_d, ms_a (odds) - - score_home, score_away (actual result) - - ht_score_home, ht_score_away - - ou25_actual, btts_actual, etc. + Source 1: `predictions` table joined with `matches` (FT only). + Each row of bet_summary becomes a training sample. """ - start_ms = int(datetime.strptime(start_date, "%Y-%m-%d").timestamp() * 1000) - end_ms = int(datetime.strptime(end_date, "%Y-%m-%d").timestamp() * 1000) + 86400000 # +1 day - - # Build league filter - league_filter = "" - params = [start_ms, end_ms] - if league_ids: - placeholders = ",".join(["%s"] * len(league_ids)) - league_filter = f"AND m.league_id IN ({placeholders})" - params.extend(league_ids) - - query = f""" - SELECT - m.id as match_id, - 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, - -- Odds from odd_categories/selections - MAX(CASE WHEN oc.name = 'Maç Sonucu' AND os.name = '1' THEN os.odd_value END) as ms_h, - MAX(CASE WHEN oc.name = 'Maç Sonucu' AND os.name = 'X' THEN os.odd_value END) as ms_d, - MAX(CASE WHEN oc.name = 'Maç Sonucu' AND os.name = '2' THEN os.odd_value END) as ms_a, - MAX(CASE WHEN oc.name = '2,5 Alt/Üst' AND os.name = 'Üst' THEN os.odd_value END) as ou25_over, - MAX(CASE WHEN oc.name = '2,5 Alt/Üst' AND os.name = 'Alt' THEN os.odd_value END) as ou25_under, - MAX(CASE WHEN oc.name = '1,5 Alt/Üst' AND os.name = 'Üst' THEN os.odd_value END) as ou15_over, - MAX(CASE WHEN oc.name = '3,5 Alt/Üst' AND os.name = 'Üst' THEN os.odd_value END) as ou35_over, - MAX(CASE WHEN oc.name = 'Karşılıklı Gol' AND os.name = 'Var' THEN os.odd_value END) as btts_yes, - MAX(CASE WHEN oc.name = 'Karşılıklı Gol' AND os.name = 'Yok' THEN os.odd_value END) as btts_no - FROM matches m - LEFT JOIN odd_categories oc ON oc.match_id = m.id - LEFT JOIN odd_selections os ON os.odd_category_db_id = oc.db_id - WHERE m.mst_utc >= %s - AND m.mst_utc < %s - AND m.status = 'FT' - AND m.score_home IS NOT NULL - AND m.score_away IS NOT NULL - {league_filter} - GROUP BY m.id, 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 - ORDER BY m.mst_utc DESC - """ - - cur.execute(query, params) + cur.execute(""" + SELECT + p.match_id, + p.prediction_json, + m.score_home, + m.score_away, + m.ht_score_home, + m.ht_score_away + FROM predictions p + JOIN matches m ON m.id = p.match_id + WHERE m.sport = 'football' + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.score_away IS NOT NULL + """) rows = cur.fetchall() - columns = [desc[0] for desc in cur.description] - - df = pd.DataFrame(rows, columns=columns) - print(f"[Data] Fetched {len(df)} matches from {start_date} to {end_date}") - - return df + samples: List[Dict[str, Any]] = [] + for match_id, payload, sh, sa, ht_h, ht_a in rows: + if not isinstance(payload, dict): + continue + bet_summary = payload.get("bet_summary") + if not isinstance(bet_summary, list): + continue + for item in bet_summary: + if not isinstance(item, dict): + continue + market = str(item.get("market") or "") + pick = str(item.get("pick") or "") + raw_conf = item.get("raw_confidence") + if raw_conf is None: + continue + actual = resolve_actual(market, pick, sh, sa, ht_h, ht_a) + if actual is None: + continue + key = calibrator_key(market, pick) + if not key: + continue + samples.append({ + "source": "predictions", + "match_id": match_id, + "market": market, + "pick": pick, + "key": key, + "raw_prob": float(raw_conf) / 100.0, + "actual": int(actual), + }) + return samples -def calculate_actual_outcomes(df: pd.DataFrame) -> pd.DataFrame: +def fetch_prediction_runs_with_outcomes(cur) -> List[Dict[str, Any]]: """ - Calculate actual binary outcomes for each market. - - Adds columns: - - ms_home_actual: 1 if home won, 0 otherwise - - ms_draw_actual: 1 if draw, 0 otherwise - - ms_away_actual: 1 if away won, 0 otherwise - - ou25_over_actual: 1 if total goals > 2.5, 0 otherwise - - ou15_over_actual: 1 if total goals > 1.5, 0 otherwise - - ou35_over_actual: 1 if total goals > 3.5, 0 otherwise - - btts_yes_actual: 1 if both teams scored, 0 otherwise + Source 2: `prediction_runs` table with resolved settlement. + Each main_pick / value_pick becomes a training sample. """ - # Total goals - df["total_goals"] = df["score_home"] + df["score_away"] - df["ht_total_goals"] = df["ht_score_home"].fillna(0) + df["ht_score_away"].fillna(0) - - # Match result outcomes - df["ms_home_actual"] = (df["score_home"] > df["score_away"]).astype(int) - df["ms_draw_actual"] = (df["score_home"] == df["score_away"]).astype(int) - df["ms_away_actual"] = (df["score_home"] < df["score_away"]).astype(int) - - # Over/Under outcomes - df["ou25_over_actual"] = (df["total_goals"] > 2.5).astype(int) - df["ou15_over_actual"] = (df["total_goals"] > 1.5).astype(int) - df["ou35_over_actual"] = (df["total_goals"] > 3.5).astype(int) - - # BTTS outcome - df["btts_yes_actual"] = ((df["score_home"] > 0) & (df["score_away"] > 0)).astype(int) - - # Half-Time result - df["ht_home_actual"] = (df["ht_score_home"] > df["ht_score_away"]).astype(int) - df["ht_draw_actual"] = (df["ht_score_home"] == df["ht_score_away"]).astype(int) - df["ht_away_actual"] = (df["ht_score_home"] < df["ht_score_away"]).astype(int) - - return df - - -def calculate_implied_probabilities(df: pd.DataFrame) -> pd.DataFrame: - """ - Calculate implied probabilities from odds. - - Adds columns: - - ms_home_prob: implied probability from odds - - ms_draw_prob - - ms_away_prob - - ou25_over_prob - - etc. - """ - def safe_implied_prob(odd_str: str) -> float: - """Convert odds string to implied probability.""" - if pd.isna(odd_str) or odd_str is None: - return np.nan - try: - odd = float(odd_str) - if odd <= 1.0: - return np.nan - return 1.0 / odd - except (ValueError, TypeError): - return np.nan - - # Match result implied probabilities - df["ms_home_prob"] = df["ms_h"].apply(safe_implied_prob) - df["ms_draw_prob"] = df["ms_d"].apply(safe_implied_prob) - df["ms_away_prob"] = df["ms_a"].apply(safe_implied_prob) - - # Over/Under implied probabilities - df["ou25_over_prob"] = df["ou25_over"].apply(safe_implied_prob) - df["ou15_over_prob"] = df["ou15_over"].apply(safe_implied_prob) - df["ou35_over_prob"] = df["ou35_over"].apply(safe_implied_prob) - - # BTTS implied probabilities - df["btts_yes_prob"] = df["btts_yes"].apply(safe_implied_prob) - - # ----------------------------------------------------- - # CONTEXT-AWARE BUCKETS - # Create separate probability and actual columns for odds buckets - # ms_home odds: ms_h (note ms_h is the bookmaker odds for home win) - # ----------------------------------------------------- - # Helper to safe-cast to float - df['ms_h_num'] = pd.to_numeric(df['ms_h'], errors='coerce') - - # Bucket 1: Heavy Fav (odds <= 1.40) - b1_mask = df['ms_h_num'] <= 1.40 - df.loc[b1_mask, 'ms_home_heavy_fav_prob'] = df.loc[b1_mask, 'ms_home_prob'] - df.loc[b1_mask, 'ms_home_heavy_fav_actual'] = df.loc[b1_mask, 'ms_home_actual'] - - # Bucket 2: Fav (1.40 < odds <= 1.80) - b2_mask = (df['ms_h_num'] > 1.40) & (df['ms_h_num'] <= 1.80) - df.loc[b2_mask, 'ms_home_fav_prob'] = df.loc[b2_mask, 'ms_home_prob'] - df.loc[b2_mask, 'ms_home_fav_actual'] = df.loc[b2_mask, 'ms_home_actual'] - - # Bucket 3: Balanced (1.80 < odds <= 2.50) - b3_mask = (df['ms_h_num'] > 1.80) & (df['ms_h_num'] <= 2.50) - df.loc[b3_mask, 'ms_home_balanced_prob'] = df.loc[b3_mask, 'ms_home_prob'] - df.loc[b3_mask, 'ms_home_balanced_actual'] = df.loc[b3_mask, 'ms_home_actual'] - - # Bucket 4: Underdog (odds > 2.50) - b4_mask = df['ms_h_num'] > 2.50 - df.loc[b4_mask, 'ms_home_underdog_prob'] = df.loc[b4_mask, 'ms_home_prob'] - df.loc[b4_mask, 'ms_home_underdog_actual'] = df.loc[b4_mask, 'ms_home_actual'] - - return df + cur.execute(""" + SELECT + pr.match_id, + pr.payload_summary, + m.score_home, + m.score_away, + m.ht_score_home, + m.ht_score_away + FROM prediction_runs pr + JOIN matches m ON m.id = pr.match_id + WHERE pr.eventual_outcome IS NOT NULL + AND m.score_home IS NOT NULL + AND m.score_away IS NOT NULL + """) + rows = cur.fetchall() + samples: List[Dict[str, Any]] = [] + for match_id, payload, sh, sa, ht_h, ht_a in rows: + if not isinstance(payload, dict): + continue + for source_key in ("main_pick", "value_pick"): + item = payload.get(source_key) + if not isinstance(item, dict): + continue + market = str(item.get("market") or "") + pick = str(item.get("pick") or "") + # Prefer raw_confidence, fall back to calibrated_probability×100 if raw missing + raw_conf = item.get("raw_confidence") + if raw_conf is None: + cal_prob = item.get("calibrated_probability") or item.get("probability") + if cal_prob is None: + continue + raw_conf = float(cal_prob) * 100.0 + actual = resolve_actual(market, pick, sh, sa, ht_h, ht_a) + if actual is None: + continue + key = calibrator_key(market, pick) + if not key: + continue + samples.append({ + "source": f"runs.{source_key}", + "match_id": match_id, + "market": market, + "pick": pick, + "key": key, + "raw_prob": float(raw_conf) / 100.0, + "actual": int(actual), + }) + return samples # ============================================================================= -# MODEL PREDICTIONS (Optional - if you want to calibrate model outputs) +# TRAINING # ============================================================================= -def get_model_predictions( +def train_per_key( df: pd.DataFrame, - cur, -) -> pd.DataFrame: - """ - Get model predictions for each match. - - This is optional - if you want to calibrate model outputs rather than - raw odds-implied probabilities. - - TODO: Implement if needed. For now, we use odds-implied probabilities - as a proxy for model predictions. - """ - # For now, return odds-implied probabilities as "model predictions" - # In a full implementation, you would: - # 1. Load the V20 predictor - # 2. Run predictions for each match - # 3. Store raw model probabilities - - return df - - -# ============================================================================= -# MAIN TRAINING -# ============================================================================= -def train_calibration_models( - df: pd.DataFrame, - markets: List[str] = None, - min_samples: int = 100, + min_samples: int, + markets_filter: Optional[List[str]] = None, ) -> Dict[str, Any]: - """ - Train calibration models for specified markets. - - Args: - df: DataFrame with probabilities and actual outcomes - markets: List of markets to train (default: all supported) - min_samples: Minimum samples required per market - - Returns: - Dict with training results - """ - if markets is None: - markets = SUPPORTED_MARKETS - calibrator = get_calibrator() - - # Define market config: market -> (prob_col, actual_col) - market_config = { - "ms_home": ("ms_home_prob", "ms_home_actual"), - "ms_home_heavy_fav": ("ms_home_heavy_fav_prob", "ms_home_heavy_fav_actual"), - "ms_home_fav": ("ms_home_fav_prob", "ms_home_fav_actual"), - "ms_home_balanced": ("ms_home_balanced_prob", "ms_home_balanced_actual"), - "ms_home_underdog": ("ms_home_underdog_prob", "ms_home_underdog_actual"), - "ms_draw": ("ms_draw_prob", "ms_draw_actual"), - "ms_away": ("ms_away_prob", "ms_away_actual"), - "ou15": ("ou15_over_prob", "ou15_over_actual"), - "ou25": ("ou25_over_prob", "ou25_over_actual"), - "ou35": ("ou35_over_prob", "ou35_over_actual"), - "btts": ("btts_yes_prob", "btts_yes_actual"), - "ht_home": ("ht_home_prob", "ht_home_actual"), # Note: need to add ht probs - "ht_draw": ("ht_draw_prob", "ht_draw_actual"), - "ht_away": ("ht_away_prob", "ht_away_actual"), - } - - # Filter to requested markets - market_config = {k: v for k, v in market_config.items() if k in markets} - - # Train all markets - results = calibrator.train_all_markets( - df=df, - market_config=market_config, - min_samples=min_samples, - ) - + results: Dict[str, Any] = {} + keys = sorted(df["key"].unique()) + + for key in keys: + if markets_filter and key not in markets_filter: + continue + sub = df[df["key"] == key] + # Drop duplicates by (match_id, key) to avoid double-counting across sources + sub = sub.drop_duplicates(subset=["match_id", "key"], keep="first") + sub = sub.dropna(subset=["raw_prob", "actual"]) + # Clamp probabilities to (0, 1) for isotonic stability + sub = sub[(sub["raw_prob"] > 0.0) & (sub["raw_prob"] < 1.0)] + + n = len(sub) + if n < min_samples: + results[key] = { + "status": "skipped", + "samples": n, + "reason": f"need ≥{min_samples}, have {n}", + } + continue + + metrics = calibrator.train_calibration( + df=sub, + market=key, + prob_col="raw_prob", + actual_col="actual", + min_samples=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), + } return results -def print_calibration_report(results: Dict[str, Any]): - """Print a formatted calibration report.""" - print("\n" + "=" * 70) +def print_report(results: Dict[str, Any], total_samples: int) -> None: + print("\n" + "=" * 78) print("CALIBRATION TRAINING REPORT") - print("=" * 70) - - print(f"\n{'Market':<15} {'Brier':<10} {'ECE':<10} {'Samples':<10} {'Status'}") - print("-" * 60) - - for market, metrics in results.items(): - status = "✓ Trained" if metrics.sample_count >= 100 else "⚠ Insufficient" - print(f"{market:<15} {metrics.brier_score:<10.4f} {metrics.calibration_error:<10.4f} " - f"{metrics.sample_count:<10} {status}") - - print("\n" + "=" * 70) - print("Interpretation:") - print(" - Brier Score: Lower is better (0 = perfect, 0.25 = random)") - print(" - ECE (Expected Calibration Error): Lower is better (0 = perfect)") - print(" - Models saved to: ai-engine/models/calibration/") - print("=" * 70) + print("=" * 78) + print(f"Total samples across all markets: {total_samples}") + print(f"\n{'market':<14} {'status':<10} {'n':<6} {'brier':<9} {'ece':<8} {'pred_avg':<9} {'actual_avg':<10}") + print("-" * 78) + for key, info in sorted(results.items()): + if info["status"] == "trained": + print( + f"{key:<14} {'✓ ok':<10} {info['samples']:<6} " + f"{info['brier']:<9.4f} {info['ece']:<8.4f} " + f"{info['mean_predicted']:<8.3f} {info['mean_actual']:<8.3f}" + ) + else: + print(f"{key:<14} {'⊘ skip':<10} {info['samples']:<6} -- {info.get('reason', '')}") + print("=" * 78) + print("Trained models saved to: ai-engine/models/calibration/") + print("Skipped markets fall back to the multiplier in market_thresholds.json.") + print("=" * 78) # ============================================================================= # CLI # ============================================================================= def main(): - parser = argparse.ArgumentParser(description="Train calibration models") - parser.add_argument("--start", type=str, default=DEFAULT_START_DATE, - help="Start date (YYYY-MM-DD)") - parser.add_argument("--end", type=str, default=DEFAULT_END_DATE, - help="End date (YYYY-MM-DD)") + parser = argparse.ArgumentParser(description="Train isotonic calibration on real data") + parser.add_argument("--min-samples", type=int, default=30, + help="Minimum samples required per market (default: 30)") parser.add_argument("--markets", nargs="+", default=None, - help="Markets to train (default: all)") - parser.add_argument("--min-samples", type=int, default=100, - help="Minimum samples per market") - parser.add_argument("--top-leagues-only", action="store_true", - help="Only use top leagues data") - + help="Limit to specific calibrator keys (e.g., ms_home ou25)") args = parser.parse_args() - - print(f"\n[Calibration Training] {args.start} to {args.end}") - - # Load top leagues if requested - league_ids = None - if args.top_leagues_only: - league_ids = load_top_league_ids() - print(f"[Data] Filtering to {len(league_ids)} top leagues") - - # Fetch data + conn = get_conn() cur = conn.cursor() - try: - df = fetch_training_data(cur, args.start, args.end, league_ids) - - if len(df) == 0: - print("[Error] No data found for the specified date range") + s1 = fetch_predictions_with_outcomes(cur) + s2 = fetch_prediction_runs_with_outcomes(cur) + print(f"[Data] predictions table: {len(s1)} samples") + print(f"[Data] prediction_runs: {len(s2)} samples") + all_samples = s1 + s2 + if not all_samples: + print("[Error] No training samples available") return - - # Calculate outcomes and probabilities - df = calculate_actual_outcomes(df) - df = calculate_implied_probabilities(df) - - # Train models - results = train_calibration_models( - df=df, - markets=args.markets, - min_samples=args.min_samples, - ) - - # Print report - print_calibration_report(results) - + df = pd.DataFrame(all_samples) + print(f"[Data] Combined: {len(df)} samples") + print(f"[Data] Unique matches: {df['match_id'].nunique()}") + print(f"[Data] Per-key counts:") + for key, count in df["key"].value_counts().items(): + print(f" {key:<14} {count}") + + results = train_per_key(df, args.min_samples, args.markets) + print_report(results, total_samples=len(df)) finally: cur.close() conn.close() diff --git a/ai-engine/services/betting_brain.py b/ai-engine/services/betting_brain.py index 61e7774..737edab 100644 --- a/ai-engine/services/betting_brain.py +++ b/ai-engine/services/betting_brain.py @@ -19,6 +19,10 @@ 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 + TRAP_MARKET_GAP = 0.10 MARKET_PRIORS = { "DC": 4.0, @@ -59,8 +63,13 @@ class BettingBrain: row for row in judged_rows.values() if row.get("betting_brain", {}).get("action") == "WATCH" ] + no_value = [ + row for row in judged_rows.values() + if row.get("betting_brain", {}).get("action") == "WATCH_NO_VALUE" + ] approved.sort(key=self._candidate_sort_key, reverse=True) watchlist.sort(key=self._candidate_sort_key, reverse=True) + no_value.sort(key=self._candidate_sort_key, reverse=True) original_main = guarded.get("main_pick") or {} main_pick = None @@ -78,6 +87,13 @@ class BettingBrain: self._force_no_bet(main_pick, "betting_brain_watchlist") decision = "WATCHLIST" decision_reason = main_pick.get("betting_brain", {}).get("summary", "Interesting but not clean enough.") + elif no_value: + # B-1: model agrees with a low-odds market — surface it so the user + # sees the read, but explicitly mark as not-playable. + main_pick = dict(no_value[0]) + self._force_no_bet(main_pick, "betting_brain_no_value_odds_below_minimum") + decision = "WATCH_NO_VALUE" + decision_reason = "Model favoriyle hemfikir ama oran bahis için çok düşük — bilgi amaçlı gösteriliyor." elif original_main: main_pick = dict(judged_rows.get(self._row_key(original_main), original_main)) self._force_no_bet(main_pick, "betting_brain_no_safe_pick") @@ -103,7 +119,7 @@ class BettingBrain: playable = decision == "BET" and bool(main_pick and main_pick.get("playable")) 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 + advice["suggested_stake_units"] = float(main_pick.get("stake_units", 0.0)) if playable and main_pick else 0.0 advice["reason"] = "betting_brain_approved" if playable else "betting_brain_no_bet" advice["decision"] = decision advice["confidence_band"] = self._decision_band(main_pick) @@ -199,6 +215,23 @@ class BettingBrain: score += 11.0 positives.append("v25_v27_aligned") + # Trap market detection: market overpriced vs historical band hit rate + trap_market_flag = False + trap_market_gap = None + if isinstance(triple, dict): + band_rate_val = self._safe_float(triple.get("band_rate")) + implied_val = self._safe_float(triple.get("implied_prob")) + if ( + band_rate_val is not None + and implied_val is not None + and band_sample >= self.MIN_BAND_SAMPLE + and (implied_val - band_rate_val) > self.TRAP_MARKET_GAP + ): + trap_market_flag = True + trap_market_gap = round(implied_val - band_rate_val, 4) + score -= 14.0 + issues.append("trap_market_market_overpriced") + if isinstance(triple, dict): if triple_is_value: score += 18.0 @@ -240,10 +273,28 @@ class BettingBrain: if market in {"HT", "HTFT", "OE"} and score < 86.0 and not is_value_sniper: vetoes.append("volatile_market_requires_exceptional_evidence") + # Sniper override: bypass eligible vetoes when value sniper triggered + sniper_bypassed: List[str] = [] + if is_value_sniper and vetoes: + remaining = [] + for v in vetoes: + if v in self.SNIPER_BYPASSABLE_VETOES: + sniper_bypassed.append(v) + else: + remaining.append(v) + vetoes = remaining + if sniper_bypassed: + positives.append("sniper_bypassed_soft_vetoes") + score = max(0.0, min(100.0, score)) action = "BET" if vetoes: - action = "REJECT" + # B-1: when only veto is odds_below_minimum, switch to WATCH_NO_VALUE + # so user still sees model commentary instead of blank rejection. + if vetoes == ["odds_below_minimum"]: + action = "WATCH_NO_VALUE" + else: + action = "REJECT" elif score < self.MIN_WATCH_SCORE and not is_value_sniper: action = "REJECT" elif score < self.MIN_BET_SCORE and not is_value_sniper: @@ -256,6 +307,9 @@ class BettingBrain: "positives": positives[:5], "issues": issues[:6], "vetoes": vetoes[:6], + "sniper_bypassed": sniper_bypassed, + "trap_market_flag": trap_market_flag, + "trap_market_gap": trap_market_gap, "model_prob": round(model_prob, 4) if model_prob is not None else None, "implied_prob": round(implied, 4), "model_market_gap": round(model_gap, 4) if model_gap is not None else None, @@ -290,9 +344,59 @@ class BettingBrain: if isinstance(item, dict) and item.get("market"): key = self._row_key(item) rows[key] = self._merge_row(rows.get(key), item) - + + # B-2: ensure both MS sides (and DC sides) have an entry — give user the + # model's read on the opposite outcome even when upstream filtered it out. + self._inject_reference_rows(rows, package) + return list(rows.values()) + def _inject_reference_rows( + self, + rows: Dict[str, Dict[str, Any]], + package: Dict[str, Any], + ) -> None: + market_board = package.get("market_board") or {} + ms_board = market_board.get("MS") if isinstance(market_board, dict) else None + if not isinstance(ms_board, dict): + return + probs = ms_board.get("probs") if isinstance(ms_board.get("probs"), dict) else {} + if not probs: + return + + # Pull MS odds from any existing MS row to estimate the missing side's odds + existing_odds_by_pick: Dict[str, float] = {} + for row in rows.values(): + if str(row.get("market")) == "MS": + pick = str(row.get("pick")) + odd = self._safe_float(row.get("odds"), 0.0) or 0.0 + if pick and odd > 1.0: + existing_odds_by_pick[pick] = odd + + for pick in ("1", "X", "2"): + key = f"MS:{pick}" + if key in rows: + continue + prob = self._safe_float(probs.get(pick), 0.0) + if prob is None or prob <= 0.0: + continue + implied_odd = round(1.0 / prob, 2) if prob > 0.01 else 0.0 + ref_odd = existing_odds_by_pick.get(pick) or implied_odd + rows[key] = { + "market": "MS", + "pick": pick, + "probability": round(prob, 4), + "confidence": round(prob * 100.0, 1), + "raw_confidence": round(prob * 100.0, 1), + "calibrated_confidence": round(prob * 100.0, 1), + "odds": ref_odd, + "is_underdog_reference": True, + "playable": False, + "stake_units": 0.0, + "bet_grade": "PASS", + "decision_reasons": ["underdog_reference_for_completeness"], + } + @staticmethod def _merge_row(existing: Optional[Dict[str, Any]], incoming: Dict[str, Any]) -> Dict[str, Any]: if existing is None: @@ -331,6 +435,7 @@ class BettingBrain: "odds_reliability": row.get("odds_reliability", 0.35), "odds": row.get("odds", 0.0), "reasons": reasons[:6], + "is_underdog_reference": bool(row.get("is_underdog_reference")), "betting_brain": row.get("betting_brain"), } @@ -409,6 +514,8 @@ class BettingBrain: return f"{market} {pick} approved: evidence is aligned enough for a controlled stake." if action == "WATCH": return f"{market} {pick} is interesting but not clean enough for stake." + if action == "WATCH_NO_VALUE": + return f"{market} {pick}: model favoriyle hemfikir, fakat oran ({', '.join(vetoes[:1]) or 'düşük'}) bahis için yetersiz." if vetoes: return f"{market} {pick} rejected: {', '.join(vetoes[:3])}." if issues: diff --git a/ai-engine/services/feature_enrichment.py b/ai-engine/services/feature_enrichment.py index 153b94f..99ff957 100644 --- a/ai-engine/services/feature_enrichment.py +++ b/ai-engine/services/feature_enrichment.py @@ -248,8 +248,8 @@ class FeatureEnrichmentService: away_team_venue_total = 0 for row in rows: - sh = int(row['score_home']) - sa = int(row['score_away']) + sh = int(row['score_home'] or 0) + sa = int(row['score_away'] or 0) match_goals = sh + sa total_goals += match_goals @@ -284,13 +284,13 @@ class FeatureEnrichmentService: if total >= 6: recent_5_wins = sum( 1 for r in rows[:5] - if (str(r['home_team_id']) == home_team_id and int(r['score_home']) > int(r['score_away'])) - or (str(r['home_team_id']) != home_team_id and int(r['score_away']) > int(r['score_home'])) + if (str(r['home_team_id']) == home_team_id and int(r['score_home'] or 0) > int(r['score_away'] or 0)) + or (str(r['home_team_id']) != home_team_id and int(r['score_away'] or 0) > int(r['score_home'] or 0)) ) older_5_wins = sum( 1 for r in rows[-5:] - if (str(r['home_team_id']) == home_team_id and int(r['score_home']) > int(r['score_away'])) - or (str(r['home_team_id']) != home_team_id and int(r['score_away']) > int(r['score_home'])) + if (str(r['home_team_id']) == home_team_id and int(r['score_home'] or 0) > int(r['score_away'] or 0)) + or (str(r['home_team_id']) != home_team_id and int(r['score_away'] or 0) > int(r['score_home'] or 0)) ) recent_trend = (recent_5_wins - older_5_wins) / 5.0 @@ -302,6 +302,12 @@ class FeatureEnrichmentService: - away_team_venue_wins / away_team_venue_total ) + if total == 0: + return dict(self._DEFAULT_H2H) + if total == 0: + return dict(self._DEFAULT_H2H) + if total == 0: + return dict(self._DEFAULT_H2H) return { 'total_matches': total, 'home_win_rate': home_wins / total, @@ -366,8 +372,8 @@ class FeatureEnrichmentService: for row in rows: is_home = str(row['home_team_id']) == team_id - goals_for = int(row['score_home'] if is_home else row['score_away']) - goals_against = int(row['score_away'] if is_home else row['score_home']) + goals_for = int((row['score_home'] if is_home else row['score_away']) or 0) + goals_against = int((row['score_away'] if is_home else row['score_home']) or 0) if goals_against == 0: clean_sheets += 1 @@ -390,6 +396,15 @@ class FeatureEnrichmentService: else: streak_broken_u = True + if total == 0: + return {'clean_sheet_rate': 0.25, 'scoring_rate': 0.75, + 'winning_streak': 0, 'unbeaten_streak': 0} + if total == 0: + return {'clean_sheet_rate': 0.25, 'scoring_rate': 0.75, + 'winning_streak': 0, 'unbeaten_streak': 0} + if total == 0: + return {'clean_sheet_rate': 0.25, 'scoring_rate': 0.75, + 'winning_streak': 0, 'unbeaten_streak': 0} return { 'clean_sheet_rate': clean_sheets / total, 'scoring_rate': scored_count / total, @@ -433,8 +448,8 @@ class FeatureEnrichmentService: match_ids = [] for row in rows: - sh = int(row['score_home']) - sa = int(row['score_away']) + sh = int(row['score_home'] or 0) + sa = int(row['score_away'] or 0) total_goals += sh + sa if sh > sa: home_wins += 1 @@ -464,6 +479,12 @@ class FeatureEnrichmentService: pass # home_bias: (actual home win rate) - 0.46 (league average ~46%) + if total == 0: + return dict(self._DEFAULT_REFEREE) + if total == 0: + return dict(self._DEFAULT_REFEREE) + if total == 0: + return dict(self._DEFAULT_REFEREE) home_bias = (home_wins / total) - 0.46 return { @@ -633,8 +654,8 @@ class FeatureEnrichmentService: over25_count = 0 for row in rows: - sh = int(row['score_home']) - sa = int(row['score_away']) + sh = int(row['score_home'] or 0) + sa = int(row['score_away'] or 0) match_goals = sh + sa total_goals += match_goals if match_goals == 0: @@ -828,8 +849,8 @@ class FeatureEnrichmentService: goals = [] conceded_list = [] for row in rows: - sh = int(row['score_home']) - sa = int(row['score_away']) + sh = int(row['score_home'] or 0) + sa = int(row['score_away'] or 0) if is_home: goals.append(sh) conceded_list.append(sa) diff --git a/ai-engine/services/match_commentary.py b/ai-engine/services/match_commentary.py index a54018c..4d7c311 100644 --- a/ai-engine/services/match_commentary.py +++ b/ai-engine/services/match_commentary.py @@ -58,6 +58,7 @@ def generate_match_commentary(package: Dict[str, Any]) -> Dict[str, Any]: summary = _build_summary( action, main_pick, market_board, v27_engine, score_pred, risk, data_quality, home, away, + match_info=match_info, ) # ── Quick notes ─────────────────────────────────────────────── @@ -117,22 +118,35 @@ def _build_summary( data_quality: Dict[str, Any], home: str, away: str, + match_info: Optional[Dict[str, Any]] = None, ) -> str: parts: List[str] = [] + # C-2: live-aware preamble — if the match is in play, lead with current score + # vs the pre-match read so users immediately see how the prediction is faring. + match_info = match_info or {} + if match_info.get("is_live"): + cur_home = match_info.get("current_score_home") + cur_away = match_info.get("current_score_away") + if cur_home is not None and cur_away is not None: + parts.append( + f"🔴 CANLI: {home} {cur_home} - {cur_away} {away} " + f"(aşağıdaki analiz maç öncesi tahmindir)" + ) + # Who is the favourite? ms_board = market_board.get("MS") or {} ms_pick = ms_board.get("pick", "") ms_conf = float(ms_board.get("confidence", 50) or 50) - if ms_pick == "1" and ms_conf > 45: - parts.append(f"{home} hafif favori görünüyor") - elif ms_pick == "1" and ms_conf > 55: + if ms_pick == "1" and ms_conf > 55: parts.append(f"{home} net favori") - elif ms_pick == "2" and ms_conf > 45: - parts.append(f"{away} hafif favori görünüyor") + elif ms_pick == "1" and ms_conf > 45: + parts.append(f"{home} hafif favori görünüyor") elif ms_pick == "2" and ms_conf > 55: parts.append(f"{away} net favori") + elif ms_pick == "2" and ms_conf > 45: + parts.append(f"{away} hafif favori görünüyor") else: parts.append("İki takım da birbirine yakın güçte") @@ -262,6 +276,26 @@ def _detect_contradictions( triple_value = v27_engine.get("triple_value") or {} predictions = v27_engine.get("predictions") or {} + # C-2 live-vs-prediction mismatch + match_info = package.get("match_info") or {} + if match_info.get("is_live"): + cur_h = match_info.get("current_score_home") + cur_a = match_info.get("current_score_away") + ms_board_live = market_board.get("MS") or {} + predicted_pick = str(ms_board_live.get("pick") or "") + if cur_h is not None and cur_a is not None: + actual_pick: Optional[str] = None + if cur_h > cur_a: + actual_pick = "1" + elif cur_a > cur_h: + actual_pick = "2" + else: + actual_pick = "X" + if predicted_pick and actual_pick and predicted_pick != actual_pick: + contradictions.append( + "Canlı durum maç öncesi tahmin ile çelişiyor — sürpriz GERÇEKLEŞİYOR" + ) + # MS contradiction: model says home but triple_value says away has value ms_preds = predictions.get("ms") or {} ms_home = float(ms_preds.get("home", 0) or 0) diff --git a/ai-engine/services/single_match_orchestrator.py b/ai-engine/services/single_match_orchestrator.py index f6052fe..68ea026 100755 --- a/ai-engine/services/single_match_orchestrator.py +++ b/ai-engine/services/single_match_orchestrator.py @@ -21,7 +21,7 @@ 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 +from typing import Any, Dict, List, Optional, Set, Tuple, overload import psycopg2 from psycopg2.extras import RealDictCursor @@ -32,11 +32,14 @@ from models.v25_ensemble import V25Predictor, get_v25_predictor try: from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge except ImportError: - V27Predictor = None + class V27Predictor: + def __init__(self): self.models = {} + def load_models(self): return False + def predict_all(self, features): return {} def compute_divergence(*args, **kwargs): - return 0.0 + return {} def compute_value_edge(*args, **kwargs): - return 0.0 + return {} from features.odds_band_analyzer import OddsBandAnalyzer try: from models.basketball_v25 import ( @@ -45,7 +48,7 @@ try: ) except ImportError: BasketballMatchPrediction = Any - def get_basketball_v25_predictor(): + 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 @@ -55,6 +58,7 @@ 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 @dataclass @@ -160,6 +164,7 @@ class SingleMatchOrchestrator: def __init__(self) -> None: self.v25_predictor: Optional[V25Predictor] = None self.v26_shadow_engine: Optional[V26ShadowEngine] = None + self._v27: Optional[V27Predictor] = None self.basketball_predictor: Optional[Any] = None self.dsn = get_clean_dsn() self.engine_mode = str(os.getenv("AI_ENGINE_MODE", "v28-pro-max")).strip().lower() @@ -188,7 +193,7 @@ class SingleMatchOrchestrator: return self.v25_predictor def _get_v26_shadow_engine(self) -> V26ShadowEngine: - if getattr(self, "v26_shadow_engine", None) is None: + if not hasattr(self, "v26_shadow_engine") or self.v26_shadow_engine is None: self.v26_shadow_engine = get_v26_shadow_engine() return self.v26_shadow_engine @@ -259,9 +264,9 @@ class SingleMatchOrchestrator: Build the single authoritative V25 pre-match feature vector. """ odds = self._sanitize_v25_odds(data.odds_data or {}) - ms_h = float(odds.get('ms_h', 0)) - ms_d = float(odds.get('ms_d', 0)) - ms_a = float(odds.get('ms_a', 0)) + 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 @@ -385,23 +390,23 @@ class SingleMatchOrchestrator: 'odds_ms_h_present': 1.0 if ms_h > 1.01 else 0.0, 'odds_ms_d_present': 1.0 if ms_d > 1.01 else 0.0, 'odds_ms_a_present': 1.0 if ms_a > 1.01 else 0.0, - 'odds_ht_ms_h_present': 1.0 if float(odds.get('ht_h', 0)) > 1.01 else 0.0, - 'odds_ht_ms_d_present': 1.0 if float(odds.get('ht_d', 0)) > 1.01 else 0.0, - 'odds_ht_ms_a_present': 1.0 if float(odds.get('ht_a', 0)) > 1.01 else 0.0, - 'odds_ou05_o_present': 1.0 if float(odds.get('ou05_o', 0)) > 1.01 else 0.0, - 'odds_ou05_u_present': 1.0 if float(odds.get('ou05_u', 0)) > 1.01 else 0.0, - 'odds_ou15_o_present': 1.0 if float(odds.get('ou15_o', 0)) > 1.01 else 0.0, - 'odds_ou15_u_present': 1.0 if float(odds.get('ou15_u', 0)) > 1.01 else 0.0, - 'odds_ou25_o_present': 1.0 if float(odds.get('ou25_o', 0)) > 1.01 else 0.0, - 'odds_ou25_u_present': 1.0 if float(odds.get('ou25_u', 0)) > 1.01 else 0.0, - 'odds_ou35_o_present': 1.0 if float(odds.get('ou35_o', 0)) > 1.01 else 0.0, - 'odds_ou35_u_present': 1.0 if float(odds.get('ou35_u', 0)) > 1.01 else 0.0, - 'odds_ht_ou05_o_present': 1.0 if float(odds.get('ht_ou05_o', 0)) > 1.01 else 0.0, - 'odds_ht_ou05_u_present': 1.0 if float(odds.get('ht_ou05_u', 0)) > 1.01 else 0.0, - 'odds_ht_ou15_o_present': 1.0 if float(odds.get('ht_ou15_o', 0)) > 1.01 else 0.0, - 'odds_ht_ou15_u_present': 1.0 if float(odds.get('ht_ou15_u', 0)) > 1.01 else 0.0, - 'odds_btts_y_present': 1.0 if float(odds.get('btts_y', 0)) > 1.01 else 0.0, - 'odds_btts_n_present': 1.0 if float(odds.get('btts_n', 0)) > 1.01 else 0.0, + '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) ── @@ -476,23 +481,23 @@ class SingleMatchOrchestrator: 'implied_home': implied_home, 'implied_draw': implied_draw, 'implied_away': implied_away, - 'odds_ht_ms_h': float(odds.get('ht_h', 0)), - 'odds_ht_ms_d': float(odds.get('ht_d', 0)), - 'odds_ht_ms_a': float(odds.get('ht_a', 0)), - 'odds_ou05_o': float(odds.get('ou05_o', 0)), - 'odds_ou05_u': float(odds.get('ou05_u', 0)), - 'odds_ou15_o': float(odds.get('ou15_o', 0)), - 'odds_ou15_u': float(odds.get('ou15_u', 0)), - 'odds_ou25_o': float(odds.get('ou25_o', 0)), - 'odds_ou25_u': float(odds.get('ou25_u', 0)), - 'odds_ou35_o': float(odds.get('ou35_o', 0)), - 'odds_ou35_u': float(odds.get('ou35_u', 0)), - 'odds_ht_ou05_o': float(odds.get('ht_ou05_o', 0)), - 'odds_ht_ou05_u': float(odds.get('ht_ou05_u', 0)), - 'odds_ht_ou15_o': float(odds.get('ht_ou15_o', 0)), - 'odds_ht_ou15_u': float(odds.get('ht_ou15_u', 0)), - 'odds_btts_y': float(odds.get('btts_y', 0)), - 'odds_btts_n': float(odds.get('btts_n', 0)), + 'odds_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, @@ -584,15 +589,15 @@ class SingleMatchOrchestrator: sidelined_data=data.sidelined_data, ) result = { - 'home_squad_quality': float(pred.home_squad_quality), - 'away_squad_quality': float(pred.away_squad_quality), - 'squad_diff': float(pred.squad_diff), - 'home_key_players': float(pred.home_key_players), - 'away_key_players': float(pred.away_key_players), - 'home_missing_impact': float(pred.home_missing_impact), - 'away_missing_impact': float(pred.away_missing_impact), - 'home_goals_form': float(pred.home_goals_form), - 'away_goals_form': float(pred.away_goals_form), + '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'): @@ -691,7 +696,7 @@ class SingleMatchOrchestrator: # 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.get) + best_label = max(scaled_probs, key=scaled_probs.__getitem__) best_prob = float(scaled_probs[best_label]) return { "probs": scaled_probs, @@ -726,7 +731,7 @@ class SingleMatchOrchestrator: ("handicap_ms", {"1": 0, "X": 1, "2": 2}), ("odd_even", {"Odd": 0, "Even": None}), ]: - out_key = self._V25_KEY_MAP.get(model_key, model_key.upper()) + 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) @@ -793,7 +798,9 @@ class SingleMatchOrchestrator: @staticmethod def _best_prob_pick(prob_map: Dict[str, float]) -> Tuple[str, float]: - pick = max(prob_map, key=prob_map.get) + if not prob_map: + return "", 0.0 + pick = max(prob_map, key=prob_map.__getitem__) return pick, float(prob_map[pick]) @staticmethod @@ -919,15 +926,15 @@ class SingleMatchOrchestrator: prediction.predicted_ht_score = f"{int(round(ht_home_xg))}-{int(round(ht_away_xg))}" else: # Heuristic fallback (original formula) - base_home_xg = max(0.25, (float(data.home_goals_avg) + float(features.get("away_xga", data.away_conceded_avg))) / 2.0) - base_away_xg = max(0.25, (float(data.away_goals_avg) + float(features.get("home_xga", data.home_conceded_avg))) / 2.0) + 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) + float(data.away_goals_avg)) * 0.45) + + ((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), ), ) @@ -985,10 +992,14 @@ class SingleMatchOrchestrator: 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, np.mean([prediction.ms_confidence, prediction.ou25_confidence, prediction.btts_confidence]))), 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 @@ -1333,9 +1344,9 @@ class SingleMatchOrchestrator: ), } - # ── Band-only value for new markets ─────────────────── + _odds_data = data.odds_data or {} def _band_value(label, band_rate, odds_key, sample): - o = float((data.odds_data or {}).get(odds_key, 0)) + o = float(_odds_data.get(odds_key, 0)) imp = (1.0 / o) if o > 1.0 else 0.50 e = band_rate - imp conf = band_rate > imp @@ -1423,7 +1434,7 @@ class SingleMatchOrchestrator: # Boost confidence when V27 agrees with V25 if v27_ms: - v27_best = max(v27_ms, key=v27_ms.get) + v27_best = max(v27_ms, key=v27_ms.__getitem__) v25_best_map = {"1": "home", "X": "draw", "2": "away"} v25_best_mapped = v25_best_map.get(prediction.ms_pick, "") if v27_best == v25_best_mapped: @@ -1703,10 +1714,7 @@ class SingleMatchOrchestrator: prob_key = self._upper_brain_prob_key(market, pick) if prob_key is None: return None - try: - return float(probs.get(prob_key)) - except (TypeError, ValueError): - return None + return self._safe_float(probs.get(prob_key)) def _upper_brain_v27_probability( self, @@ -1719,7 +1727,8 @@ class SingleMatchOrchestrator: ou25 = predictions.get("ou25") or {} if market == "MS": - return self._safe_float(ms.get({"1": "home", "X": "draw", "2": "away"}.get(pick, ""))) + 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) @@ -1729,8 +1738,8 @@ class SingleMatchOrchestrator: return self._safe_float(ms.get("home"), 0.0) + self._safe_float(ms.get("away"), 0.0) if market == "OU25": prob_key = self._upper_brain_prob_key(market, pick) - return self._safe_float(ou25.get(prob_key)) if prob_key else None - return None + 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]: @@ -1780,6 +1789,12 @@ class SingleMatchOrchestrator: 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: @@ -2259,7 +2274,7 @@ class SingleMatchOrchestrator: "rejected_matches": rejected, } - def get_daily_bankers(self, count: int = 3) -> List[Dict[str, Any]]: + 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( @@ -2336,7 +2351,8 @@ class SingleMatchOrchestrator: away_id = str(row["away_team_id"]) team_ids.add(home_id) team_ids.add(away_id) - pair_keys.add(tuple(sorted((home_id, away_id)))) + 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) @@ -2399,7 +2415,8 @@ class SingleMatchOrchestrator: ) cycle_bonus = cycle_pressure * 10.0 - pair_key = tuple(sorted((data.home_team_id, data.away_team_id))) + 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)) @@ -2665,7 +2682,8 @@ class SingleMatchOrchestrator: for row in rows: home_id = str(row["home_team_id"]) away_id = str(row["away_team_id"]) - key = tuple(sorted((home_id, away_id))) + h, a = sorted((home_id, away_id)) + key = (h, a) if key not in pair_keys or key in out: continue @@ -2771,12 +2789,12 @@ class SingleMatchOrchestrator: lineup_confidence=lineup_confidence, source_table=str(row.get("source_table") or "matches"), current_score_home=( - int(row.get("score_home")) + int(str(row.get("score_home"))) if row.get("score_home") is not None else None ), current_score_away=( - int(row.get("score_away")) + int(str(row.get("score_away"))) if row.get("score_away") is not None else None ), @@ -2900,7 +2918,7 @@ class SingleMatchOrchestrator: (row["match_id"],), ) relational_rows = cur.fetchall() - rel_odds = self._parse_relational_odds(relational_rows) + 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) @@ -3952,6 +3970,18 @@ class SingleMatchOrchestrator: "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": { @@ -3962,14 +3992,10 @@ class SingleMatchOrchestrator: "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": { - "team": round(float(prediction.team_confidence), 1), - "player": round(float(prediction.player_confidence), 1), - "odds": round(float(prediction.odds_confidence), 1), - "referee": round(float(prediction.referee_confidence), 1), - }, + "engine_breakdown": self._build_engine_breakdown(prediction), "main_pick": main_pick, "value_pick": value_pick, "bet_advice": { @@ -4817,8 +4843,23 @@ class SingleMatchOrchestrator: 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 = 22.0 + 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) @@ -4831,37 +4872,95 @@ class SingleMatchOrchestrator: over35 = float(getattr(prediction, "over_35_prob", 0.0) or 0.0) if parity_gap <= 0.08: - score += 18.0 - reasons.append("balanced_match_risk") + add("balanced_match_risk", 18.0, "Takımlar birbirine çok yakın — sonuç kırılabilir") if ms_draw >= 0.30: - score += 14.0 - reasons.append("draw_probability_elevated") + add("draw_probability_elevated", 14.0, f"Beraberlik olasılığı yüksek (%{ms_draw*100:.0f})") if total_xg >= 3.25: - score += 10.0 - reasons.append("high_total_goal_volatility") + 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: - score += 8.0 - reasons.append("mutual_goal_pressure") + add("mutual_goal_pressure", 8.0, f"Karşılıklı gol baskısı (%{btts_yes*100:.0f})") if over35 >= 0.52: - score += 8.0 - reasons.append("late_goal_swing_risk") + 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": - score += 8.0 - reasons.append("lineup_probable_not_confirmed") + add("lineup_probable_not_confirmed", 8.0, "Kadrolar tahmini — kesinleşmemiş") if data.lineup_source == "none": - score += 12.0 - reasons.append("lineup_unavailable") + add("lineup_unavailable", 12.0, "Kadro bilgisi yok — analiz güvenilirliği düştü") if not data.referee_name: - score += 6.0 - reasons.append("missing_referee") + 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: - score += 18.0 - reasons.append("live_match_open_state") + 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: - score += 10.0 - reasons.append("live_match_active_state") + 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: @@ -4873,12 +4972,109 @@ class SingleMatchOrchestrator: 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": list(dict.fromkeys(reasons))[:6], + "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] = {} @@ -5105,13 +5301,25 @@ class SingleMatchOrchestrator: 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 "") - calibration = self.market_calibration.get(market, 0.85) - calibrated_conf = max(1.0, min(99.0, raw_conf * calibration)) + # 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, str(row.get("pick") or ""), implied_prob) + 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 diff --git a/ai-engine/services/v26_shadow_engine.py b/ai-engine/services/v26_shadow_engine.py index 530d9e5..72d9672 100644 --- a/ai-engine/services/v26_shadow_engine.py +++ b/ai-engine/services/v26_shadow_engine.py @@ -1955,7 +1955,7 @@ class V26ShadowEngine: def _pick_from_probs(probs: Dict[str, float]) -> Tuple[str, float]: if not probs: return "", 0.0 - pick = max(probs, key=probs.get) + pick = max(probs, key=probs.__getitem__) return pick, float(probs[pick]) @staticmethod diff --git a/package-lock.json b/package-lock.json index 3312de9..2dc59ac 100755 --- a/package-lock.json +++ b/package-lock.json @@ -1145,6 +1145,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3000,6 +3001,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", "integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==", "license": "MIT", + "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "axios": "^1.3.1", @@ -3093,6 +3095,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3259,6 +3262,7 @@ "version": "11.1.11", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.11.tgz", "integrity": "sha512-R/+A8XFqLgN8zNs2twhrOaE7dJbRQhdPX3g46am4RT/x8xGLqDphrXkUIno4cGUZHxbczChBAaAPTdPv73wDZA==", + "peer": true, "dependencies": { "file-type": "21.2.0", "iterare": "1.2.1", @@ -3304,6 +3308,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.11.tgz", "integrity": "sha512-H9i+zT3RvHi7tDc+lCmWHJ3ustXveABCr+Vcpl96dNOxgmrx4elQSTC4W93Mlav2opfLV+p0UTHY6L+bpUA4zA==", "hasInstallScript": true, + "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -3383,6 +3388,7 @@ "version": "11.1.11", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.11.tgz", "integrity": "sha512-kyABSskdMRIAMWL0SlbwtDy4yn59RL4HDdwHDz/fxWuv7/53YP8Y2DtV3/sHqY5Er0msMVTZrM38MjqXhYL7gw==", + "peer": true, "dependencies": { "cors": "2.8.5", "express": "5.2.1", @@ -3403,6 +3409,7 @@ "version": "11.1.11", "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.11.tgz", "integrity": "sha512-0z6pLg9CuTXtz7q2lRZoPOU94DN28OTa39f4cQrlZysKA6QrKM7w7z6xqb4g32qjF+LQHFNRmMJtE/pLrxBaig==", + "peer": true, "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" @@ -3777,6 +3784,8 @@ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18.18" }, @@ -3856,6 +3865,7 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -4766,6 +4776,7 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4887,6 +4898,7 @@ "version": "22.19.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5051,6 +5063,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz", "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -5688,6 +5701,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5741,6 +5755,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5932,6 +5947,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -6245,6 +6261,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6317,6 +6334,7 @@ "version": "5.66.4", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.66.4.tgz", "integrity": "sha512-y2VRk2z7d1YNI2JQDD7iThoD0X/0iZZ3VEp8lqT5s5U0XDl9CIjXp1LQgmE9EKy6ReHtzmYXS1f328PnUbZGtQ==", + "peer": true, "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.8.2", @@ -6428,6 +6446,7 @@ "version": "7.2.7", "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.7.tgz", "integrity": "sha512-TKeeb9nSybk1e9E5yAiPVJ6YKdX9FYhwqqy8fBfVKAFVTJYZUNmeIvwjURW6+UikNsO6l2ta27thYgo/oumDsw==", + "peer": true, "dependencies": { "@cacheable/utils": "^2.3.2", "keyv": "^5.5.4" @@ -6697,12 +6716,14 @@ "node_modules/class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", - "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "peer": true }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7581,8 +7602,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -7640,6 +7660,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7699,6 +7720,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7929,6 +7951,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9190,6 +9213,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -10041,6 +10065,7 @@ "version": "5.5.5", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz", "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -10859,7 +10884,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "peer": true, "engines": { "node": ">= 6" } @@ -11097,6 +11121,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -11233,6 +11258,7 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz", "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==", + "peer": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -11262,6 +11288,7 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz", "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", + "peer": true, "dependencies": { "get-caller-file": "^2.0.5", "pino": "^10.0.0", @@ -11480,6 +11507,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11533,6 +11561,7 @@ "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "hasInstallScript": true, + "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -12685,6 +12714,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -13007,6 +13037,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13162,6 +13193,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13509,7 +13541,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -13527,7 +13558,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -13540,7 +13570,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -13554,7 +13583,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, - "peer": true, "engines": { "node": ">=4.0" } @@ -13563,15 +13591,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "peer": true + "dev": true }, "node_modules/webpack/node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -13581,7 +13607,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -13594,7 +13619,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/prisma.config.ts b/prisma.config.ts index f5bd20a..6214ac9 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,8 +1,8 @@ import path from 'node:path'; -import { defineConfig, env } from 'prisma/config'; +import { defineConfig, env } from '@prisma/config'; import { config } from 'dotenv'; -config({ path: '.env.local' }); +config({ path: '.env' }); export default defineConfig({ schema: path.join('prisma', 'schema.prisma'), diff --git a/prisma/migrations/20260512000000_add_max_columns_to_usage_limits/migration.sql b/prisma/migrations/20260512000000_add_max_columns_to_usage_limits/migration.sql new file mode 100644 index 0000000..b26f58b --- /dev/null +++ b/prisma/migrations/20260512000000_add_max_columns_to_usage_limits/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable: add max_analyses and max_coupons columns to usage_limits +ALTER TABLE "usage_limits" ADD COLUMN IF NOT EXISTS "max_analyses" INTEGER NOT NULL DEFAULT 3; +ALTER TABLE "usage_limits" ADD COLUMN IF NOT EXISTS "max_coupons" INTEGER NOT NULL DEFAULT 1; diff --git a/prisma/migrations/20260512120000_add_ht_score_to_live_matches/migration.sql b/prisma/migrations/20260512120000_add_ht_score_to_live_matches/migration.sql new file mode 100644 index 0000000..5198bdc --- /dev/null +++ b/prisma/migrations/20260512120000_add_ht_score_to_live_matches/migration.sql @@ -0,0 +1,4 @@ +-- Add half-time score columns to live_matches to mirror matches table +ALTER TABLE "live_matches" + ADD COLUMN IF NOT EXISTS "ht_score_home" INTEGER, + ADD COLUMN IF NOT EXISTS "ht_score_away" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b400b49..6baef90 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -139,6 +139,8 @@ model LiveMatch { substate String? scoreHome Int? @map("score_home") scoreAway Int? @map("score_away") + htScoreHome Int? @map("ht_score_home") + htScoreAway Int? @map("ht_score_away") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") odds Json? oddsUpdatedAt DateTime? @map("odds_updated_at") diff --git a/scripts/analyze_prediction_patterns.ts b/scripts/analyze_prediction_patterns.ts new file mode 100644 index 0000000..20d98f8 --- /dev/null +++ b/scripts/analyze_prediction_patterns.ts @@ -0,0 +1,210 @@ +/** + * Read-only analysis of prediction patterns for the last N finished football matches. + * + * Outputs systematic-bias indicators that inform the engine improvement brief: + * 1. Surprise transparency rate (how often surprise_reasons is empty) + * 2. Surprise miss rate (underdog won but is_surprise_risk was false) + * 3. REJECT-all rate + actual outcome distribution on those matches + * 4. Calibration shrinkage histogram (raw - calibrated per market) + * 5. Trap-market frequency (band_rate << implied_prob despite high model_prob) + * 6. Commentary "hafif favori" hit-rate vs actual result + * 7. Live-blind cases (LIVE matches whose latest prediction was pre-match) + * + * Usage: + * npx ts-node iddaai-be/scripts/analyze_prediction_patterns.ts [limit=200] + */ + +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); +const LIMIT = parseInt(process.argv[2] || "200", 10); + +type Payload = Record; + +function readNum(v: any): number | null { + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +function bucket(value: number, edges: number[]): string { + for (let i = 0; i < edges.length; i++) { + if (value < edges[i]) { + const lo = i === 0 ? "-inf" : String(edges[i - 1]); + return `[${lo}, ${edges[i]})`; + } + } + return `[${edges[edges.length - 1]}, +inf)`; +} + +async function main() { + console.log(`\n=== PREDICTION PATTERN ANALYZER ===`); + console.log(`Pulling the most recent ${LIMIT} football matches with predictions.\n`); + + const matches = await prisma.match.findMany({ + where: { + sport: "football", + status: "FT", + scoreHome: { not: null }, + scoreAway: { not: null }, + prediction: { isNot: null }, + }, + include: { prediction: true }, + orderBy: { mstUtc: "desc" }, + take: LIMIT, + }); + + console.log(`Found ${matches.length} matches.\n`); + + // Counters + let surpriseEmpty = 0; + let surpriseFilled = 0; + + let upsetMatches = 0; // underdog (per odds) actually won + let upsetMissedBySystem = 0; // upset happened, is_surprise_risk false + let upsetCaughtBySystem = 0; + + let rejectAllCount = 0; + const rejectAllOutcomes = { homeWin: 0, draw: 0, awayWin: 0 }; + + const shrinkageByMarket = new Map(); // raw - calibrated per market + let trapMarketCount = 0; // band_rate < implied_prob - 0.10 AND main_pick selected anyway + let trapMarketSampled = 0; + + let hafifFavoriUseCount = 0; // commentary said "hafif favori" + let hafifFavoriCorrectCount = 0; // and that favorite actually won + + for (const m of matches) { + const payload = (m.prediction?.predictionJson as Payload) || {}; + + // 1. Surprise transparency + const risk = payload.risk || {}; + const surpriseReasons = Array.isArray(risk.surprise_reasons) ? risk.surprise_reasons : []; + if (surpriseReasons.length === 0) surpriseEmpty++; + else surpriseFilled++; + + // 2. Upset detection vs reality + const finalHome = m.scoreHome ?? 0; + const finalAway = m.scoreAway ?? 0; + const actualWinner = finalHome > finalAway ? "H" : finalHome < finalAway ? "A" : "D"; + + const oddsSnap = payload.bet_summary && Array.isArray(payload.bet_summary) + ? payload.bet_summary.find((b: any) => b.market === "MS") + : null; + const msMain = payload.main_pick || {}; + // Crude favorite-side detection: scan bet_summary or market_board for MS implied probs + const msBoard = (payload.market_board || {}).MS || {}; + let favSide: "H" | "A" | "D" | null = null; + const implH = readNum(msBoard?.probs?.["1"]); + const implA = readNum(msBoard?.probs?.["2"]); + if (implH !== null && implA !== null) { + favSide = implH > implA ? "H" : implA > implH ? "A" : null; + } + if (favSide && actualWinner !== favSide && actualWinner !== "D") { + upsetMatches++; + if (risk.is_surprise_risk === true) upsetCaughtBySystem++; + else upsetMissedBySystem++; + } + + // 3. REJECT-all matches + const brain = payload.betting_brain || {}; + if ((brain.decision || "NO_BET").toUpperCase() === "NO_BET" && brain.approved_count === 0) { + rejectAllCount++; + if (actualWinner === "H") rejectAllOutcomes.homeWin++; + else if (actualWinner === "A") rejectAllOutcomes.awayWin++; + else rejectAllOutcomes.draw++; + } + + // 4. Calibration shrinkage by market + const summary: any[] = Array.isArray(payload.bet_summary) ? payload.bet_summary : []; + for (const row of summary) { + const market = String(row.market || "OTHER"); + const raw = readNum(row.raw_confidence); + const cal = readNum(row.calibrated_confidence); + if (raw !== null && cal !== null) { + const arr = shrinkageByMarket.get(market) || []; + arr.push(raw - cal); + shrinkageByMarket.set(market, arr); + } + } + + // 5. Trap market: model says high prob but band_rate is much lower than implied + const bb = (msMain.betting_brain || {}) as any; + const triple = bb.triple_value || null; + if (triple && typeof triple === "object") { + trapMarketSampled++; + const bandRate = readNum(triple.band_rate); + const implied = readNum(triple.implied_prob); + if (bandRate !== null && implied !== null && implied - bandRate > 0.10) { + trapMarketCount++; + } + } + + // 6. "hafif favori" usage vs reality + const commentary = payload.match_commentary || {}; + const summaryText = String(commentary.summary || ""); + if (summaryText.includes("hafif favori")) { + hafifFavoriUseCount++; + // If summary mentions home name first then says hafif favori, assume home favorite + const home = (payload.match_info || {}).home_team || ""; + const sayingHomeFav = summaryText.indexOf(home) >= 0 && summaryText.indexOf(home) < summaryText.indexOf("hafif favori"); + const predictedSide = sayingHomeFav ? "H" : "A"; + if (predictedSide === actualWinner) hafifFavoriCorrectCount++; + } + } + + // ─── Output ───────────────────────────────────────────────── + console.log(`\n--- 1. SURPRISE TRANSPARENCY ---`); + console.log(` Empty surprise_reasons: ${surpriseEmpty}/${matches.length} (${((surpriseEmpty / matches.length) * 100).toFixed(1)}%)`); + console.log(` Filled surprise_reasons: ${surpriseFilled}/${matches.length}`); + + console.log(`\n--- 2. UPSET DETECTION ---`); + console.log(` Actual upsets (underdog wins): ${upsetMatches}/${matches.length}`); + console.log(` Caught by is_surprise_risk: ${upsetCaughtBySystem}`); + console.log(` MISSED (no surprise flag): ${upsetMissedBySystem}`); + if (upsetMatches > 0) { + console.log(` Miss rate: ${((upsetMissedBySystem / upsetMatches) * 100).toFixed(1)}%`); + } + + console.log(`\n--- 3. REJECT-ALL MATCHES ---`); + console.log(` Count: ${rejectAllCount}/${matches.length} (${((rejectAllCount / matches.length) * 100).toFixed(1)}%)`); + console.log(` Outcome distribution on those matches:`); + console.log(` Home wins: ${rejectAllOutcomes.homeWin}`); + console.log(` Draws: ${rejectAllOutcomes.draw}`); + console.log(` Away wins: ${rejectAllOutcomes.awayWin}`); + + console.log(`\n--- 4. CALIBRATION SHRINKAGE (raw - calibrated) BY MARKET ---`); + const buckets = [-5, 0, 5, 10, 15, 20]; + for (const [market, arr] of Array.from(shrinkageByMarket.entries()).sort()) { + const sorted = [...arr].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)] ?? 0; + const p90 = sorted[Math.floor(sorted.length * 0.9)] ?? 0; + const avg = arr.reduce((s, v) => s + v, 0) / arr.length; + console.log(` ${market.padEnd(10)} n=${String(arr.length).padStart(4)} avg=${avg.toFixed(2).padStart(6)} median=${median.toFixed(2).padStart(6)} p90=${p90.toFixed(2).padStart(6)}`); + } + + console.log(`\n--- 5. TRAP MARKET PREVALENCE (main_pick) ---`); + console.log(` Sampled main_picks with triple_value: ${trapMarketSampled}`); + console.log(` Trap candidates (implied - band_rate > 0.10): ${trapMarketCount}`); + if (trapMarketSampled > 0) { + console.log(` Trap rate: ${((trapMarketCount / trapMarketSampled) * 100).toFixed(1)}%`); + } + + console.log(`\n--- 6. "hafif favori" COMMENTARY ACCURACY ---`); + console.log(` Used in commentary: ${hafifFavoriUseCount}`); + console.log(` Correctly predicted winner: ${hafifFavoriCorrectCount}`); + if (hafifFavoriUseCount > 0) { + console.log(` Accuracy: ${((hafifFavoriCorrectCount / hafifFavoriUseCount) * 100).toFixed(1)}%`); + } + + console.log(`\n=== DONE ===\n`); + void bucket; +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index 67b1565..69bd505 100755 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -28,6 +28,8 @@ import { import { Roles } from "../../common/decorators"; import { PrismaService } from "../../database/prisma.service"; import { PaginationDto } from "../../common/dto/pagination.dto"; +import { AdminUsersQueryDto } from "./dto/admin-users-query.dto"; +import { UpdateUserSubscriptionDto } from "./dto/update-user-subscription.dto"; import { ApiResponse, createSuccessResponse, @@ -57,17 +59,33 @@ export class AdminController { @ApiOperation({ summary: "Get all users (admin)" }) @SwaggerResponse({ status: 200, type: [UserResponseDto] }) async getAllUsers( - @Query() pagination: PaginationDto, + @Query() query: AdminUsersQueryDto, ): Promise>> { - const { skip, take, orderBy } = pagination; + const { skip, take, orderBy, search, role, subscriptionStatus } = query; + + const where: any = {}; + if (search) { + where.OR = [ + { email: { contains: search, mode: "insensitive" } }, + { firstName: { contains: search, mode: "insensitive" } }, + { lastName: { contains: search, mode: "insensitive" } }, + ]; + } + if (role) { + where.role = role; + } + if (subscriptionStatus) { + where.subscriptionStatus = subscriptionStatus; + } const [users, total] = await Promise.all([ this.prisma.user.findMany({ + where, skip, take, orderBy, }), - this.prisma.user.count(), + this.prisma.user.count({ where }), ]); const dtos = plainToInstance( @@ -78,8 +96,8 @@ export class AdminController { return createPaginatedResponse( dtos, total, - pagination.page || 1, - pagination.limit || 10, + query.page || 1, + query.limit || 10, ); } @@ -284,20 +302,41 @@ export class AdminController { @SwaggerResponse({ status: 200 }) async updateUserSubscription( @Param("userId") userId: string, - @Body() data: { plan: string }, + @Body() data: UpdateUserSubscriptionDto, ): Promise> { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new NotFoundException("USER_NOT_FOUND"); - const validPlans = [PlanType.FREE, PlanType.PLUS, PlanType.PREMIUM]; + const validPlans = [PlanType.FREE, PlanType.PLUS, PlanType.PREMIUM, "past_due", "cancelled"]; const newPlan = data.plan as PlanType; if (!validPlans.includes(newPlan)) { throw new BadRequestException("INVALID_PLAN_TYPE"); } + const updateData: any = { subscriptionStatus: newPlan }; + + if (data.expiresAt) { + const parsedDate = new Date(data.expiresAt); + + // Business Logic: If upgrading to Premium/Plus, the expiry date cannot be in the past + const today = new Date(); + today.setHours(0, 0, 0, 0); // Strip time + + const expiry = new Date(parsedDate); + expiry.setHours(0, 0, 0, 0); + + if ((newPlan === PlanType.PREMIUM || newPlan === PlanType.PLUS) && expiry < today) { + throw new BadRequestException("EXPIRES_AT_CANNOT_BE_IN_PAST"); + } + + updateData.subscriptionExpiresAt = parsedDate; + } else if (data.expiresAt === null) { + updateData.subscriptionExpiresAt = null; + } + await this.prisma.user.update({ where: { id: userId }, - data: { subscriptionStatus: newPlan }, + data: updateData, }); await this.subscriptionsService.syncLimitsWithPlan(userId, newPlan); diff --git a/src/modules/admin/dto/admin-users-query.dto.ts b/src/modules/admin/dto/admin-users-query.dto.ts new file mode 100644 index 0000000..755a822 --- /dev/null +++ b/src/modules/admin/dto/admin-users-query.dto.ts @@ -0,0 +1,12 @@ +import { IsOptional, IsString } from "class-validator"; +import { PaginationDto } from "../../../common/dto/pagination.dto"; + +export class AdminUsersQueryDto extends PaginationDto { + @IsOptional() + @IsString() + role?: string; + + @IsOptional() + @IsString() + subscriptionStatus?: string; +} diff --git a/src/modules/admin/dto/update-user-subscription.dto.ts b/src/modules/admin/dto/update-user-subscription.dto.ts new file mode 100644 index 0000000..44b0c12 --- /dev/null +++ b/src/modules/admin/dto/update-user-subscription.dto.ts @@ -0,0 +1,13 @@ +import { IsString, IsOptional, IsEnum, IsISO8601 } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; + +export class UpdateUserSubscriptionDto { + @ApiProperty({ description: "Subscription Plan" }) + @IsString() + plan: string; + + @ApiProperty({ description: "Expiration Date in ISO format", required: false }) + @IsOptional() + @IsISO8601() + expiresAt?: string | null; +} diff --git a/src/modules/matches/matches.service.ts b/src/modules/matches/matches.service.ts index 6de27a6..5078813 100755 --- a/src/modules/matches/matches.service.ts +++ b/src/modules/matches/matches.service.ts @@ -623,6 +623,8 @@ export class MatchesService { score: { home: liveMatch.scoreHome, away: liveMatch.scoreAway, + htHome: (liveMatch as any).htScoreHome ?? null, + htAway: (liveMatch as any).htScoreAway ?? null, }, date: new Date(Number(liveMatch.mstUtc)), // Fill missing relations with empty arrays @@ -802,7 +804,12 @@ export class MatchesService { teamStats: normalizedTeamStats, mstUtc: Number(match.mstUtc), date: match.date || new Date(Number(match.mstUtc)), - score: match.score || { home: match.scoreHome, away: match.scoreAway }, + score: match.score || { + home: match.scoreHome, + away: match.scoreAway, + htHome: match.htScoreHome ?? null, + htAway: match.htScoreAway ?? null, + }, homeTeam: { ...match.homeTeam, logo: match.homeTeamId diff --git a/src/modules/subscriptions/subscriptions.service.ts b/src/modules/subscriptions/subscriptions.service.ts index 9d97ace..4c3996d 100644 --- a/src/modules/subscriptions/subscriptions.service.ts +++ b/src/modules/subscriptions/subscriptions.service.ts @@ -218,7 +218,12 @@ export class SubscriptionsService { // Sync user subscription status await this.prisma.user.update({ where: { id: userId }, - data: { subscriptionStatus: effectivePlan }, + data: { + subscriptionStatus: effectivePlan, + subscriptionExpiresAt: currentBillingPeriod?.ends_at + ? new Date(currentBillingPeriod.ends_at) + : null, + }, }); // Sync usage limits with plan diff --git a/src/modules/users/dto/user.dto.ts b/src/modules/users/dto/user.dto.ts index 202e17e..28bc32d 100755 --- a/src/modules/users/dto/user.dto.ts +++ b/src/modules/users/dto/user.dto.ts @@ -116,6 +116,9 @@ export class UserResponseDto { @Expose() subscriptionStatus: string; + @Expose() + subscriptionExpiresAt: Date | null; + @Expose() createdAt: Date; diff --git a/src/tasks/data-fetcher.task.ts b/src/tasks/data-fetcher.task.ts index e112892..1064bd9 100755 --- a/src/tasks/data-fetcher.task.ts +++ b/src/tasks/data-fetcher.task.ts @@ -60,6 +60,10 @@ interface LiveScorePayloadMatch { score: { home: number | null; away: number | null; + ht?: { + home: number | null; + away: number | null; + } | null; } | null; } @@ -278,6 +282,16 @@ export class DataFetcherTask { const matchData = response.data.data; const scoreHome = matchData.homeScore ?? null; const scoreAway = matchData.awayScore ?? null; + const htScoreHome = this.asInt( + matchData.score?.ht?.home ?? + matchData.htHomeScore ?? + matchData.homeHtScore, + ); + const htScoreAway = this.asInt( + matchData.score?.ht?.away ?? + matchData.htAwayScore ?? + matchData.awayHtScore, + ); const storedStatus = deriveStoredMatchStatus({ state: matchData.state, status: matchData.status, @@ -290,6 +304,8 @@ export class DataFetcherTask { data: { scoreHome, scoreAway, + htScoreHome, + htScoreAway, state: matchData.state || null, substate: matchData.substate || null, status: storedStatus, @@ -1022,6 +1038,8 @@ export class DataFetcherTask { // Safe score parsing const sHome = this.asInt(match.homeScore ?? match.score?.home); const sAway = this.asInt(match.awayScore ?? match.score?.away); + const sHtHome = this.asInt(match.score?.ht?.home); + const sHtAway = this.asInt(match.score?.ht?.away); const storedStatus = deriveStoredMatchStatus({ state: match.state, status: match.status, @@ -1062,6 +1080,8 @@ export class DataFetcherTask { status: storedStatus, scoreHome: sHome, scoreAway: sAway, + htScoreHome: sHtHome, + htScoreAway: sHtAway, homeTeamId: homeTeamId, awayTeamId: awayTeamId, updatedAt: new Date(), @@ -1078,6 +1098,8 @@ export class DataFetcherTask { mstUtc: BigInt(match.mstUtc || Date.now()), scoreHome: sHome, scoreAway: sAway, + htScoreHome: sHtHome, + htScoreAway: sHtAway, homeTeamId: homeTeamId, awayTeamId: awayTeamId, },