This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user