"""Market Board Mixin — market rows, decoration, surprise profile, data quality. Auto-extracted mixin module — split from services/single_match_orchestrator.py. All methods here are composed into SingleMatchOrchestrator via inheritance. `self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are initialised in the main __init__. """ from __future__ import annotations import json import re import time import math import os import pickle from collections import defaultdict from typing import Any, Dict, List, Optional, Set, Tuple, overload import pandas as pd import numpy as np import psycopg2 from psycopg2.extras import RealDictCursor from data.db import get_clean_dsn from schemas.prediction import FullMatchPrediction from schemas.match_data import MatchData from models.v25_ensemble import V25Predictor, get_v25_predictor try: from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge except ImportError: class V27Predictor: # type: ignore[no-redef] def __init__(self): self.models = {} def load_models(self): return False def predict_all(self, features): return {} def compute_divergence(*args, **kwargs): return {} def compute_value_edge(*args, **kwargs): return {} from features.odds_band_analyzer import OddsBandAnalyzer try: from models.basketball_v25 import ( BasketballMatchPrediction, get_basketball_v25_predictor, ) except ImportError: BasketballMatchPrediction = Any # type: ignore[misc] def get_basketball_v25_predictor() -> Any: raise ImportError("Basketball predictor is not available") from core.engines.player_predictor import PlayerPrediction, get_player_predictor from services.feature_enrichment import FeatureEnrichmentService from services.betting_brain import BettingBrain from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine from services.match_commentary import generate_match_commentary from utils.top_leagues import load_top_league_ids from utils.league_reliability import load_league_reliability from config.config_loader import build_threshold_dict, get_threshold_default from models.calibration import get_calibrator class MarketBoardMixin: def _build_prediction_package( self, data: MatchData, prediction: FullMatchPrediction, v25_signal: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: quality = self._compute_data_quality(data) raw_market_rows = self._build_market_rows(data, prediction, v25_signal) raw_market_rows = self._apply_market_consistency( raw_market_rows, data, prediction, ) market_rows = [ self._decorate_market_row(data, prediction, quality, row) for row in raw_market_rows ] market_rows.sort( key=lambda row: ( 1 if row.get("playable") else 0, float(row.get("play_score", 0.0)), ), reverse=True, ) playable_rows = [row for row in market_rows if row.get("playable")] MIN_ODDS = 1.30 playable_with_odds = [ row for row in playable_rows if float(row.get("odds", 0.0)) >= MIN_ODDS ] if playable_with_odds: playable_with_odds.sort( key=lambda r: ( float(r.get("ev_edge", 0.0)), float(r.get("play_score", 0.0)), ), reverse=True, ) main_pick = playable_with_odds[0] main_pick["is_guaranteed"] = False main_pick["pick_reason"] = "positive_ev_after_odds_band_gate" else: fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0] fallback_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) main_pick = fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None) if main_pick: main_pick["is_guaranteed"] = False main_pick["playable"] = False main_pick["stake_units"] = 0.0 main_pick["bet_grade"] = "PASS" main_pick["pick_reason"] = "no_playable_value_after_odds_band_gate" aggressive_pick = None htft_probs = prediction.ht_ft_probs or {} aggressive_candidates = [ ("1/2", float(htft_probs.get("1/2", 0.0))), ("2/1", float(htft_probs.get("2/1", 0.0))), ("X/1", float(htft_probs.get("X/1", 0.0))), ("X/2", float(htft_probs.get("X/2", 0.0))), ] aggressive_candidates.sort(key=lambda item: item[1], reverse=True) if ( aggressive_candidates and aggressive_candidates[0][1] > 0.03 and self._market_has_real_pick_odds("HTFT", aggressive_candidates[0][0], data.odds_data or {}) ): aggressive_pick = { "market": "HT/FT", "pick": aggressive_candidates[0][0], "probability": round(aggressive_candidates[0][1], 4), "confidence": round(aggressive_candidates[0][1] * 100, 1), "odds": None, } value_pick = None # Esnek/Değerli (Value) Pick: Yüksek oran (>= 1.60) ve fena olmayan güven (>= %40) value_candidates = [ row for row in playable_rows if float(row.get("odds", 0.0)) >= 1.60 # V34: Lowered min calibrated_confidence for value candidates from 40.0 to 25.0 # to allow high-odds value bets (which naturally have lower probabilities). and float(row.get("calibrated_confidence", 0.0)) >= 25.0 ] if value_candidates: # Score them by (ev_edge) to reward actual mathematical value value_candidates.sort(key=lambda r: float(r.get("ev_edge", 0.0)), reverse=True) for v_cand in value_candidates: if not main_pick or (v_cand["market"] != main_pick["market"] or v_cand["pick"] != main_pick["pick"]): value_pick = v_cand break supporting: List[Dict[str, Any]] = [] for row in market_rows: if main_pick and row["market"] == main_pick["market"] and row["pick"] == main_pick["pick"]: continue supporting.append(row) supporting = supporting[:6] bet_summary = [self._to_bet_summary_item(row) for row in market_rows] reasons = self._build_reasoning_factors(data, prediction, quality) market_board = { "MS": { "pick": prediction.ms_pick, "confidence": round(float(prediction.ms_confidence), 1), "probs": { "1": round(float(prediction.ms_home_prob), 4), "X": round(float(prediction.ms_draw_prob), 4), "2": round(float(prediction.ms_away_prob), 4), }, }, "DC": { "pick": prediction.dc_pick, "confidence": round(float(prediction.dc_confidence), 1), "probs": { "1X": round(float(prediction.dc_1x_prob), 4), "X2": round(float(prediction.dc_x2_prob), 4), "12": round(float(prediction.dc_12_prob), 4), }, }, "OU15": { "pick": prediction.ou15_pick, "confidence": round(float(prediction.ou15_confidence), 1), "probs": { "over": round(float(prediction.over_15_prob), 4), "under": round(float(prediction.under_15_prob), 4), }, }, "OU25": { "pick": prediction.ou25_pick, "confidence": round(float(prediction.ou25_confidence), 1), "probs": { "over": round(float(prediction.over_25_prob), 4), "under": round(float(prediction.under_25_prob), 4), }, }, "OU35": { "pick": prediction.ou35_pick, "confidence": round(float(prediction.ou35_confidence), 1), "probs": { "over": round(float(prediction.over_35_prob), 4), "under": round(float(prediction.under_35_prob), 4), }, }, "BTTS": { "pick": prediction.btts_pick, "confidence": round(float(prediction.btts_confidence), 1), "probs": { "yes": round(float(prediction.btts_yes_prob), 4), "no": round(float(prediction.btts_no_prob), 4), }, }, "HT": { "pick": prediction.ht_pick, "confidence": round(float(prediction.ht_confidence), 1), "probs": { "1": round(float(prediction.ht_home_prob), 4), "X": round(float(prediction.ht_draw_prob), 4), "2": round(float(prediction.ht_away_prob), 4), }, }, "HTFT": { "probs": {k: round(float(v), 4) for k, v in htft_probs.items()}, }, "OE": { "pick": prediction.odd_even_pick, "probs": { "odd": round(float(prediction.odd_prob), 4), "even": round(float(prediction.even_prob), 4), }, }, "HT_OU05": { "pick": prediction.ht_ou_pick, "confidence": round(float(max(prediction.ht_over_05_prob, prediction.ht_under_05_prob) * 100), 1), "probs": { "over": round(float(prediction.ht_over_05_prob), 4), "under": round(float(prediction.ht_under_05_prob), 4), }, }, "HT_OU15": { "pick": prediction.ht_ou15_pick, "confidence": round(float(max(prediction.ht_over_15_prob, prediction.ht_under_15_prob) * 100), 1), "probs": { "over": round(float(prediction.ht_over_15_prob), 4), "under": round(float(prediction.ht_under_15_prob), 4), }, }, "CARDS": { "pick": prediction.card_pick, "confidence": round(float(prediction.cards_confidence), 1), "total": round(float(prediction.total_cards_pred), 1), "probs": { "over": round(float(prediction.cards_over_prob), 4), "under": round(float(prediction.cards_under_prob), 4), }, }, "HCAP": { "pick": prediction.handicap_pick, "confidence": round(float(prediction.handicap_confidence), 1), "probs": { "1": round(float(prediction.handicap_home_prob), 4), "X": round(float(prediction.handicap_draw_prob), 4), "2": round(float(prediction.handicap_away_prob), 4), }, }, } if v25_signal: market_board = self._merge_v25_market_board(market_board, v25_signal) available_markets = {str(row.get("market") or "") for row in market_rows} market_board = { market: payload for market, payload in market_board.items() if market in available_markets } # Determine simulation mode for the response _resp_status = str(data.status or "").upper() _resp_state = str(data.state or "").upper() is_simulation = _resp_status in {"FT", "FINISHED"} or _resp_state in {"POSTGAME", "POST_GAME"} return { "model_version": "v28-pro-max", "simulation_mode": "pre_match" if is_simulation else None, "match_info": { "match_id": data.match_id, "match_name": f"{data.home_team_name} vs {data.away_team_name}", "home_team": data.home_team_name, "away_team": data.away_team_name, "league": data.league_name, "match_date_ms": data.match_date_ms, "sport": data.sport, # Live snapshot — match_commentary uses this to detect upset-in-progress "status": data.status, "state": data.state, "is_live": self._is_live_match(data), "current_score_home": data.current_score_home, "current_score_away": data.current_score_away, }, "prediction_freshness": { "generated_at_ms": int(time.time() * 1000), "is_pre_match_snapshot": True, # Stale when the match is already underway — UI should warn the user. "is_stale_for_live": self._is_live_match(data), }, "data_quality": quality, "risk": { "level": prediction.risk_level, "score": round(float(prediction.risk_score), 1), "is_surprise_risk": bool(prediction.is_surprise_risk), "surprise_type": prediction.surprise_type, "surprise_score": round(float(getattr(prediction, "surprise_score", 0.0) or 0.0), 1), "surprise_comment": str(getattr(prediction, "surprise_comment", "") or ""), "surprise_reasons": list(getattr(prediction, "surprise_reasons", []) or []), "surprise_breakdown": list(getattr(prediction, "surprise_breakdown", []) or []), "warnings": prediction.risk_warnings, }, "engine_breakdown": self._build_engine_breakdown(prediction), "main_pick": main_pick, "value_pick": value_pick, "bet_advice": { "playable": bool(main_pick and main_pick.get("playable")), "suggested_stake_units": float(main_pick.get("stake_units", 0.0)) if (main_pick and main_pick.get("playable")) else 0.0, "reason": "playable_pick_found" if (main_pick and main_pick.get("playable")) else "no_bet_conditions_met", }, "bet_summary": bet_summary, "supporting_picks": supporting, "aggressive_pick": aggressive_pick, "scenario_top5": prediction.ft_scores_top5, "score_prediction": { "ft": prediction.predicted_ft_score, "ht": prediction.predicted_ht_score, "xg_home": round(float(prediction.home_xg), 2), "xg_away": round(float(prediction.away_xg), 2), "xg_total": round(float(prediction.total_xg), 2), }, "market_board": market_board, "others": { "handicap": prediction.handicap_pick, "cards": { "total": round(float(prediction.total_cards_pred), 1), "pick": prediction.card_pick, }, }, "v25_signal": { "available": v25_signal is not None, "markets": v25_signal if v25_signal else None, "value_bets": v25_signal.get('value_bets', []) if v25_signal else [], "ensemble_weights": {"v25": 1.0}, }, "reasoning_factors": reasons, } def _real_market_odds(self, odds_data: Dict[str, Any], key: str) -> float: """ Return the odds value for a given key, but 1.0 if it's a known default or missing. The prediction engine needs default odds (2.65/3.20) as ML features, but market rows must NOT use them for EV edge / Kelly calculations. Returning 1.0 acts as a neutral multiplier, avoiding zero-out errors. """ val = float(odds_data.get(key, 1.0)) if val <= 1.01: return 1.0 _DEFAULTS: Dict[str, float] = { "ms_h": self.DEFAULT_MS_H, "ms_d": self.DEFAULT_MS_D, "ms_a": self.DEFAULT_MS_A, "ml_h": 1.90, "ml_a": 1.90, "ht_h": 2.4, "ht_d": 1.9, "ht_a": 3.1, "ht_ou05_o": 1.9, "ht_ou05_u": 1.9, "ht_ou15_o": 2.4, "ht_ou15_u": 1.5, "ou15_o": 1.4, "ou15_u": 2.6, "ou25_o": 1.9, "ou25_u": 1.9, "ou35_o": 2.7, "ou35_u": 1.4, "btts_y": 1.9, "btts_n": 1.9, "dc_1x": 1.2, "dc_x2": 1.4, "dc_12": 1.2, "oe_odd": 1.9, "oe_even": 1.9, "cards_o": 1.9, "cards_u": 1.9, "tot_o": 1.90, "tot_u": 1.90, } if key in _DEFAULTS and abs(val - _DEFAULTS[key]) < 1e-6: return 1.0 return val @staticmethod def _v25_pick_to_market_pick(market: str, pick: str) -> str: if market == "BTTS": return "KG Var" if pick == "Yes" else "KG Yok" if pick == "No" else pick if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}: return "Üst" if pick == "Over" else "Alt" if pick == "Under" else pick if market == "OE": return "Tek" if pick == "Odd" else "Çift" if pick == "Even" else pick return pick def _v25_market_odds(self, odds: Dict[str, Any], market: str, pick: str) -> float: normalized_pick = str(pick or "").strip() if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}: normalized_pick = "Over" if ("Üst" in normalized_pick or "Over" in normalized_pick) else "Under" elif market == "BTTS": normalized_pick = "Yes" if normalized_pick in {"KG Var", "Var", "Yes"} else "No" elif market == "OE": normalized_pick = "Odd" if normalized_pick in {"Tek", "Odd"} else "Even" elif market == "DC": normalized_pick = normalized_pick.replace("-", "").upper() elif market == "HCAP" and normalized_pick.startswith("Handikap"): if " 1" in normalized_pick: normalized_pick = "1" elif " X" in normalized_pick: normalized_pick = "X" elif " 2" in normalized_pick: normalized_pick = "2" key_map = { "MS": {"1": "ms_h", "X": "ms_d", "2": "ms_a"}, "DC": {"1X": "dc_1x", "X2": "dc_x2", "12": "dc_12"}, "OU15": {"Over": "ou15_o", "Under": "ou15_u"}, "OU25": {"Over": "ou25_o", "Under": "ou25_u"}, "OU35": {"Over": "ou35_o", "Under": "ou35_u"}, "BTTS": {"Yes": "btts_y", "No": "btts_n"}, "HT": {"1": "ht_h", "X": "ht_d", "2": "ht_a"}, "HT_OU05": {"Over": "ht_ou05_o", "Under": "ht_ou05_u"}, "HT_OU15": {"Over": "ht_ou15_o", "Under": "ht_ou15_u"}, "OE": {"Odd": "oe_odd", "Even": "oe_even"}, "CARDS": {"Over": "cards_o", "Under": "cards_u"}, "HCAP": {"1": "hcap_h", "X": "hcap_d", "2": "hcap_a"}, } if market == "HTFT": return round(float(odds.get(f"htft_{normalized_pick.replace('/', '').lower()}", 1.0)), 2) odds_key = key_map.get(market, {}).get(normalized_pick) if not odds_key: return 1.0 return round(self._real_market_odds(odds, odds_key), 2) def _market_has_real_pick_odds(self, market: str, pick: str, odds: Dict[str, Any]) -> bool: if market not in self.ODDS_REQUIRED_MARKETS: return True return self._v25_market_odds(odds, market, pick) > 1.01 def _odds_band_verdict( self, data: MatchData, market: str, pick: str, implied_prob: float, ) -> Dict[str, Any]: features = getattr(data, "odds_band_features", {}) or {} market_key = str(market or "").upper() if not isinstance(features, dict) or implied_prob <= 0.0: return { "required": market_key in self.odds_band_min_sample, "available": False, "band_prob": 0.0, "band_sample": 0.0, "band_edge": 0.0, "aligned": False, "reason": "odds_band_unavailable", } pick_key = self._normalize_pick_token(pick) band_prob = 0.0 sample = 0.0 if market_key == "MS": if pick_key == "1": band_prob = float(features.get("home_band_ms_win_rate", 0.0) or 0.0) sample = float(features.get("home_band_ms_sample", 0.0) or 0.0) elif pick_key == "2": band_prob = float(features.get("away_band_ms_win_rate", 0.0) or 0.0) sample = float(features.get("away_band_ms_sample", 0.0) or 0.0) elif pick_key in {"X", "0"}: home_draw = float(features.get("home_band_ms_draw_rate", 0.0) or 0.0) away_draw = float(features.get("away_band_ms_draw_rate", 0.0) or 0.0) band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw) sample = max( float(features.get("home_band_ms_sample", 0.0) or 0.0), float(features.get("away_band_ms_sample", 0.0) or 0.0), ) elif market_key == "DC": dc_key = pick_key.replace("-", "").lower() band_prob = float(features.get(f"band_dc_{dc_key}_rate", 0.0) or 0.0) sample = float(features.get(f"band_dc_{dc_key}_sample", 0.0) or 0.0) elif market_key in {"OU15", "OU25", "OU35"}: suffix = {"OU15": "ou15", "OU25": "ou25", "OU35": "ou35"}[market_key] rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate" band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0) sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0) elif market_key == "BTTS": is_yes = "VAR" in pick_key or "YES" in pick_key or pick_key == "Y" band_prob = float(features.get(f"band_btts_{'yes' if is_yes else 'no'}_rate", 0.0) or 0.0) sample = float(features.get("band_btts_sample", 0.0) or 0.0) elif market_key == "HT": if pick_key == "1": band_prob = float(features.get("home_band_ht_win_rate", 0.0) or 0.0) sample = float(features.get("home_band_ht_sample", 0.0) or 0.0) elif pick_key == "2": band_prob = float(features.get("away_band_ht_win_rate", 0.0) or 0.0) sample = float(features.get("away_band_ht_sample", 0.0) or 0.0) elif pick_key in {"X", "0"}: home_draw = float(features.get("home_band_ht_draw_rate", 0.0) or 0.0) away_draw = float(features.get("away_band_ht_draw_rate", 0.0) or 0.0) band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw) sample = max( float(features.get("home_band_ht_sample", 0.0) or 0.0), float(features.get("away_band_ht_sample", 0.0) or 0.0), ) elif market_key in {"HT_OU05", "HT_OU15"}: suffix = "ht_ou05" if market_key == "HT_OU05" else "ht_ou15" rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate" band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0) sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0) band_edge = band_prob - implied_prob if band_prob > 0.0 else 0.0 required_sample = float(self.odds_band_min_sample.get(market_key, 0.0)) required_edge = float(self.odds_band_min_edge.get(market_key, 0.0)) available = band_prob > 0.0 and sample >= required_sample aligned = available and band_edge >= required_edge reason = "odds_band_confirms_value" if required_sample > 0.0 and sample < required_sample: reason = "odds_band_sample_too_low" elif band_prob <= 0.0: reason = "odds_band_missing_probability" elif band_edge < required_edge: reason = f"odds_band_no_value_{band_edge:+.3f}" return { "required": market_key in self.odds_band_min_sample, "available": available, "band_prob": band_prob, "band_sample": sample, "band_edge": band_edge, "aligned": aligned, "reason": reason, } @staticmethod def _normalize_pick_token(pick: str) -> str: return ( str(pick or "") .strip() .upper() .replace("İ", "I") .replace("Ü", "U") .replace("Ş", "S") .replace("Ğ", "G") .replace("Ö", "O") .replace("Ç", "C") ) @staticmethod def _pick_is_over(pick_key: str) -> bool: return "UST" in pick_key or "OVER" in pick_key @staticmethod def _goal_line_for_market(market: str) -> Optional[float]: return { "OU15": 1.5, "OU25": 2.5, "OU35": 3.5, "HT_OU05": 0.5, "HT_OU15": 1.5, "CARDS": 4.5, }.get(market) def _is_live_match(self, data: MatchData) -> bool: status = str(data.status or "").upper() if status in {"NS", "FT", "POSTPONED", "CANC", "ABD"}: return False return data.current_score_home is not None and data.current_score_away is not None def _apply_market_consistency( self, rows: List[Dict[str, Any]], data: MatchData, prediction: FullMatchPrediction, ) -> List[Dict[str, Any]]: if not rows: return rows is_live = self._is_live_match(data) current_goals = ( int(data.current_score_home or 0) + int(data.current_score_away or 0) if is_live else 0 ) both_scored = ( bool(data.current_score_home and data.current_score_home > 0) and bool(data.current_score_away and data.current_score_away > 0) ) predicted_total = float(getattr(prediction, "total_xg", 0.0) or 0.0) over25_prob = float(getattr(prediction, "over_25_prob", 0.0) or 0.0) over35_prob = float(getattr(prediction, "over_35_prob", 0.0) or 0.0) btts_yes_prob = float(getattr(prediction, "btts_yes_prob", 0.0) or 0.0) home_xg = float(getattr(prediction, "home_xg", 0.0) or 0.0) away_xg = float(getattr(prediction, "away_xg", 0.0) or 0.0) xg_gap = abs(home_xg - away_xg) ht_under05_prob = float(getattr(prediction, "ht_under_05_prob", 0.0) or 0.0) ht_over05_prob = float(getattr(prediction, "ht_over_05_prob", 0.0) or 0.0) ht_home_prob = float(getattr(prediction, "ht_home_prob", 0.0) or 0.0) ht_draw_prob = float(getattr(prediction, "ht_draw_prob", 0.0) or 0.0) ht_away_prob = float(getattr(prediction, "ht_away_prob", 0.0) or 0.0) htft_probs = getattr(prediction, "ht_ft_probs", {}) or {} first_half_goal_from_htft = float( sum( float(prob or 0.0) for outcome, prob in htft_probs.items() if str(outcome).startswith(("1/", "2/")) ) ) adjusted: List[Dict[str, Any]] = [] for row in rows: market = str(row.get("market") or "") pick = str(row.get("pick") or "") probability = float(row.get("probability") or 0.0) confidence = float(row.get("confidence") or (probability * 100.0)) reasons = list(row.get("consistency_reasons") or []) impossible = False if is_live: if market == "BTTS" and pick == "KG Yok" and both_scored: impossible = True reasons.append("live_state_impossible_market") line = self._goal_line_for_market(market) if line is not None and "Alt" in pick and current_goals > line: impossible = True reasons.append("live_score_exceeds_under_line") if impossible: continue penalty = 0.0 line = self._goal_line_for_market(market) if line is not None: if "Alt" in pick and predicted_total > (line + 0.35): penalty += min(32.0, (predicted_total - line) * 18.0) reasons.append("score_model_conflicts_with_under_pick") if "Üst" in pick and predicted_total < (line - 0.35): penalty += min(24.0, (line - predicted_total) * 16.0) reasons.append("score_model_conflicts_with_over_pick") if market == "OU35" and "Alt" in pick: if over25_prob >= 0.78: penalty += 14.0 reasons.append("market_stack_conflict_over25") if btts_yes_prob >= 0.74: penalty += 10.0 reasons.append("market_stack_conflict_btts") if is_live and current_goals >= 3: penalty += 24.0 reasons.append("live_total_goals_close_to_line") if market == "BTTS" and pick == "KG Yok" and predicted_total >= 2.8: penalty += 16.0 reasons.append("score_model_conflicts_with_btts_no") if market == "MS": if pick == "X" and xg_gap >= 0.95: penalty += 18.0 reasons.append("score_model_conflicts_with_draw_pick") if pick == "1" and (away_xg - home_xg) >= 0.85: penalty += 20.0 reasons.append("score_model_conflicts_with_home_pick") if pick == "2" and (home_xg - away_xg) >= 0.85: penalty += 20.0 reasons.append("score_model_conflicts_with_away_pick") if market == "HT_OU05": if "Alt" in pick: if max(ht_home_prob, ht_away_prob) >= 0.42: penalty += 22.0 reasons.append("first_half_result_conflicts_with_goalless_half") if first_half_goal_from_htft >= 0.45: penalty += 20.0 reasons.append("first_half_htft_conflicts_with_goalless_half") if "Üst" in pick and ht_draw_prob >= 0.56 and ht_under05_prob >= 0.54: penalty += 14.0 reasons.append("first_half_draw_conflicts_with_goal_pick") if market == "HT" and pick in {"1", "2"} and ht_under05_prob >= 0.56: penalty += 28.0 reasons.append("first_half_goalless_conflicts_with_result_pick") if market == "HTFT": htft_first_half = pick.split("/")[0] if "/" in pick else "" if htft_first_half in {"1", "2"} and ht_under05_prob >= 0.56: penalty += 34.0 reasons.append("first_half_goalless_conflicts_with_htft_pick") if htft_first_half == "X" and ht_over05_prob >= 0.68: penalty += 16.0 reasons.append("first_half_goal_pressure_conflicts_with_htft_draw") if penalty > 0: probability *= max(0.35, 1.0 - (penalty / 100.0)) confidence = max(1.0, confidence - penalty) next_row = dict(row) next_row["probability"] = round(probability, 4) next_row["confidence"] = round(confidence, 1) if reasons: next_row["consistency_reasons"] = reasons adjusted.append(next_row) return adjusted def _build_surprise_profile( self, data: MatchData, prediction: FullMatchPrediction, ) -> Dict[str, Any]: """ Produces an explainable surprise profile. Each factor pushes the base score and contributes: - a human-readable Turkish reason - a `breakdown` entry with code, points, label """ BASE_SCORE = 22.0 breakdown: List[Dict[str, Any]] = [] reasons: List[str] = [] score = BASE_SCORE def add(code: str, points: float, label: str) -> None: nonlocal score score += points reasons.append(label) breakdown.append({"code": code, "points": round(points, 1), "label": label}) ms_home = float(getattr(prediction, "ms_home_prob", 0.0) or 0.0) ms_draw = float(getattr(prediction, "ms_draw_prob", 0.0) or 0.0) ms_away = float(getattr(prediction, "ms_away_prob", 0.0) or 0.0) top_prob = max(ms_home, ms_draw, ms_away) second_prob = sorted([ms_home, ms_draw, ms_away], reverse=True)[1] parity_gap = top_prob - second_prob total_xg = float(getattr(prediction, "total_xg", 0.0) or 0.0) btts_yes = float(getattr(prediction, "btts_yes_prob", 0.0) or 0.0) over35 = float(getattr(prediction, "over_35_prob", 0.0) or 0.0) if parity_gap <= 0.08: add("balanced_match_risk", 18.0, "Takımlar birbirine çok yakın — sonuç kırılabilir") if ms_draw >= 0.30: add("draw_probability_elevated", 14.0, f"Beraberlik olasılığı yüksek (%{ms_draw*100:.0f})") if total_xg >= 3.25: add("high_total_goal_volatility", 10.0, f"Toplam gol beklentisi yüksek (xG {total_xg:.1f}) — açık skor riski") if btts_yes >= 0.68: add("mutual_goal_pressure", 8.0, f"Karşılıklı gol baskısı (%{btts_yes*100:.0f})") if over35 >= 0.52: add("late_goal_swing_risk", 8.0, "Geç gol/skor değişimi riski") # Odds-based traps (favorite odds trap from UpsetEngineV2 logic) ms_h_odd = self._safe_float((data.odds_data or {}).get("ms_h"), 0.0) ms_a_odd = self._safe_float((data.odds_data or {}).get("ms_a"), 0.0) ms_d_odd = self._safe_float((data.odds_data or {}).get("ms_d"), 0.0) favorite_side = None favorite_odd = 0.0 if ms_h_odd > 1.01 and ms_a_odd > 1.01: if ms_h_odd <= ms_a_odd: favorite_side, favorite_odd = "home", ms_h_odd else: favorite_side, favorite_odd = "away", ms_a_odd # Favorite odds trap (1.40-1.60 historically %33+ surprise rate) if 1.40 <= favorite_odd < 1.60: add( "favorite_odds_trap", 12.0, f"Favori oranı tuzak aralığında ({favorite_odd:.2f}) — tarihsel sürpriz oranı %30+", ) elif 1.20 <= favorite_odd < 1.30: add( "low_odds_trap_suspicion", 6.0, f"Favori oranı çok düşük ({favorite_odd:.2f}) — piyasa aşırı güveniyor olabilir", ) # Bookmaker margin if ms_h_odd > 1.01 and ms_a_odd > 1.01 and ms_d_odd > 1.01: margin = (1 / ms_h_odd + 1 / ms_d_odd + 1 / ms_a_odd) - 1 if margin > 0.20: add( "bookmaker_margin_high", 10.0, f"Bookmaker marjı çok yüksek (%{margin*100:.1f}) — bahisçi risk görüyor", ) elif margin > 0.18: add( "bookmaker_margin_elevated", 6.0, f"Bookmaker marjı yüksek (%{margin*100:.1f})", ) # Away favorite carries inherent extra risk if favorite_side == "away" and favorite_odd > 0: add( "away_favorite_extra_risk", 6.0, "Deplasman favorisi — atmosfer ve seyahat ek risk yaratır", ) if data.lineup_source == "probable_xi": add("lineup_probable_not_confirmed", 8.0, "Kadrolar tahmini — kesinleşmemiş") if data.lineup_source == "none": add("lineup_unavailable", 12.0, "Kadro bilgisi yok — analiz güvenilirliği düştü") if not data.referee_name: add("missing_referee", 6.0, "Hakem atanmamış — disiplin/avantaj sinyali eksik") if self._is_live_match(data): current_goals = int(data.current_score_home or 0) + int(data.current_score_away or 0) if current_goals >= 3: add("live_match_open_state", 18.0, f"Maç şu an açık skorlu ({current_goals} gol) — pre-match tahminler riskli") elif current_goals >= 2: add("live_match_active_state", 10.0, f"Maç canlı ve hareketli ({current_goals} gol)") # Live underdog leading (pre-match favorite is losing) cur_home = int(data.current_score_home or 0) cur_away = int(data.current_score_away or 0) if favorite_side == "home" and cur_away > cur_home: add( "live_underdog_leading", 20.0, "Canlı: deplasman önde, pre-match ev sahibi favorisiydi — sürpriz GERÇEKLEŞİYOR", ) elif favorite_side == "away" and cur_home > cur_away: add( "live_underdog_leading", 20.0, "Canlı: ev sahibi önde, pre-match deplasman favorisiydi — sürpriz GERÇEKLEŞİYOR", ) score = max(0.0, min(100.0, score)) if score >= 75: comment = "Bu maçta sürpriz ve kırılma riski yüksek. Ana tahminler yatabilir; tekli yerine daha temkinli yaklaşım gerekir." elif score >= 55: comment = "Bu maçta belirgin sürpriz sinyalleri var. Tahminler yön verse de kupon kararında temkinli olunmalı." elif score >= 40: comment = "Maçta orta seviyede belirsizlik var. Tahminler yorum için faydalı ama güven payı sınırlı." else: comment = "Sürpriz riski düşük görünüyor. Tahminler normal güven bandında okunabilir." # Deduplicate reasons by text while preserving order deduped_reasons = list(dict.fromkeys(reasons))[:8] # Same dedup logic for breakdown (by code) seen_codes: Set[str] = set() deduped_breakdown: List[Dict[str, Any]] = [] for entry in breakdown: if entry["code"] in seen_codes: continue seen_codes.add(entry["code"]) deduped_breakdown.append(entry) return { "score": round(score, 1), "comment": comment, "reasons": deduped_reasons, "breakdown": deduped_breakdown[:10], "base_score": BASE_SCORE, } @staticmethod def _normalize_v25_probs(market: str, probs: Dict[str, Any]) -> Dict[str, float]: out: Dict[str, float] = {} for key, value in (probs or {}).items(): if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}: norm_key = "over" if key == "Over" else "under" if key == "Under" else str(key).lower() elif market == "BTTS": norm_key = "yes" if key == "Yes" else "no" if key == "No" else str(key).lower() elif market == "OE": norm_key = "odd" if key == "Odd" else "even" if key == "Even" else str(key).lower() else: norm_key = str(key) out[norm_key] = round(float(value), 4) return out def _merge_v25_market_rows( self, rows: List[Dict[str, Any]], odds: Dict[str, Any], v25_signal: Optional[Dict[str, Any]], ) -> List[Dict[str, Any]]: if not v25_signal: return rows by_market = {row.get("market"): dict(row) for row in rows} for market, payload in v25_signal.items(): if market == "value_bets" or not isinstance(payload, dict): continue pick = str(payload.get("pick") or "") if not self._market_has_real_pick_odds(market, pick, odds): continue probability = float(payload.get("probability") or 0.0) by_market[market] = { "market": market, "pick": self._v25_pick_to_market_pick(market, pick), "probability": round(probability, 4), "confidence": round(float(payload.get("confidence") or probability * 100.0), 1), "odds": self._v25_market_odds(odds, market, pick), } preferred_order = [ "MS", "DC", "OU15", "OU25", "OU35", "BTTS", "HT", "HT_OU05", "HT_OU15", "HTFT", "OE", "CARDS", "HCAP", ] return [by_market[key] for key in preferred_order if key in by_market] def _merge_v25_market_board( self, market_board: Dict[str, Any], v25_signal: Optional[Dict[str, Any]], ) -> Dict[str, Any]: if not v25_signal: return market_board merged = dict(market_board) for market, payload in v25_signal.items(): if market == "value_bets" or not isinstance(payload, dict): continue merged[market] = { "pick": self._v25_pick_to_market_pick(market, str(payload.get("pick") or "")), "confidence": round(float(payload.get("confidence") or 0.0), 1), "probs": self._normalize_v25_probs(market, payload.get("probs") or {}), } return merged def _build_market_rows( self, data: MatchData, pred: FullMatchPrediction, v25_signal: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: odds = data.odds_data rows = [ { "market": "MS", "pick": pred.ms_pick, "probability": round( float(max(pred.ms_home_prob, pred.ms_draw_prob, pred.ms_away_prob)), 4, ), "confidence": round(float(pred.ms_confidence), 1), "odds": round(self._real_market_odds(odds, {"1": "ms_h", "X": "ms_d", "2": "ms_a"}.get(pred.ms_pick, "ms_h")), 2), }, { "market": "DC", "pick": pred.dc_pick, "probability": round( float(max(pred.dc_1x_prob, pred.dc_x2_prob, pred.dc_12_prob)), 4, ), "confidence": round(float(pred.dc_confidence), 1), "odds": round(float(odds.get(f"dc_{pred.dc_pick.lower()}", 1.0)), 2), }, { "market": "OU15", "pick": pred.ou15_pick, "probability": round(float(pred.over_15_prob if "Üst" in pred.ou15_pick or "Over" in pred.ou15_pick else pred.under_15_prob), 4), "confidence": round(float(pred.ou15_confidence), 1), "odds": round(float(odds.get("ou15_o" if "Üst" in pred.ou15_pick or "Over" in pred.ou15_pick else "ou15_u", 1.0)), 2), }, { "market": "OU25", "pick": pred.ou25_pick, "probability": round(float(pred.over_25_prob if "Üst" in pred.ou25_pick or "Over" in pred.ou25_pick else pred.under_25_prob), 4), "confidence": round(float(pred.ou25_confidence), 1), "odds": round(float(odds.get("ou25_o" if "Üst" in pred.ou25_pick or "Over" in pred.ou25_pick else "ou25_u", 1.0)), 2), }, { "market": "OU35", "pick": pred.ou35_pick, "probability": round(float(pred.over_35_prob if "Üst" in pred.ou35_pick or "Over" in pred.ou35_pick else pred.under_35_prob), 4), "confidence": round(float(pred.ou35_confidence), 1), "odds": round(float(odds.get("ou35_o" if "Üst" in pred.ou35_pick or "Over" in pred.ou35_pick else "ou35_u", 1.0)), 2), }, { "market": "BTTS", "pick": pred.btts_pick, "probability": round(float(pred.btts_yes_prob if "Var" in pred.btts_pick or "Yes" in pred.btts_pick else pred.btts_no_prob), 4), "confidence": round(float(pred.btts_confidence), 1), "odds": round(float(odds.get("btts_y" if "Var" in pred.btts_pick or "Yes" in pred.btts_pick else "btts_n", 1.0)), 2), }, { "market": "HT", "pick": pred.ht_pick, "probability": round(float(max(pred.ht_home_prob, pred.ht_draw_prob, pred.ht_away_prob)), 4), "confidence": round(float(pred.ht_confidence), 1), "odds": round(float(odds.get({"1": "ht_h", "X": "ht_d", "2": "ht_a"}.get(pred.ht_pick, "ht_h"), 1.0)), 2), }, { "market": "HT_OU05", "pick": pred.ht_ou_pick, "probability": round(float(pred.ht_over_05_prob if "Üst" in pred.ht_ou_pick or "Over" in pred.ht_ou_pick else pred.ht_under_05_prob), 4), "confidence": round(float(max(pred.ht_over_05_prob, pred.ht_under_05_prob) * 100), 1), "odds": round(float(odds.get("ht_ou05_o" if "Üst" in pred.ht_ou_pick or "Over" in pred.ht_ou_pick else "ht_ou05_u", 1.0)), 2), }, { "market": "HT_OU15", "pick": pred.ht_ou15_pick, "probability": round(float(pred.ht_over_15_prob if "Üst" in pred.ht_ou15_pick or "Over" in pred.ht_ou15_pick else pred.ht_under_15_prob), 4), "confidence": round(float(max(pred.ht_over_15_prob, pred.ht_under_15_prob) * 100), 1), "odds": round(float(odds.get("ht_ou15_o" if "Üst" in pred.ht_ou15_pick or "Over" in pred.ht_ou15_pick else "ht_ou15_u", 1.0)), 2), }, { "market": "OE", "pick": pred.odd_even_pick, "probability": round(float(pred.odd_prob if "Tek" in pred.odd_even_pick else pred.even_prob), 4), "confidence": round(float(max(pred.odd_prob, pred.even_prob) * 100), 1), "odds": round(float(odds.get("oe_odd" if "Tek" in pred.odd_even_pick else "oe_even", 1.0)), 2), }, { "market": "CARDS", "pick": pred.card_pick, "probability": round(float(pred.cards_over_prob if "Üst" in pred.card_pick or "Over" in pred.card_pick else pred.cards_under_prob), 4), "confidence": round(float(pred.cards_confidence), 1), "odds": round(float(odds.get("cards_o" if "Üst" in pred.card_pick or "Over" in pred.card_pick else "cards_u", 1.0)), 2), }, { "market": "HCAP", "pick": pred.handicap_pick, "probability": round(float( pred.handicap_home_prob if pred.handicap_pick == "1" else pred.handicap_draw_prob if pred.handicap_pick == "X" else pred.handicap_away_prob ), 4), "confidence": round(float(pred.handicap_confidence), 1), "odds": round(float( odds.get( {"1": "hcap_h", "X": "hcap_d", "2": "hcap_a"}.get(pred.handicap_pick, "hcap_h"), 1.0, ) ), 2), }, ] # HT/FT Market - 9 possible outcomes htft_probs = pred.ht_ft_probs or {} if htft_probs: # Find the highest probability HT/FT outcome htft_labels = ("1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2") best_htft = max(htft_labels, key=lambda x: float(htft_probs.get(x, 0.0))) best_htft_prob = float(htft_probs.get(best_htft, 0.0)) # Map HT/FT labels to odds keys htft_odds_key = f"htft_{best_htft.replace('/', '').lower()}" # e.g., htft_11, htft_1x, htft_12 htft_odds = float(odds.get(htft_odds_key, 1.0)) rows.append({ "market": "HTFT", "pick": best_htft, "probability": round(best_htft_prob, 4), "confidence": round(best_htft_prob * 100, 1), "odds": round(htft_odds, 2), }) rows = [ row for row in rows if self._market_has_real_pick_odds( str(row.get("market") or ""), str(row.get("pick") or ""), odds, ) ] return self._merge_v25_market_rows(rows, odds, v25_signal) def _decorate_market_row( self, data: MatchData, prediction: FullMatchPrediction, quality: Dict[str, Any], row: Dict[str, Any], ) -> Dict[str, Any]: """ Decorate a raw market row with playability, grading, and staking. V20+Quant hybrid: - All existing V20+ safety gates preserved (lineup, risk, quality, conf) - Edge: EV formula → (prob × odds) - 1.0 (not simple prob - implied) - Staking: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll) - Grading: Edge-based → A(>10%), B(>5%), C(>2%), PASS """ market = str(row.get("market") or "") raw_conf = float(row.get("confidence") or 0.0) prob = float(row.get("probability") or 0.0) odd = float(row.get("odds") or 0.0) pick_str = str(row.get("pick") or "") # Trained isotonic calibrator (preferred) — falls back to multiplier if not trained. # IMPORTANT: trainer was fed (raw_confidence/100, actual). Orchestrator must feed # the same shape — using `prob` (which may differ from raw_conf/100 due to upstream # confidence boosting) would give the calibrator an out-of-distribution input. calibrator = get_calibrator() cal_key = self._calibrator_key(market, pick_str) if cal_key and cal_key in calibrator.calibrators: cal_input = max(0.001, min(0.999, raw_conf / 100.0)) cal_prob = calibrator.calibrate(cal_key, cal_input, odds_val=odd if odd > 1.0 else None) calibrated_conf = max(1.0, min(99.0, cal_prob * 100.0)) else: multiplier = self.market_calibration.get(market, 0.85) calibrated_conf = max(1.0, min(99.0, raw_conf * multiplier)) min_conf = self.market_min_conf.get(market, 55.0) implied_prob = (1.0 / odd) if odd > 1.0 else 0.0 band_verdict = self._odds_band_verdict(data, market, pick_str, implied_prob) # ── V31: League-specific odds reliability ────────────────────── # Higher reliability → trust odds-based edge more in play_score # Lower reliability → lean more on model confidence, less on edge odds_rel = self.league_reliability.get( str(data.league_id or ""), 0.35 # default for unknown leagues ) # Edge weight: reliable league → edge matters more (up to 120%) # unreliable league → edge matters less (down to 60%) edge_multiplier = 0.60 + (odds_rel * 0.60) # range: 0.60 – 1.20 risk_level = str(prediction.risk_level or "MEDIUM").upper() risk_penalty = {"LOW": 0.0, "MEDIUM": 3.0, "HIGH": 8.0, "EXTREME": 12.0}.get( risk_level, 5.0, ) quality_label = str(quality.get("label") or "MEDIUM").upper() quality_penalty = {"HIGH": 0.0, "MEDIUM": 3.0, "LOW": 7.0}.get( quality_label, 5.0, ) # V33: Removed probability deflation. Deflating probability breaks normalization # (probs no longer sum to 1) and mathematically guarantees negative EV edge. # Data quality and confidence penalties are already applied to play_score. model_calibrated_prob = prob band_prob = float(band_verdict.get("band_prob", 0.0) or 0.0) if bool(band_verdict.get("available")): calibrated_probability = ( (model_calibrated_prob * 0.45) + (band_prob * 0.35) + (implied_prob * 0.20) ) elif implied_prob > 0.0: calibrated_probability = (model_calibrated_prob * 0.65) + (implied_prob * 0.35) else: calibrated_probability = model_calibrated_prob calibrated_probability = max(0.0, min(0.99, calibrated_probability)) model_edge = model_calibrated_prob - implied_prob if implied_prob > 0 else 0.0 ev_edge = (calibrated_probability * odd) - 1.0 if odd > 1.0 else 0.0 simple_edge = calibrated_probability - implied_prob if implied_prob > 0 else 0.0 home_n = len(data.home_lineup or []) away_n = len(data.away_lineup or []) lineup_missing = home_n < 9 or away_n < 9 lineup_sensitive = market in ("MS", "BTTS", "HT", "HTFT") lineup_penalty = 5.0 if lineup_missing and lineup_sensitive else 0.0 if data.lineup_source == "probable_xi" and lineup_sensitive: lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0))) lineup_penalty += max(1.0, (1.0 - lineup_conf) * 5.0) # ── V20+ Safety gates (PRESERVED) ───────────────────────────── min_play_score = self.market_min_play_score.get(market, 68.0) min_edge = self.market_min_edge.get(market, 0.02) reasons: List[str] = [] playable = True # V34: Broadened value_sniper bypass — odds-aware model rarely shows 3% EV edge # Allow high-confidence predictions OR modest positive EV to bypass secondary gates is_value_sniper = ev_edge >= 0.008 or calibrated_conf >= 55.0 if calibrated_conf < min_conf: if not is_value_sniper: playable = False reasons.append("below_calibrated_conf_threshold") if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01: playable = False reasons.append("market_odds_missing") if risk_level in ("HIGH", "EXTREME") and quality_label == "LOW": playable = False reasons.append("high_risk_low_data_quality") if lineup_missing and lineup_sensitive: # V32: Don't hard-block, apply heavy penalty instead # This allows high-confidence predictions to still surface lineup_penalty += 8.0 reasons.append("lineup_insufficient_for_market") if data.lineup_source == "probable_xi" and lineup_sensitive: # V32: Penalty instead of hard block # Most pre-match predictions use probable_xi — blocking kills all output lineup_penalty += 6.0 reasons.append("lineup_probable_xi_penalty") # V34: Added confidence bonus — high raw model probability gets a boost # This prevents over-penalization when edge is near-zero but model is confident raw_top_prob = float(row.get("probability", 0.0)) confidence_bonus = 0.0 if raw_top_prob >= 0.65: confidence_bonus = 15.0 elif raw_top_prob >= 0.55: confidence_bonus = 10.0 elif raw_top_prob >= 0.45: confidence_bonus = 5.0 base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier) + confidence_bonus play_score = max( 0.0, min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty), ) # V34: odds_band gate — only hard-block when band data is AVAILABLE and aligned=False # When band data is sparse (available=False), skip alignment check entirely band_available = bool(band_verdict.get("available", False)) if band_available and bool(band_verdict.get("required")) and not bool(band_verdict.get("aligned")): if not is_value_sniper: playable = False reasons.append(str(band_verdict.get("reason") or "odds_band_not_aligned")) elif not band_available and bool(band_verdict.get("required")): # Sparse data — log but don't block reasons.append("odds_band_data_sparse_skipped") # V34: REMOVED model_not_above_market gate entirely # V25 model is odds-informed BY DESIGN → model output ≈ market-implied probability # Requiring model > market is mathematically impossible with this architecture # The negative_model_edge gate below still catches truly anti-value picks # V34: negative edge threshold relaxed — odds-aware model's edge is naturally near zero # Reliable league: -0.08, unreliable: up to -0.15 # Only blocks truly anti-value picks (model significantly below market) neg_edge_threshold = -0.08 - (1.0 - odds_rel) * 0.07 if odd > 1.0 and simple_edge < neg_edge_threshold: if not is_value_sniper: playable = False reasons.append(f"negative_model_edge_{simple_edge:+.3f}") # V34: Added value_sniper bypass — was missing before, causing hard blocks if odd > 1.0 and ev_edge < min_edge: if not is_value_sniper: playable = False reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}") if play_score < min_play_score: if not is_value_sniper: playable = False reasons.append("insufficient_play_score") if not reasons: reasons.append("market_passed_all_gates") consistency_reasons = [ str(reason) for reason in row.get("consistency_reasons", []) if reason ] if consistency_reasons: reasons.extend(consistency_reasons) reasons = list(dict.fromkeys(reasons)) # ── V2 Quant: Edge-based grading (replaces play_score bands) ── if not playable: grade = "PASS" stake_units = 0.0 elif ev_edge > 0.10: grade = "A" # V2 Quant: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll) stake_units = self._kelly_stake(calibrated_probability, odd) reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A") elif ev_edge > 0.05: grade = "B" stake_units = self._kelly_stake(calibrated_probability, odd) reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B") elif ev_edge > 0.02: grade = "C" stake_units = self._kelly_stake(calibrated_probability, odd) reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_C") else: # Passes all V20+ gates but no mathematical edge over bookie grade = "C" stake_units = 0.25 # minimum stake (conservative) reasons.append("no_ev_edge_minimum_stake") out = dict(row) out.update( { "raw_confidence": round(raw_conf, 1), "calibrated_confidence": round(calibrated_conf, 1), "min_required_confidence": round(min_conf, 1), "min_required_play_score": round(min_play_score, 1), "min_required_edge": round(min_edge, 4), "edge": round(ev_edge, 4), "model_probability": round(prob, 4), "model_edge": round(model_edge, 4), "calibrated_probability": round(calibrated_probability, 4), "implied_prob": round(implied_prob, 4), "ev_edge": round(ev_edge, 4), "is_value_sniper": is_value_sniper, "odds_band_probability": round(float(band_verdict.get("band_prob", 0.0) or 0.0), 4), "odds_band_sample": round(float(band_verdict.get("band_sample", 0.0) or 0.0), 1), "odds_band_edge": round(float(band_verdict.get("band_edge", 0.0) or 0.0), 4), "odds_band_aligned": bool(band_verdict.get("aligned")), "odds_reliability": round(odds_rel, 4), "play_score": round(play_score, 1), "playable": playable, "bet_grade": grade, "stake_units": stake_units, "decision_reasons": reasons[:5], }, ) return out @staticmethod def _kelly_stake(true_prob: float, decimal_odds: float) -> float: """ Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll). Full Kelly: f* = ((b × p) - q) / b where b = odds - 1, p = true_prob, q = 1 - p Quarter-Kelly reduces variance and ruin risk on noisy sports data. Returns stake in units, capped at 3.0. """ if decimal_odds <= 1.0 or true_prob <= 0.0 or true_prob >= 1.0: return 0.25 # minimum fallback b = decimal_odds - 1.0 p = true_prob q = 1.0 - p f_star = ((b * p) - q) / b if f_star <= 0.0: return 0.25 # minimum fallback kelly_fraction = 0.25 # quarter-Kelly bankroll_units = 10.0 stake = f_star * kelly_fraction * bankroll_units stake = min(stake, 3.0) # cap return round(max(0.25, stake), 1) @staticmethod def _to_bet_summary_item(row: Dict[str, Any]) -> Dict[str, Any]: return { "market": row.get("market"), "pick": row.get("pick"), "raw_confidence": row.get("raw_confidence", row.get("confidence")), "calibrated_confidence": row.get("calibrated_confidence", row.get("confidence")), "bet_grade": row.get("bet_grade", "PASS"), "playable": bool(row.get("playable")), "stake_units": float(row.get("stake_units", 0.0)), "play_score": row.get("play_score", 0.0), "ev_edge": row.get("ev_edge", row.get("edge", 0.0)), "is_value_sniper": bool(row.get("is_value_sniper")), "model_probability": row.get("model_probability", row.get("probability", 0.0)), "model_edge": row.get("model_edge", 0.0), "calibrated_probability": row.get("calibrated_probability", row.get("probability", 0.0)), "implied_prob": row.get("implied_prob", 0.0), "odds_band_probability": row.get("odds_band_probability", 0.0), "odds_band_sample": row.get("odds_band_sample", 0.0), "odds_band_edge": row.get("odds_band_edge", 0.0), "odds_band_aligned": bool(row.get("odds_band_aligned")), "odds_reliability": row.get("odds_reliability", 0.35), "odds": row.get("odds", 0.0), "reasons": row.get("decision_reasons", []), } def _compute_data_quality(self, data: MatchData) -> Dict[str, Any]: if str(data.sport or "football").lower() == "basketball": return self._compute_basketball_data_quality(data) flags: List[str] = [] ms_keys = ("ms_h", "ms_d", "ms_a") has_ms = all(k in data.odds_data for k in ms_keys) has_market_depth = any(k not in ms_keys for k in data.odds_data.keys()) is_default_ms = ( abs(float(data.odds_data.get("ms_h", 0.0)) - self.DEFAULT_MS_H) < 1e-6 and abs(float(data.odds_data.get("ms_d", 0.0)) - self.DEFAULT_MS_D) < 1e-6 and abs(float(data.odds_data.get("ms_a", 0.0)) - self.DEFAULT_MS_A) < 1e-6 ) has_real_ms = has_ms and (has_market_depth or (not is_default_ms)) odds_score = 1.0 if has_real_ms else (0.6 if has_ms else 0.4) if odds_score < 1.0: flags.append("missing_full_ms_odds") home_n = len(data.home_lineup or []) away_n = len(data.away_lineup or []) lineup_score = min(home_n, away_n) / 11.0 if min(home_n, away_n) > 0 else 0.0 if data.lineup_source == "probable_xi": lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0))) lineup_score *= max(0.45, min(0.88, lineup_conf)) flags.append("lineup_probable_not_confirmed") if lineup_conf < 0.65: flags.append("lineup_projection_low_confidence") elif data.lineup_source == "none": flags.append("lineup_unavailable") if lineup_score < 0.7: flags.append("lineup_incomplete") ref_score = 1.0 if data.referee_name else 0.6 if not data.referee_name: flags.append("missing_referee") if data.source_table == "live_matches": flags.append("live_match_pre_match_features") feature_source = str(getattr(data, "feature_source", "") or "") if feature_source == "live_prematch_enrichment": flags.append("ai_features_inferred_from_history") total_score = (odds_score * 0.45) + (lineup_score * 0.45) + (ref_score * 0.10) # When statistical features are inferred (not from pre-computed table), # ELO/form/H2H data reliability is unknown — cap quality at MEDIUM. if feature_source == "live_prematch_enrichment": total_score = min(total_score, 0.74) if total_score >= 0.8: label = "HIGH" elif total_score >= 0.55: label = "MEDIUM" else: label = "LOW" if label == "HIGH" and ( data.lineup_source == "probable_xi" or not data.referee_name or feature_source == "live_prematch_enrichment" ): label = "MEDIUM" return { "label": label, "score": round(total_score, 3), "home_lineup_count": home_n, "away_lineup_count": away_n, "lineup_source": data.lineup_source, "lineup_confidence": round(float(getattr(data, "lineup_confidence", 0.0) or 0.0), 3), "feature_source": feature_source or "unknown", "flags": flags, } def _build_reasoning_factors( self, data: MatchData, prediction: FullMatchPrediction, quality: Dict[str, Any], ) -> List[str]: factors: List[str] = [] if prediction.odds_confidence >= prediction.team_confidence: factors.append("market_signal_dominant") else: factors.append("team_form_signal_dominant") if prediction.player_confidence >= 60: factors.append("lineup_signal_strong") elif not data.home_lineup or not data.away_lineup: factors.append("lineup_signal_weak") if data.lineup_source == "probable_xi": factors.append("lineup_probable_xi_used") if prediction.is_surprise_risk: factors.append("upset_risk_detected") if quality["label"] == "LOW": factors.append("limited_data_confidence") if prediction.risk_warnings: factors.extend([f"risk:{w}" for w in prediction.risk_warnings[:2]]) return factors