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
+110 -3
View File
@@ -19,6 +19,10 @@ class BettingBrain:
SOFT_DIVERGENCE = 0.14
EXTREME_MODEL_PROB = 0.85
EXTREME_GAP = 0.30
# Vetoes that is_value_sniper bypasses (does NOT bypass odds_below_minimum)
SNIPER_BYPASSABLE_VETOES = {"calibrated_confidence_too_low", "play_score_too_low"}
# Trap market: market implied probability massively exceeds historical band hit rate
TRAP_MARKET_GAP = 0.10
MARKET_PRIORS = {
"DC": 4.0,
@@ -59,8 +63,13 @@ class BettingBrain:
row for row in judged_rows.values()
if row.get("betting_brain", {}).get("action") == "WATCH"
]
no_value = [
row for row in judged_rows.values()
if row.get("betting_brain", {}).get("action") == "WATCH_NO_VALUE"
]
approved.sort(key=self._candidate_sort_key, reverse=True)
watchlist.sort(key=self._candidate_sort_key, reverse=True)
no_value.sort(key=self._candidate_sort_key, reverse=True)
original_main = guarded.get("main_pick") or {}
main_pick = None
@@ -78,6 +87,13 @@ class BettingBrain:
self._force_no_bet(main_pick, "betting_brain_watchlist")
decision = "WATCHLIST"
decision_reason = main_pick.get("betting_brain", {}).get("summary", "Interesting but not clean enough.")
elif no_value:
# B-1: model agrees with a low-odds market — surface it so the user
# sees the read, but explicitly mark as not-playable.
main_pick = dict(no_value[0])
self._force_no_bet(main_pick, "betting_brain_no_value_odds_below_minimum")
decision = "WATCH_NO_VALUE"
decision_reason = "Model favoriyle hemfikir ama oran bahis için çok düşük — bilgi amaçlı gösteriliyor."
elif original_main:
main_pick = dict(judged_rows.get(self._row_key(original_main), original_main))
self._force_no_bet(main_pick, "betting_brain_no_safe_pick")
@@ -103,7 +119,7 @@ class BettingBrain:
playable = decision == "BET" and bool(main_pick and main_pick.get("playable"))
advice = dict(guarded.get("bet_advice") or {})
advice["playable"] = playable
advice["suggested_stake_units"] = float(main_pick.get("stake_units", 0.0)) if playable else 0.0
advice["suggested_stake_units"] = float(main_pick.get("stake_units", 0.0)) if playable and main_pick else 0.0
advice["reason"] = "betting_brain_approved" if playable else "betting_brain_no_bet"
advice["decision"] = decision
advice["confidence_band"] = self._decision_band(main_pick)
@@ -199,6 +215,23 @@ class BettingBrain:
score += 11.0
positives.append("v25_v27_aligned")
# Trap market detection: market overpriced vs historical band hit rate
trap_market_flag = False
trap_market_gap = None
if isinstance(triple, dict):
band_rate_val = self._safe_float(triple.get("band_rate"))
implied_val = self._safe_float(triple.get("implied_prob"))
if (
band_rate_val is not None
and implied_val is not None
and band_sample >= self.MIN_BAND_SAMPLE
and (implied_val - band_rate_val) > self.TRAP_MARKET_GAP
):
trap_market_flag = True
trap_market_gap = round(implied_val - band_rate_val, 4)
score -= 14.0
issues.append("trap_market_market_overpriced")
if isinstance(triple, dict):
if triple_is_value:
score += 18.0
@@ -240,10 +273,28 @@ class BettingBrain:
if market in {"HT", "HTFT", "OE"} and score < 86.0 and not is_value_sniper:
vetoes.append("volatile_market_requires_exceptional_evidence")
# Sniper override: bypass eligible vetoes when value sniper triggered
sniper_bypassed: List[str] = []
if is_value_sniper and vetoes:
remaining = []
for v in vetoes:
if v in self.SNIPER_BYPASSABLE_VETOES:
sniper_bypassed.append(v)
else:
remaining.append(v)
vetoes = remaining
if sniper_bypassed:
positives.append("sniper_bypassed_soft_vetoes")
score = max(0.0, min(100.0, score))
action = "BET"
if vetoes:
action = "REJECT"
# B-1: when only veto is odds_below_minimum, switch to WATCH_NO_VALUE
# so user still sees model commentary instead of blank rejection.
if vetoes == ["odds_below_minimum"]:
action = "WATCH_NO_VALUE"
else:
action = "REJECT"
elif score < self.MIN_WATCH_SCORE and not is_value_sniper:
action = "REJECT"
elif score < self.MIN_BET_SCORE and not is_value_sniper:
@@ -256,6 +307,9 @@ class BettingBrain:
"positives": positives[:5],
"issues": issues[:6],
"vetoes": vetoes[:6],
"sniper_bypassed": sniper_bypassed,
"trap_market_flag": trap_market_flag,
"trap_market_gap": trap_market_gap,
"model_prob": round(model_prob, 4) if model_prob is not None else None,
"implied_prob": round(implied, 4),
"model_market_gap": round(model_gap, 4) if model_gap is not None else None,
@@ -290,9 +344,59 @@ class BettingBrain:
if isinstance(item, dict) and item.get("market"):
key = self._row_key(item)
rows[key] = self._merge_row(rows.get(key), item)
# B-2: ensure both MS sides (and DC sides) have an entry — give user the
# model's read on the opposite outcome even when upstream filtered it out.
self._inject_reference_rows(rows, package)
return list(rows.values())
def _inject_reference_rows(
self,
rows: Dict[str, Dict[str, Any]],
package: Dict[str, Any],
) -> None:
market_board = package.get("market_board") or {}
ms_board = market_board.get("MS") if isinstance(market_board, dict) else None
if not isinstance(ms_board, dict):
return
probs = ms_board.get("probs") if isinstance(ms_board.get("probs"), dict) else {}
if not probs:
return
# Pull MS odds from any existing MS row to estimate the missing side's odds
existing_odds_by_pick: Dict[str, float] = {}
for row in rows.values():
if str(row.get("market")) == "MS":
pick = str(row.get("pick"))
odd = self._safe_float(row.get("odds"), 0.0) or 0.0
if pick and odd > 1.0:
existing_odds_by_pick[pick] = odd
for pick in ("1", "X", "2"):
key = f"MS:{pick}"
if key in rows:
continue
prob = self._safe_float(probs.get(pick), 0.0)
if prob is None or prob <= 0.0:
continue
implied_odd = round(1.0 / prob, 2) if prob > 0.01 else 0.0
ref_odd = existing_odds_by_pick.get(pick) or implied_odd
rows[key] = {
"market": "MS",
"pick": pick,
"probability": round(prob, 4),
"confidence": round(prob * 100.0, 1),
"raw_confidence": round(prob * 100.0, 1),
"calibrated_confidence": round(prob * 100.0, 1),
"odds": ref_odd,
"is_underdog_reference": True,
"playable": False,
"stake_units": 0.0,
"bet_grade": "PASS",
"decision_reasons": ["underdog_reference_for_completeness"],
}
@staticmethod
def _merge_row(existing: Optional[Dict[str, Any]], incoming: Dict[str, Any]) -> Dict[str, Any]:
if existing is None:
@@ -331,6 +435,7 @@ class BettingBrain:
"odds_reliability": row.get("odds_reliability", 0.35),
"odds": row.get("odds", 0.0),
"reasons": reasons[:6],
"is_underdog_reference": bool(row.get("is_underdog_reference")),
"betting_brain": row.get("betting_brain"),
}
@@ -409,6 +514,8 @@ class BettingBrain:
return f"{market} {pick} approved: evidence is aligned enough for a controlled stake."
if action == "WATCH":
return f"{market} {pick} is interesting but not clean enough for stake."
if action == "WATCH_NO_VALUE":
return f"{market} {pick}: model favoriyle hemfikir, fakat oran ({', '.join(vetoes[:1]) or 'düşük'}) bahis için yetersiz."
if vetoes:
return f"{market} {pick} rejected: {', '.join(vetoes[:3])}."
if issues:
+35 -14
View File
@@ -248,8 +248,8 @@ class FeatureEnrichmentService:
away_team_venue_total = 0
for row in rows:
sh = int(row['score_home'])
sa = int(row['score_away'])
sh = int(row['score_home'] or 0)
sa = int(row['score_away'] or 0)
match_goals = sh + sa
total_goals += match_goals
@@ -284,13 +284,13 @@ class FeatureEnrichmentService:
if total >= 6:
recent_5_wins = sum(
1 for r in rows[:5]
if (str(r['home_team_id']) == home_team_id and int(r['score_home']) > int(r['score_away']))
or (str(r['home_team_id']) != home_team_id and int(r['score_away']) > int(r['score_home']))
if (str(r['home_team_id']) == home_team_id and int(r['score_home'] or 0) > int(r['score_away'] or 0))
or (str(r['home_team_id']) != home_team_id and int(r['score_away'] or 0) > int(r['score_home'] or 0))
)
older_5_wins = sum(
1 for r in rows[-5:]
if (str(r['home_team_id']) == home_team_id and int(r['score_home']) > int(r['score_away']))
or (str(r['home_team_id']) != home_team_id and int(r['score_away']) > int(r['score_home']))
if (str(r['home_team_id']) == home_team_id and int(r['score_home'] or 0) > int(r['score_away'] or 0))
or (str(r['home_team_id']) != home_team_id and int(r['score_away'] or 0) > int(r['score_home'] or 0))
)
recent_trend = (recent_5_wins - older_5_wins) / 5.0
@@ -302,6 +302,12 @@ class FeatureEnrichmentService:
- away_team_venue_wins / away_team_venue_total
)
if total == 0:
return dict(self._DEFAULT_H2H)
if total == 0:
return dict(self._DEFAULT_H2H)
if total == 0:
return dict(self._DEFAULT_H2H)
return {
'total_matches': total,
'home_win_rate': home_wins / total,
@@ -366,8 +372,8 @@ class FeatureEnrichmentService:
for row in rows:
is_home = str(row['home_team_id']) == team_id
goals_for = int(row['score_home'] if is_home else row['score_away'])
goals_against = int(row['score_away'] if is_home else row['score_home'])
goals_for = int((row['score_home'] if is_home else row['score_away']) or 0)
goals_against = int((row['score_away'] if is_home else row['score_home']) or 0)
if goals_against == 0:
clean_sheets += 1
@@ -390,6 +396,15 @@ class FeatureEnrichmentService:
else:
streak_broken_u = True
if total == 0:
return {'clean_sheet_rate': 0.25, 'scoring_rate': 0.75,
'winning_streak': 0, 'unbeaten_streak': 0}
if total == 0:
return {'clean_sheet_rate': 0.25, 'scoring_rate': 0.75,
'winning_streak': 0, 'unbeaten_streak': 0}
if total == 0:
return {'clean_sheet_rate': 0.25, 'scoring_rate': 0.75,
'winning_streak': 0, 'unbeaten_streak': 0}
return {
'clean_sheet_rate': clean_sheets / total,
'scoring_rate': scored_count / total,
@@ -433,8 +448,8 @@ class FeatureEnrichmentService:
match_ids = []
for row in rows:
sh = int(row['score_home'])
sa = int(row['score_away'])
sh = int(row['score_home'] or 0)
sa = int(row['score_away'] or 0)
total_goals += sh + sa
if sh > sa:
home_wins += 1
@@ -464,6 +479,12 @@ class FeatureEnrichmentService:
pass
# home_bias: (actual home win rate) - 0.46 (league average ~46%)
if total == 0:
return dict(self._DEFAULT_REFEREE)
if total == 0:
return dict(self._DEFAULT_REFEREE)
if total == 0:
return dict(self._DEFAULT_REFEREE)
home_bias = (home_wins / total) - 0.46
return {
@@ -633,8 +654,8 @@ class FeatureEnrichmentService:
over25_count = 0
for row in rows:
sh = int(row['score_home'])
sa = int(row['score_away'])
sh = int(row['score_home'] or 0)
sa = int(row['score_away'] or 0)
match_goals = sh + sa
total_goals += match_goals
if match_goals == 0:
@@ -828,8 +849,8 @@ class FeatureEnrichmentService:
goals = []
conceded_list = []
for row in rows:
sh = int(row['score_home'])
sa = int(row['score_away'])
sh = int(row['score_home'] or 0)
sa = int(row['score_away'] or 0)
if is_home:
goals.append(sh)
conceded_list.append(sa)
+39 -5
View File
@@ -58,6 +58,7 @@ def generate_match_commentary(package: Dict[str, Any]) -> Dict[str, Any]:
summary = _build_summary(
action, main_pick, market_board, v27_engine,
score_pred, risk, data_quality, home, away,
match_info=match_info,
)
# ── Quick notes ───────────────────────────────────────────────
@@ -117,22 +118,35 @@ def _build_summary(
data_quality: Dict[str, Any],
home: str,
away: str,
match_info: Optional[Dict[str, Any]] = None,
) -> str:
parts: List[str] = []
# C-2: live-aware preamble — if the match is in play, lead with current score
# vs the pre-match read so users immediately see how the prediction is faring.
match_info = match_info or {}
if match_info.get("is_live"):
cur_home = match_info.get("current_score_home")
cur_away = match_info.get("current_score_away")
if cur_home is not None and cur_away is not None:
parts.append(
f"🔴 CANLI: {home} {cur_home} - {cur_away} {away} "
f"(aşağıdaki analiz maç öncesi tahmindir)"
)
# Who is the favourite?
ms_board = market_board.get("MS") or {}
ms_pick = ms_board.get("pick", "")
ms_conf = float(ms_board.get("confidence", 50) or 50)
if ms_pick == "1" and ms_conf > 45:
parts.append(f"{home} hafif favori görünüyor")
elif ms_pick == "1" and ms_conf > 55:
if ms_pick == "1" and ms_conf > 55:
parts.append(f"{home} net favori")
elif ms_pick == "2" and ms_conf > 45:
parts.append(f"{away} hafif favori görünüyor")
elif ms_pick == "1" and ms_conf > 45:
parts.append(f"{home} hafif favori görünüyor")
elif ms_pick == "2" and ms_conf > 55:
parts.append(f"{away} net favori")
elif ms_pick == "2" and ms_conf > 45:
parts.append(f"{away} hafif favori görünüyor")
else:
parts.append("İki takım da birbirine yakın güçte")
@@ -262,6 +276,26 @@ def _detect_contradictions(
triple_value = v27_engine.get("triple_value") or {}
predictions = v27_engine.get("predictions") or {}
# C-2 live-vs-prediction mismatch
match_info = package.get("match_info") or {}
if match_info.get("is_live"):
cur_h = match_info.get("current_score_home")
cur_a = match_info.get("current_score_away")
ms_board_live = market_board.get("MS") or {}
predicted_pick = str(ms_board_live.get("pick") or "")
if cur_h is not None and cur_a is not None:
actual_pick: Optional[str] = None
if cur_h > cur_a:
actual_pick = "1"
elif cur_a > cur_h:
actual_pick = "2"
else:
actual_pick = "X"
if predicted_pick and actual_pick and predicted_pick != actual_pick:
contradictions.append(
"Canlı durum maç öncesi tahmin ile çelişiyor — sürpriz GERÇEKLEŞİYOR"
)
# MS contradiction: model says home but triple_value says away has value
ms_preds = predictions.get("ms") or {}
ms_home = float(ms_preds.get("home", 0) or 0)
+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
+1 -1
View File
@@ -1955,7 +1955,7 @@ class V26ShadowEngine:
def _pick_from_probs(probs: Dict[str, float]) -> Tuple[str, float]:
if not probs:
return "", 0.0
pick = max(probs, key=probs.get)
pick = max(probs, key=probs.__getitem__)
return pick, float(probs[pick])
@staticmethod