main
Deploy Iddaai Backend / build-and-deploy (push) Failing after 2m6s

This commit is contained in:
2026-05-12 02:43:02 +03:00
parent f8599bdb9a
commit b6d64b59bf
35 changed files with 1400 additions and 630 deletions
+315 -107
View File
@@ -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