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