"""Prediction Mixin — V25 signal extraction and prediction building. Auto-extracted mixin module — split from services/single_match_orchestrator.py. All methods here are composed into SingleMatchOrchestrator via inheritance. `self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are initialised in the main __init__. """ from __future__ import annotations import json import re import time import math import os import pickle from collections import defaultdict from typing import Any, Dict, List, Optional, Set, Tuple, overload import pandas as pd import numpy as np import psycopg2 from psycopg2.extras import RealDictCursor from data.db import get_clean_dsn from schemas.prediction import FullMatchPrediction from schemas.match_data import MatchData from models.v25_ensemble import V25Predictor, get_v25_predictor try: from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge except ImportError: class V27Predictor: # type: ignore[no-redef] def __init__(self): self.models = {} def load_models(self): return False def predict_all(self, features): return {} def compute_divergence(*args, **kwargs): return {} def compute_value_edge(*args, **kwargs): return {} from features.odds_band_analyzer import OddsBandAnalyzer try: from models.basketball_v25 import ( BasketballMatchPrediction, get_basketball_v25_predictor, ) except ImportError: BasketballMatchPrediction = Any # type: ignore[misc] def get_basketball_v25_predictor() -> Any: raise ImportError("Basketball predictor is not available") from core.engines.player_predictor import PlayerPrediction, get_player_predictor from services.feature_enrichment import FeatureEnrichmentService from services.betting_brain import BettingBrain from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine from services.match_commentary import generate_match_commentary from utils.top_leagues import load_top_league_ids from utils.league_reliability import load_league_reliability from config.config_loader import build_threshold_dict, get_threshold_default, get_config from models.calibration import get_calibrator from models.league_model import get_league_model_loader, FILE_TO_SIGNAL class PredictionMixin: def _get_score_model(self) -> Optional[Dict]: """Load XGBoost score prediction model (non-fatal).""" if hasattr(self, "_score_model_cache"): return self._score_model_cache score_model_path = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "models", "xgb_score.pkl", ) try: if os.path.exists(score_model_path): with open(score_model_path, "rb") as f: model_data = pickle.load(f) if all(k in model_data for k in ("home_model", "away_model", "ht_home_model", "ht_away_model", "features")): self._score_model_cache = model_data print(f"[SCORE] ✅ Score model loaded ({len(model_data['features'])} features)") return self._score_model_cache except Exception as e: print(f"[SCORE] ⚠ Load failed (non-fatal, using heuristic): {e}") self._score_model_cache = None return None def _predict_score_with_model(self, features: Dict[str, float]) -> Optional[Dict[str, float]]: """Predict FT/HT scores using XGBoost score model.""" score_model = self._get_score_model() if score_model is None: return None try: import pandas as _pd model_features = score_model["features"] row = {f: float(features.get(f, 0)) for f in model_features} df = _pd.DataFrame([row]) ft_home = max(0.0, float(score_model["home_model"].predict(df)[0])) ft_away = max(0.0, float(score_model["away_model"].predict(df)[0])) ht_home = max(0.0, float(score_model["ht_home_model"].predict(df)[0])) ht_away = max(0.0, float(score_model["ht_away_model"].predict(df)[0])) return { "ft_home": round(ft_home, 2), "ft_away": round(ft_away, 2), "ht_home": round(ht_home, 2), "ht_away": round(ht_away, 2), } except Exception as e: print(f"[SCORE] ⚠ Prediction error (fallback to heuristic): {e}") return None _V25_KEY_MAP = { "ms": "MS", "ou15": "OU15", "ou25": "OU25", "ou35": "OU35", "btts": "BTTS", "ht_result": "HT", "ht_ou05": "HT_OU05", "ht_ou15": "HT_OU15", "htft": "HTFT", "cards_ou45": "CARDS", "handicap_ms": "HCAP", "odd_even": "OE", } def _get_v25_signal( self, data: MatchData, features: Optional[Dict[str, float]] = None, ) -> Dict[str, Any]: """ Get V25 ensemble predictions for all available markets. Returns a dict keyed by UPPERCASE market name (MS, OU25, BTTS, etc.) each with a 'probs' sub-dict that _prob_map can consume. CRITICAL: Keys MUST be uppercase to match _build_v25_prediction lookups. """ v25 = self._get_v25_predictor() feature_row = features or self._build_v25_features(data) signal: Dict[str, Any] = {} # ── League-specific model override ───────────────────────────────── league_id = getattr(data, "league_id", None) league_model = None if league_id: try: league_model = get_league_model_loader().get(league_id) except Exception: league_model = None if league_model: # Predict all available markets with league-specific XGBoost for mkey, sig_key in FILE_TO_SIGNAL.items(): probs = league_model.predict_market(mkey, feature_row) if probs: best_label = max(probs, key=probs.__getitem__) signal[sig_key] = { "probs": probs, "raw_probs": probs, "pick": best_label, "probability": float(probs[best_label]), "confidence": round(float(probs[best_label]) * 100.0, 1), "source": "league_specific", } if signal: print(f" [LEAGUE-MODEL] {league_id}: {len(signal)} markets predicted") # Fill remaining markets from general V25 (markets not in league model) # fall through to general prediction below for missing ones def _temperature_scale(probs_dict: Dict[str, float], temperature: float = 1.5) -> Dict[str, float]: """ Apply temperature scaling to soften overconfident model outputs. LightGBM often produces extreme probabilities (e.g., 0.999 / 0.001). Temperature scaling converts to log-odds, divides by T, then re-normalizes. T=1.0 → no change, T>1 → softer probabilities. Standard approach for post-hoc model calibration (Guo et al., 2017). V34: Reduced from 2.5 to 1.5 — V25 model is already calibrated via odds-aware training. Excessive flattening was destroying signal. """ import math eps = 1e-7 # numerical stability n = len(probs_dict) # V34: Reduced temperature — odds-aware model is already calibrated # Binary markets (2-class) tend to be more overconfident in LGB if n <= 2: T = max(temperature, 1.5) # was 2.0 elif n == 3: T = max(temperature * 0.8, 1.2) # was 1.5 — 3-way slightly less aggressive else: T = max(temperature * 0.6, 1.0) # was 1.3 — 9-way (HTFT) already spread # Convert to log-odds and apply temperature labels = list(probs_dict.keys()) log_odds = [] for label in labels: p = max(eps, min(1.0 - eps, float(probs_dict[label]))) log_odds.append(math.log(p) / T) # Softmax re-normalization max_lo = max(log_odds) exp_vals = [math.exp(lo - max_lo) for lo in log_odds] total = sum(exp_vals) scaled = {} for i, label in enumerate(labels): scaled[label] = exp_vals[i] / total return scaled calibrator = get_calibrator() _temperature = float(get_config().get('model_ensemble.temperature', 1.5)) # Map (market_key, label) → calibrator market key _CAL_KEY_MAP: Dict[str, str] = { "ms_1": "ms_home", "ms_x": "ms_draw", "ms_2": "ms_away", "ou15_over": "ou15", "ou15_under": "ou15", "ou25_over": "ou25", "ou25_under": "ou25", "ou35_over": "ou35", "ou35_under": "ou35", "btts_yes": "btts", "btts_no": "btts", "ht_1": "ht_home", "ht_x": "ht_draw", "ht_2": "ht_away", } def _enrich_signal_entry(probs_dict: Dict[str, float], market_key: str = "") -> Dict[str, Any]: """Temperature scaling + Isotonic calibration pipeline.""" scaled_probs = _temperature_scale(probs_dict, temperature=_temperature) # Isotonic calibration per outcome (if trained models exist) if market_key: calibrated = {} for label, prob in scaled_probs.items(): raw_key = f"{market_key}_{label}".lower().replace(" ", "_") cal_key = _CAL_KEY_MAP.get(raw_key, raw_key) calibrated[label] = calibrator.calibrate(cal_key, prob) total = sum(calibrated.values()) if total > 0: calibrated = {k: v / total for k, v in calibrated.items()} scaled_probs = calibrated best_label = max(scaled_probs, key=scaled_probs.__getitem__) best_prob = float(scaled_probs[best_label]) return { "probs": scaled_probs, "raw_probs": probs_dict, "pick": best_label, "probability": best_prob, "confidence": round(best_prob * 100.0, 1), } # Core markets using dedicated methods (skip if league model already covered them) if "MS" not in signal: h, d, a = v25.predict_ms(feature_row) signal["MS"] = _enrich_signal_entry({"1": h, "X": d, "2": a}, "ms") print(f" [V25-SIGNAL] MS → H={h:.4f} D={d:.4f} A={a:.4f}") else: print(f" [LEAGUE-MODEL] MS → {signal['MS']['probs']}") if "OU25" not in signal: over25, under25 = v25.predict_ou25(feature_row) signal["OU25"] = _enrich_signal_entry({"Over": over25, "Under": under25}, "ou25") print(f" [V25-SIGNAL] OU25 → O={over25:.4f} U={under25:.4f}") if "BTTS" not in signal: btts_y, btts_n = v25.predict_btts(feature_row) signal["BTTS"] = _enrich_signal_entry({"Yes": btts_y, "No": btts_n}, "btts") print(f" [V25-SIGNAL] BTTS → Y={btts_y:.4f} N={btts_n:.4f}") # Additional markets via generic predict_market (skip if league model covered them) for model_key, label_map in [ ("ou15", {"Over": 0, "Under": None}), ("ou35", {"Over": 0, "Under": None}), ("ht_result", {"1": 0, "X": 1, "2": 2}), ("ht_ou05", {"Over": 0, "Under": None}), ("ht_ou15", {"Over": 0, "Under": None}), ("htft", None), ("cards_ou45", {"Over": 0, "Under": None}), ("handicap_ms", {"1": 0, "X": 1, "2": 2}), ("odd_even", {"Odd": 0, "Even": None}), ]: out_key = str(self._V25_KEY_MAP.get(model_key, model_key.upper())) if out_key in signal: continue # already predicted by league-specific model if not v25.has_market(model_key): continue raw = v25.predict_market(model_key, feature_row) if raw is None: continue if label_map is None: # HTFT — 9 combinations htft_labels = ["1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2"] probs_dict = {} for i, label in enumerate(htft_labels): probs_dict[label] = float(raw[i]) if i < len(raw) else 0.0 signal[out_key] = _enrich_signal_entry(probs_dict, model_key) elif len(label_map) == 2: # Binary market labels = list(label_map.keys()) p = float(raw[0]) if len(raw) >= 1 else None if p is None: print(f" [V25-SIGNAL] {out_key} → EMPTY raw output, skipped") continue signal[out_key] = _enrich_signal_entry({labels[0]: p, labels[1]: 1.0 - p}, model_key) elif len(label_map) == 3: # 3-class market labels = list(label_map.keys()) probs_dict = {} for i, label in enumerate(labels): if i >= len(raw): print(f" [V25-SIGNAL] {out_key} → insufficient probabilities in raw output") break probs_dict[label] = float(raw[i]) else: signal[out_key] = _enrich_signal_entry(probs_dict, model_key) if out_key in signal: print(f" [V25-SIGNAL] {out_key} → {signal[out_key]['probs']}") print(f" [V25-SIGNAL] Total markets with real predictions: {len(signal)}") if not signal: raise RuntimeError("V25 model produced ZERO market predictions — cannot continue") return signal @staticmethod def _prob_map(signal: Optional[Dict[str, Any]], market: str, defaults: Dict[str, float]) -> Dict[str, float]: """Extract normalised probabilities from signal. If the signal contains real model output for this market, use it. If the market is missing from the signal, log a warning and return the defaults as a LAST RESORT (so the pipeline doesn't crash). The defaults are ONLY used for non-core / secondary markets that may not have a trained model yet (e.g. CARDS, HCAP, OE). """ market_payload = signal.get(market, {}) if isinstance(signal, dict) else {} probs = market_payload.get("probs", {}) if isinstance(market_payload, dict) else {} if not isinstance(probs, dict) or not probs: print(f" ⚠️ [PROB_MAP] Market '{market}' NOT found in V25 signal — model output missing") return dict(defaults) out = {key: float(probs.get(key, value)) for key, value in defaults.items()} total = sum(out.values()) if total <= 0: print(f" ⚠️ [PROB_MAP] Market '{market}' has zero total probability") return dict(defaults) return {key: value / total for key, value in out.items()} @staticmethod def _is_cup_game(league_name: str) -> bool: """Detect cup/knockout competitions where home advantage is significantly weaker.""" name = (league_name or "").lower() cup_keywords = ( "kupa", "cup", "coupe", "copa", "coppa", "pokal", "trophy", "shield", "challenge", "ziraat", "süper kupa", "super cup", ) return any(kw in name for kw in cup_keywords) @staticmethod def _best_prob_pick(prob_map: Dict[str, float]) -> Tuple[str, float]: if not prob_map: return "", 0.0 pick = max(prob_map, key=prob_map.__getitem__) return pick, float(prob_map[pick]) @staticmethod def _poisson_score_top5(home_xg: float, away_xg: float, max_goals: int = 5) -> List[Dict[str, Any]]: def poisson_p(lmbda: float, k: int) -> float: return math.exp(-lmbda) * (lmbda ** k) / math.factorial(k) scores: List[Tuple[str, float]] = [] for home_goals in range(max_goals + 1): for away_goals in range(max_goals + 1): prob = poisson_p(home_xg, home_goals) * poisson_p(away_xg, away_goals) scores.append((f"{home_goals}-{away_goals}", prob)) scores.sort(key=lambda item: item[1], reverse=True) return [ {"score": score, "prob": round(prob, 4)} for score, prob in scores[:5] ] def _build_v25_prediction( self, data: MatchData, features: Dict[str, float], v25_signal: Dict[str, Any], ) -> FullMatchPrediction: prediction = FullMatchPrediction( match_id=data.match_id, home_team=data.home_team_name, away_team=data.away_team_name, ) ms_probs = self._prob_map(v25_signal, "MS", {"1": 0.33, "X": 0.34, "2": 0.33}) ou15_probs = self._prob_map(v25_signal, "OU15", {"Under": 0.5, "Over": 0.5}) ou25_probs = self._prob_map(v25_signal, "OU25", {"Under": 0.5, "Over": 0.5}) ou35_probs = self._prob_map(v25_signal, "OU35", {"Under": 0.5, "Over": 0.5}) btts_probs = self._prob_map(v25_signal, "BTTS", {"No": 0.5, "Yes": 0.5}) ht_probs = self._prob_map(v25_signal, "HT", {"1": 0.33, "X": 0.34, "2": 0.33}) ht_ou05_probs = self._prob_map(v25_signal, "HT_OU05", {"Under": 0.5, "Over": 0.5}) ht_ou15_probs = self._prob_map(v25_signal, "HT_OU15", {"Under": 0.5, "Over": 0.5}) htft_probs = self._prob_map( v25_signal, "HTFT", {"1/1": 1 / 9, "1/X": 1 / 9, "1/2": 1 / 9, "X/1": 1 / 9, "X/X": 1 / 9, "X/2": 1 / 9, "2/1": 1 / 9, "2/X": 1 / 9, "2/2": 1 / 9}, ) oe_probs = self._prob_map(v25_signal, "OE", {"Even": 0.5, "Odd": 0.5}) cards_probs = self._prob_map(v25_signal, "CARDS", {"Under": 0.5, "Over": 0.5}) hcap_probs = self._prob_map(v25_signal, "HCAP", {"1": 0.33, "X": 0.34, "2": 0.33}) # Cup game: dampen home advantage — model trained on league data overestimates home edge is_cup = self._is_cup_game(getattr(data, "league_name", "") or "") if is_cup: # Shift 8% of home probability toward away and draw (rotation, neutral venue effect) cup_transfer = ms_probs["1"] * 0.08 ms_probs = { "1": ms_probs["1"] - cup_transfer, "X": ms_probs["X"] + cup_transfer * 0.4, "2": ms_probs["2"] + cup_transfer * 0.6, } total = sum(ms_probs.values()) ms_probs = {k: v / total for k, v in ms_probs.items()} prediction.ms_home_prob = ms_probs["1"] prediction.ms_draw_prob = ms_probs["X"] prediction.ms_away_prob = ms_probs["2"] prediction.ms_pick, ms_top = self._best_prob_pick(ms_probs) prediction.ms_confidence = ms_top * 100.0 prediction.dc_1x_prob = prediction.ms_home_prob + prediction.ms_draw_prob prediction.dc_x2_prob = prediction.ms_draw_prob + prediction.ms_away_prob prediction.dc_12_prob = prediction.ms_home_prob + prediction.ms_away_prob dc_probs = {"1X": prediction.dc_1x_prob, "X2": prediction.dc_x2_prob, "12": prediction.dc_12_prob} prediction.dc_pick, dc_top = self._best_prob_pick(dc_probs) prediction.dc_confidence = dc_top * 100.0 prediction.over_15_prob = ou15_probs["Over"] prediction.under_15_prob = ou15_probs["Under"] prediction.ou15_pick = "1.5 Üst" if prediction.over_15_prob >= prediction.under_15_prob else "1.5 Alt" prediction.ou15_confidence = max(prediction.over_15_prob, prediction.under_15_prob) * 100.0 prediction.over_25_prob = ou25_probs["Over"] prediction.under_25_prob = ou25_probs["Under"] prediction.ou25_pick = "2.5 Üst" if prediction.over_25_prob >= prediction.under_25_prob else "2.5 Alt" prediction.ou25_confidence = max(prediction.over_25_prob, prediction.under_25_prob) * 100.0 prediction.over_35_prob = ou35_probs["Over"] prediction.under_35_prob = ou35_probs["Under"] prediction.ou35_pick = "3.5 Üst" if prediction.over_35_prob >= prediction.under_35_prob else "3.5 Alt" prediction.ou35_confidence = max(prediction.over_35_prob, prediction.under_35_prob) * 100.0 prediction.btts_yes_prob = btts_probs["Yes"] prediction.btts_no_prob = btts_probs["No"] prediction.btts_pick = "KG Var" if prediction.btts_yes_prob >= prediction.btts_no_prob else "KG Yok" prediction.btts_confidence = max(prediction.btts_yes_prob, prediction.btts_no_prob) * 100.0 prediction.ht_home_prob = ht_probs["1"] prediction.ht_draw_prob = ht_probs["X"] prediction.ht_away_prob = ht_probs["2"] prediction.ht_pick, ht_top = self._best_prob_pick(ht_probs) prediction.ht_confidence = ht_top * 100.0 prediction.ht_over_05_prob = ht_ou05_probs["Over"] prediction.ht_under_05_prob = ht_ou05_probs["Under"] prediction.ht_ou_pick = "İY 0.5 Üst" if prediction.ht_over_05_prob >= prediction.ht_under_05_prob else "İY 0.5 Alt" prediction.ht_over_15_prob = ht_ou15_probs["Over"] prediction.ht_under_15_prob = ht_ou15_probs["Under"] prediction.ht_ou15_pick = "İY 1.5 Üst" if prediction.ht_over_15_prob >= prediction.ht_under_15_prob else "İY 1.5 Alt" prediction.ht_ft_probs = htft_probs prediction.odd_prob = oe_probs["Odd"] prediction.even_prob = oe_probs["Even"] prediction.odd_even_pick = "Tek" if prediction.odd_prob >= prediction.even_prob else "Çift" prediction.cards_over_prob = cards_probs["Over"] prediction.cards_under_prob = cards_probs["Under"] prediction.card_pick = "4.5 Üst" if prediction.cards_over_prob >= prediction.cards_under_prob else "4.5 Alt" prediction.cards_confidence = max(prediction.cards_over_prob, prediction.cards_under_prob) * 100.0 prediction.handicap_home_prob = hcap_probs["1"] prediction.handicap_draw_prob = hcap_probs["X"] prediction.handicap_away_prob = hcap_probs["2"] prediction.handicap_pick, hcap_top = self._best_prob_pick(hcap_probs) prediction.handicap_confidence = hcap_top * 100.0 # ── Score Prediction: Model-first, heuristic fallback ────────── ms_edge = prediction.ms_home_prob - prediction.ms_away_prob score_result = self._predict_score_with_model(features) if score_result is not None: # ML model predicted scores prediction.home_xg = score_result["ft_home"] prediction.away_xg = score_result["ft_away"] prediction.total_xg = round(prediction.home_xg + prediction.away_xg, 2) ht_home_xg = score_result["ht_home"] ht_away_xg = score_result["ht_away"] prediction.predicted_ft_score = f"{int(round(prediction.home_xg))}-{int(round(prediction.away_xg))}" prediction.predicted_ht_score = f"{int(round(ht_home_xg))}-{int(round(ht_away_xg))}" else: # Heuristic fallback (original formula) base_home_xg = max(0.25, (float(data.home_goals_avg or 1.3) + float(features.get("away_xga", data.away_conceded_avg) or 1.2)) / 2.0) base_away_xg = max(0.25, (float(data.away_goals_avg or 1.3) + float(features.get("home_xga", data.home_conceded_avg) or 1.2)) / 2.0) # ms_edge already computed above total_target = max( 1.4, min( 4.8, (float(features.get("league_avg_goals", 2.7)) * 0.55) + ((float(data.home_goals_avg or 1.3) + float(data.away_goals_avg or 1.3)) * 0.45) + ((prediction.over_25_prob - prediction.under_25_prob) * 1.15), ), ) home_xg = max(0.2, base_home_xg + (ms_edge * 0.55) + ((prediction.btts_yes_prob - 0.5) * 0.18)) away_xg = max(0.2, base_away_xg - (ms_edge * 0.55) + ((prediction.btts_yes_prob - 0.5) * 0.18)) scale = total_target / max(home_xg + away_xg, 0.1) prediction.home_xg = round(home_xg * scale, 2) prediction.away_xg = round(away_xg * scale, 2) prediction.total_xg = round(prediction.home_xg + prediction.away_xg, 2) # Cup game: reduce xG by 20% — rotation + lower motivation + defensive tactics if is_cup: prediction.home_xg = round(prediction.home_xg * 0.80, 2) prediction.away_xg = round(prediction.away_xg * 0.80, 2) prediction.total_xg = round(prediction.home_xg + prediction.away_xg, 2) prediction.predicted_ft_score = f"{int(round(prediction.home_xg))}-{int(round(prediction.away_xg))}" prediction.predicted_ht_score = f"{int(round(prediction.home_xg * 0.45))}-{int(round(prediction.away_xg * 0.45))}" prediction.ft_scores_top5 = self._poisson_score_top5(prediction.home_xg, prediction.away_xg) # Score prediction: find the most likely scoreline consistent with the MS pick # Instead of just rounding xG (misleading), filter Poisson top scores by result direction ms_pick = prediction.ms_pick # "1", "X", or "2" top5 = prediction.ft_scores_top5 if top5 and ms_pick in ("1", "X", "2"): def _result_of(score_str: str) -> str: try: h, a = map(int, score_str.split("-")) if h > a: return "1" if h < a: return "2" return "X" except Exception: return "?" # Filter to scorelines matching the predicted result matching = [s for s in top5 if _result_of(s["score"]) == ms_pick] if matching: best = matching[0] # already sorted by probability desc h_str, a_str = best["score"].split("-") prediction.predicted_ft_score = best["score"] # Recalculate HT score proportionally from the FT pick h_val, a_val = int(h_str), int(a_str) prediction.predicted_ht_score = f"{int(round(h_val * 0.45))}-{int(round(a_val * 0.45))}" max_market_conf = max( prediction.ms_confidence, prediction.ou15_confidence, prediction.ou25_confidence, prediction.ou35_confidence, prediction.btts_confidence, prediction.ht_confidence, prediction.cards_confidence, prediction.handicap_confidence, ) lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0))) lineup_penalty = 12.0 if data.lineup_source == "none" else max(1.5, (1.0 - lineup_conf) * 8.0) if data.lineup_source == "probable_xi" else 0.0 referee_penalty = 6.0 if not data.referee_name else 0.0 parity_penalty = 8.0 if abs(ms_edge) < 0.08 else 0.0 # Cup game penalty: model trained on league data has lower reliability for cup matches cup_penalty = 10.0 if is_cup else 0.0 # Bookmaker margin penalty: high margin signals that even the market is uncertain bm_margin = 0.0 odds_data = getattr(data, "odds_data", {}) or {} _h, _d, _a = float(odds_data.get("ms_h") or 0), float(odds_data.get("ms_d") or 0), float(odds_data.get("ms_a") or 0) if _h > 1.01 and _d > 1.01 and _a > 1.01: bm_margin = (1 / _h + 1 / _d + 1 / _a) - 1 bookmaker_penalty = 12.0 if bm_margin > 0.20 else 6.0 if bm_margin > 0.15 else 0.0 prediction.risk_score = round(min(100.0, max(10.0, 100.0 - max_market_conf + lineup_penalty + referee_penalty + parity_penalty + cup_penalty + bookmaker_penalty)), 1) if prediction.risk_score >= 78: prediction.risk_level = "EXTREME" elif prediction.risk_score >= 62: prediction.risk_level = "HIGH" elif prediction.risk_score >= 40: prediction.risk_level = "MEDIUM" else: prediction.risk_level = "LOW" prediction.is_surprise_risk = prediction.risk_level in {"HIGH", "EXTREME"} or prediction.ms_draw_prob >= 0.30 prediction.surprise_type = "balanced_match_risk" if abs(ms_edge) < 0.08 else "draw_pressure" if prediction.ms_draw_prob >= 0.30 else "" prediction.risk_warnings = [] if is_cup: prediction.risk_warnings.append("cup_game_home_advantage_reduced") if bookmaker_penalty > 0: prediction.risk_warnings.append(f"bookmaker_margin_high_{bm_margin*100:.0f}pct") if data.lineup_source == "probable_xi": prediction.risk_warnings.append("lineup_probable_not_confirmed") if lineup_conf < 0.65: prediction.risk_warnings.append("lineup_projection_low_confidence") if data.lineup_source == "none": prediction.risk_warnings.append("lineup_unavailable") if not data.referee_name: prediction.risk_warnings.append("missing_referee") if prediction.ms_draw_prob >= 0.30: prediction.risk_warnings.append("draw_probability_elevated") prediction.upset_score = int(round(max(0.0, min(100.0, (prediction.ms_draw_prob + min(prediction.ms_home_prob, prediction.ms_away_prob)) * 100.0)))) prediction.upset_level = "HIGH" if prediction.upset_score >= 65 else "MEDIUM" if prediction.upset_score >= 45 else "LOW" prediction.upset_reasons = [prediction.surprise_type] if prediction.surprise_type else [] surprise = self._build_surprise_profile(data, prediction) prediction.surprise_score = surprise["score"] prediction.surprise_comment = surprise["comment"] prediction.surprise_reasons = surprise["reasons"] prediction.surprise_breakdown = surprise.get("breakdown", []) # Auto-flag is_surprise_risk when score crosses 45 even if other paths didn't fire if surprise["score"] >= 45.0: prediction.is_surprise_risk = True prediction.team_confidence = round(max(35.0, min(95.0, 45.0 + (abs(ms_edge) * 85.0) + (abs(float(features.get("form_elo_diff", 0.0))) / 40.0))), 1) prediction.player_confidence = round(max(20.0, min(95.0, 38.0 + (float(features.get("home_key_players", 0.0)) + float(features.get("away_key_players", 0.0))) * 2.0 - (float(features.get("home_missing_impact", 0.0)) + float(features.get("away_missing_impact", 0.0))) * 22.0)), 1) prediction.odds_confidence = round(max(30.0, min(95.0, float(np.mean([prediction.ms_confidence, prediction.ou25_confidence, prediction.btts_confidence])))), 1) prediction.referee_confidence = 62.0 if data.referee_name else 35.0 prediction.total_cards_pred = 4.8 if prediction.cards_over_prob >= prediction.cards_under_prob else 4.1 prediction.total_corners_pred = round(8.8 + (prediction.over_25_prob - 0.5) * 2.5, 1) prediction.corner_pick = "9.5 Üst" if prediction.total_corners_pred >= 9.5 else "9.5 Alt" prediction.analysis_details = { "primary_model": "v25", "features_source": "v25.pre_match", "market_count": len([key for key in v25_signal.keys() if key != "value_bets"]), "lineup_source": data.lineup_source, } return prediction def _build_engine_breakdown(self, prediction: FullMatchPrediction) -> Dict[str, Any]: """ Engine breakdown with backward-compatible flat scores + rich detail siblings. Shape: { team: 74.1, player: 55.7, odds: 55.2, referee: 62.0, # legacy flat scores detail: { team: {score, label, ...}, player: {...}, ... } } """ components = { "team": ("Takım modeli", float(prediction.team_confidence)), "player": ("Oyuncu / kadro modeli", float(prediction.player_confidence)), "odds": ("Oran piyasası", float(prediction.odds_confidence)), "referee": ("Hakem etkisi", float(prediction.referee_confidence)), } flat: Dict[str, Any] = {} detail: Dict[str, Any] = {} for key, (display, raw) in components.items(): score = round(raw, 1) label, interpretation = self._confidence_label(score) flat[key] = score detail[key] = { "score": score, "label": label, "display_name": display, "interpretation": interpretation, } flat["detail"] = detail return flat