Files
iddaai-be/ai-engine/services/single_match_orchestrator.py
T
fahricansecer bfddcaca7d
Deploy Iddaai Backend / build-and-deploy (push) Successful in 6s
gg
2026-05-05 21:27:06 +03:00

5396 lines
233 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Single Match Orchestrator (V20+)
================================
Primary prediction orchestration for frontend/live match clicks and automation.
Design goals:
- One authoritative match package contract.
- Scenario-consistent market board from a single prediction output.
- Data quality and risk tagging for consumer UX.
"""
from __future__ import annotations
import json
import re
import time
import math
import os
import pickle
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
import psycopg2
from psycopg2.extras import RealDictCursor
from data.db import get_clean_dsn
from models.v20_ensemble import FullMatchPrediction
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
def compute_divergence(*args, **kwargs):
return 0.0
def compute_value_edge(*args, **kwargs):
return 0.0
from features.odds_band_analyzer import OddsBandAnalyzer
try:
from models.basketball_v25 import (
BasketballMatchPrediction,
get_basketball_v25_predictor,
)
except ImportError:
BasketballMatchPrediction = Any
def get_basketball_v25_predictor():
raise ImportError("Basketball predictor is not available")
from core.engines.player_predictor import PlayerPrediction, get_player_predictor
from services.feature_enrichment import FeatureEnrichmentService
from services.betting_brain import BettingBrain
from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine
from utils.top_leagues import load_top_league_ids
from utils.league_reliability import load_league_reliability
@dataclass
class MatchData:
match_id: str
home_team_id: str
away_team_id: str
home_team_name: str
away_team_name: str
match_date_ms: int
sport: str
league_id: Optional[str]
league_name: str
referee_name: Optional[str]
odds_data: Dict[str, float]
home_lineup: Optional[List[str]]
away_lineup: Optional[List[str]]
sidelined_data: Optional[Dict[str, Any]]
home_goals_avg: float
home_conceded_avg: float
away_goals_avg: float
away_conceded_avg: float
home_position: int
away_position: int
lineup_source: str
status: str = ""
state: Optional[str] = None
substate: Optional[str] = None
current_score_home: Optional[int] = None
current_score_away: Optional[int] = None
lineup_confidence: float = 0.0
class SingleMatchOrchestrator:
"""Main V20+ application service used by API endpoints."""
DEFAULT_MS_H = 2.65
DEFAULT_MS_D = 3.20
DEFAULT_MS_A = 2.65
RELATIONAL_ODDS_KEYS = (
"ms_h",
"ms_d",
"ms_a",
"dc_1x",
"dc_x2",
"dc_12",
"ou15_o",
"ou15_u",
"ou25_o",
"ou25_u",
"ou35_o",
"ou35_u",
"btts_y",
"btts_n",
"ht_h",
"ht_d",
"ht_a",
"ht_ou05_o",
"ht_ou05_u",
"ht_ou15_o",
"ht_ou15_u",
"cards_o",
"cards_u",
"hcap_h",
"hcap_d",
"hcap_a",
"ml_h",
"ml_a",
"tot_line",
"tot_o",
"tot_u",
"spread_home_line",
"spread_h",
"spread_a",
)
V25_ODDS_FEATURE_KEYS = (
"ms_h", "ms_d", "ms_a",
"ht_h", "ht_d", "ht_a",
"ou05_o", "ou05_u",
"ou15_o", "ou15_u",
"ou25_o", "ou25_u",
"ou35_o", "ou35_u",
"ht_ou05_o", "ht_ou05_u",
"ht_ou15_o", "ht_ou15_u",
"btts_y", "btts_n",
)
ODDS_REQUIRED_MARKETS = (
"MS",
"DC",
"OU15",
"OU25",
"OU35",
"BTTS",
"HT",
"HT_OU05",
"HT_OU15",
"HTFT",
"OE",
"CARDS",
"HCAP",
)
def __init__(self) -> None:
self.v25_predictor: Optional[V25Predictor] = None
self.v26_shadow_engine: Optional[V26ShadowEngine] = 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()
self.top_league_ids = load_top_league_ids()
self.league_reliability = load_league_reliability()
self.enrichment = FeatureEnrichmentService()
self.odds_band_analyzer = OddsBandAnalyzer()
# ── V32 Calibration Rebalance ──────────────────────────────────
# RULE: max_reachable = 100 × calibration MUST be > min_conf + 8
# Previous values had 5 markets where this was IMPOSSIBLE:
# HT(0.42×100=42 < 45), HCAP(0.40×100=40 < 46), HTFT(0.28×100=28 < 32)
# HT_OU15(0.46×100=46 < 48), CARDS(0.45×100=45 < 48)
# These markets could NEVER become playable → all predictions were PASS.
#
# New calibration: conservative but mathematically achievable.
# Each market's calibration ensures high-confidence model outputs CAN pass.
self.market_calibration: Dict[str, float] = {
"MS": 0.62, # max=62 vs min=42 ✓ (was 0.48→max=48 vs 44 ⚠️)
"DC": 0.82, # max=82 vs min=52 ✓ (unchanged, already good)
"OU15": 0.84, # max=84 vs min=55 ✓ (unchanged, already good)
"OU25": 0.68, # max=68 vs min=48 ✓ (was 0.54→max=54 vs 52 ⚠️)
"OU35": 0.60, # max=60 vs min=48 ✓ (was 0.44→max=44 vs 54 ❌)
"BTTS": 0.65, # max=65 vs min=46 ✓ (was 0.50→max=50 vs 50 ⚠️)
"HT": 0.58, # max=58 vs min=40 ✓ (was 0.42→max=42 vs 45 ❌)
"HT_OU05": 0.68, # max=68 vs min=50 ✓ (unchanged)
"HT_OU15": 0.60, # max=60 vs min=42 ✓ (was 0.46→max=46 vs 48 ❌)
"OE": 0.62, # max=62 vs min=46 ✓ (was 0.58→max=58 vs 50 ok)
"CARDS": 0.58, # max=58 vs min=42 ✓ (was 0.45→max=45 vs 48 ❌)
"HCAP": 0.56, # max=56 vs min=40 ✓ (was 0.40→max=40 vs 46 ❌)
"HTFT": 0.45, # max=45 vs min=28 ✓ (was 0.28→max=28 vs 32 ❌)
}
# Min confidence: lowered to be achievable (max_reachable - 16 to -20)
self.market_min_conf: Dict[str, float] = {
"MS": 42.0, # was 44 — 3-way market, hard to get high conf
"DC": 52.0, # was 55 — double chance is easier
"OU15": 55.0, # was 58 — binary + usually high conf
"OU25": 48.0, # was 52 — core market, allow more through
"OU35": 48.0, # was 54 — lowered to let signals pass
"BTTS": 46.0, # was 50 — binary market
"HT": 40.0, # was 45 — was ❌ impossible, now achievable
"HT_OU05": 50.0, # was 54 — binary HT market
"HT_OU15": 42.0, # was 48 — was ❌ impossible, now achievable
"OE": 46.0, # was 50 — coin-flip market, lower bar
"CARDS": 42.0, # was 48 — was ❌ impossible, now achievable
"HCAP": 40.0, # was 46 — was ❌ impossible, now achievable
"HTFT": 28.0, # was 32 — was ❌ impossible, 9-way market
}
# Min play score: moderate reduction to allow more C-grade bets
self.market_min_play_score: Dict[str, float] = {
"MS": 65.0, # was 72 — let more MS through for tracking
"DC": 58.0, # was 62 — DC is high accuracy
"OU15": 60.0, # was 64 — strong market per backtest
"OU25": 64.0, # was 70 — core market
"OU35": 68.0, # was 76 — riskier market
"BTTS": 64.0, # was 70 — allow more signals
"HT": 66.0, # was 74 — was never reachable anyway
"HT_OU05": 60.0, # was 64 — strong backtest market
"HT_OU15": 64.0, # was 72 — moderate
"OE": 60.0, # was 66 — low priority market
"CARDS": 66.0, # was 74 — niche market
"HCAP": 68.0, # was 76 — risky
"HTFT": 72.0, # was 82 — 9-way, very risky
}
self.market_min_edge: Dict[str, float] = {
"MS": 0.02, # was 0.03 — slight relaxation
"DC": 0.01, # unchanged
"OU15": 0.01, # unchanged
"OU25": 0.02, # unchanged
"OU35": 0.03, # was 0.04
"BTTS": 0.02, # was 0.03
"HT": 0.03, # was 0.04
"HT_OU05": 0.01, # unchanged
"HT_OU15": 0.02, # was 0.03
"OE": 0.02, # unchanged
"CARDS": 0.02, # was 0.03
"HCAP": 0.03, # was 0.04
"HTFT": 0.05, # was 0.06
}
def _get_v25_predictor(self) -> V25Predictor:
if self.v25_predictor is None:
try:
self.v25_predictor = get_v25_predictor()
print(f"[V25] ✅ Predictor loaded: {len(self.v25_predictor.models)} market models")
except Exception as e:
print(f"[V25] ❌ PREDICTOR LOAD FAILED: {e}")
raise
return self.v25_predictor
def _get_v26_shadow_engine(self) -> V26ShadowEngine:
if getattr(self, "v26_shadow_engine", None) is None:
self.v26_shadow_engine = get_v26_shadow_engine()
return self.v26_shadow_engine
def _get_v27_predictor(self) -> Optional[V27Predictor]:
"""Non-fatal V27 loader — returns None if models can't load."""
if V27Predictor is None:
return None
if getattr(self, "_v27", None) is not None:
return self._v27
try:
pred = V27Predictor()
if pred.load_models():
self._v27 = pred
print(f"[V27] ✅ Predictor loaded: {sum(len(v) for v in pred.models.values())} models")
return self._v27
except Exception as e:
print(f"[V27] ⚠ Load failed (non-fatal): {e}")
self._v27 = None
return None
def _get_score_model(self) -> Optional[Dict]:
"""Load XGBoost score prediction model (non-fatal)."""
if hasattr(self, "_score_model_cache"):
return self._score_model_cache
score_model_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"models", "xgb_score.pkl",
)
try:
if os.path.exists(score_model_path):
with open(score_model_path, "rb") as f:
model_data = pickle.load(f)
if all(k in model_data for k in ("home_model", "away_model", "ht_home_model", "ht_away_model", "features")):
self._score_model_cache = model_data
print(f"[SCORE] ✅ Score model loaded ({len(model_data['features'])} features)")
return self._score_model_cache
except Exception as e:
print(f"[SCORE] ⚠ Load failed (non-fatal, using heuristic): {e}")
self._score_model_cache = None
return None
def _predict_score_with_model(self, features: Dict[str, float]) -> Optional[Dict[str, float]]:
"""Predict FT/HT scores using XGBoost score model."""
score_model = self._get_score_model()
if score_model is None:
return None
try:
import pandas as _pd
model_features = score_model["features"]
row = {f: float(features.get(f, 0)) for f in model_features}
df = _pd.DataFrame([row])
ft_home = max(0.0, float(score_model["home_model"].predict(df)[0]))
ft_away = max(0.0, float(score_model["away_model"].predict(df)[0]))
ht_home = max(0.0, float(score_model["ht_home_model"].predict(df)[0]))
ht_away = max(0.0, float(score_model["ht_away_model"].predict(df)[0]))
return {
"ft_home": round(ft_home, 2),
"ft_away": round(ft_away, 2),
"ht_home": round(ht_home, 2),
"ht_away": round(ht_away, 2),
}
except Exception as e:
print(f"[SCORE] ⚠ Prediction error (fallback to heuristic): {e}")
return None
def _build_v25_features(self, data: MatchData) -> Dict[str, float]:
"""
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))
# Implied probabilities (vig-normalised)
implied_home, implied_draw, implied_away = 0.33, 0.33, 0.33
if ms_h > 0 and ms_d > 0 and ms_a > 0:
raw_sum = 1 / ms_h + 1 / ms_d + 1 / ms_a
implied_home = (1 / ms_h) / raw_sum
implied_draw = (1 / ms_d) / raw_sum
implied_away = (1 / ms_a) / raw_sum
upset_potential = max(
0.0,
min(
1.0,
1.0 - abs(implied_home - implied_away) + (implied_draw * 0.35),
),
)
# All enrichment queries in a single DB connection
home_elo, away_elo = 1500.0, 1500.0
home_venue_elo, away_venue_elo = 1500.0, 1500.0
home_form_elo_val, away_form_elo_val = 1500.0, 1500.0
enr = self.enrichment
try:
with psycopg2.connect(self.dsn) as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
# ELO (overall + venue + form) - Updated for sport-partitioned schema
cur.execute(
"SELECT home_elo, away_elo, "
" home_home_elo, away_away_elo, "
" home_form_elo, away_form_elo "
"FROM football_ai_features "
"WHERE match_id = %s LIMIT 1",
(data.match_id,),
)
elo_row = cur.fetchone()
if elo_row:
home_elo = float(elo_row.get('home_elo') or 1500.0)
away_elo = float(elo_row.get('away_elo') or 1500.0)
home_venue_elo = float(elo_row.get('home_home_elo') or home_elo)
away_venue_elo = float(elo_row.get('away_away_elo') or away_elo)
home_form_elo_val = float(elo_row.get('home_form_elo') or home_elo)
away_form_elo_val = float(elo_row.get('away_form_elo') or away_elo)
# Enrichment queries
home_stats = enr.compute_team_stats(cur, data.home_team_id, data.match_date_ms)
away_stats = enr.compute_team_stats(cur, data.away_team_id, data.match_date_ms)
h2h = enr.compute_h2h(cur, data.home_team_id, data.away_team_id, data.match_date_ms)
home_form = enr.compute_form_streaks(cur, data.home_team_id, data.match_date_ms)
away_form = enr.compute_form_streaks(cur, data.away_team_id, data.match_date_ms)
ref = enr.compute_referee_stats(cur, data.referee_name, data.match_date_ms)
league = enr.compute_league_averages(cur, data.league_id, data.match_date_ms)
home_momentum = enr.compute_momentum(cur, data.home_team_id, data.match_date_ms)
away_momentum = enr.compute_momentum(cur, data.away_team_id, data.match_date_ms)
# V27 enrichment
home_rolling = enr.compute_rolling_stats(cur, data.home_team_id, data.match_date_ms)
away_rolling = enr.compute_rolling_stats(cur, data.away_team_id, data.match_date_ms)
home_venue = enr.compute_venue_stats(cur, data.home_team_id, data.match_date_ms, is_home=True)
away_venue = enr.compute_venue_stats(cur, data.away_team_id, data.match_date_ms, is_home=False)
home_rest = enr.compute_days_rest(cur, data.home_team_id, data.match_date_ms)
away_rest = enr.compute_days_rest(cur, data.away_team_id, data.match_date_ms)
# V28 Odds-Band Historical Performance
odds_band_features = self.odds_band_analyzer.compute_all(
cur=cur,
home_team_id=data.home_team_id,
away_team_id=data.away_team_id,
league_id=data.league_id,
odds=odds,
before_ts=data.match_date_ms,
referee_name=data.referee_name,
)
except Exception:
# Full fallback — use all defaults
home_stats = dict(enr._DEFAULT_TEAM_STATS)
away_stats = dict(enr._DEFAULT_TEAM_STATS)
h2h = dict(enr._DEFAULT_H2H)
home_form = dict(enr._DEFAULT_FORM)
away_form = dict(enr._DEFAULT_FORM)
ref = dict(enr._DEFAULT_REFEREE)
league = dict(enr._DEFAULT_LEAGUE)
home_momentum = 0.0
away_momentum = 0.0
# V27 fallbacks
home_rolling = dict(enr._DEFAULT_ROLLING)
away_rolling = dict(enr._DEFAULT_ROLLING)
home_venue = dict(enr._DEFAULT_VENUE)
away_venue = dict(enr._DEFAULT_VENUE)
home_rest = 7.0
away_rest = 7.0
odds_band_features = {} # V28 fallback
odds_presence = {
'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,
}
# ── Calendar features (V27) ──
import datetime
match_dt = datetime.datetime.utcfromtimestamp(data.match_date_ms / 1000)
match_month = match_dt.month
is_season_start = 1.0 if match_month in (7, 8, 9) else 0.0
is_season_end = 1.0 if match_month in (5, 6) else 0.0
# ── Derived / Interaction features (V27) ──
elo_diff = home_elo - away_elo
form_elo_diff = home_form_elo_val - away_form_elo_val
attack_vs_defense_home = data.home_goals_avg - data.away_conceded_avg
attack_vs_defense_away = data.away_goals_avg - data.home_conceded_avg
xga_home = data.home_conceded_avg
xga_away = data.away_conceded_avg
xg_diff = xga_home - xga_away
mom_diff = home_momentum - away_momentum
form_momentum_interaction = mom_diff * form_elo_diff / 1000.0
elo_form_consistency = 1.0 - abs(elo_diff - form_elo_diff) / max(abs(elo_diff), 100.0)
upset_x_elo_gap = upset_potential * abs(elo_diff) / 500.0
return {
# META (1)
'mst_utc': float(data.match_date_ms),
# ELO (8)
'home_overall_elo': home_elo,
'away_overall_elo': away_elo,
'elo_diff': elo_diff,
'home_home_elo': home_venue_elo,
'away_away_elo': away_venue_elo,
'home_form_elo': home_form_elo_val,
'away_form_elo': away_form_elo_val,
'form_elo_diff': form_elo_diff,
# Form (12)
'home_goals_avg': data.home_goals_avg,
'home_conceded_avg': data.home_conceded_avg,
'away_goals_avg': data.away_goals_avg,
'away_conceded_avg': data.away_conceded_avg,
'home_clean_sheet_rate': home_form['clean_sheet_rate'],
'away_clean_sheet_rate': away_form['clean_sheet_rate'],
'home_scoring_rate': home_form['scoring_rate'],
'away_scoring_rate': away_form['scoring_rate'],
'home_winning_streak': home_form['winning_streak'],
'away_winning_streak': away_form['winning_streak'],
'home_unbeaten_streak': home_form['unbeaten_streak'],
'away_unbeaten_streak': away_form['unbeaten_streak'],
# H2H (10 — original 6 + V27 expanded 4)
'h2h_total_matches': h2h['total_matches'],
'h2h_home_win_rate': h2h['home_win_rate'],
'h2h_draw_rate': h2h['draw_rate'],
'h2h_avg_goals': h2h['avg_goals'],
'h2h_btts_rate': h2h['btts_rate'],
'h2h_over25_rate': h2h['over25_rate'],
'h2h_home_goals_avg': h2h['home_goals_avg'],
'h2h_away_goals_avg': h2h['away_goals_avg'],
'h2h_recent_trend': h2h['recent_trend'],
'h2h_venue_advantage': h2h['venue_advantage'],
# Stats (8)
'home_avg_possession': home_stats['avg_possession'],
'away_avg_possession': away_stats['avg_possession'],
'home_avg_shots_on_target': home_stats['avg_shots_on_target'],
'away_avg_shots_on_target': away_stats['avg_shots_on_target'],
'home_shot_conversion': home_stats['shot_conversion'],
'away_shot_conversion': away_stats['shot_conversion'],
'home_avg_corners': home_stats['avg_corners'],
'away_avg_corners': away_stats['avg_corners'],
# Odds (24)
'odds_ms_h': ms_h,
'odds_ms_d': ms_d,
'odds_ms_a': ms_a,
'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_presence,
# League (9 — original 2 + V27 expanded 5 + xga 2)
'home_xga': xga_home,
'away_xga': xga_away,
'league_avg_goals': league['avg_goals'],
'league_zero_goal_rate': league['zero_goal_rate'],
'league_home_win_rate': league['home_win_rate'],
'league_draw_rate': league['draw_rate'],
'league_btts_rate': league['btts_rate'],
'league_ou25_rate': league['ou25_rate'],
'league_reliability_score': league['reliability_score'],
# Upset (4)
'upset_atmosphere': 0.0,
'upset_motivation': 0.0,
'upset_fatigue': 0.0,
'upset_potential': upset_potential,
# Referee (5)
'referee_home_bias': ref['home_bias'],
'referee_avg_goals': ref['avg_goals'],
'referee_cards_total': ref['cards_total'],
'referee_avg_yellow': ref['avg_yellow'],
'referee_experience': ref['experience'],
# Momentum (3)
'home_momentum_score': home_momentum,
'away_momentum_score': away_momentum,
'momentum_diff': mom_diff,
# ── V27 Rolling Stats (13) ──
'home_rolling5_goals': home_rolling['rolling5_goals'],
'home_rolling5_conceded': home_rolling['rolling5_conceded'],
'home_rolling10_goals': home_rolling['rolling10_goals'],
'home_rolling10_conceded': home_rolling['rolling10_conceded'],
'home_rolling20_goals': home_rolling['rolling20_goals'],
'home_rolling20_conceded': home_rolling['rolling20_conceded'],
'away_rolling5_goals': away_rolling['rolling5_goals'],
'away_rolling5_conceded': away_rolling['rolling5_conceded'],
'away_rolling10_goals': away_rolling['rolling10_goals'],
'away_rolling10_conceded': away_rolling['rolling10_conceded'],
'home_rolling5_cs': home_rolling['rolling5_cs'],
'away_rolling5_cs': away_rolling['rolling5_cs'],
# ── V27 Venue Stats (4) ──
'home_venue_goals': home_venue['venue_goals'],
'home_venue_conceded': home_venue['venue_conceded'],
'away_venue_goals': away_venue['venue_goals'],
'away_venue_conceded': away_venue['venue_conceded'],
# ── V27 Goal Trend (2) ──
'home_goal_trend': home_rolling['rolling5_goals'] - home_rolling['rolling10_goals'],
'away_goal_trend': away_rolling['rolling5_goals'] - away_rolling['rolling10_goals'],
# ── V27 Calendar (4) ──
'home_days_rest': home_rest,
'away_days_rest': away_rest,
'match_month': float(match_month),
'is_season_start': is_season_start,
'is_season_end': is_season_end,
# ── V27 Interaction (6) ──
'attack_vs_defense_home': attack_vs_defense_home,
'attack_vs_defense_away': attack_vs_defense_away,
'xg_diff': xg_diff,
'form_momentum_interaction': form_momentum_interaction,
'elo_form_consistency': elo_form_consistency,
'upset_x_elo_gap': upset_x_elo_gap,
# Squad Features (9) — PlayerPredictorEngine
**self._get_squad_features(data),
# V28 Odds-Band Historical Performance Features
**odds_band_features,
}
def _get_squad_features(self, data: MatchData) -> Dict[str, float]:
"""Non-fatal squad analysis. Returns neutral-average defaults on failure.
Design note (V32-fix): Previous 0.0 defaults caused the model to treat
missing lineups as 'both teams have zero quality', producing overly
conservative predictions (e.g. static 1.5 Under). Neutral averages let
the model fall back on stronger signals (odds, ELO, form, H2H).
"""
defaults = {
'home_squad_quality': 12.0, 'away_squad_quality': 12.0, 'squad_diff': 0.0,
'home_key_players': 3.0, 'away_key_players': 3.0,
'home_missing_impact': 0.0, 'away_missing_impact': 0.0,
'home_goals_form': 1.3, 'away_goals_form': 1.3,
}
try:
engine = get_player_predictor()
pred = engine.predict(
match_id=data.match_id,
home_team_id=data.home_team_id,
away_team_id=data.away_team_id,
home_lineup=data.home_lineup,
away_lineup=data.away_lineup,
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),
}
# Sanity check: squad_quality must be in training range (~3-36)
for side in ('home', 'away'):
sq = result[f'{side}_squad_quality']
if sq > 50 or sq < 0:
print(f"🚨 SCALE MISMATCH: {side}_squad_quality={sq:.1f} "
f"(expected 3-36). Check player_predictor formula!")
return result
except Exception as e:
print(f"⚠️ Squad features failed: {e}")
return defaults
# ── V25 internal key → _build_v25_prediction key mapping ──
_V25_KEY_MAP = {
"ms": "MS",
"ou15": "OU15",
"ou25": "OU25",
"ou35": "OU35",
"btts": "BTTS",
"ht_result": "HT",
"ht_ou05": "HT_OU05",
"ht_ou15": "HT_OU15",
"htft": "HTFT",
"cards_ou45": "CARDS",
"handicap_ms": "HCAP",
"odd_even": "OE",
}
def _get_v25_signal(
self,
data: MatchData,
features: Optional[Dict[str, float]] = None,
) -> Dict[str, Any]:
"""
Get V25 ensemble predictions for all available markets.
Returns a dict keyed by UPPERCASE market name (MS, OU25, BTTS, etc.)
each with a 'probs' sub-dict that _prob_map can consume.
CRITICAL: Keys MUST be uppercase to match _build_v25_prediction lookups.
"""
v25 = self._get_v25_predictor()
feature_row = features or self._build_v25_features(data)
signal: Dict[str, Any] = {}
def _temperature_scale(probs_dict: Dict[str, float], temperature: float = 2.5) -> Dict[str, float]:
"""
Apply temperature scaling to soften overconfident model outputs.
LightGBM often produces extreme probabilities (e.g., 0.999 / 0.001).
Temperature scaling converts to log-odds, divides by T, then re-normalizes.
T=1.0 → no change, T>1 → softer probabilities.
Standard approach for post-hoc model calibration (Guo et al., 2017).
"""
import math
eps = 1e-7 # numerical stability
n = len(probs_dict)
# Determine appropriate temperature based on market type
# Binary markets (2-class) tend to be more overconfident in LGB
if n <= 2:
T = max(temperature, 2.0)
elif n == 3:
T = max(temperature * 0.8, 1.5) # 3-way slightly less aggressive
else:
T = max(temperature * 0.6, 1.3) # 9-way (HTFT) already spread
# Convert to log-odds and apply temperature
labels = list(probs_dict.keys())
log_odds = []
for label in labels:
p = max(eps, min(1.0 - eps, float(probs_dict[label])))
log_odds.append(math.log(p) / T)
# Softmax re-normalization
max_lo = max(log_odds)
exp_vals = [math.exp(lo - max_lo) for lo in log_odds]
total = sum(exp_vals)
scaled = {}
for i, label in enumerate(labels):
scaled[label] = exp_vals[i] / total
return scaled
def _enrich_signal_entry(probs_dict: Dict[str, float]) -> Dict[str, Any]:
"""Add pick, probability, confidence to a signal entry from its probs.
Applies temperature scaling to convert overconfident LightGBM outputs
into realistic, calibrated probabilities.
"""
# Apply temperature scaling to soften extreme probabilities
scaled_probs = _temperature_scale(probs_dict, temperature=2.5)
best_label = max(scaled_probs, key=scaled_probs.get)
best_prob = float(scaled_probs[best_label])
return {
"probs": scaled_probs,
"raw_probs": probs_dict, # keep originals for debugging
"pick": best_label,
"probability": best_prob,
"confidence": round(best_prob * 100.0, 1),
}
# Core markets using dedicated methods
h, d, a = v25.predict_ms(feature_row)
signal["MS"] = _enrich_signal_entry({"1": h, "X": d, "2": a})
print(f" [V25-SIGNAL] MS → H={h:.4f} D={d:.4f} A={a:.4f}")
over25, under25 = v25.predict_ou25(feature_row)
signal["OU25"] = _enrich_signal_entry({"Over": over25, "Under": under25})
print(f" [V25-SIGNAL] OU25 → O={over25:.4f} U={under25:.4f}")
btts_y, btts_n = v25.predict_btts(feature_row)
signal["BTTS"] = _enrich_signal_entry({"Yes": btts_y, "No": btts_n})
print(f" [V25-SIGNAL] BTTS → Y={btts_y:.4f} N={btts_n:.4f}")
# Additional markets via generic predict_market
for model_key, label_map in [
("ou15", {"Over": 0, "Under": None}),
("ou35", {"Over": 0, "Under": None}),
("ht_result", {"1": 0, "X": 1, "2": 2}),
("ht_ou05", {"Over": 0, "Under": None}),
("ht_ou15", {"Over": 0, "Under": None}),
("htft", None),
("cards_ou45", {"Over": 0, "Under": None}),
("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())
if not v25.has_market(model_key):
continue
raw = v25.predict_market(model_key, feature_row)
if raw is None:
continue
if label_map is None:
# HTFT — 9 combinations
htft_labels = ["1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2"]
probs_dict = {}
for i, label in enumerate(htft_labels):
probs_dict[label] = float(raw[i]) if i < len(raw) else 0.0
signal[out_key] = _enrich_signal_entry(probs_dict)
elif len(label_map) == 2:
# Binary market
labels = list(label_map.keys())
p = float(raw[0]) if len(raw) >= 1 else None
if p is None:
print(f" [V25-SIGNAL] {out_key} → EMPTY raw output, skipped")
continue
signal[out_key] = _enrich_signal_entry({labels[0]: p, labels[1]: 1.0 - p})
elif len(label_map) == 3:
# 3-class market
labels = list(label_map.keys())
probs_dict = {}
for i, label in enumerate(labels):
if i >= len(raw):
print(f" [V25-SIGNAL] {out_key} → insufficient probabilities in raw output")
break
probs_dict[label] = float(raw[i])
else:
signal[out_key] = _enrich_signal_entry(probs_dict)
if out_key in signal:
print(f" [V25-SIGNAL] {out_key}{signal[out_key]['probs']}")
print(f" [V25-SIGNAL] Total markets with real predictions: {len(signal)}")
if not signal:
raise RuntimeError("V25 model produced ZERO market predictions — cannot continue")
return signal
@staticmethod
def _prob_map(signal: Optional[Dict[str, Any]], market: str, defaults: Dict[str, float]) -> Dict[str, float]:
"""Extract normalised probabilities from signal.
If the signal contains real model output for this market, use it.
If the market is missing from the signal, log a warning and return
the defaults as a LAST RESORT (so the pipeline doesn't crash).
The defaults are ONLY used for non-core / secondary markets that
may not have a trained model yet (e.g. CARDS, HCAP, OE).
"""
market_payload = signal.get(market, {}) if isinstance(signal, dict) else {}
probs = market_payload.get("probs", {}) if isinstance(market_payload, dict) else {}
if not isinstance(probs, dict) or not probs:
print(f" ⚠️ [PROB_MAP] Market '{market}' NOT found in V25 signal — model output missing")
return dict(defaults)
out = {key: float(probs.get(key, value)) for key, value in defaults.items()}
total = sum(out.values())
if total <= 0:
print(f" ⚠️ [PROB_MAP] Market '{market}' has zero total probability")
return dict(defaults)
return {key: value / total for key, value in out.items()}
@staticmethod
def _best_prob_pick(prob_map: Dict[str, float]) -> Tuple[str, float]:
pick = max(prob_map, key=prob_map.get)
return pick, float(prob_map[pick])
@staticmethod
def _poisson_score_top5(home_xg: float, away_xg: float, max_goals: int = 5) -> List[Dict[str, Any]]:
def poisson_p(lmbda: float, k: int) -> float:
return math.exp(-lmbda) * (lmbda ** k) / math.factorial(k)
scores: List[Tuple[str, float]] = []
for home_goals in range(max_goals + 1):
for away_goals in range(max_goals + 1):
prob = poisson_p(home_xg, home_goals) * poisson_p(away_xg, away_goals)
scores.append((f"{home_goals}-{away_goals}", prob))
scores.sort(key=lambda item: item[1], reverse=True)
return [
{"score": score, "prob": round(prob, 4)}
for score, prob in scores[:5]
]
def _build_v25_prediction(
self,
data: MatchData,
features: Dict[str, float],
v25_signal: Dict[str, Any],
) -> FullMatchPrediction:
prediction = FullMatchPrediction(
match_id=data.match_id,
home_team=data.home_team_name,
away_team=data.away_team_name,
)
ms_probs = self._prob_map(v25_signal, "MS", {"1": 0.33, "X": 0.34, "2": 0.33})
ou15_probs = self._prob_map(v25_signal, "OU15", {"Under": 0.5, "Over": 0.5})
ou25_probs = self._prob_map(v25_signal, "OU25", {"Under": 0.5, "Over": 0.5})
ou35_probs = self._prob_map(v25_signal, "OU35", {"Under": 0.5, "Over": 0.5})
btts_probs = self._prob_map(v25_signal, "BTTS", {"No": 0.5, "Yes": 0.5})
ht_probs = self._prob_map(v25_signal, "HT", {"1": 0.33, "X": 0.34, "2": 0.33})
ht_ou05_probs = self._prob_map(v25_signal, "HT_OU05", {"Under": 0.5, "Over": 0.5})
ht_ou15_probs = self._prob_map(v25_signal, "HT_OU15", {"Under": 0.5, "Over": 0.5})
htft_probs = self._prob_map(
v25_signal,
"HTFT",
{"1/1": 1 / 9, "1/X": 1 / 9, "1/2": 1 / 9, "X/1": 1 / 9, "X/X": 1 / 9, "X/2": 1 / 9, "2/1": 1 / 9, "2/X": 1 / 9, "2/2": 1 / 9},
)
oe_probs = self._prob_map(v25_signal, "OE", {"Even": 0.5, "Odd": 0.5})
cards_probs = self._prob_map(v25_signal, "CARDS", {"Under": 0.5, "Over": 0.5})
hcap_probs = self._prob_map(v25_signal, "HCAP", {"1": 0.33, "X": 0.34, "2": 0.33})
prediction.ms_home_prob = ms_probs["1"]
prediction.ms_draw_prob = ms_probs["X"]
prediction.ms_away_prob = ms_probs["2"]
prediction.ms_pick, ms_top = self._best_prob_pick(ms_probs)
prediction.ms_confidence = ms_top * 100.0
prediction.dc_1x_prob = prediction.ms_home_prob + prediction.ms_draw_prob
prediction.dc_x2_prob = prediction.ms_draw_prob + prediction.ms_away_prob
prediction.dc_12_prob = prediction.ms_home_prob + prediction.ms_away_prob
dc_probs = {"1X": prediction.dc_1x_prob, "X2": prediction.dc_x2_prob, "12": prediction.dc_12_prob}
prediction.dc_pick, dc_top = self._best_prob_pick(dc_probs)
prediction.dc_confidence = dc_top * 100.0
prediction.over_15_prob = ou15_probs["Over"]
prediction.under_15_prob = ou15_probs["Under"]
prediction.ou15_pick = "1.5 Üst" if prediction.over_15_prob >= prediction.under_15_prob else "1.5 Alt"
prediction.ou15_confidence = max(prediction.over_15_prob, prediction.under_15_prob) * 100.0
prediction.over_25_prob = ou25_probs["Over"]
prediction.under_25_prob = ou25_probs["Under"]
prediction.ou25_pick = "2.5 Üst" if prediction.over_25_prob >= prediction.under_25_prob else "2.5 Alt"
prediction.ou25_confidence = max(prediction.over_25_prob, prediction.under_25_prob) * 100.0
prediction.over_35_prob = ou35_probs["Over"]
prediction.under_35_prob = ou35_probs["Under"]
prediction.ou35_pick = "3.5 Üst" if prediction.over_35_prob >= prediction.under_35_prob else "3.5 Alt"
prediction.ou35_confidence = max(prediction.over_35_prob, prediction.under_35_prob) * 100.0
prediction.btts_yes_prob = btts_probs["Yes"]
prediction.btts_no_prob = btts_probs["No"]
prediction.btts_pick = "KG Var" if prediction.btts_yes_prob >= prediction.btts_no_prob else "KG Yok"
prediction.btts_confidence = max(prediction.btts_yes_prob, prediction.btts_no_prob) * 100.0
prediction.ht_home_prob = ht_probs["1"]
prediction.ht_draw_prob = ht_probs["X"]
prediction.ht_away_prob = ht_probs["2"]
prediction.ht_pick, ht_top = self._best_prob_pick(ht_probs)
prediction.ht_confidence = ht_top * 100.0
prediction.ht_over_05_prob = ht_ou05_probs["Over"]
prediction.ht_under_05_prob = ht_ou05_probs["Under"]
prediction.ht_ou_pick = "İY 0.5 Üst" if prediction.ht_over_05_prob >= prediction.ht_under_05_prob else "İY 0.5 Alt"
prediction.ht_over_15_prob = ht_ou15_probs["Over"]
prediction.ht_under_15_prob = ht_ou15_probs["Under"]
prediction.ht_ou15_pick = "İY 1.5 Üst" if prediction.ht_over_15_prob >= prediction.ht_under_15_prob else "İY 1.5 Alt"
prediction.ht_ft_probs = htft_probs
prediction.odd_prob = oe_probs["Odd"]
prediction.even_prob = oe_probs["Even"]
prediction.odd_even_pick = "Tek" if prediction.odd_prob >= prediction.even_prob else "Çift"
prediction.cards_over_prob = cards_probs["Over"]
prediction.cards_under_prob = cards_probs["Under"]
prediction.card_pick = "4.5 Üst" if prediction.cards_over_prob >= prediction.cards_under_prob else "4.5 Alt"
prediction.cards_confidence = max(prediction.cards_over_prob, prediction.cards_under_prob) * 100.0
prediction.handicap_home_prob = hcap_probs["1"]
prediction.handicap_draw_prob = hcap_probs["X"]
prediction.handicap_away_prob = hcap_probs["2"]
prediction.handicap_pick, hcap_top = self._best_prob_pick(hcap_probs)
prediction.handicap_confidence = hcap_top * 100.0
# ── Score Prediction: Model-first, heuristic fallback ──────────
ms_edge = prediction.ms_home_prob - prediction.ms_away_prob
score_result = self._predict_score_with_model(features)
if score_result is not None:
# ML model predicted scores
prediction.home_xg = score_result["ft_home"]
prediction.away_xg = score_result["ft_away"]
prediction.total_xg = round(prediction.home_xg + prediction.away_xg, 2)
ht_home_xg = score_result["ht_home"]
ht_away_xg = score_result["ht_away"]
prediction.predicted_ft_score = f"{int(round(prediction.home_xg))}-{int(round(prediction.away_xg))}"
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)
# 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)
+ ((prediction.over_25_prob - prediction.under_25_prob) * 1.15),
),
)
home_xg = max(0.2, base_home_xg + (ms_edge * 0.55) + ((prediction.btts_yes_prob - 0.5) * 0.18))
away_xg = max(0.2, base_away_xg - (ms_edge * 0.55) + ((prediction.btts_yes_prob - 0.5) * 0.18))
scale = total_target / max(home_xg + away_xg, 0.1)
prediction.home_xg = round(home_xg * scale, 2)
prediction.away_xg = round(away_xg * scale, 2)
prediction.total_xg = round(prediction.home_xg + prediction.away_xg, 2)
prediction.predicted_ft_score = f"{int(round(prediction.home_xg))}-{int(round(prediction.away_xg))}"
prediction.predicted_ht_score = f"{int(round(prediction.home_xg * 0.45))}-{int(round(prediction.away_xg * 0.45))}"
prediction.ft_scores_top5 = self._poisson_score_top5(prediction.home_xg, prediction.away_xg)
max_market_conf = max(
prediction.ms_confidence,
prediction.ou15_confidence,
prediction.ou25_confidence,
prediction.ou35_confidence,
prediction.btts_confidence,
prediction.ht_confidence,
prediction.cards_confidence,
prediction.handicap_confidence,
)
lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0)))
lineup_penalty = 12.0 if data.lineup_source == "none" else max(1.5, (1.0 - lineup_conf) * 8.0) if data.lineup_source == "probable_xi" else 0.0
referee_penalty = 6.0 if not data.referee_name else 0.0
parity_penalty = 8.0 if abs(ms_edge) < 0.08 else 0.0
prediction.risk_score = round(min(100.0, max(10.0, 100.0 - max_market_conf + lineup_penalty + referee_penalty + parity_penalty)), 1)
if prediction.risk_score >= 78:
prediction.risk_level = "EXTREME"
elif prediction.risk_score >= 62:
prediction.risk_level = "HIGH"
elif prediction.risk_score >= 40:
prediction.risk_level = "MEDIUM"
else:
prediction.risk_level = "LOW"
prediction.is_surprise_risk = prediction.risk_level in {"HIGH", "EXTREME"} or prediction.ms_draw_prob >= 0.30
prediction.surprise_type = "balanced_match_risk" if abs(ms_edge) < 0.08 else "draw_pressure" if prediction.ms_draw_prob >= 0.30 else ""
prediction.risk_warnings = []
if data.lineup_source == "probable_xi":
prediction.risk_warnings.append("lineup_probable_not_confirmed")
if lineup_conf < 0.65:
prediction.risk_warnings.append("lineup_projection_low_confidence")
if data.lineup_source == "none":
prediction.risk_warnings.append("lineup_unavailable")
if not data.referee_name:
prediction.risk_warnings.append("missing_referee")
if prediction.ms_draw_prob >= 0.30:
prediction.risk_warnings.append("draw_probability_elevated")
prediction.upset_score = int(round(max(0.0, min(100.0, (prediction.ms_draw_prob + min(prediction.ms_home_prob, prediction.ms_away_prob)) * 100.0))))
prediction.upset_level = "HIGH" if prediction.upset_score >= 65 else "MEDIUM" if prediction.upset_score >= 45 else "LOW"
prediction.upset_reasons = [prediction.surprise_type] if prediction.surprise_type else []
surprise = self._build_surprise_profile(data, prediction)
prediction.surprise_score = surprise["score"]
prediction.surprise_comment = surprise["comment"]
prediction.surprise_reasons = surprise["reasons"]
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.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
prediction.total_corners_pred = round(8.8 + (prediction.over_25_prob - 0.5) * 2.5, 1)
prediction.corner_pick = "9.5 Üst" if prediction.total_corners_pred >= 9.5 else "9.5 Alt"
prediction.analysis_details = {
"primary_model": "v25",
"features_source": "v25.pre_match",
"market_count": len([key for key in v25_signal.keys() if key != "value_bets"]),
"lineup_source": data.lineup_source,
}
return prediction
def _get_basketball_predictor(self) -> Any:
if self.basketball_predictor is None:
self.basketball_predictor = get_basketball_v25_predictor()
return self.basketball_predictor
def analyze_match(self, match_id: str) -> Optional[Dict[str, Any]]:
data = self._load_match_data(match_id)
if data is None:
return None
# ── Pre-Match Simulation Mode ────────────────────────────
# Force all matches (live and finished) into pre-match state so the
# engine purely predicts based on pre-match odds and context, ignoring
# current live scores and preventing live state penalties.
_status_upper = str(data.status or "").upper()
if _status_upper not in {"NS", "POSTPONED", "CANC", "ABD"}:
data.status = "NS"
data.state = "preGame"
data.current_score_home = None
data.current_score_away = None
sport_key = str(data.sport or "football").lower()
if sport_key == "basketball":
prediction = self._get_basketball_predictor().predict(
match_id=data.match_id,
home_team_id=data.home_team_id,
away_team_id=data.away_team_id,
home_team_name=data.home_team_name,
away_team_name=data.away_team_name,
match_date_ms=data.match_date_ms,
league_id=data.league_id,
league_name=data.league_name,
odds_data=data.odds_data,
sidelined_data=data.sidelined_data,
)
return self._build_basketball_prediction_package(data, prediction)
features = self._build_v25_features(data)
# ── DEBUG: log critical feature values to diagnose absurd predictions ──
_debug_keys = [
'home_overall_elo', 'away_overall_elo', 'elo_diff',
'odds_ms_h', 'odds_ms_d', 'odds_ms_a',
'implied_home', 'implied_draw', 'implied_away',
'home_goals_avg', 'away_goals_avg',
'home_conceded_avg', 'away_conceded_avg',
'home_momentum_score', 'away_momentum_score',
'home_squad_quality', 'away_squad_quality',
]
print("── [DEBUG] Feature values for model input ──")
for _dk in _debug_keys:
print(f" {_dk}: {features.get(_dk, 'MISSING')}")
print(f" Total features: {len(features)}")
print("── [DEBUG END] ──")
v25_signal = self._get_v25_signal(data, features)
prediction = self._build_v25_prediction(data, features, v25_signal)
base_package = self._build_prediction_package(data, prediction, v25_signal)
# ── V27 Dual-Engine Divergence ──────────────────────────────
v27_predictor = self._get_v27_predictor()
if v27_predictor is not None:
try:
v27_preds = v27_predictor.predict_all(features)
# MS divergence
v27_ms = v27_preds.get("ms")
if v27_ms:
v25_ms_probs = {
"home": prediction.ms_home_prob,
"draw": prediction.ms_draw_prob,
"away": prediction.ms_away_prob,
}
ms_divergence = compute_divergence(v25_ms_probs, v27_ms)
ms_odds = {
"home": float((data.odds_data or {}).get("ms_h", 0)),
"draw": float((data.odds_data or {}).get("ms_d", 0)),
"away": float((data.odds_data or {}).get("ms_a", 0)),
}
ms_value = compute_value_edge(v25_ms_probs, v27_ms, ms_odds)
else:
ms_divergence = {}
ms_value = {}
# OU25 divergence
v27_ou25 = v27_preds.get("ou25")
if v27_ou25:
v25_ou25_probs = {
"under": prediction.under_25_prob,
"over": prediction.over_25_prob,
}
ou25_divergence = compute_divergence(v25_ou25_probs, v27_ou25)
ou25_odds = {
"under": float((data.odds_data or {}).get("ou25_u", 0)),
"over": float((data.odds_data or {}).get("ou25_o", 0)),
}
ou25_value = compute_value_edge(v25_ou25_probs, v27_ou25, ou25_odds)
else:
ou25_divergence = {}
ou25_value = {}
# ── V28 Odds-Band Historical Performance ─────────────
odds_band_ms_home = {
"win_rate": features.get("home_band_ms_win_rate", 0.33),
"draw_rate": features.get("home_band_ms_draw_rate", 0.33),
"loss_rate": features.get("home_band_ms_loss_rate", 0.34),
"sample": features.get("home_band_ms_sample", 0),
"avg_goals_scored": features.get("home_band_ms_avg_goals_scored", 1.3),
"avg_goals_conceded": features.get("home_band_ms_avg_goals_conceded", 1.1),
}
odds_band_ms_away = {
"win_rate": features.get("away_band_ms_win_rate", 0.33),
"draw_rate": features.get("away_band_ms_draw_rate", 0.33),
"loss_rate": features.get("away_band_ms_loss_rate", 0.34),
"sample": features.get("away_band_ms_sample", 0),
"avg_goals_scored": features.get("away_band_ms_avg_goals_scored", 1.3),
"avg_goals_conceded": features.get("away_band_ms_avg_goals_conceded", 1.1),
}
odds_band_ou25 = {
"over_rate": features.get("band_ou25_over_rate", 0.50),
"under_rate": features.get("band_ou25_under_rate", 0.50),
"avg_total_goals": features.get("band_ou25_avg_total_goals", 2.5),
"sample": features.get("band_ou25_sample", 0),
}
odds_band_ou15 = {
"over_rate": features.get("band_ou15_over_rate", 0.65),
"under_rate": features.get("band_ou15_under_rate", 0.35),
"avg_total_goals": features.get("band_ou15_avg_total_goals", 2.5),
"sample": features.get("band_ou15_sample", 0),
}
odds_band_ou35 = {
"over_rate": features.get("band_ou35_over_rate", 0.35),
"under_rate": features.get("band_ou35_under_rate", 0.65),
"avg_total_goals": features.get("band_ou35_avg_total_goals", 2.5),
"sample": features.get("band_ou35_sample", 0),
}
odds_band_btts = {
"yes_rate": features.get("band_btts_yes_rate", 0.50),
"no_rate": features.get("band_btts_no_rate", 0.50),
"sample": features.get("band_btts_sample", 0),
}
odds_band_dc = {
"1x_rate": features.get("band_dc_1x_rate", 0.60),
"x2_rate": features.get("band_dc_x2_rate", 0.60),
"12_rate": features.get("band_dc_12_rate", 0.67),
"1x_sample": features.get("band_dc_1x_sample", 0),
"x2_sample": features.get("band_dc_x2_sample", 0),
"12_sample": features.get("band_dc_12_sample", 0),
}
odds_band_ht_home = {
"win_rate": features.get("home_band_ht_win_rate", 0.33),
"draw_rate": features.get("home_band_ht_draw_rate", 0.40),
"loss_rate": features.get("home_band_ht_loss_rate", 0.27),
"sample": features.get("home_band_ht_sample", 0),
}
odds_band_ht_away = {
"win_rate": features.get("away_band_ht_win_rate", 0.33),
"draw_rate": features.get("away_band_ht_draw_rate", 0.40),
"loss_rate": features.get("away_band_ht_loss_rate", 0.27),
"sample": features.get("away_band_ht_sample", 0),
}
odds_band_ht_ou05 = {
"over_rate": features.get("band_ht_ou05_over_rate", 0.50),
"under_rate": features.get("band_ht_ou05_under_rate", 0.50),
"sample": features.get("band_ht_ou05_sample", 0),
}
odds_band_ht_ou15 = {
"over_rate": features.get("band_ht_ou15_over_rate", 0.35),
"under_rate": features.get("band_ht_ou15_under_rate", 0.65),
"sample": features.get("band_ht_ou15_sample", 0),
}
odds_band_oe = {
"odd_rate": features.get("band_oe_odd_rate", 0.50),
"even_rate": features.get("band_oe_even_rate", 0.50),
"sample": features.get("band_oe_sample", 0),
}
# Cards (Kart) band — hakem + takım profili
odds_band_cards = {
"referee_avg": features.get("band_cards_referee_avg", 0.0),
"referee_over_rate": features.get("band_cards_referee_over_rate", 0.50),
"referee_sample": features.get("band_cards_referee_sample", 0),
"team_avg": features.get("band_cards_team_avg", 0.0),
"team_over_rate": features.get("band_cards_team_over_rate", 0.50),
"team_sample": features.get("band_cards_team_sample", 0),
"combined_over_rate": features.get("band_cards_combined_over_rate", 0.50),
"sample": features.get("band_cards_sample", 0),
}
# HTFT (İY/MS) 9 combination rates
odds_band_htft = {}
for combo in ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22"):
odds_band_htft[combo] = {
"rate": features.get(f"band_htft_{combo}_rate", 0.11),
"sample": features.get(f"band_htft_{combo}_sample", 0),
}
# ── Triple Value Detection ────────────────────────────
ms_odds = {
"home": float((data.odds_data or {}).get("ms_h", 0)),
"draw": float((data.odds_data or {}).get("ms_d", 0)),
"away": float((data.odds_data or {}).get("ms_a", 0)),
}
triple_value = {}
for outcome_key, band_key, odds_key in [
("home", "home", "home"),
("away", "away", "away"),
]:
v27_prob = (v27_ms or {}).get(outcome_key, 0)
band_rate = (odds_band_ms_home if band_key == "home"
else odds_band_ms_away)["win_rate"]
mkt_odds = ms_odds.get(odds_key, 0)
implied_prob = (1.0 / mkt_odds) if mkt_odds > 1.0 else 0.33
combined_prob = (v27_prob + band_rate) / 2.0 if v27_prob > 0 else band_rate
edge = combined_prob - implied_prob
band_sample = (odds_band_ms_home if band_key == "home"
else odds_band_ms_away)["sample"]
v27_confirms = v27_prob > implied_prob
band_confirms = band_rate > implied_prob
confirmation_count = sum([v27_confirms, band_confirms])
triple_value[outcome_key] = {
"v27_prob": round(v27_prob, 4),
"band_rate": round(band_rate, 4),
"implied_prob": round(implied_prob, 4),
"combined_prob": round(combined_prob, 4),
"edge": round(edge, 4),
"band_sample": band_sample,
"confirmations": confirmation_count,
"is_value": (
confirmation_count >= 2
and edge > 0.05
and band_sample >= 8
),
}
# OU25 triple value
ou25_over_odds = float((data.odds_data or {}).get("ou25_o", 0))
v27_ou25_over = (v27_ou25 or {}).get("over", 0) if v27_ou25 else 0
ou25_band_rate = odds_band_ou25["over_rate"]
ou25_implied = (1.0 / ou25_over_odds) if ou25_over_odds > 1.0 else 0.50
ou25_combined = (v27_ou25_over + ou25_band_rate) / 2.0 if v27_ou25_over > 0 else ou25_band_rate
ou25_edge = ou25_combined - ou25_implied
ou25_v27_confirms = v27_ou25_over > ou25_implied
ou25_band_confirms = ou25_band_rate > ou25_implied
ou25_conf_count = sum([ou25_v27_confirms, ou25_band_confirms])
triple_value["ou25_over"] = {
"v27_prob": round(v27_ou25_over, 4),
"band_rate": round(ou25_band_rate, 4),
"implied_prob": round(ou25_implied, 4),
"combined_prob": round(ou25_combined, 4),
"edge": round(ou25_edge, 4),
"band_sample": odds_band_ou25["sample"],
"confirmations": ou25_conf_count,
"is_value": (
ou25_conf_count >= 2
and ou25_edge > 0.05
and odds_band_ou25["sample"] >= 8
),
}
# BTTS triple value
btts_yes_odds = float((data.odds_data or {}).get("btts_y", 0))
btts_implied = (1.0 / btts_yes_odds) if btts_yes_odds > 1.0 else 0.50
btts_band_rate = odds_band_btts["yes_rate"]
btts_combined = btts_band_rate
btts_edge = btts_combined - btts_implied
btts_band_confirms = btts_band_rate > btts_implied
triple_value["btts_yes"] = {
"band_rate": round(btts_band_rate, 4),
"implied_prob": round(btts_implied, 4),
"combined_prob": round(btts_combined, 4),
"edge": round(btts_edge, 4),
"band_sample": odds_band_btts["sample"],
"confirmations": 1 if btts_band_confirms else 0,
"is_value": (
btts_band_confirms
and btts_edge > 0.05
and odds_band_btts["sample"] >= 8
),
}
# ── Band-only value for new markets ───────────────────
def _band_value(label, band_rate, odds_key, sample):
o = float((data.odds_data or {}).get(odds_key, 0))
imp = (1.0 / o) if o > 1.0 else 0.50
e = band_rate - imp
conf = band_rate > imp
return {
"band_rate": round(band_rate, 4),
"implied_prob": round(imp, 4),
"edge": round(e, 4),
"band_sample": sample,
"is_value": conf and e > 0.05 and sample >= 8,
}
triple_value["ou15_over"] = _band_value(
"ou15", odds_band_ou15["over_rate"], "ou15_o", odds_band_ou15["sample"])
triple_value["ou35_over"] = _band_value(
"ou35", odds_band_ou35["over_rate"], "ou35_o", odds_band_ou35["sample"])
triple_value["dc_1x"] = _band_value(
"dc1x", odds_band_dc["1x_rate"], "dc_1x", odds_band_dc["1x_sample"])
triple_value["dc_x2"] = _band_value(
"dcx2", odds_band_dc["x2_rate"], "dc_x2", odds_band_dc["x2_sample"])
triple_value["dc_12"] = _band_value(
"dc12", odds_band_dc["12_rate"], "dc_12", odds_band_dc["12_sample"])
triple_value["ht_home"] = _band_value(
"ht_h", odds_band_ht_home["win_rate"], "ht_h", odds_band_ht_home["sample"])
triple_value["ht_away"] = _band_value(
"ht_a", odds_band_ht_away["win_rate"], "ht_a", odds_band_ht_away["sample"])
triple_value["ht_ou05_over"] = _band_value(
"htou05", odds_band_ht_ou05["over_rate"], "ht_ou05_o", odds_band_ht_ou05["sample"])
triple_value["ht_ou15_over"] = _band_value(
"htou15", odds_band_ht_ou15["over_rate"], "ht_ou15_o", odds_band_ht_ou15["sample"])
triple_value["oe_odd"] = _band_value(
"oe", odds_band_oe["odd_rate"], "oe_odd", odds_band_oe["sample"])
# Cards triple value — composite (hakem + takım)
triple_value["cards_over"] = _band_value(
"cards", odds_band_cards["combined_over_rate"], "cards_o",
odds_band_cards["sample"])
# HTFT triple value — 9 combinations
for combo in ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22"):
htft_combo_data = odds_band_htft.get(combo, {})
triple_value[f"htft_{combo}"] = _band_value(
f"htft_{combo}", htft_combo_data.get("rate", 0.11),
f"htft_{combo}", htft_combo_data.get("sample", 0))
# Attach to package
base_package["v27_engine"] = {
"version": "v28-pro-max",
"approach": "odds-free fundamentals + full odds-band analytics + cards + htft",
"predictions": {
"ms": v27_ms or {},
"ou25": v27_ou25 or {},
},
"divergence": {
"ms": ms_divergence,
"ou25": ou25_divergence,
},
"value_edge": {
"ms": ms_value,
"ou25": ou25_value,
},
"odds_band": {
"ms_home": odds_band_ms_home,
"ms_away": odds_band_ms_away,
"ou25": odds_band_ou25,
"ou15": odds_band_ou15,
"ou35": odds_band_ou35,
"btts": odds_band_btts,
"dc": odds_band_dc,
"ht_home": odds_band_ht_home,
"ht_away": odds_band_ht_away,
"ht_ou05": odds_band_ht_ou05,
"ht_ou15": odds_band_ht_ou15,
"oe": odds_band_oe,
"cards": odds_band_cards,
"htft": odds_band_htft,
},
"triple_value": triple_value,
}
# Boost confidence when V27 agrees with V25
if v27_ms:
v27_best = max(v27_ms, key=v27_ms.get)
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:
# Engines agree → boost confidence by up to 5%
boost = min(5.0, abs(ms_divergence.get(v27_best, 0)) * 50)
# Additional boost if odds-band also confirms
band_val = triple_value.get(v25_best_mapped, {})
if band_val.get("is_value"):
boost = min(8.0, boost + 3.0) # Triple confirmation extra boost
prediction.ms_confidence = min(95.0, prediction.ms_confidence + boost)
market_board = base_package.get("market_board")
if isinstance(market_board, dict) and isinstance(market_board.get("MS"), dict):
market_board["MS"]["confidence"] = round(float(prediction.ms_confidence), 1)
base_package["v27_engine"]["consensus"] = "AGREE"
else:
base_package["v27_engine"]["consensus"] = "DISAGREE"
# Update analysis details
base_package.setdefault("analysis_details", {})
base_package["analysis_details"]["dual_engine"] = True
base_package["analysis_details"]["v27_loaded"] = True
base_package["analysis_details"]["odds_band_loaded"] = True
except Exception as e:
print(f"[V27] ⚠ Prediction failed (non-fatal): {e}")
base_package.setdefault("analysis_details", {})
base_package["analysis_details"]["v27_loaded"] = False
base_package = self._apply_upper_brain_guards(base_package)
mode = str(getattr(self, "engine_mode", "v28-pro-max") or "v28-pro-max").lower()
if mode not in {"v25", "v26", "dual", "v28", "v28-pro-max"}:
mode = "v25"
quality = base_package.get("data_quality", self._compute_data_quality(data))
shadow_package = self._get_v26_shadow_engine().build_package(
data=data,
prediction=prediction,
v25_signal=v25_signal,
quality=quality,
)
if mode == "v26":
return shadow_package
if mode == "dual":
merged = dict(base_package)
merged.update(
{
"shadow_engine": shadow_package,
"shadow_engine_version": shadow_package.get("model_version"),
"calibration_version": shadow_package.get("calibration_version"),
"decision_trace_id": shadow_package.get("decision_trace_id"),
"market_reliability": shadow_package.get("market_reliability", {}),
}
)
return merged
return base_package
def _apply_upper_brain_guards(self, package: Dict[str, Any]) -> Dict[str, Any]:
return BettingBrain().judge(package)
v27_engine = package.get("v27_engine")
if not isinstance(v27_engine, dict) or not v27_engine.get("triple_value"):
return package
guarded = dict(package)
vetoed_keys = set()
guarded_keys = set()
def mark_guard(item: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(item, dict):
return item
out = dict(item)
assessment = self._upper_brain_assessment(out, guarded)
if not assessment.get("applies"):
return out
key = f"{out.get('market')}:{out.get('pick')}"
guarded_keys.add(key)
out["upper_brain"] = assessment
reason_key = "decision_reasons" if "decision_reasons" in out else "reasons"
reasons = list(out.get(reason_key) or [])
for reason in assessment.get("reason_codes", []):
if reason not in reasons:
reasons.append(reason)
out[reason_key] = reasons[:6]
if assessment.get("veto"):
vetoed_keys.add(key)
out["playable"] = False
out["stake_units"] = 0.0
out["bet_grade"] = "PASS"
out["is_guaranteed"] = False
out["pick_reason"] = "upper_brain_veto"
if "signal_tier" in out:
out["signal_tier"] = "PASS"
elif assessment.get("downgrade"):
out["is_guaranteed"] = False
if out.get("signal_tier") == "CORE":
out["signal_tier"] = "LEAN"
if out.get("pick_reason") == "high_accuracy_market":
out["pick_reason"] = "upper_brain_downgraded"
return out
main_pick = mark_guard(guarded.get("main_pick") or {})
value_pick = mark_guard(guarded.get("value_pick") or {}) if guarded.get("value_pick") else None
supporting = [
mark_guard(row)
for row in list(guarded.get("supporting_picks") or [])
if isinstance(row, dict)
]
bet_summary = [
mark_guard(row)
for row in list(guarded.get("bet_summary") or [])
if isinstance(row, dict)
]
main_safe = bool(main_pick and main_pick.get("playable") and not main_pick.get("upper_brain", {}).get("veto"))
if not main_safe:
candidates = [
row for row in supporting
if row.get("playable")
and not row.get("upper_brain", {}).get("veto")
and float(row.get("odds", 0.0) or 0.0) >= 1.30
]
candidates.sort(key=lambda row: float(row.get("play_score", 0.0) or 0.0), reverse=True)
if candidates:
main_pick = dict(candidates[0])
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "upper_brain_reselected"
reasons = list(main_pick.get("decision_reasons") or [])
if "upper_brain_reselected_after_veto" not in reasons:
reasons.append("upper_brain_reselected_after_veto")
main_pick["decision_reasons"] = reasons[:6]
elif main_pick:
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "upper_brain_no_safe_pick"
if main_pick:
supporting = [
row for row in supporting
if not (
row.get("market") == main_pick.get("market")
and row.get("pick") == main_pick.get("pick")
)
][:6]
guarded["main_pick"] = main_pick if main_pick else None
guarded["value_pick"] = value_pick
guarded["supporting_picks"] = supporting
guarded["bet_summary"] = bet_summary
playable = bool(main_pick and main_pick.get("playable") and not main_pick.get("upper_brain", {}).get("veto"))
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
if playable:
advice["reason"] = "playable_pick_found"
elif vetoed_keys:
advice["reason"] = "upper_brain_no_safe_pick"
else:
advice["reason"] = "no_bet_conditions_met"
guarded["bet_advice"] = advice
guarded["upper_brain"] = {
"applied": True,
"guarded_count": len(guarded_keys),
"vetoed_count": len(vetoed_keys),
"vetoed": sorted(vetoed_keys)[:8],
"rules": {
"min_band_sample": 8,
"max_v25_v27_divergence": 0.18,
"dc_requires_triple_value": True,
},
}
guarded.setdefault("analysis_details", {})
guarded["analysis_details"]["upper_brain_guards_applied"] = True
guarded["analysis_details"]["upper_brain_vetoed_count"] = len(vetoed_keys)
return guarded
def _upper_brain_assessment(
self,
item: Dict[str, Any],
package: Dict[str, Any],
) -> Dict[str, Any]:
market = str(item.get("market") or "")
pick = str(item.get("pick") or "")
if not market or not pick:
return {"applies": False}
v27_engine = package.get("v27_engine") or {}
triple_value = v27_engine.get("triple_value") or {}
model_prob = self._upper_brain_market_probability(item, package)
v27_prob = self._upper_brain_v27_probability(market, pick, v27_engine)
triple_key = self._upper_brain_triple_key(market, pick)
triple = triple_value.get(triple_key) if triple_key else None
veto = False
downgrade = False
reasons: List[str] = []
divergence = None
if model_prob is not None and v27_prob is not None:
divergence = abs(float(model_prob) - float(v27_prob))
if divergence >= 0.18:
veto = True
reasons.append("upper_brain_v25_v27_divergence")
elif divergence >= 0.12:
downgrade = True
reasons.append("upper_brain_v25_v27_warning")
if isinstance(triple, dict):
band_sample = int(float(triple.get("band_sample", 0) or 0))
is_value = bool(triple.get("is_value"))
if market == "DC":
if band_sample < 8:
veto = True
reasons.append("upper_brain_band_sample_too_low")
elif not is_value:
veto = True
reasons.append("upper_brain_triple_value_rejected")
elif market in {"MS", "OU25"} and band_sample > 0 and band_sample < 8:
downgrade = True
reasons.append("upper_brain_band_sample_thin")
elif market in {"OU15", "HT_OU05"} and band_sample < 8:
downgrade = True
reasons.append("upper_brain_band_sample_thin")
consensus = str(v27_engine.get("consensus") or "").upper()
if consensus == "DISAGREE" and market in {"MS", "DC"} and not veto:
downgrade = True
reasons.append("upper_brain_consensus_disagree")
applies = bool(reasons or triple is not None or v27_prob is not None)
return {
"applies": applies,
"veto": veto,
"downgrade": downgrade,
"reason_codes": reasons,
"model_prob": round(float(model_prob), 4) if model_prob is not None else None,
"v27_prob": round(float(v27_prob), 4) if v27_prob is not None else None,
"divergence": round(float(divergence), 4) if divergence is not None else None,
"triple_key": triple_key,
"triple_value": triple,
}
def _upper_brain_market_probability(
self,
item: Dict[str, Any],
package: Dict[str, Any],
) -> Optional[float]:
raw_prob = item.get("probability")
if raw_prob is not None:
try:
return float(raw_prob)
except (TypeError, ValueError):
pass
market = str(item.get("market") or "")
pick = str(item.get("pick") or "")
board = package.get("market_board") or {}
payload = board.get(market) if isinstance(board, dict) else None
probs = payload.get("probs") if isinstance(payload, dict) else None
if not isinstance(probs, dict):
return None
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
def _upper_brain_v27_probability(
self,
market: str,
pick: str,
v27_engine: Dict[str, Any],
) -> Optional[float]:
predictions = v27_engine.get("predictions") or {}
ms = predictions.get("ms") or {}
ou25 = predictions.get("ou25") or {}
if market == "MS":
return self._safe_float(ms.get({"1": "home", "X": "draw", "2": "away"}.get(pick, "")))
if market == "DC":
if pick == "1X":
return self._safe_float(ms.get("home"), 0.0) + self._safe_float(ms.get("draw"), 0.0)
if pick == "X2":
return self._safe_float(ms.get("draw"), 0.0) + self._safe_float(ms.get("away"), 0.0)
if pick == "12":
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
@staticmethod
def _upper_brain_prob_key(market: str, pick: str) -> Optional[str]:
pick_norm = str(pick or "").strip().casefold()
if market in {"MS", "HT", "HCAP"}:
return pick if pick in {"1", "X", "2"} else None
if market == "DC":
return pick.upper() if pick.upper() in {"1X", "X2", "12"} else None
if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
if "over" in pick_norm or "st" in pick_norm:
return "over"
if "under" in pick_norm or "alt" in pick_norm:
return "under"
if market == "BTTS":
if "yes" in pick_norm or "var" in pick_norm:
return "yes"
if "no" in pick_norm or "yok" in pick_norm:
return "no"
if market == "OE":
if "odd" in pick_norm or "tek" in pick_norm:
return "odd"
if "even" in pick_norm or "ift" in pick_norm:
return "even"
if market == "HTFT" and "/" in pick:
return pick
return None
def _upper_brain_triple_key(self, market: str, pick: str) -> Optional[str]:
prob_key = self._upper_brain_prob_key(market, pick)
if market == "MS":
return {"1": "home", "2": "away"}.get(pick)
if market == "DC":
return f"dc_{pick.lower()}" if pick.upper() in {"1X", "X2", "12"} else None
if market in {"OU15", "OU25", "OU35"} and prob_key == "over":
return f"{market.lower()}_over"
if market == "BTTS" and prob_key == "yes":
return "btts_yes"
if market == "HT":
return {"1": "ht_home", "2": "ht_away"}.get(pick)
if market in {"HT_OU05", "HT_OU15"} and prob_key == "over":
return f"{market.lower()}_over"
if market == "OE" and prob_key == "odd":
return "oe_odd"
if market == "CARDS" and prob_key == "over":
return "cards_over"
if market == "HTFT" and "/" in pick:
return f"htft_{pick.replace('/', '').lower()}"
return None
@staticmethod
def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
return default
def analyze_match_htms(self, match_id: str) -> Optional[Dict[str, Any]]:
"""
HT/MS focused response for upset-hunting workflows.
This endpoint is intentionally additive and does not mutate the
standard /v20plus/analyze package contract.
"""
data = self._load_match_data(match_id)
if data is None:
return None
if str(data.sport or "").lower() != "football":
return {
"status": "skip",
"match_id": match_id,
"reason": "unsupported_sport",
"engine_used": "htms_router",
}
is_top_league = self._is_top_league(data.league_id)
engine_used = "v20plus_top_htms"
# Hard gate: HT/MS upset model is trained on top leagues only.
if not is_top_league:
return {
"status": "skip",
"match_id": match_id,
"reason": "out_of_training_scope",
"engine_used": engine_used,
"data_quality": {
"label": "LOW",
"flags": ["league_out_of_scope"],
},
}
missing_requirements = self._missing_htms_requirements(data)
if missing_requirements:
return {
"status": "skip",
"match_id": match_id,
"reason": "missing_critical_data",
"missing": missing_requirements,
"engine_used": engine_used,
"data_quality": {
"label": "LOW",
"flags": [f"missing_{item}" for item in missing_requirements],
},
}
base_package = self.analyze_match(match_id)
if not base_package:
return None
data_quality = base_package.get("data_quality", {})
market_board = base_package.get("market_board", {})
ms_market = market_board.get("MS", {})
ht_market = market_board.get("HT", {})
htft_probs = market_board.get("HTFT", {}).get("probs", {})
reversal_probs = {
"1/2": float(htft_probs.get("1/2", 0.0)),
"2/1": float(htft_probs.get("2/1", 0.0)),
"X/1": float(htft_probs.get("X/1", 0.0)),
"X/2": float(htft_probs.get("X/2", 0.0)),
}
top_reversal = max(reversal_probs.items(), key=lambda item: item[1])
ms_conf = float(ms_market.get("confidence", 0.0))
ht_conf = float(ht_market.get("confidence", 0.0))
base_conf = (ms_conf + ht_conf) / 2.0
confidence_cap = 100.0
penalties: List[str] = []
if data.lineup_source == "probable_xi":
confidence_cap = min(confidence_cap, 72.0)
penalties.append("lineup_probable_xi")
if data.lineup_source == "none":
confidence_cap = min(confidence_cap, 58.0)
penalties.append("lineup_unavailable")
if str(data_quality.get("label", "LOW")).upper() == "LOW":
confidence_cap = min(confidence_cap, 55.0)
penalties.append("low_data_quality")
final_conf = min(base_conf, confidence_cap)
upset_score = self._compute_htms_upset_score(
reversal_probs=reversal_probs,
odds_data=data.odds_data,
is_top_league=is_top_league,
)
upset_threshold = 58.0 if is_top_league else 54.0
upset_playable = (
upset_score >= upset_threshold
and top_reversal[1] >= 0.045
and final_conf >= 45.0
and "low_data_quality" not in penalties
)
return {
"status": "ok",
"engine_used": engine_used,
"match_info": base_package.get("match_info", {}),
"data_quality": data_quality,
"htms_core": {
"ms_pick": ms_market.get("pick"),
"ms_confidence": round(ms_conf, 1),
"ht_pick": ht_market.get("pick"),
"ht_confidence": round(ht_conf, 1),
"combined_confidence": round(final_conf, 1),
"confidence_cap": round(confidence_cap, 1),
"penalties": penalties,
},
"surprise_hunter": {
"upset_score": round(upset_score, 1),
"threshold": upset_threshold,
"playable": upset_playable,
"top_reversal_pick": top_reversal[0],
"top_reversal_prob": round(top_reversal[1], 4),
"reversal_probs": {
key: round(value, 4) for key, value in reversal_probs.items()
},
},
"risk": base_package.get("risk", {}),
"reasoning_factors": base_package.get("reasoning_factors", []),
}
def _is_top_league(self, league_id: Optional[str]) -> bool:
if not league_id:
return False
return str(league_id) in self.top_league_ids
def _missing_htms_requirements(self, data: MatchData) -> List[str]:
missing: List[str] = []
ms_keys = ("ms_h", "ms_d", "ms_a")
ht_keys = ("ht_h", "ht_d", "ht_a")
if not all(float(data.odds_data.get(k, 0.0) or 0.0) > 1.0 for k in ms_keys):
missing.append("ms_odds")
if not all(float(data.odds_data.get(k, 0.0) or 0.0) > 1.0 for k in ht_keys):
missing.append("ht_odds")
return missing
def _compute_htms_upset_score(
self,
reversal_probs: Dict[str, float],
odds_data: Dict[str, float],
is_top_league: bool,
) -> float:
ms_h = self._to_float(odds_data.get("ms_h"), 0.0)
ms_a = self._to_float(odds_data.get("ms_a"), 0.0)
if ms_h <= 1.0 or ms_a <= 1.0:
favorite_gap = 0.0
else:
favorite_gap = abs(ms_h - ms_a)
reversal_max = max(reversal_probs.values()) if reversal_probs else 0.0
reversal_sum = sum(reversal_probs.values())
# Strong favorite + reversal probability is the core upset signal.
gap_factor = min(1.0, favorite_gap / 2.0)
score = (
(reversal_max * 100.0 * 0.60)
+ (reversal_sum * 100.0 * 0.25)
+ (gap_factor * 100.0 * 0.15)
)
if not is_top_league:
# Non-top leagues are noisier; keep it slightly conservative.
score *= 0.92
return max(0.0, min(100.0, score))
def build_coupon(
self,
match_ids: List[str],
strategy: str = "BALANCED",
max_matches: Optional[int] = None,
min_confidence: Optional[float] = None,
) -> Dict[str, Any]:
strategy_name = (strategy or "BALANCED").upper()
strategy_config = {
"SAFE": {"max_matches": 4, "min_conf": 66.0},
"BALANCED": {"max_matches": 5, "min_conf": 58.0},
"AGGRESSIVE": {"max_matches": 8, "min_conf": 52.0},
"VALUE": {"max_matches": 8, "min_conf": 48.0},
"MIRACLE": {"max_matches": 10, "min_conf": 44.0},
}
cfg = strategy_config.get(strategy_name, strategy_config["BALANCED"])
max_allowed = max_matches if max_matches is not None else cfg["max_matches"]
min_conf = min_confidence if min_confidence is not None else cfg["min_conf"]
candidates: List[Dict[str, Any]] = []
rejected: List[Dict[str, Any]] = []
for match_id in match_ids:
package = self.analyze_match(match_id)
if not package:
rejected.append({"match_id": match_id, "reason": "match_not_found"})
continue
risk_level = str(package.get("risk", {}).get("level", "MEDIUM")).upper()
data_quality = str(package.get("data_quality", {}).get("label", "MEDIUM")).upper()
match_candidates: List[Dict[str, Any]] = []
seen_keys: Set[Tuple[str, str]] = set()
bet_summary = package.get("bet_summary") or []
raw_picks = []
for candidate in [
package.get("main_pick"),
package.get("value_pick"),
*(package.get("supporting_picks") or []),
]:
if isinstance(candidate, dict):
raw_picks.append(candidate)
for candidate in bet_summary:
if isinstance(candidate, dict):
raw_picks.append(candidate)
for candidate in raw_picks:
market = str(candidate.get("market") or "")
pick = str(candidate.get("pick") or "")
if not market or not pick:
continue
dedupe_key = (market, pick)
if dedupe_key in seen_keys:
continue
seen_keys.add(dedupe_key)
calibrated_conf = float(
candidate.get("calibrated_confidence", candidate.get("confidence", 0.0))
or 0.0
)
odds = float(candidate.get("odds", 0.0) or 0.0)
probability = float(candidate.get("probability", 0.0) or 0.0)
play_score = float(candidate.get("play_score", 0.0) or 0.0)
ev_edge = float(
candidate.get("ev_edge", candidate.get("edge", 0.0)) or 0.0
)
playable = bool(candidate.get("playable"))
bet_grade = str(candidate.get("bet_grade", "PASS")).upper()
if odds <= 1.01:
continue
strict_candidate = (
playable
and calibrated_conf >= min_conf
and bet_grade != "PASS"
)
if strategy_name == "SAFE":
strict_pass = strict_candidate
if odds > 2.35 or play_score < 60.0 or risk_level in {"HIGH", "EXTREME"}:
strict_pass = False
if data_quality == "LOW" or ev_edge < 0.01 or bet_grade == "PASS":
strict_pass = False
strict_score = (
calibrated_conf * 1.10
+ play_score * 0.90
+ (ev_edge * 180.0)
- abs(odds - 1.55) * 12.0
)
soft_pass = (
calibrated_conf >= max(min_conf - 10.0, 56.0)
and odds <= 2.70
and play_score >= 50.0
and risk_level != "EXTREME"
and data_quality != "LOW"
and ev_edge >= -0.01
)
soft_score = (
calibrated_conf
+ play_score * 0.85
+ (ev_edge * 140.0)
- abs(odds - 1.65) * 9.0
)
elif strategy_name == "BALANCED":
strict_pass = strict_candidate
if odds > 3.40 or play_score < 52.0 or risk_level == "EXTREME":
strict_pass = False
if ev_edge < 0.0 or bet_grade == "PASS":
strict_pass = False
strict_score = (
calibrated_conf
+ play_score
+ (ev_edge * 220.0)
+ min(odds, 3.0) * 3.0
)
soft_pass = (
calibrated_conf >= max(min_conf - 10.0, 48.0)
and odds <= 4.20
and play_score >= 44.0
and risk_level != "EXTREME"
and ev_edge >= -0.015
)
soft_score = (
calibrated_conf * 0.95
+ play_score * 0.90
+ (ev_edge * 180.0)
+ min(odds, 3.5) * 3.5
)
elif strategy_name == "AGGRESSIVE":
strict_pass = strict_candidate
if odds < 1.35 or odds > 7.50 or play_score < 46.0:
strict_pass = False
if risk_level == "EXTREME" or bet_grade == "PASS":
strict_pass = False
strict_score = (
calibrated_conf * 0.85
+ play_score * 0.75
+ (ev_edge * 260.0)
+ min(odds, 6.0) * 7.0
)
soft_pass = (
calibrated_conf >= max(min_conf - 10.0, 42.0)
and 1.25 <= odds <= 8.50
and play_score >= 40.0
and risk_level != "EXTREME"
and ev_edge >= -0.02
)
soft_score = (
calibrated_conf * 0.80
+ play_score * 0.70
+ (ev_edge * 210.0)
+ min(odds, 7.0) * 7.5
)
elif strategy_name == "VALUE":
strict_pass = strict_candidate
if odds < 1.55 or play_score < 48.0 or ev_edge < 0.03:
strict_pass = False
if risk_level == "EXTREME" or data_quality == "LOW" or bet_grade == "PASS":
strict_pass = False
strict_score = (
calibrated_conf * 0.75
+ play_score * 0.85
+ (ev_edge * 320.0)
+ min(odds, 6.5) * 8.0
)
soft_pass = (
calibrated_conf >= max(min_conf - 10.0, 40.0)
and odds >= 1.35
and play_score >= 40.0
and risk_level != "EXTREME"
and data_quality != "LOW"
and ev_edge >= 0.0
)
soft_score = (
calibrated_conf * 0.70
+ play_score * 0.80
+ (ev_edge * 260.0)
+ min(odds, 7.0) * 7.0
)
else: # MIRACLE
strict_pass = strict_candidate
if odds < 2.10 or play_score < 40.0 or ev_edge < 0.01:
strict_pass = False
if risk_level == "EXTREME" or bet_grade == "PASS":
strict_pass = False
strict_score = (
calibrated_conf * 0.55
+ play_score * 0.60
+ (ev_edge * 260.0)
+ min(odds, 10.0) * 10.0
)
soft_pass = (
calibrated_conf >= max(min_conf - 10.0, 36.0)
and odds >= 1.60
and play_score >= 34.0
and risk_level != "EXTREME"
and ev_edge >= -0.02
)
soft_score = (
calibrated_conf * 0.50
+ play_score * 0.55
+ (ev_edge * 200.0)
+ min(odds, 10.0) * 9.0
)
fallback_pass = (
calibrated_conf >= max(min_conf - 14.0, 34.0)
and odds >= 1.20
and play_score >= 32.0
and risk_level != "EXTREME"
)
fallback_score = (
calibrated_conf * 0.60
+ play_score * 0.65
+ (ev_edge * 120.0)
+ min(odds, 6.0) * 4.0
)
strategy_score = strict_score
selection_mode = "strict"
if strict_pass:
pass
elif soft_pass:
strategy_score = soft_score
selection_mode = "soft"
elif fallback_pass:
strategy_score = fallback_score
selection_mode = "fallback"
else:
continue
match_candidates.append(
{
"match_id": package["match_info"]["match_id"],
"match_name": package["match_info"]["match_name"],
"market": market,
"pick": pick,
"probability": probability,
"confidence": calibrated_conf,
"odds": odds,
"risk_level": risk_level,
"data_quality": data_quality,
"bet_grade": bet_grade,
"playable": playable,
"play_score": round(play_score, 1),
"ev_edge": round(ev_edge, 4),
"selection_mode": selection_mode,
"strategy_score": round(strategy_score, 3),
}
)
if not match_candidates:
rejected.append(
{
"match_id": match_id,
"reason": "no_strategy_fit",
"threshold": min_conf,
}
)
continue
match_candidates.sort(
key=lambda item: (
float(item.get("strategy_score", 0.0)),
float(item.get("confidence", 0.0)),
float(item.get("ev_edge", 0.0)),
),
reverse=True,
)
candidates.append(match_candidates[0])
candidates.sort(
key=lambda item: (
float(item.get("strategy_score", 0.0)),
float(item.get("confidence", 0.0)),
float(item.get("ev_edge", 0.0)),
),
reverse=True,
)
selected = candidates[: max(1, max_allowed)]
total_odds = 1.0
win_probability = 1.0
for pick in selected:
odd = float(pick.get("odds") or 1.0)
prob = float(pick.get("probability") or 0.0)
total_odds *= odd if odd > 1.0 else 1.0
win_probability *= prob
return {
"strategy": strategy_name,
"generated_at": __import__("datetime").datetime.utcnow().isoformat() + "Z",
"match_count": len(selected),
"bets": selected,
"total_odds": round(total_odds, 2),
"expected_win_rate": round(win_probability, 4),
"rejected_matches": rejected,
}
def get_daily_bankers(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(
"""
SELECT id
FROM live_matches
WHERE mst_utc > EXTRACT(EPOCH FROM NOW()) * 1000
AND mst_utc < EXTRACT(EPOCH FROM NOW() + INTERVAL '24 hours') * 1000
ORDER BY mst_utc ASC
LIMIT 60
""",
)
ids = [row["id"] for row in cur.fetchall()]
if not ids:
return []
coupon = self.build_coupon(
match_ids=ids,
strategy="SAFE",
max_matches=max(1, count),
min_confidence=78.0,
)
return coupon.get("bets", [])[: max(1, count)]
def get_reversal_watchlist(
self,
count: int = 20,
horizon_hours: int = 72,
min_score: float = 45.0,
top_leagues_only: bool = False,
) -> Dict[str, Any]:
safe_count = max(1, min(100, int(count)))
safe_horizon = max(6, min(168, int(horizon_hours)))
safe_min_score = max(0.0, min(100.0, float(min_score)))
now_ms = int(time.time() * 1000)
horizon_ms = now_ms + (safe_horizon * 60 * 60 * 1000)
with psycopg2.connect(self.dsn) as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
lm.id,
lm.home_team_id,
lm.away_team_id,
lm.league_id,
lm.mst_utc
FROM live_matches lm
WHERE lm.sport = 'football'
AND lm.mst_utc >= %s
AND lm.mst_utc <= %s
ORDER BY lm.mst_utc ASC
LIMIT 200
""",
(now_ms, horizon_ms),
)
raw_candidates = cur.fetchall()
candidates = [
row
for row in raw_candidates
if row.get("home_team_id") and row.get("away_team_id")
]
if top_leagues_only:
candidates = [
row for row in candidates if self._is_top_league(row.get("league_id"))
]
team_ids: Set[str] = set()
pair_keys: Set[Tuple[str, str]] = set()
for row in candidates:
home_id = str(row["home_team_id"])
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))))
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)
watch_items_all: List[Dict[str, Any]] = []
scanned = 0
for row in candidates:
match_id = str(row["id"])
data = self._load_match_data(match_id)
if data is None:
continue
package = self.analyze_match(match_id)
if not package:
continue
scanned += 1
htft_probs = package.get("market_board", {}).get("HTFT", {}).get("probs", {})
prob_12 = float(htft_probs.get("1/2", 0.0))
prob_21 = float(htft_probs.get("2/1", 0.0))
if prob_12 <= 0.0 and prob_21 <= 0.0:
continue
overall_htft_pick = None
overall_htft_prob = 0.0
if htft_probs:
overall_htft_pick, overall_htft_prob = max(
htft_probs.items(),
key=lambda item: float(item[1]),
)
reversal_sum = prob_12 + prob_21
reversal_max = max(prob_12, prob_21)
top_pick = "2/1" if prob_21 >= prob_12 else "1/2"
top_prob = prob_21 if top_pick == "2/1" else prob_12
ms_h = self._to_float(data.odds_data.get("ms_h"), 0.0)
ms_a = self._to_float(data.odds_data.get("ms_a"), 0.0)
gap = abs(ms_h - ms_a) if ms_h > 1.0 and ms_a > 1.0 else 0.0
favorite_odd = min(ms_h, ms_a) if ms_h > 1.0 and ms_a > 1.0 else 0.0
# Reversal events are rare (~5% baseline), so convert raw probs to a more useful
# watchlist scale where p in [0.02, 0.08] becomes meaningfully separable.
base_score = (reversal_max * 100.0 * 8.0) + (reversal_sum * 100.0 * 4.0)
balance_bonus = 0.0
if gap > 0.0:
balance_bonus = max(0.0, (1.0 - min(gap, 1.2) / 1.2) * 7.0)
elif ms_h > 1.0 and ms_a > 1.0:
balance_bonus = 2.0
favorite_bonus = 0.0
if favorite_odd > 0.0 and favorite_odd <= 1.70 and reversal_max >= 0.02:
favorite_bonus = min(8.0, (1.70 - favorite_odd) * 12.0)
home_metrics = team_cycle.get(data.home_team_id, {})
away_metrics = team_cycle.get(data.away_team_id, {})
cycle_pressure = max(
float(home_metrics.get("cycle_pressure", 0.0)),
float(away_metrics.get("cycle_pressure", 0.0)),
)
cycle_bonus = cycle_pressure * 10.0
pair_key = tuple(sorted((data.home_team_id, data.away_team_id)))
pair_ctx = h2h_ctx.get(pair_key, {})
blowout_bonus = 0.0
last_diff = int(pair_ctx.get("goal_diff", 0))
if abs(last_diff) >= 3:
blowout_bonus = 6.0
if abs(last_diff) >= 5:
blowout_bonus += 3.0
ou25_o = self._to_float(data.odds_data.get("ou25_o"), 0.0)
tempo_bonus = 0.0
if ou25_o > 1.0 and ou25_o <= 1.72:
tempo_bonus = 2.5
watch_score = max(
0.0,
min(
100.0,
base_score + balance_bonus + favorite_bonus + cycle_bonus + blowout_bonus + tempo_bonus,
),
)
reason_codes: List[str] = []
if top_prob >= 0.045:
reason_codes.append("reversal_prob_hot")
elif top_prob >= 0.030:
reason_codes.append("reversal_prob_warm")
if gap > 0.0 and gap <= 0.80:
reason_codes.append("balanced_matchup")
if favorite_bonus > 0.0:
reason_codes.append("strong_favorite_reversal_window")
if cycle_pressure >= 0.55:
reason_codes.append("team_reversal_cycle_pressure")
if blowout_bonus > 0.0:
reason_codes.append("h2h_blowout_rematch")
if tempo_bonus > 0.0:
reason_codes.append("high_tempo_profile")
if not reason_codes:
reason_codes.append("model_signal_only")
item = (
{
"match_id": data.match_id,
"match_name": f"{data.home_team_name} vs {data.away_team_name}",
"match_date_ms": data.match_date_ms,
"league_id": data.league_id,
"league": data.league_name,
"risk_band": self._watchlist_risk_band(watch_score),
"watch_score": round(watch_score, 2),
"top_pick": top_pick,
"top_pick_prob": round(top_prob, 4),
"top_pick_scope": "reversal_only",
"overall_htft_pick": overall_htft_pick,
"overall_htft_pick_prob": round(float(overall_htft_prob), 4),
"reversal_probs": {
"1/2": round(prob_12, 4),
"2/1": round(prob_21, 4),
},
"odds_snapshot": {
"ms_h": round(ms_h, 2) if ms_h > 0 else None,
"ms_a": round(ms_a, 2) if ms_a > 0 else None,
"ms_gap": round(gap, 3),
"favorite_odd": round(favorite_odd, 2) if favorite_odd > 0 else None,
},
"pattern_signals": {
"home_cycle_pressure": round(float(home_metrics.get("cycle_pressure", 0.0)), 3),
"away_cycle_pressure": round(float(away_metrics.get("cycle_pressure", 0.0)), 3),
"home_matches_since_last_reversal": int(home_metrics.get("matches_since_last_reversal", 99)),
"away_matches_since_last_reversal": int(away_metrics.get("matches_since_last_reversal", 99)),
"h2h_last_goal_diff": last_diff if pair_ctx else None,
"h2h_last_result": pair_ctx.get("result"),
},
"reason_codes": reason_codes,
}
)
watch_items_all.append(item)
watch_items_all.sort(
key=lambda item: (
float(item.get("watch_score", 0.0)),
float(item.get("top_pick_prob", 0.0)),
),
reverse=True,
)
selected = [
item for item in watch_items_all if float(item.get("watch_score", 0.0)) >= safe_min_score
][:safe_count]
preview = watch_items_all[: min(5, len(watch_items_all))]
return {
"engine": "v28.main",
"generated_at": __import__("datetime").datetime.utcnow().isoformat() + "Z",
"horizon_hours": safe_horizon,
"min_score": round(safe_min_score, 2),
"top_leagues_only": bool(top_leagues_only),
"scanned_matches": scanned,
"candidate_matches": len(candidates),
"listed_matches": len(selected),
"watchlist": selected,
"top_candidates_preview": preview,
}
def _fetch_team_reversal_cycle_metrics(
self,
cur: RealDictCursor,
team_ids: Set[str],
now_ms: int,
) -> Dict[str, Dict[str, float]]:
if not team_ids:
return {}
cur.execute(
"""
WITH team_matches AS (
SELECT
m.home_team_id AS team_id,
m.mst_utc,
CASE
WHEN m.ht_score_home > m.ht_score_away THEN 'L'
WHEN m.ht_score_home < m.ht_score_away THEN 'T'
ELSE 'D'
END AS ht_state,
CASE
WHEN m.score_home > m.score_away THEN 'W'
WHEN m.score_home < m.score_away THEN 'L'
ELSE 'D'
END AS ft_state
FROM matches m
WHERE m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.ht_score_home IS NOT NULL
AND m.ht_score_away IS NOT NULL
AND m.home_team_id = ANY(%s)
AND m.mst_utc < %s
UNION ALL
SELECT
m.away_team_id AS team_id,
m.mst_utc,
CASE
WHEN m.ht_score_away > m.ht_score_home THEN 'L'
WHEN m.ht_score_away < m.ht_score_home THEN 'T'
ELSE 'D'
END AS ht_state,
CASE
WHEN m.score_away > m.score_home THEN 'W'
WHEN m.score_away < m.score_home THEN 'L'
ELSE 'D'
END AS ft_state
FROM matches m
WHERE m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.ht_score_home IS NOT NULL
AND m.ht_score_away IS NOT NULL
AND m.away_team_id = ANY(%s)
AND m.mst_utc < %s
),
ranked AS (
SELECT
team_id,
mst_utc,
ht_state,
ft_state,
ROW_NUMBER() OVER (PARTITION BY team_id ORDER BY mst_utc DESC) AS rn
FROM team_matches
)
SELECT team_id, mst_utc, ht_state, ft_state
FROM ranked
WHERE rn <= 80
ORDER BY team_id ASC, mst_utc DESC
""",
(list(team_ids), now_ms, list(team_ids), now_ms),
)
rows = cur.fetchall()
by_team: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for row in rows:
by_team[str(row["team_id"])].append(row)
out: Dict[str, Dict[str, float]] = {}
for team_id in team_ids:
team_rows = by_team.get(str(team_id), [])
if not team_rows:
out[str(team_id)] = {
"recent_reversal_rate": 0.0,
"matches_since_last_reversal": 99.0,
"avg_gap_matches": 12.0,
"cycle_pressure": 0.0,
}
continue
reversal_indexes: List[int] = []
recent_reversal = 0
recent_n = min(15, len(team_rows))
for idx, row in enumerate(team_rows, start=1):
ht_state = str(row.get("ht_state") or "")
ft_state = str(row.get("ft_state") or "")
is_reversal = (ht_state == "L" and ft_state == "L") or (ht_state == "T" and ft_state == "W")
if idx <= recent_n and is_reversal:
recent_reversal += 1
if is_reversal:
reversal_indexes.append(idx)
recent_rate = (recent_reversal / recent_n) if recent_n > 0 else 0.0
since_last = float(reversal_indexes[0]) if reversal_indexes else 99.0
gaps: List[float] = []
if len(reversal_indexes) >= 2:
for i in range(1, len(reversal_indexes)):
gaps.append(float(reversal_indexes[i] - reversal_indexes[i - 1]))
avg_gap = (sum(gaps) / len(gaps)) if gaps else 12.0
if avg_gap <= 0:
avg_gap = 12.0
cycle_pressure = 0.0
if reversal_indexes:
tolerance = max(3.0, avg_gap * 0.7)
diff = abs(since_last - avg_gap)
cycle_pressure = max(0.0, 1.0 - (diff / tolerance))
out[str(team_id)] = {
"recent_reversal_rate": round(recent_rate, 4),
"matches_since_last_reversal": round(since_last, 2),
"avg_gap_matches": round(avg_gap, 2),
"cycle_pressure": round(cycle_pressure, 4),
}
return out
def _fetch_h2h_reversal_context(
self,
cur: RealDictCursor,
pair_keys: Set[Tuple[str, str]],
now_ms: int,
) -> Dict[Tuple[str, str], Dict[str, Any]]:
if not pair_keys:
return {}
team_ids = sorted({team_id for pair in pair_keys for team_id in pair})
cur.execute(
"""
SELECT
m.home_team_id,
m.away_team_id,
m.score_home,
m.score_away,
m.ht_score_home,
m.ht_score_away,
m.mst_utc
FROM matches m
WHERE m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.home_team_id = ANY(%s)
AND m.away_team_id = ANY(%s)
AND m.mst_utc < %s
ORDER BY m.mst_utc DESC
LIMIT 4000
""",
(team_ids, team_ids, now_ms),
)
rows = cur.fetchall()
out: Dict[Tuple[str, str], Dict[str, Any]] = {}
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)))
if key not in pair_keys or key in out:
continue
score_home = int(row["score_home"])
score_away = int(row["score_away"])
goal_diff = score_home - score_away
out[key] = {
"goal_diff": goal_diff,
"result": f"{score_home}-{score_away}",
"match_date_ms": int(row["mst_utc"] or 0),
}
if len(out) >= len(pair_keys):
break
return out
@staticmethod
def _watchlist_risk_band(score: float) -> str:
if score >= 68.0:
return "HIGH"
if score >= 54.0:
return "MEDIUM"
return "LOW"
def _load_match_data(self, match_id: str) -> Optional[MatchData]:
with psycopg2.connect(self.dsn) as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
row = self._fetch_live_match(cur, match_id)
if not row:
row = self._fetch_hist_match(cur, match_id)
if not row:
return None
home_team_id = row.get("home_team_id")
away_team_id = row.get("away_team_id")
if not home_team_id or not away_team_id:
# Hard gate: predictions with unknown teams are noisy and misleading.
return None
odds_data = self._extract_odds(cur, row)
home_lineup, away_lineup, lineup_source, lineup_confidence = self._extract_lineups(cur, row)
sidelined = self._parse_json_dict(row.get("sidelined"))
match_date_ms = int(row.get("match_date_ms") or 0)
league_id = str(row.get("league_id")) if row.get("league_id") else None
home_id_str = str(home_team_id)
away_id_str = str(away_team_id)
home_goals_avg, home_conceded_avg = self._calculate_team_form(
cur=cur,
team_id=home_id_str,
before_date_ms=match_date_ms,
)
away_goals_avg, away_conceded_avg = self._calculate_team_form(
cur=cur,
team_id=away_id_str,
before_date_ms=match_date_ms,
)
home_position = self._estimate_league_position(
cur=cur,
team_id=home_id_str,
league_id=league_id,
before_date_ms=match_date_ms,
)
away_position = self._estimate_league_position(
cur=cur,
team_id=away_id_str,
league_id=league_id,
before_date_ms=match_date_ms,
)
return MatchData(
match_id=str(row["match_id"]),
home_team_id=home_id_str,
away_team_id=away_id_str,
home_team_name=row.get("home_team_name") or "Home",
away_team_name=row.get("away_team_name") or "Away",
match_date_ms=match_date_ms,
sport=str(row.get("sport") or "football").lower(),
league_id=league_id,
league_name=row.get("league_name") or "",
referee_name=row.get("referee_name"),
odds_data=odds_data,
home_lineup=home_lineup,
away_lineup=away_lineup,
sidelined_data=sidelined,
home_goals_avg=home_goals_avg,
home_conceded_avg=home_conceded_avg,
away_goals_avg=away_goals_avg,
away_conceded_avg=away_conceded_avg,
home_position=home_position,
away_position=away_position,
lineup_source=lineup_source,
status=str(row.get("status") or ""),
state=row.get("state"),
substate=row.get("substate"),
lineup_confidence=lineup_confidence,
current_score_home=(
int(row.get("score_home"))
if row.get("score_home") is not None
else None
),
current_score_away=(
int(row.get("score_away"))
if row.get("score_away") is not None
else None
),
)
def _fetch_live_match(self, cur: RealDictCursor, match_id: str) -> Optional[Dict[str, Any]]:
cur.execute(
"""
SELECT
lm.id as match_id,
lm.home_team_id,
lm.away_team_id,
lm.league_id,
lm.sport,
lm.mst_utc as match_date_ms,
lm.status,
lm.state,
lm.substate,
lm.score_home,
lm.score_away,
lm.odds,
lm.lineups,
lm.sidelined,
lm.referee_name,
ht.name as home_team_name,
at.name as away_team_name,
l.name as league_name
FROM live_matches lm
LEFT JOIN teams ht ON ht.id = lm.home_team_id
LEFT JOIN teams at ON at.id = lm.away_team_id
LEFT JOIN leagues l ON l.id = lm.league_id
WHERE lm.id = %s
LIMIT 1
""",
(match_id,),
)
return cur.fetchone()
def _fetch_hist_match(self, cur: RealDictCursor, match_id: str) -> Optional[Dict[str, Any]]:
cur.execute(
"""
SELECT
m.id as match_id,
m.home_team_id,
m.away_team_id,
m.league_id,
m.sport,
m.mst_utc as match_date_ms,
m.status,
m.state,
NULL::text as substate,
m.score_home,
m.score_away,
NULL::jsonb as odds,
NULL::jsonb as lineups,
NULL::jsonb as sidelined,
ref.name as referee_name,
ht.name as home_team_name,
at.name as away_team_name,
l.name as league_name
FROM matches m
LEFT JOIN teams ht ON ht.id = m.home_team_id
LEFT JOIN teams at ON at.id = m.away_team_id
LEFT JOIN leagues l ON l.id = m.league_id
LEFT JOIN match_officials ref ON ref.match_id = m.id AND ref.role_id = 1
WHERE m.id = %s
LIMIT 1
""",
(match_id,),
)
return cur.fetchone()
def _extract_odds(self, cur: RealDictCursor, row: Dict[str, Any]) -> Dict[str, float]:
odds_data = self._parse_odds_json(row.get("odds"))
sport_key = str(row.get("sport") or "football").lower()
missing_relational_keys = [k for k in self.RELATIONAL_ODDS_KEYS if k not in odds_data]
if missing_relational_keys:
# fallback to relational odds tables when live odds JSON is incomplete
cur.execute(
"""
SELECT oc.name as category_name, os.name as selection_name, os.odd_value
FROM odd_categories oc
JOIN odd_selections os ON os.odd_category_db_id = oc.db_id
WHERE oc.match_id = %s
ORDER BY oc.db_id ASC, os.db_id ASC
""",
(row["match_id"],),
)
relational_rows = cur.fetchall()
rel_odds = self._parse_relational_odds(relational_rows)
if rel_odds:
for key, value in rel_odds.items():
odds_data.setdefault(key, value)
if sport_key == "basketball":
# Reuse football aliases when source only publishes generic match-result naming.
if "ml_h" not in odds_data and "ms_h" in odds_data:
odds_data["ml_h"] = float(odds_data["ms_h"])
if "ml_a" not in odds_data and "ms_a" in odds_data:
odds_data["ml_a"] = float(odds_data["ms_a"])
if "ml_h" not in odds_data:
odds_data["ml_h"] = 1.90
if "ml_a" not in odds_data:
odds_data["ml_a"] = 1.90
if "tot_line" in odds_data and "tot_o" not in odds_data:
odds_data["tot_o"] = 1.90
if "tot_line" in odds_data and "tot_u" not in odds_data:
odds_data["tot_u"] = 1.90
else:
if "ms_h" not in odds_data:
odds_data["ms_h"] = self.DEFAULT_MS_H
if "ms_d" not in odds_data:
odds_data["ms_d"] = self.DEFAULT_MS_D
if "ms_a" not in odds_data:
odds_data["ms_a"] = self.DEFAULT_MS_A
return odds_data
def _extract_lineups(
self,
cur: RealDictCursor,
row: Dict[str, Any],
) -> Tuple[Optional[List[str]], Optional[List[str]], str, float]:
live_lineups = row.get("lineups")
status_upper = str(row.get("status") or "").upper()
state_upper = str(row.get("state") or "").upper()
substate_upper = str(row.get("substate") or "").upper()
can_trust_feed_lineups = (
status_upper in {"LIVE", "1H", "2H", "HT", "FT", "FINISHED"}
or state_upper in {"LIVE", "FIRSTHALF", "SECONDHALF", "POSTGAME", "POST_GAME"}
or substate_upper in {"LIVE", "FIRSTHALF", "SECONDHALF"}
)
home, away = self._parse_lineups_json(live_lineups) if can_trust_feed_lineups else (None, None)
if (home and len(home) >= 9) and (away and len(away) >= 9):
return home, away, "confirmed_live", 1.0
home_id = str(row["home_team_id"])
away_id = str(row["away_team_id"])
# fallback 1: current match participation table.
# Trust this only for live/finished matches; pre-match rows can be stale feed snapshots.
if can_trust_feed_lineups:
cur.execute(
"""
SELECT team_id, player_id
FROM match_player_participation
WHERE match_id = %s
AND is_starting = true
""",
(row["match_id"],),
)
rows = cur.fetchall()
if rows:
home_players = [str(r["player_id"]) for r in rows if str(r["team_id"]) == home_id]
away_players = [str(r["player_id"]) for r in rows if str(r["team_id"]) == away_id]
if not home and home_players:
home = home_players
if not away and away_players:
away = away_players
if (home and len(home) >= 9) and (away and len(away) >= 9):
return home, away, "confirmed_participation", 0.98
# fallback 2: probable XI from historical starts before match date
before_date_ms = int(row.get("match_date_ms") or 0)
sidelined = self._parse_json_dict(row.get("sidelined")) or {}
home_excluded = self._sidelined_player_ids(sidelined.get("homeTeam"))
away_excluded = self._sidelined_player_ids(sidelined.get("awayTeam"))
used_probable = False
home_conf = 0.0
away_conf = 0.0
if not home or len(home) < 9:
home, home_conf = self._build_probable_xi(
cur,
home_id,
before_date_ms,
excluded_player_ids=home_excluded,
)
used_probable = used_probable or bool(home)
if not away or len(away) < 9:
away, away_conf = self._build_probable_xi(
cur,
away_id,
before_date_ms,
excluded_player_ids=away_excluded,
)
used_probable = used_probable or bool(away)
if used_probable:
inferred_conf = min(
home_conf if home else 0.0,
away_conf if away else 0.0,
)
return home, away, "probable_xi", inferred_conf
return home, away, "none", 0.0
def _calculate_team_form(
self,
cur: RealDictCursor,
team_id: str,
before_date_ms: int,
limit: int = 5,
) -> Tuple[float, float]:
if not team_id:
return 1.5, 1.2
cur.execute(
"""
SELECT
m.home_team_id,
m.away_team_id,
m.score_home,
m.score_away
FROM matches m
WHERE (m.home_team_id = %s OR m.away_team_id = %s)
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %s
ORDER BY m.mst_utc DESC
LIMIT %s
""",
(team_id, team_id, before_date_ms, limit),
)
rows = cur.fetchall()
if not rows:
return 1.5, 1.2
weighted_for = 0.0
weighted_against = 0.0
total_weight = 0.0
for idx, row in enumerate(rows):
weight = float(limit - idx)
is_home = str(row["home_team_id"]) == team_id
goals_for = float(row["score_home"] if is_home else row["score_away"])
goals_against = float(row["score_away"] if is_home else row["score_home"])
weighted_for += goals_for * weight
weighted_against += goals_against * weight
total_weight += weight
if total_weight <= 0:
return 1.5, 1.2
return weighted_for / total_weight, weighted_against / total_weight
def _estimate_league_position(
self,
cur: RealDictCursor,
team_id: str,
league_id: Optional[str],
before_date_ms: int,
) -> int:
if not team_id or not league_id:
return 10
try:
cur.execute(
"""
SELECT
tm.team_id,
SUM(tm.points)::int AS points
FROM (
SELECT
m.home_team_id AS team_id,
CASE
WHEN m.score_home > m.score_away THEN 3
WHEN m.score_home = m.score_away THEN 1
ELSE 0
END AS points
FROM matches m
WHERE m.league_id = %s
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %s
UNION ALL
SELECT
m.away_team_id AS team_id,
CASE
WHEN m.score_away > m.score_home THEN 3
WHEN m.score_away = m.score_home THEN 1
ELSE 0
END AS points
FROM matches m
WHERE m.league_id = %s
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %s
) tm
GROUP BY tm.team_id
ORDER BY points DESC
""",
(league_id, before_date_ms, league_id, before_date_ms),
)
rows = cur.fetchall()
if not rows:
return 10
for idx, row in enumerate(rows, start=1):
if str(row["team_id"]) == team_id:
return idx
return min(20, len(rows))
except Exception:
return 10
def _build_probable_xi(
self,
cur: RealDictCursor,
team_id: str,
before_date_ms: int,
match_limit: int = 5,
lookback_days: int = 370,
max_staleness_days: int = 120,
excluded_player_ids: Optional[Set[str]] = None,
) -> Tuple[Optional[List[str]], float]:
if not team_id:
return None, 0.0
min_date_ms = max(0, before_date_ms - (lookback_days * 24 * 60 * 60 * 1000))
cur.execute(
"""
SELECT
mpp.player_id,
m.id AS match_id,
m.mst_utc,
m.home_team_id,
m.away_team_id
FROM match_player_participation mpp
JOIN matches m ON m.id = mpp.match_id
WHERE mpp.team_id = %s
AND mpp.is_starting = true
AND NOT EXISTS (
SELECT 1
FROM match_player_participation later_mpp
JOIN matches later_m ON later_m.id = later_mpp.match_id
WHERE later_mpp.player_id = mpp.player_id
AND later_mpp.team_id <> %s
AND later_m.mst_utc > m.mst_utc
AND later_m.mst_utc < %s
AND (
later_m.status = 'FT'
OR later_m.state = 'postGame'
OR (later_m.score_home IS NOT NULL AND later_m.score_away IS NOT NULL)
)
)
AND m.id IN (
SELECT m2.id
FROM matches m2
JOIN match_player_participation recent_mpp
ON recent_mpp.match_id = m2.id
AND recent_mpp.team_id = %s
AND recent_mpp.is_starting = true
WHERE (m2.home_team_id = %s OR m2.away_team_id = %s)
AND (
m2.status = 'FT'
OR m2.state = 'postGame'
OR (m2.score_home IS NOT NULL AND m2.score_away IS NOT NULL)
)
AND m2.mst_utc < %s
AND m2.mst_utc >= %s
GROUP BY m2.id
HAVING COUNT(recent_mpp.*) >= 9
ORDER BY MAX(m2.mst_utc) DESC
LIMIT %s
)
ORDER BY m.mst_utc DESC
""",
(
team_id,
team_id,
before_date_ms,
team_id,
team_id,
team_id,
before_date_ms,
min_date_ms,
match_limit,
),
)
rows = cur.fetchall()
if not rows:
return None, 0.0
latest_mst = max(int(row.get("mst_utc") or 0) for row in rows)
age_days = (before_date_ms - latest_mst) / (24 * 60 * 60 * 1000)
stale_projection = age_days > max_staleness_days
excluded = {str(pid) for pid in (excluded_player_ids or set()) if pid}
match_order: Dict[str, int] = {}
for row in rows:
match_id = str(row["match_id"])
if match_id not in match_order:
match_order[match_id] = len(match_order)
player_scores: Dict[str, Dict[str, float]] = {}
for row in rows:
player_id = str(row["player_id"])
if player_id in excluded:
continue
idx = match_order.get(str(row["match_id"]), match_limit)
recency_weight = max(1.0, float(match_limit - idx))
score = recency_weight
if idx == 0:
score += 3.0
elif idx == 1:
score += 1.5
stats = player_scores.setdefault(
player_id,
{
"score": 0.0,
"starts": 0.0,
"last_seen_rank": float(idx),
},
)
stats["score"] += score
stats["starts"] += 1.0
stats["last_seen_rank"] = min(stats["last_seen_rank"], float(idx))
if not player_scores:
return None, 0.0
ranked = sorted(
player_scores.items(),
key=lambda item: (
item[1]["score"],
item[1]["starts"],
-item[1]["last_seen_rank"],
),
reverse=True,
)
lineup = [player_id for player_id, _ in ranked[:11]]
coverage = min(1.0, len(lineup) / 11.0)
available_matches = max(1, len(match_order))
history_score = min(1.0, available_matches / float(match_limit))
core_stability = 0.0
if ranked:
stable_core = sum(1 for _, stats in ranked[:11] if stats["starts"] >= 2.0)
core_stability = stable_core / 11.0
staleness_factor = max(
0.35,
min(1.0, float(max_staleness_days) / max(age_days, 1.0)),
)
confidence = (
(coverage * 0.45) + (history_score * 0.25) + (core_stability * 0.30)
) * staleness_factor
if excluded:
confidence *= 0.92
confidence_cap = 0.58 if stale_projection else 0.88
return lineup or None, round(max(0.0, min(confidence_cap, confidence)), 3)
@staticmethod
def _sidelined_player_ids(team_data: Any) -> Set[str]:
if not isinstance(team_data, dict):
return set()
players = team_data.get("players")
if not isinstance(players, list):
return set()
ids: Set[str] = set()
for player in players:
if not isinstance(player, dict):
continue
player_id = (
player.get("playerId")
or player.get("player_id")
or player.get("id")
or player.get("personId")
)
if player_id:
ids.add(str(player_id))
return ids
def _parse_odds_json(self, odds_json: Any) -> Dict[str, float]:
odds_json = self._parse_json_dict(odds_json)
if odds_json is None:
return {}
parsed: Dict[str, float] = {}
for category, selections in odds_json.items():
if not isinstance(selections, dict):
continue
category_text = str(category or "")
category_norm = self._normalize_text(category)
if category_norm in ("ms", "maç sonucu", "mac sonucu"):
parsed["ms_h"] = self._selection_value(selections, ("1",), 0.0)
parsed["ms_d"] = self._selection_value(selections, ("x", "0"), 0.0)
parsed["ms_a"] = self._selection_value(selections, ("2",), 0.0)
elif "maç sonucu (uzt. dahil)" in category_norm or "mac sonucu (uzt. dahil)" in category_norm:
parsed["ml_h"] = self._selection_value(selections, ("1",), 0.0)
parsed["ml_a"] = self._selection_value(selections, ("2",), 0.0)
elif category_norm in ("1. yarı sonucu", "1. yari sonucu", "ilk yarı sonucu", "ilk yari sonucu", "iy sonucu"):
parsed["ht_h"] = self._selection_value(selections, ("1",), 0.0)
parsed["ht_d"] = self._selection_value(selections, ("x", "0"), 0.0)
parsed["ht_a"] = self._selection_value(selections, ("2",), 0.0)
elif self._is_first_half_ou05_category(category_norm):
parsed["ht_ou05_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0)
parsed["ht_ou05_u"] = self._selection_value(selections, ("alt", "under"), 0.0)
elif self._is_first_half_ou15_category(category_norm):
parsed["ht_ou15_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0)
parsed["ht_ou15_u"] = self._selection_value(selections, ("alt", "under"), 0.0)
elif category_norm in ("2.5 alt/üst", "2,5 alt/üst"):
parsed["ou25_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0)
parsed["ou25_u"] = self._selection_value(selections, ("alt", "under"), 0.0)
elif category_norm in ("1.5 alt/üst", "1,5 alt/üst"):
parsed["ou15_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0)
parsed["ou15_u"] = self._selection_value(selections, ("alt", "under"), 0.0)
elif category_norm in ("3.5 alt/üst", "3,5 alt/üst"):
parsed["ou35_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0)
parsed["ou35_u"] = self._selection_value(selections, ("alt", "under"), 0.0)
elif category_norm in ("karşılıklı gol", "karsilikli gol", "kg"):
parsed["btts_y"] = self._selection_value(selections, ("var", "yes"), 0.0)
parsed["btts_n"] = self._selection_value(selections, ("yok", "no"), 0.0)
elif category_norm in ("çifte şans", "cifte sans"):
parsed["dc_1x"] = self._selection_value(selections, ("1-x", "1x"), 0.0)
parsed["dc_x2"] = self._selection_value(selections, ("x-2", "x2"), 0.0)
parsed["dc_12"] = self._selection_value(selections, ("1-2", "12"), 0.0)
elif category_norm in ("tek/çift", "tek/cift"):
parsed["oe_odd"] = self._selection_value(selections, ("tek", "odd"), 0.0)
parsed["oe_even"] = self._selection_value(selections, ("çift", "cift", "even"), 0.0)
elif self._is_cards_ou_category(category_norm):
parsed["cards_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0)
parsed["cards_u"] = self._selection_value(selections, ("alt", "under"), 0.0)
elif category_norm in (
"ilk yarı/maç sonucu",
"ilk yari/mac sonucu",
"iy/ms",
):
for sel_key, sel_val in selections.items():
norm_sel = self._normalize_text(sel_key)
if "/" in norm_sel:
odds_key = f"htft_{norm_sel.replace('/', '').lower()}"
parsed[odds_key] = self._to_float(sel_val, 0.0)
# Basketball full-game total line, e.g. "Alt/Üst (163,5)"
if self._is_basketball_total_category(category_norm):
if "tot_line" not in parsed:
line = self._extract_parenthesized_number(category_text)
if line is not None:
parsed["tot_line"] = line
parsed.setdefault("tot_o", self._selection_value(selections, ("üst", "ust", "over"), 0.0))
parsed.setdefault("tot_u", self._selection_value(selections, ("alt", "under"), 0.0))
# Basketball spread, e.g. "Hnd. MS (0:5,5)"
if (
"hnd. ms" in category_norm
or "hand. ms" in category_norm
or "hnd ms" in category_norm
):
home_line = self._parse_handicap_home_line(category_text)
if home_line is not None and "spread_home_line" not in parsed:
parsed["spread_home_line"] = home_line
if home_line is not None:
self._set_basketball_handicap_odds(parsed, selections, home_line)
elif self._is_football_handicap_category(category_norm):
self._set_football_handicap_odds(parsed, selections)
return parsed
def _parse_relational_odds(self, rows: List[Dict[str, Any]]) -> Dict[str, float]:
parsed: Dict[str, float] = {}
for row in rows:
category_name = str(row.get("category_name") or "")
selection_name = str(row.get("selection_name") or "")
category_norm = self._normalize_text(category_name)
selection_norm = self._normalize_text(selection_name)
odd_val = self._to_float(row.get("odd_value"), 0.0)
if odd_val <= 0:
continue
if category_norm in ("maç sonucu", "mac sonucu", "ms"):
if selection_norm == "1":
parsed["ms_h"] = odd_val
elif selection_norm in ("x", "0"):
parsed["ms_d"] = odd_val
elif selection_norm == "2":
parsed["ms_a"] = odd_val
elif "maç sonucu (uzt. dahil)" in category_norm or "mac sonucu (uzt. dahil)" in category_norm:
if selection_norm == "1":
parsed.setdefault("ml_h", odd_val)
elif selection_norm == "2":
parsed.setdefault("ml_a", odd_val)
elif category_norm in ("1. yarı sonucu", "1. yari sonucu", "ilk yarı sonucu", "ilk yari sonucu", "iy sonucu"):
if selection_norm == "1":
parsed["ht_h"] = odd_val
elif selection_norm in ("x", "0"):
parsed["ht_d"] = odd_val
elif selection_norm == "2":
parsed["ht_a"] = odd_val
elif self._is_first_half_ou05_category(category_norm):
if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
parsed["ht_ou05_o"] = odd_val
elif "alt" in selection_norm or "under" in selection_norm:
parsed["ht_ou05_u"] = odd_val
elif self._is_first_half_ou15_category(category_norm):
if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
parsed["ht_ou15_o"] = odd_val
elif "alt" in selection_norm or "under" in selection_norm:
parsed["ht_ou15_u"] = odd_val
elif category_norm in ("2,5 alt/üst", "2.5 alt/üst"):
if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
parsed["ou25_o"] = odd_val
elif "alt" in selection_norm or "under" in selection_norm:
parsed["ou25_u"] = odd_val
elif category_norm in ("1,5 alt/üst", "1.5 alt/üst"):
if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
parsed["ou15_o"] = odd_val
elif "alt" in selection_norm or "under" in selection_norm:
parsed["ou15_u"] = odd_val
elif category_norm in ("3,5 alt/üst", "3.5 alt/üst"):
if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
parsed["ou35_o"] = odd_val
elif "alt" in selection_norm or "under" in selection_norm:
parsed["ou35_u"] = odd_val
elif category_norm in ("karşılıklı gol", "karsilikli gol", "kg"):
if selection_norm == "var" or "yes" in selection_norm:
parsed["btts_y"] = odd_val
elif selection_norm == "yok" or "no" in selection_norm:
parsed["btts_n"] = odd_val
elif category_norm in ("çifte şans", "cifte sans"):
if selection_norm in ("1-x", "1x"):
parsed["dc_1x"] = odd_val
elif selection_norm in ("x-2", "x2"):
parsed["dc_x2"] = odd_val
elif selection_norm in ("1-2", "12"):
parsed["dc_12"] = odd_val
elif category_norm in ("tek/çift", "tek/cift"):
if selection_norm in ("tek", "odd"):
parsed["oe_odd"] = odd_val
elif selection_norm in ("çift", "cift", "even"):
parsed["oe_even"] = odd_val
elif self._is_cards_ou_category(category_norm):
if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
parsed["cards_o"] = odd_val
elif "alt" in selection_norm or "under" in selection_norm:
parsed["cards_u"] = odd_val
elif category_norm in (
"ilk yarı/maç sonucu",
"ilk yari/mac sonucu",
"iy/ms",
):
if "/" in selection_norm:
odds_key = f"htft_{selection_norm.replace('/', '').lower()}"
parsed[odds_key] = odd_val
if self._is_basketball_total_category(category_norm):
if "tot_line" not in parsed:
line = self._extract_parenthesized_number(category_name)
if line is not None:
parsed["tot_line"] = line
if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
parsed.setdefault("tot_o", odd_val)
elif "alt" in selection_norm or "under" in selection_norm:
parsed.setdefault("tot_u", odd_val)
if (
"hnd. ms" in category_norm
or "hand. ms" in category_norm
or "hnd ms" in category_norm
):
home_line = self._parse_handicap_home_line(category_name)
if home_line is not None and "spread_home_line" not in parsed:
parsed["spread_home_line"] = home_line
if home_line is not None:
sel_map = {selection_name: odd_val}
self._set_basketball_handicap_odds(parsed, sel_map, home_line)
elif self._is_football_handicap_category(category_norm):
self._set_football_handicap_odds(parsed, {selection_name: odd_val})
return parsed
def _is_basketball_total_category(self, category_norm: str) -> bool:
if "alt/üst" not in category_norm and "alt/ust" not in category_norm:
return False
banned = (
"1. yarı",
"1. yari",
"periyot",
"ev sahibi",
"deplasman",
)
return not any(token in category_norm for token in banned)
def _is_first_half_ou05_category(self, category_norm: str) -> bool:
if "alt/üst" not in category_norm and "alt/ust" not in category_norm:
return False
if not any(
token in category_norm
for token in ("1. yarı", "1. yari", "ilk yarı", "ilk yari")
):
if not re.search(r"\biy\b", category_norm):
return False
# Exclude team-specific first-half totals (home/away) and non-goal props.
if any(token in category_norm for token in ("ev sahibi", "deplasman", "korner", "kart")):
return False
# Match only exact 0.5 line (avoid false positives like 100,5 / 90,5 in basketball totals).
for token in re.findall(r"\d+(?:[.,]\d+)?", category_norm):
try:
if abs(float(token.replace(",", ".")) - 0.5) < 1e-9:
return True
except Exception:
continue
return False
def _is_first_half_ou15_category(self, category_norm: str) -> bool:
if "alt/üst" not in category_norm and "alt/ust" not in category_norm:
return False
if not any(
token in category_norm
for token in ("1. yarı", "1. yari", "ilk yarı", "ilk yari")
):
if not re.search(r"\biy\b", category_norm):
return False
if any(token in category_norm for token in ("ev sahibi", "deplasman", "korner", "kart")):
return False
for token in re.findall(r"\d+(?:[.,]\d+)?", category_norm):
try:
if abs(float(token.replace(",", ".")) - 1.5) < 1e-9:
return True
except Exception:
continue
return False
def _is_cards_ou_category(self, category_norm: str) -> bool:
if "kart" not in category_norm and "card" not in category_norm:
return False
return "alt/üst" in category_norm or "alt/ust" in category_norm
def _is_football_handicap_category(self, category_norm: str) -> bool:
if any(token in category_norm for token in ("hnd. ms", "hand. ms", "hnd ms")):
return False
return any(
token in category_norm
for token in (
"handikapli maç sonucu",
"handikapli mac sonucu",
"handikaplı maç sonucu",
"hnd. maç sonucu",
"hnd. mac sonucu",
"hnd maç sonucu",
"hnd mac sonucu",
)
)
def _extract_parenthesized_number(self, category_name: str) -> Optional[float]:
if not category_name:
return None
try:
left = category_name.find("(")
right = category_name.find(")", left + 1)
if left < 0 or right < 0:
return None
raw = category_name[left + 1 : right].strip().replace(",", ".")
out = float(raw)
return out if out > 0 else None
except Exception:
return None
def _parse_handicap_home_line(self, category_name: str) -> Optional[float]:
if not category_name:
return None
try:
left = category_name.find("(")
right = category_name.find(")", left + 1)
if left < 0 or right < 0:
return None
payload = category_name[left + 1 : right].strip().replace(",", ".")
if ":" not in payload:
return None
home_raw, away_raw = payload.split(":", 1)
home_hcp = float(home_raw.strip())
away_hcp = float(away_raw.strip())
if abs(home_hcp) < 1e-6 and away_hcp > 0:
return -away_hcp
if home_hcp > 0 and abs(away_hcp) < 1e-6:
return home_hcp
if abs(home_hcp - away_hcp) < 1e-6 and home_hcp > 0:
return 0.0
except Exception:
return None
return None
def _set_basketball_handicap_odds(
self,
out: Dict[str, float],
selections: Dict[str, Any],
home_line: float,
) -> None:
if not isinstance(selections, dict):
return
has_home_plus = False
home_plus_odd = 0.0
one_odd = 0.0
two_odd = 0.0
for key, value in selections.items():
norm_key = self._normalize_text(key)
odd = self._to_float(value, 0.0)
if odd <= 1.0:
continue
if norm_key == "1":
one_odd = odd
elif norm_key == "2":
two_odd = odd
if "+h" in norm_key or norm_key.endswith("h"):
has_home_plus = True
home_plus_odd = odd
if home_line < 0:
# Home gives points. \"1\" normally means home -line covers.
if one_odd > 1.0:
out.setdefault("spread_h", one_odd)
if home_plus_odd > 1.0:
out.setdefault("spread_a", home_plus_odd)
elif two_odd > 1.0:
out.setdefault("spread_a", two_odd)
elif home_line > 0:
# Home receives points. +h entry or \"1\" means home side.
if home_plus_odd > 1.0:
out.setdefault("spread_h", home_plus_odd)
elif one_odd > 1.0:
out.setdefault("spread_h", one_odd)
if two_odd > 1.0:
out.setdefault("spread_a", two_odd)
else:
if one_odd > 1.0:
out.setdefault("spread_h", one_odd)
if two_odd > 1.0:
out.setdefault("spread_a", two_odd)
def _set_football_handicap_odds(
self,
out: Dict[str, float],
selections: Dict[str, Any],
) -> None:
if not isinstance(selections, dict):
return
for key, value in selections.items():
norm_key = self._normalize_text(key)
odd = self._to_float(value, 0.0)
if odd <= 1.0:
continue
if norm_key == "1":
out["hcap_h"] = odd
elif norm_key in ("x", "0"):
out["hcap_d"] = odd
elif norm_key == "2":
out["hcap_a"] = odd
def _parse_lineups_json(
self,
lineups_json: Any,
) -> Tuple[Optional[List[str]], Optional[List[str]]]:
if isinstance(lineups_json, str):
try:
lineups_json = json.loads(lineups_json)
except Exception:
lineups_json = None
if not isinstance(lineups_json, dict):
return None, None
def parse_side(side: str) -> Optional[List[str]]:
# Try direct access first (home/away at root level)
side_obj = lineups_json.get(side)
# Fallback: Check if inside "stats" key (Mackolik format)
if not isinstance(side_obj, (dict, list)):
stats = lineups_json.get("stats")
if isinstance(stats, dict):
side_obj = stats.get(side)
if not isinstance(side_obj, (dict, list)):
return None
# Try standard formats (xi, starting, lineup)
entries = None
if isinstance(side_obj, dict):
entries = side_obj.get("xi") or side_obj.get("starting") or side_obj.get("lineup")
# If the dict itself contains player dicts (no wrapper keys)
if not entries and "position" in side_obj:
# side_obj is likely a single player dict, wrap it
entries = [side_obj]
elif isinstance(side_obj, list):
# side_obj is already a list of players
entries = side_obj
if not isinstance(entries, list):
return None
ids: List[str] = []
for p in entries:
if isinstance(p, dict):
player_id = p.get("id") or p.get("playerId") or p.get("personId")
if player_id:
ids.append(str(player_id))
elif p:
ids.append(str(p))
return ids or None
return parse_side("home"), parse_side("away")
def _build_prediction_package(
self,
data: MatchData,
prediction: FullMatchPrediction,
v25_signal: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
quality = self._compute_data_quality(data)
raw_market_rows = self._build_market_rows(data, prediction, v25_signal)
raw_market_rows = self._apply_market_consistency(
raw_market_rows,
data,
prediction,
)
market_rows = [
self._decorate_market_row(data, prediction, quality, row)
for row in raw_market_rows
]
market_rows.sort(
key=lambda row: (
1 if row.get("playable") else 0,
float(row.get("play_score", 0.0)),
),
reverse=True,
)
playable_rows = [row for row in market_rows if row.get("playable")]
# GUARANTEED PICK LOGIC (V32 - Calibration-aware):
# Runtime replay insights:
# - Trust only markets that remain robust after pre-match replay.
# - Current strongest football markets: DC, OU15, HT_OU05.
#
# Priority 1: High-accuracy market (DC/OU15/HT_OU05/OU25) + Odds >= 1.30 + Conf >= 44%
# Priority 2: Any playable + Odds >= 1.30 + Conf >= 44%
# Priority 3: Playable + Odds >= 1.30
# Priority 4: Best non-playable (fallback)
MIN_ODDS = 1.30
MIN_CONFIDENCE = 44.0 # V32: lowered from 52 to match new calibration
# High-accuracy markets from backtest (prioritize these)
HIGH_ACCURACY_MARKETS = {"DC", "OU15", "HT_OU05"}
# Priority 1: High-accuracy markets with good odds and confidence
high_accuracy_picks = [
row for row in playable_rows
if row.get("market") in HIGH_ACCURACY_MARKETS
and float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
]
if high_accuracy_picks:
# Sort by play_score, pick the best
high_accuracy_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = high_accuracy_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "high_accuracy_market"
else:
# Priority 2: Any playable with odds >= 1.30 and confidence >= 40%
guaranteed_picks = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
]
if guaranteed_picks:
guaranteed_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = guaranteed_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "confidence_threshold_met"
else:
# Priority 3: Fallback - playable with odds >= 1.30
playable_with_odds = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS
]
if playable_with_odds:
playable_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = playable_with_odds[0]
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "odds_only_fallback"
else:
# Priority 4: Last resort - any playable or first market WITH ODDS > 0
fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
main_pick = playable_rows[0] if playable_rows else (fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None))
if main_pick:
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "last_resort"
aggressive_pick = None
htft_probs = prediction.ht_ft_probs or {}
aggressive_candidates = [
("1/2", float(htft_probs.get("1/2", 0.0))),
("2/1", float(htft_probs.get("2/1", 0.0))),
("X/1", float(htft_probs.get("X/1", 0.0))),
("X/2", float(htft_probs.get("X/2", 0.0))),
]
aggressive_candidates.sort(key=lambda item: item[1], reverse=True)
if (
aggressive_candidates
and aggressive_candidates[0][1] > 0.03
and self._market_has_real_pick_odds("HTFT", aggressive_candidates[0][0], data.odds_data or {})
):
aggressive_pick = {
"market": "HT/FT",
"pick": aggressive_candidates[0][0],
"probability": round(aggressive_candidates[0][1], 4),
"confidence": round(aggressive_candidates[0][1] * 100, 1),
"odds": None,
}
value_pick = None
# Esnek/Değerli (Value) Pick: Yüksek oran (>= 1.60) ve fena olmayan güven (>= %40)
value_candidates = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= 1.60
and float(row.get("calibrated_confidence", 0.0)) >= 40.0
]
if value_candidates:
# Score them by (play_score * odds) to reward higher odds
value_candidates.sort(key=lambda r: float(r.get("play_score", 0.0)) * float(r.get("odds", 1.0)), reverse=True)
for v_cand in value_candidates:
if not main_pick or (v_cand["market"] != main_pick["market"] or v_cand["pick"] != main_pick["pick"]):
value_pick = v_cand
break
supporting: List[Dict[str, Any]] = []
for row in market_rows:
if main_pick and row["market"] == main_pick["market"] and row["pick"] == main_pick["pick"]:
continue
supporting.append(row)
supporting = supporting[:6]
bet_summary = [self._to_bet_summary_item(row) for row in market_rows]
reasons = self._build_reasoning_factors(data, prediction, quality)
market_board = {
"MS": {
"pick": prediction.ms_pick,
"confidence": round(float(prediction.ms_confidence), 1),
"probs": {
"1": round(float(prediction.ms_home_prob), 4),
"X": round(float(prediction.ms_draw_prob), 4),
"2": round(float(prediction.ms_away_prob), 4),
},
},
"DC": {
"pick": prediction.dc_pick,
"confidence": round(float(prediction.dc_confidence), 1),
"probs": {
"1X": round(float(prediction.dc_1x_prob), 4),
"X2": round(float(prediction.dc_x2_prob), 4),
"12": round(float(prediction.dc_12_prob), 4),
},
},
"OU15": {
"pick": prediction.ou15_pick,
"confidence": round(float(prediction.ou15_confidence), 1),
"probs": {
"over": round(float(prediction.over_15_prob), 4),
"under": round(float(prediction.under_15_prob), 4),
},
},
"OU25": {
"pick": prediction.ou25_pick,
"confidence": round(float(prediction.ou25_confidence), 1),
"probs": {
"over": round(float(prediction.over_25_prob), 4),
"under": round(float(prediction.under_25_prob), 4),
},
},
"OU35": {
"pick": prediction.ou35_pick,
"confidence": round(float(prediction.ou35_confidence), 1),
"probs": {
"over": round(float(prediction.over_35_prob), 4),
"under": round(float(prediction.under_35_prob), 4),
},
},
"BTTS": {
"pick": prediction.btts_pick,
"confidence": round(float(prediction.btts_confidence), 1),
"probs": {
"yes": round(float(prediction.btts_yes_prob), 4),
"no": round(float(prediction.btts_no_prob), 4),
},
},
"HT": {
"pick": prediction.ht_pick,
"confidence": round(float(prediction.ht_confidence), 1),
"probs": {
"1": round(float(prediction.ht_home_prob), 4),
"X": round(float(prediction.ht_draw_prob), 4),
"2": round(float(prediction.ht_away_prob), 4),
},
},
"HTFT": {
"probs": {k: round(float(v), 4) for k, v in htft_probs.items()},
},
"OE": {
"pick": prediction.odd_even_pick,
"probs": {
"odd": round(float(prediction.odd_prob), 4),
"even": round(float(prediction.even_prob), 4),
},
},
"HT_OU05": {
"pick": prediction.ht_ou_pick,
"confidence": round(float(max(prediction.ht_over_05_prob, prediction.ht_under_05_prob) * 100), 1),
"probs": {
"over": round(float(prediction.ht_over_05_prob), 4),
"under": round(float(prediction.ht_under_05_prob), 4),
},
},
"HT_OU15": {
"pick": prediction.ht_ou15_pick,
"confidence": round(float(max(prediction.ht_over_15_prob, prediction.ht_under_15_prob) * 100), 1),
"probs": {
"over": round(float(prediction.ht_over_15_prob), 4),
"under": round(float(prediction.ht_under_15_prob), 4),
},
},
"CARDS": {
"pick": prediction.card_pick,
"confidence": round(float(prediction.cards_confidence), 1),
"total": round(float(prediction.total_cards_pred), 1),
"probs": {
"over": round(float(prediction.cards_over_prob), 4),
"under": round(float(prediction.cards_under_prob), 4),
},
},
"HCAP": {
"pick": prediction.handicap_pick,
"confidence": round(float(prediction.handicap_confidence), 1),
"probs": {
"1": round(float(prediction.handicap_home_prob), 4),
"X": round(float(prediction.handicap_draw_prob), 4),
"2": round(float(prediction.handicap_away_prob), 4),
},
},
}
if v25_signal:
market_board = self._merge_v25_market_board(market_board, v25_signal)
available_markets = {str(row.get("market") or "") for row in market_rows}
market_board = {
market: payload
for market, payload in market_board.items()
if market in available_markets
}
# Determine simulation mode for the response
_resp_status = str(data.status or "").upper()
_resp_state = str(data.state or "").upper()
is_simulation = _resp_status in {"FT", "FINISHED"} or _resp_state in {"POSTGAME", "POST_GAME"}
return {
"model_version": "v28-pro-max",
"simulation_mode": "pre_match" if is_simulation else None,
"match_info": {
"match_id": data.match_id,
"match_name": f"{data.home_team_name} vs {data.away_team_name}",
"home_team": data.home_team_name,
"away_team": data.away_team_name,
"league": data.league_name,
"match_date_ms": data.match_date_ms,
"sport": data.sport,
},
"data_quality": quality,
"risk": {
"level": prediction.risk_level,
"score": round(float(prediction.risk_score), 1),
"is_surprise_risk": bool(prediction.is_surprise_risk),
"surprise_type": prediction.surprise_type,
"surprise_score": round(float(getattr(prediction, "surprise_score", 0.0) or 0.0), 1),
"surprise_comment": str(getattr(prediction, "surprise_comment", "") or ""),
"surprise_reasons": list(getattr(prediction, "surprise_reasons", []) or []),
"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),
},
"main_pick": main_pick,
"value_pick": value_pick,
"bet_advice": {
"playable": bool(main_pick and main_pick.get("playable")),
"suggested_stake_units": float(main_pick.get("stake_units", 0.0)) if (main_pick and main_pick.get("playable")) else 0.0,
"reason": "playable_pick_found" if (main_pick and main_pick.get("playable")) else "no_bet_conditions_met",
},
"bet_summary": bet_summary,
"supporting_picks": supporting,
"aggressive_pick": aggressive_pick,
"scenario_top5": prediction.ft_scores_top5,
"score_prediction": {
"ft": prediction.predicted_ft_score,
"ht": prediction.predicted_ht_score,
"xg_home": round(float(prediction.home_xg), 2),
"xg_away": round(float(prediction.away_xg), 2),
"xg_total": round(float(prediction.total_xg), 2),
},
"market_board": market_board,
"others": {
"handicap": prediction.handicap_pick,
"cards": {
"total": round(float(prediction.total_cards_pred), 1),
"pick": prediction.card_pick,
},
},
"v25_signal": {
"available": v25_signal is not None,
"markets": v25_signal if v25_signal else None,
"value_bets": v25_signal.get('value_bets', []) if v25_signal else [],
"ensemble_weights": {"v25": 1.0},
},
"reasoning_factors": reasons,
}
def _build_basketball_prediction_package(
self,
data: MatchData,
prediction: Dict[str, Any],
) -> Dict[str, Any]:
quality = self._compute_data_quality(data)
raw_market_rows = self._build_basketball_market_rows(data, prediction)
market_rows = [
self._decorate_basketball_market_row(data, prediction, quality, row)
for row in raw_market_rows
]
market_rows.sort(
key=lambda row: (
1 if row.get("playable") else 0,
float(row.get("play_score", 0.0)),
),
reverse=True,
)
playable_rows = [row for row in market_rows if row.get("playable")]
# GUARANTEED PICK LOGIC (Optimized - same as football)
MIN_ODDS = 1.30
MIN_CONFIDENCE = 40.0
HIGH_ACCURACY_MARKETS = {"ML", "TOT", "SPREAD"}
high_accuracy_picks = [
row for row in playable_rows
if row.get("market_type") in HIGH_ACCURACY_MARKETS
and float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
]
if high_accuracy_picks:
high_accuracy_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = high_accuracy_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "high_accuracy_market"
else:
guaranteed_picks = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
]
if guaranteed_picks:
guaranteed_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = guaranteed_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "confidence_threshold_met"
else:
playable_with_odds = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS
]
if playable_with_odds:
playable_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = playable_with_odds[0]
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "odds_only_fallback"
else:
fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
main_pick = playable_rows[0] if playable_rows else (fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None))
if main_pick:
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "last_resort"
supporting: List[Dict[str, Any]] = []
for row in market_rows:
if main_pick and row["market"] == main_pick["market"] and row["pick"] == main_pick["pick"]:
continue
supporting.append(row)
supporting = supporting[:5]
bet_summary = [self._to_bet_summary_item(row) for row in market_rows]
scenarios = self._build_basketball_scenarios(prediction)
reasons = self._build_basketball_reasoning_factors(data, prediction, quality)
aggressive_pick: Optional[Dict[str, Any]] = None
risk_level = prediction.get("risk_level", "MEDIUM")
risk_score = float(prediction.get("risk_score", 50.0) or 50.0)
# Build aggressive pick if available from Spreak in market_board
board = prediction.get("market_board", {})
if risk_level in ("LOW", "MEDIUM") and "Spread" in board:
spr_data = board["Spread"]
probs = list(spr_data.values())
keys = list(spr_data.keys())
if len(probs) >= 2:
prob_a = float(str(probs[0]).replace('%', '')) / 100.0
prob_h = float(str(probs[1]).replace('%', '')) / 100.0
max_prob = max(prob_a, prob_h)
spr_pick = "Home" if prob_h >= prob_a else "Away"
conf = 50.0
line_str = "Spread"
for b in prediction.get("bet_summary", []):
if b["market"] == "Spread":
conf = float(b["confidence"])
line_str = b["pick"]
aggressive_pick = {
"market": "SPREAD",
"pick": line_str,
"probability": round(max_prob, 4),
"confidence": round(conf, 1),
"odds": round(
float(
data.odds_data.get(
"spread_h" if spr_pick == "Home" else "spread_a", 0.0
)
),
2,
),
}
scores = prediction.get("score_prediction", {})
home_score = scores.get("home_expected", 80.0)
away_score = scores.get("away_expected", 80.0)
total_score = scores.get("total_expected", 160.0)
mb_out = {
"PLAYER_TOP": board.get("PLAYER_TOP", []),
}
if "ML" in board:
ml_data = board["ML"]
keys = list(ml_data.keys())
if len(keys) >= 2:
mb_out["ML"] = {
"pick": prediction.get("main_pick", ""),
"confidence": 60.0,
"probs": {
"1": round(float(str(ml_data[keys[0]]).replace('%', '')) / 100.0, 4),
"2": round(float(str(ml_data[keys[1]]).replace('%', '')) / 100.0, 4),
},
}
if "Totals" in board:
tot_data = board["Totals"]
keys = list(tot_data.keys())
if len(keys) >= 2:
mb_out["TOTAL"] = {
"line": 160.5,
"pick": prediction.get("main_pick", ""),
"confidence": 60.0,
"probs": {
"under": round(float(str(tot_data[keys[0]]).replace('%', '')) / 100.0, 4),
"over": round(float(str(tot_data[keys[1]]).replace('%', '')) / 100.0, 4),
},
}
if "Spread" in board:
spr_data = board["Spread"]
keys = list(spr_data.keys())
if len(keys) >= 2:
mb_out["SPREAD"] = {
"line_home": 0.0,
"pick": prediction.get("main_pick", ""),
"confidence": 60.0,
"probs": {
"away_cover": round(float(str(spr_data[keys[0]]).replace('%', '')) / 100.0, 4),
"home_cover": round(float(str(spr_data[keys[1]]).replace('%', '')) / 100.0, 4),
},
}
return {
"model_version": str(prediction.get("engine_version") or "v28.main.basketball"),
"match_info": {
"match_id": data.match_id,
"match_name": f"{data.home_team_name} vs {data.away_team_name}",
"home_team": data.home_team_name,
"away_team": data.away_team_name,
"league": data.league_name,
"match_date_ms": data.match_date_ms,
"sport": data.sport,
},
"data_quality": quality,
"risk": {
"level": risk_level,
"score": round(risk_score, 1),
"is_surprise_risk": False,
"surprise_type": "",
"warnings": [],
},
"engine_breakdown": prediction.get("engine_breakdown")
or {
"team": 60.0,
"player": 60.0,
"odds": 80.0,
"referee": 50.0,
},
"main_pick": main_pick,
"bet_advice": {
"playable": bool(main_pick and main_pick.get("playable")),
"suggested_stake_units": float(main_pick.get("stake_units", 0.0))
if (main_pick and main_pick.get("playable"))
else 0.0,
"reason": "playable_pick_found"
if (main_pick and main_pick.get("playable"))
else "no_bet_conditions_met",
},
"bet_summary": bet_summary,
"supporting_picks": supporting,
"aggressive_pick": aggressive_pick,
"scenario_top5": scenarios,
"score_prediction": {
"ft": f"{int(round(home_score))}-{int(round(away_score))}",
"ht": f"{int(round(home_score * 0.52))}-{int(round(away_score * 0.52))}",
"xg_home": round(float(home_score), 2),
"xg_away": round(float(away_score), 2),
"xg_total": round(float(total_score), 2),
},
"market_board": mb_out,
"reasoning_factors": reasons,
}
def _build_basketball_market_rows(
self,
data: MatchData,
pred: Dict[str, Any],
) -> List[Dict[str, Any]]:
odds = data.odds_data
market_board = pred.get("market_board", {})
# 1. Moneyline
ml_row = None
if "ML" in market_board:
ml_data = market_board["ML"]
# To get specific pick (MS 1 or MS 2), look at the probability values
probs = list(ml_data.values())
keys = list(ml_data.keys())
if len(probs) >= 2:
prob_1 = float(str(probs[0]).replace('%', '')) / 100.0
prob_2 = float(str(probs[1]).replace('%', '')) / 100.0
max_prob = max(prob_1, prob_2)
# Derive pick string
ml_pick_val = keys[0] if prob_1 >= prob_2 else keys[1]
ml_pick = "1" if "1" in ml_pick_val else "2"
ml_odd_key = "ml_h" if ml_pick == "1" else "ml_a"
# Find confidence from bet summary
conf = 50.0
for b in pred.get("bet_summary", []):
if b["market"] == "Moneyline": conf = float(b["confidence"])
ml_row = {
"market": "ML",
"pick": ml_pick,
"probability": round(max_prob, 4),
"confidence": round(conf, 1),
"odds": round(float(odds.get(ml_odd_key, 0.0)), 2),
}
# 2. Totals
tot_row = None
if "Totals" in market_board:
tot_data = market_board["Totals"]
probs = list(tot_data.values())
keys = list(tot_data.keys())
if len(probs) >= 2:
prob_u = float(str(probs[0]).replace('%', '')) / 100.0
prob_o = float(str(probs[1]).replace('%', '')) / 100.0
max_prob = max(prob_u, prob_o)
pick_str = keys[1] if prob_o >= prob_u else keys[0]
tot_pick = "Over" if "Over" in pick_str else "Under"
line_val = pick_str.replace("Over", "").replace("Under", "").strip()
conf = 50.0
for b in pred.get("bet_summary", []):
if b["market"] == "Totals": conf = float(b["confidence"])
tot_row = {
"market": "TOTAL",
"pick": f"{tot_pick} {line_val}",
"probability": round(max_prob, 4),
"confidence": round(conf, 1),
"odds": round(float(odds.get("tot_o" if tot_pick == "Over" else "tot_u", 0.0)), 2),
}
# 3. Spread
spr_row = None
if "Spread" in market_board:
spr_data = market_board["Spread"]
probs = list(spr_data.values())
keys = list(spr_data.keys())
if len(probs) >= 2:
prob_a = float(str(probs[0]).replace('%', '')) / 100.0
prob_h = float(str(probs[1]).replace('%', '')) / 100.0
max_prob = max(prob_a, prob_h)
spr_pick = "Home" if prob_h >= prob_a else "Away"
conf = 50.0
line_str = ""
for b in pred.get("bet_summary", []):
if b["market"] == "Spread":
conf = float(b["confidence"])
line_str = b["pick"]
spr_row = {
"market": "SPREAD",
"pick": spr_pick + " " + line_str,
"probability": round(max_prob, 4),
"confidence": round(conf, 1),
"odds": round(float(odds.get("spread_h" if spr_pick == "Home" else "spread_a", 0.0)), 2),
}
# Return valid rows
rows = []
if ml_row: rows.append(ml_row)
if tot_row: rows.append(tot_row)
if spr_row: rows.append(spr_row)
return rows
def _decorate_basketball_market_row(
self,
data: MatchData,
prediction: Dict[str, Any],
quality: Dict[str, Any],
row: Dict[str, Any],
) -> Dict[str, Any]:
market = str(row.get("market") or "")
raw_conf = float(row.get("confidence") or 0.0)
prob = float(row.get("probability") or 0.0)
odd = float(row.get("odds") or 0.0)
calibration = {"ML": 0.90, "TOTAL": 0.88, "SPREAD": 0.86}.get(market, 0.88)
min_conf = {"ML": 55.0, "TOTAL": 56.0, "SPREAD": 55.0}.get(market, 55.0)
calibrated_conf = max(1.0, min(99.0, raw_conf * calibration))
implied_prob = (1.0 / odd) if odd > 1.0 else 0.0
edge = prob - implied_prob if implied_prob > 0 else 0.0
risk_level = str(prediction.get("risk_level", "MEDIUM")).upper()
risk_penalty = {"LOW": 0.0, "MEDIUM": 3.0, "HIGH": 8.0, "EXTREME": 12.0}.get(
risk_level,
4.0,
)
quality_label = str(quality.get("label") or "MEDIUM").upper()
quality_penalty = {"HIGH": 0.0, "MEDIUM": 2.0, "LOW": 6.0}.get(
quality_label,
4.0,
)
base_score = calibrated_conf + (edge * 100.0)
play_score = max(0.0, min(100.0, base_score - risk_penalty - quality_penalty))
reasons: List[str] = []
playable = True
min_play_score = self.market_min_play_score.get(market, 68.0)
min_edge = self.market_min_edge.get(market, 0.02)
if calibrated_conf < min_conf:
playable = False
reasons.append("below_calibrated_conf_threshold")
if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01:
playable = False
reasons.append("market_odds_missing")
if risk_level in ("HIGH", "EXTREME") and quality_label == "LOW":
playable = False
reasons.append("high_risk_low_data_quality")
if odd > 1.0 and edge < -0.05:
playable = False
reasons.append("negative_model_edge")
if not reasons:
reasons.append("market_passed_all_gates")
if not playable:
grade = "PASS"
stake_units = 0.0
elif play_score >= 72:
grade = "A"
stake_units = 1.0
elif play_score >= 61:
grade = "B"
stake_units = 0.5
else:
grade = "C"
stake_units = 0.25
out = dict(row)
out.update(
{
"raw_confidence": round(raw_conf, 1),
"calibrated_confidence": round(calibrated_conf, 1),
"min_required_confidence": round(min_conf, 1),
"edge": round(edge, 4),
"play_score": round(play_score, 1),
"playable": playable,
"bet_grade": grade,
"stake_units": stake_units,
"decision_reasons": reasons[:3],
},
)
return out
def _build_basketball_scenarios(
self,
prediction: Dict[str, Any],
) -> List[Dict[str, Any]]:
scores = prediction.get("score_prediction", {})
home = float(scores.get("home_expected", 80.0))
away = float(scores.get("away_expected", 80.0))
templates = [
(0.00, 0.23),
(+3.5, 0.20),
(-3.5, 0.19),
(+6.0, 0.16),
(-6.0, 0.14),
]
out: List[Dict[str, Any]] = []
for delta, prob in templates:
h = int(round(home + delta))
a = int(round(away - delta))
out.append({"score": f"{h}-{a}", "prob": prob})
return out
def _build_basketball_reasoning_factors(
self,
data: MatchData,
prediction: Dict[str, Any],
quality: Dict[str, Any],
) -> List[str]:
factors: List[str] = []
# XGBoost models are odds-aware, weight it heavily
factors.append("market_signal_dominant")
if quality.get("label") in ("HIGH", "MEDIUM"):
factors.append("player_form_signal_strong")
else:
factors.append("player_form_signal_limited")
if prediction.get("is_surprise_risk"):
factors.append("upset_risk_detected")
if quality.get("label") == "LOW":
factors.append("limited_data_confidence")
factors.append("basketball_points_model")
return factors
def _real_market_odds(self, odds_data: Dict[str, Any], key: str) -> float:
"""
Return the odds value for a given key, but 1.0 if it's a known default or missing.
The prediction engine needs default odds (2.65/3.20) as ML features,
but market rows must NOT use them for EV edge / Kelly calculations.
Returning 1.0 acts as a neutral multiplier, avoiding zero-out errors.
"""
val = float(odds_data.get(key, 1.0))
if val <= 1.01:
return 1.0
_DEFAULTS: Dict[str, float] = {
"ms_h": self.DEFAULT_MS_H,
"ms_d": self.DEFAULT_MS_D,
"ms_a": self.DEFAULT_MS_A,
"ml_h": 1.90,
"ml_a": 1.90,
"ht_h": 2.4,
"ht_d": 1.9,
"ht_a": 3.1,
"ht_ou05_o": 1.9,
"ht_ou05_u": 1.9,
"ht_ou15_o": 2.4,
"ht_ou15_u": 1.5,
"ou15_o": 1.4,
"ou15_u": 2.6,
"ou25_o": 1.9,
"ou25_u": 1.9,
"ou35_o": 2.7,
"ou35_u": 1.4,
"btts_y": 1.9,
"btts_n": 1.9,
"dc_1x": 1.2,
"dc_x2": 1.4,
"dc_12": 1.2,
"oe_odd": 1.9,
"oe_even": 1.9,
"cards_o": 1.9,
"cards_u": 1.9,
"tot_o": 1.90,
"tot_u": 1.90,
}
if key in _DEFAULTS and abs(val - _DEFAULTS[key]) < 1e-6:
return 1.0
return val
def _sanitize_v25_odds(self, odds_data: Dict[str, Any]) -> Dict[str, float]:
sanitized: Dict[str, float] = {}
for key in self.V25_ODDS_FEATURE_KEYS:
sanitized[key] = self._real_market_odds(odds_data, key)
for key in ("dc_1x", "dc_x2", "dc_12", "oe_odd", "oe_even", "cards_o", "cards_u", "hcap_h", "hcap_d", "hcap_a"):
if key in odds_data:
sanitized[key] = self._real_market_odds(odds_data, key)
return sanitized
@staticmethod
def _v25_pick_to_market_pick(market: str, pick: str) -> str:
if market == "BTTS":
return "KG Var" if pick == "Yes" else "KG Yok" if pick == "No" else pick
if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
return "Üst" if pick == "Over" else "Alt" if pick == "Under" else pick
if market == "OE":
return "Tek" if pick == "Odd" else "Çift" if pick == "Even" else pick
return pick
def _v25_market_odds(self, odds: Dict[str, Any], market: str, pick: str) -> float:
normalized_pick = str(pick or "").strip()
if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
normalized_pick = "Over" if ("Üst" in normalized_pick or "Over" in normalized_pick) else "Under"
elif market == "BTTS":
normalized_pick = "Yes" if normalized_pick in {"KG Var", "Var", "Yes"} else "No"
elif market == "OE":
normalized_pick = "Odd" if normalized_pick in {"Tek", "Odd"} else "Even"
elif market == "DC":
normalized_pick = normalized_pick.replace("-", "").upper()
elif market == "HCAP" and normalized_pick.startswith("Handikap"):
if " 1" in normalized_pick:
normalized_pick = "1"
elif " X" in normalized_pick:
normalized_pick = "X"
elif " 2" in normalized_pick:
normalized_pick = "2"
key_map = {
"MS": {"1": "ms_h", "X": "ms_d", "2": "ms_a"},
"DC": {"1X": "dc_1x", "X2": "dc_x2", "12": "dc_12"},
"OU15": {"Over": "ou15_o", "Under": "ou15_u"},
"OU25": {"Over": "ou25_o", "Under": "ou25_u"},
"OU35": {"Over": "ou35_o", "Under": "ou35_u"},
"BTTS": {"Yes": "btts_y", "No": "btts_n"},
"HT": {"1": "ht_h", "X": "ht_d", "2": "ht_a"},
"HT_OU05": {"Over": "ht_ou05_o", "Under": "ht_ou05_u"},
"HT_OU15": {"Over": "ht_ou15_o", "Under": "ht_ou15_u"},
"OE": {"Odd": "oe_odd", "Even": "oe_even"},
"CARDS": {"Over": "cards_o", "Under": "cards_u"},
"HCAP": {"1": "hcap_h", "X": "hcap_d", "2": "hcap_a"},
}
if market == "HTFT":
return round(float(odds.get(f"htft_{normalized_pick.replace('/', '').lower()}", 1.0)), 2)
odds_key = key_map.get(market, {}).get(normalized_pick)
if not odds_key:
return 1.0
return round(self._real_market_odds(odds, odds_key), 2)
def _market_has_real_pick_odds(self, market: str, pick: str, odds: Dict[str, Any]) -> bool:
if market not in self.ODDS_REQUIRED_MARKETS:
return True
return self._v25_market_odds(odds, market, pick) > 1.01
@staticmethod
def _goal_line_for_market(market: str) -> Optional[float]:
return {
"OU15": 1.5,
"OU25": 2.5,
"OU35": 3.5,
"HT_OU05": 0.5,
"HT_OU15": 1.5,
"CARDS": 4.5,
}.get(market)
def _is_live_match(self, data: MatchData) -> bool:
status = str(data.status or "").upper()
if status in {"NS", "FT", "POSTPONED", "CANC", "ABD"}:
return False
return data.current_score_home is not None and data.current_score_away is not None
def _apply_market_consistency(
self,
rows: List[Dict[str, Any]],
data: MatchData,
prediction: FullMatchPrediction,
) -> List[Dict[str, Any]]:
if not rows:
return rows
is_live = self._is_live_match(data)
current_goals = (
int(data.current_score_home or 0) + int(data.current_score_away or 0)
if is_live
else 0
)
both_scored = (
bool(data.current_score_home and data.current_score_home > 0)
and bool(data.current_score_away and data.current_score_away > 0)
)
predicted_total = float(getattr(prediction, "total_xg", 0.0) or 0.0)
over25_prob = float(getattr(prediction, "over_25_prob", 0.0) or 0.0)
over35_prob = float(getattr(prediction, "over_35_prob", 0.0) or 0.0)
btts_yes_prob = float(getattr(prediction, "btts_yes_prob", 0.0) or 0.0)
home_xg = float(getattr(prediction, "home_xg", 0.0) or 0.0)
away_xg = float(getattr(prediction, "away_xg", 0.0) or 0.0)
xg_gap = abs(home_xg - away_xg)
ht_under05_prob = float(getattr(prediction, "ht_under_05_prob", 0.0) or 0.0)
ht_over05_prob = float(getattr(prediction, "ht_over_05_prob", 0.0) or 0.0)
ht_home_prob = float(getattr(prediction, "ht_home_prob", 0.0) or 0.0)
ht_draw_prob = float(getattr(prediction, "ht_draw_prob", 0.0) or 0.0)
ht_away_prob = float(getattr(prediction, "ht_away_prob", 0.0) or 0.0)
htft_probs = getattr(prediction, "ht_ft_probs", {}) or {}
first_half_goal_from_htft = float(
sum(
float(prob or 0.0)
for outcome, prob in htft_probs.items()
if str(outcome).startswith(("1/", "2/"))
)
)
adjusted: List[Dict[str, Any]] = []
for row in rows:
market = str(row.get("market") or "")
pick = str(row.get("pick") or "")
probability = float(row.get("probability") or 0.0)
confidence = float(row.get("confidence") or (probability * 100.0))
reasons = list(row.get("consistency_reasons") or [])
impossible = False
if is_live:
if market == "BTTS" and pick == "KG Yok" and both_scored:
impossible = True
reasons.append("live_state_impossible_market")
line = self._goal_line_for_market(market)
if line is not None and "Alt" in pick and current_goals > line:
impossible = True
reasons.append("live_score_exceeds_under_line")
if impossible:
continue
penalty = 0.0
line = self._goal_line_for_market(market)
if line is not None:
if "Alt" in pick and predicted_total > (line + 0.35):
penalty += min(32.0, (predicted_total - line) * 18.0)
reasons.append("score_model_conflicts_with_under_pick")
if "Üst" in pick and predicted_total < (line - 0.35):
penalty += min(24.0, (line - predicted_total) * 16.0)
reasons.append("score_model_conflicts_with_over_pick")
if market == "OU35" and "Alt" in pick:
if over25_prob >= 0.78:
penalty += 14.0
reasons.append("market_stack_conflict_over25")
if btts_yes_prob >= 0.74:
penalty += 10.0
reasons.append("market_stack_conflict_btts")
if is_live and current_goals >= 3:
penalty += 24.0
reasons.append("live_total_goals_close_to_line")
if market == "BTTS" and pick == "KG Yok" and predicted_total >= 2.8:
penalty += 16.0
reasons.append("score_model_conflicts_with_btts_no")
if market == "MS":
if pick == "X" and xg_gap >= 0.95:
penalty += 18.0
reasons.append("score_model_conflicts_with_draw_pick")
if pick == "1" and (away_xg - home_xg) >= 0.85:
penalty += 20.0
reasons.append("score_model_conflicts_with_home_pick")
if pick == "2" and (home_xg - away_xg) >= 0.85:
penalty += 20.0
reasons.append("score_model_conflicts_with_away_pick")
if market == "HT_OU05":
if "Alt" in pick:
if max(ht_home_prob, ht_away_prob) >= 0.42:
penalty += 22.0
reasons.append("first_half_result_conflicts_with_goalless_half")
if first_half_goal_from_htft >= 0.45:
penalty += 20.0
reasons.append("first_half_htft_conflicts_with_goalless_half")
if "Üst" in pick and ht_draw_prob >= 0.56 and ht_under05_prob >= 0.54:
penalty += 14.0
reasons.append("first_half_draw_conflicts_with_goal_pick")
if market == "HT" and pick in {"1", "2"} and ht_under05_prob >= 0.56:
penalty += 28.0
reasons.append("first_half_goalless_conflicts_with_result_pick")
if market == "HTFT":
htft_first_half = pick.split("/")[0] if "/" in pick else ""
if htft_first_half in {"1", "2"} and ht_under05_prob >= 0.56:
penalty += 34.0
reasons.append("first_half_goalless_conflicts_with_htft_pick")
if htft_first_half == "X" and ht_over05_prob >= 0.68:
penalty += 16.0
reasons.append("first_half_goal_pressure_conflicts_with_htft_draw")
if penalty > 0:
probability *= max(0.35, 1.0 - (penalty / 100.0))
confidence = max(1.0, confidence - penalty)
next_row = dict(row)
next_row["probability"] = round(probability, 4)
next_row["confidence"] = round(confidence, 1)
if reasons:
next_row["consistency_reasons"] = reasons
adjusted.append(next_row)
return adjusted
def _build_surprise_profile(
self,
data: MatchData,
prediction: FullMatchPrediction,
) -> Dict[str, Any]:
reasons: List[str] = []
score = 22.0
ms_home = float(getattr(prediction, "ms_home_prob", 0.0) or 0.0)
ms_draw = float(getattr(prediction, "ms_draw_prob", 0.0) or 0.0)
ms_away = float(getattr(prediction, "ms_away_prob", 0.0) or 0.0)
top_prob = max(ms_home, ms_draw, ms_away)
second_prob = sorted([ms_home, ms_draw, ms_away], reverse=True)[1]
parity_gap = top_prob - second_prob
total_xg = float(getattr(prediction, "total_xg", 0.0) or 0.0)
btts_yes = float(getattr(prediction, "btts_yes_prob", 0.0) or 0.0)
over35 = float(getattr(prediction, "over_35_prob", 0.0) or 0.0)
if parity_gap <= 0.08:
score += 18.0
reasons.append("balanced_match_risk")
if ms_draw >= 0.30:
score += 14.0
reasons.append("draw_probability_elevated")
if total_xg >= 3.25:
score += 10.0
reasons.append("high_total_goal_volatility")
if btts_yes >= 0.68:
score += 8.0
reasons.append("mutual_goal_pressure")
if over35 >= 0.52:
score += 8.0
reasons.append("late_goal_swing_risk")
if data.lineup_source == "probable_xi":
score += 8.0
reasons.append("lineup_probable_not_confirmed")
if data.lineup_source == "none":
score += 12.0
reasons.append("lineup_unavailable")
if not data.referee_name:
score += 6.0
reasons.append("missing_referee")
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")
elif current_goals >= 2:
score += 10.0
reasons.append("live_match_active_state")
score = max(0.0, min(100.0, score))
if score >= 75:
comment = "Bu maçta sürpriz ve kırılma riski yüksek. Ana tahminler yatabilir; tekli yerine daha temkinli yaklaşım gerekir."
elif score >= 55:
comment = "Bu maçta belirgin sürpriz sinyalleri var. Tahminler yön verse de kupon kararında temkinli olunmalı."
elif score >= 40:
comment = "Maçta orta seviyede belirsizlik var. Tahminler yorum için faydalı ama güven payı sınırlı."
else:
comment = "Sürpriz riski düşük görünüyor. Tahminler normal güven bandında okunabilir."
return {
"score": round(score, 1),
"comment": comment,
"reasons": list(dict.fromkeys(reasons))[:6],
}
@staticmethod
def _normalize_v25_probs(market: str, probs: Dict[str, Any]) -> Dict[str, float]:
out: Dict[str, float] = {}
for key, value in (probs or {}).items():
if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
norm_key = "over" if key == "Over" else "under" if key == "Under" else str(key).lower()
elif market == "BTTS":
norm_key = "yes" if key == "Yes" else "no" if key == "No" else str(key).lower()
elif market == "OE":
norm_key = "odd" if key == "Odd" else "even" if key == "Even" else str(key).lower()
else:
norm_key = str(key)
out[norm_key] = round(float(value), 4)
return out
def _merge_v25_market_rows(
self,
rows: List[Dict[str, Any]],
odds: Dict[str, Any],
v25_signal: Optional[Dict[str, Any]],
) -> List[Dict[str, Any]]:
if not v25_signal:
return rows
by_market = {row.get("market"): dict(row) for row in rows}
for market, payload in v25_signal.items():
if market == "value_bets" or not isinstance(payload, dict):
continue
pick = str(payload.get("pick") or "")
if not self._market_has_real_pick_odds(market, pick, odds):
continue
probability = float(payload.get("probability") or 0.0)
by_market[market] = {
"market": market,
"pick": self._v25_pick_to_market_pick(market, pick),
"probability": round(probability, 4),
"confidence": round(float(payload.get("confidence") or probability * 100.0), 1),
"odds": self._v25_market_odds(odds, market, pick),
}
preferred_order = [
"MS", "DC", "OU15", "OU25", "OU35", "BTTS",
"HT", "HT_OU05", "HT_OU15", "HTFT", "OE", "CARDS", "HCAP",
]
return [by_market[key] for key in preferred_order if key in by_market]
def _merge_v25_market_board(
self,
market_board: Dict[str, Any],
v25_signal: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
if not v25_signal:
return market_board
merged = dict(market_board)
for market, payload in v25_signal.items():
if market == "value_bets" or not isinstance(payload, dict):
continue
merged[market] = {
"pick": self._v25_pick_to_market_pick(market, str(payload.get("pick") or "")),
"confidence": round(float(payload.get("confidence") or 0.0), 1),
"probs": self._normalize_v25_probs(market, payload.get("probs") or {}),
}
return merged
def _build_market_rows(
self,
data: MatchData,
pred: FullMatchPrediction,
v25_signal: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
odds = data.odds_data
rows = [
{
"market": "MS",
"pick": pred.ms_pick,
"probability": round(
float(max(pred.ms_home_prob, pred.ms_draw_prob, pred.ms_away_prob)),
4,
),
"confidence": round(float(pred.ms_confidence), 1),
"odds": round(self._real_market_odds(odds, {"1": "ms_h", "X": "ms_d", "2": "ms_a"}.get(pred.ms_pick, "ms_h")), 2),
},
{
"market": "DC",
"pick": pred.dc_pick,
"probability": round(
float(max(pred.dc_1x_prob, pred.dc_x2_prob, pred.dc_12_prob)),
4,
),
"confidence": round(float(pred.dc_confidence), 1),
"odds": round(float(odds.get(f"dc_{pred.dc_pick.lower()}", 1.0)), 2),
},
{
"market": "OU15",
"pick": pred.ou15_pick,
"probability": round(float(pred.over_15_prob if "Üst" in pred.ou15_pick or "Over" in pred.ou15_pick else pred.under_15_prob), 4),
"confidence": round(float(pred.ou15_confidence), 1),
"odds": round(float(odds.get("ou15_o" if "Üst" in pred.ou15_pick or "Over" in pred.ou15_pick else "ou15_u", 1.0)), 2),
},
{
"market": "OU25",
"pick": pred.ou25_pick,
"probability": round(float(pred.over_25_prob if "Üst" in pred.ou25_pick or "Over" in pred.ou25_pick else pred.under_25_prob), 4),
"confidence": round(float(pred.ou25_confidence), 1),
"odds": round(float(odds.get("ou25_o" if "Üst" in pred.ou25_pick or "Over" in pred.ou25_pick else "ou25_u", 1.0)), 2),
},
{
"market": "OU35",
"pick": pred.ou35_pick,
"probability": round(float(pred.over_35_prob if "Üst" in pred.ou35_pick or "Over" in pred.ou35_pick else pred.under_35_prob), 4),
"confidence": round(float(pred.ou35_confidence), 1),
"odds": round(float(odds.get("ou35_o" if "Üst" in pred.ou35_pick or "Over" in pred.ou35_pick else "ou35_u", 1.0)), 2),
},
{
"market": "BTTS",
"pick": pred.btts_pick,
"probability": round(float(pred.btts_yes_prob if "Var" in pred.btts_pick or "Yes" in pred.btts_pick else pred.btts_no_prob), 4),
"confidence": round(float(pred.btts_confidence), 1),
"odds": round(float(odds.get("btts_y" if "Var" in pred.btts_pick or "Yes" in pred.btts_pick else "btts_n", 1.0)), 2),
},
{
"market": "HT",
"pick": pred.ht_pick,
"probability": round(float(max(pred.ht_home_prob, pred.ht_draw_prob, pred.ht_away_prob)), 4),
"confidence": round(float(pred.ht_confidence), 1),
"odds": round(float(odds.get({"1": "ht_h", "X": "ht_d", "2": "ht_a"}.get(pred.ht_pick, "ht_h"), 1.0)), 2),
},
{
"market": "HT_OU05",
"pick": pred.ht_ou_pick,
"probability": round(float(pred.ht_over_05_prob if "Üst" in pred.ht_ou_pick or "Over" in pred.ht_ou_pick else pred.ht_under_05_prob), 4),
"confidence": round(float(max(pred.ht_over_05_prob, pred.ht_under_05_prob) * 100), 1),
"odds": round(float(odds.get("ht_ou05_o" if "Üst" in pred.ht_ou_pick or "Over" in pred.ht_ou_pick else "ht_ou05_u", 1.0)), 2),
},
{
"market": "HT_OU15",
"pick": pred.ht_ou15_pick,
"probability": round(float(pred.ht_over_15_prob if "Üst" in pred.ht_ou15_pick or "Over" in pred.ht_ou15_pick else pred.ht_under_15_prob), 4),
"confidence": round(float(max(pred.ht_over_15_prob, pred.ht_under_15_prob) * 100), 1),
"odds": round(float(odds.get("ht_ou15_o" if "Üst" in pred.ht_ou15_pick or "Over" in pred.ht_ou15_pick else "ht_ou15_u", 1.0)), 2),
},
{
"market": "OE",
"pick": pred.odd_even_pick,
"probability": round(float(pred.odd_prob if "Tek" in pred.odd_even_pick else pred.even_prob), 4),
"confidence": round(float(max(pred.odd_prob, pred.even_prob) * 100), 1),
"odds": round(float(odds.get("oe_odd" if "Tek" in pred.odd_even_pick else "oe_even", 1.0)), 2),
},
{
"market": "CARDS",
"pick": pred.card_pick,
"probability": round(float(pred.cards_over_prob if "Üst" in pred.card_pick or "Over" in pred.card_pick else pred.cards_under_prob), 4),
"confidence": round(float(pred.cards_confidence), 1),
"odds": round(float(odds.get("cards_o" if "Üst" in pred.card_pick or "Over" in pred.card_pick else "cards_u", 1.0)), 2),
},
{
"market": "HCAP",
"pick": pred.handicap_pick,
"probability": round(float(
pred.handicap_home_prob if pred.handicap_pick == "1"
else pred.handicap_draw_prob if pred.handicap_pick == "X"
else pred.handicap_away_prob
), 4),
"confidence": round(float(pred.handicap_confidence), 1),
"odds": round(float(
odds.get(
{"1": "hcap_h", "X": "hcap_d", "2": "hcap_a"}.get(pred.handicap_pick, "hcap_h"),
1.0,
)
), 2),
},
]
# HT/FT Market - 9 possible outcomes
htft_probs = pred.ht_ft_probs or {}
if htft_probs:
# Find the highest probability HT/FT outcome
htft_labels = ("1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2")
best_htft = max(htft_labels, key=lambda x: float(htft_probs.get(x, 0.0)))
best_htft_prob = float(htft_probs.get(best_htft, 0.0))
# Map HT/FT labels to odds keys
htft_odds_key = f"htft_{best_htft.replace('/', '').lower()}" # e.g., htft_11, htft_1x, htft_12
htft_odds = float(odds.get(htft_odds_key, 1.0))
rows.append({
"market": "HTFT",
"pick": best_htft,
"probability": round(best_htft_prob, 4),
"confidence": round(best_htft_prob * 100, 1),
"odds": round(htft_odds, 2),
})
rows = [
row for row in rows
if self._market_has_real_pick_odds(
str(row.get("market") or ""),
str(row.get("pick") or ""),
odds,
)
]
return self._merge_v25_market_rows(rows, odds, v25_signal)
def _decorate_market_row(
self,
data: MatchData,
prediction: FullMatchPrediction,
quality: Dict[str, Any],
row: Dict[str, Any],
) -> Dict[str, Any]:
"""
Decorate a raw market row with playability, grading, and staking.
V20+Quant hybrid:
- All existing V20+ safety gates preserved (lineup, risk, quality, conf)
- Edge: EV formula → (prob × odds) - 1.0 (not simple prob - implied)
- Staking: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll)
- Grading: Edge-based → A(>10%), B(>5%), C(>2%), PASS
"""
market = str(row.get("market") or "")
raw_conf = float(row.get("confidence") or 0.0)
prob = float(row.get("probability") or 0.0)
odd = float(row.get("odds") or 0.0)
calibration = self.market_calibration.get(market, 0.85)
calibrated_conf = max(1.0, min(99.0, raw_conf * calibration))
min_conf = self.market_min_conf.get(market, 55.0)
# ── V2 Quant: EV Edge formula ──────────────────────────────────
# Old: edge = prob - (1/odd) ← simple probability difference
# New: edge = (prob × odd) - 1 ← Expected Value (what a quant uses)
implied_prob = (1.0 / odd) if odd > 1.0 else 0.0
ev_edge = (prob * odd) - 1.0 if odd > 1.0 else 0.0
simple_edge = prob - implied_prob if implied_prob > 0 else 0.0
# ── V31: League-specific odds reliability ──────────────────────
# Higher reliability → trust odds-based edge more in play_score
# Lower reliability → lean more on model confidence, less on edge
odds_rel = self.league_reliability.get(
str(data.league_id or ""), 0.35 # default for unknown leagues
)
# Edge weight: reliable league → edge matters more (up to 120%)
# unreliable league → edge matters less (down to 60%)
edge_multiplier = 0.60 + (odds_rel * 0.60) # range: 0.60 1.20
risk_level = str(prediction.risk_level or "MEDIUM").upper()
risk_penalty = {"LOW": 0.0, "MEDIUM": 3.0, "HIGH": 8.0, "EXTREME": 12.0}.get(
risk_level,
5.0,
)
quality_label = str(quality.get("label") or "MEDIUM").upper()
quality_penalty = {"HIGH": 0.0, "MEDIUM": 3.0, "LOW": 7.0}.get(
quality_label,
5.0,
)
home_n = len(data.home_lineup or [])
away_n = len(data.away_lineup or [])
lineup_missing = home_n < 9 or away_n < 9
lineup_sensitive = market in ("MS", "BTTS", "HT", "HTFT")
lineup_penalty = 5.0 if lineup_missing and lineup_sensitive else 0.0
if data.lineup_source == "probable_xi" and lineup_sensitive:
lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0)))
lineup_penalty += max(1.0, (1.0 - lineup_conf) * 5.0)
# V31: edge contribution weighted by league odds reliability
base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier)
play_score = max(
0.0,
min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty),
)
# ── V20+ Safety gates (PRESERVED) ─────────────────────────────
min_play_score = self.market_min_play_score.get(market, 68.0)
min_edge = self.market_min_edge.get(market, 0.02)
reasons: List[str] = []
playable = True
if calibrated_conf < min_conf:
playable = False
reasons.append("below_calibrated_conf_threshold")
if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01:
playable = False
reasons.append("market_odds_missing")
if risk_level in ("HIGH", "EXTREME") and quality_label == "LOW":
playable = False
reasons.append("high_risk_low_data_quality")
if lineup_missing and lineup_sensitive:
# V32: Don't hard-block, apply heavy penalty instead
# This allows high-confidence predictions to still surface
lineup_penalty += 8.0
reasons.append("lineup_insufficient_for_market")
if data.lineup_source == "probable_xi" and lineup_sensitive:
# V32: Penalty instead of hard block
# Most pre-match predictions use probable_xi — blocking kills all output
lineup_penalty += 6.0
reasons.append("lineup_probable_xi_penalty")
# V31: negative edge threshold adapts to league reliability
# Reliable league: stricter (-0.03), unreliable: looser (-0.08)
neg_edge_threshold = -0.03 - (1.0 - odds_rel) * 0.05
if odd > 1.0 and simple_edge < neg_edge_threshold:
playable = False
reasons.append(f"negative_model_edge_{simple_edge:+.3f}")
if odd > 1.0 and ev_edge < min_edge:
playable = False
reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}")
if play_score < min_play_score:
playable = False
reasons.append("insufficient_play_score")
if not reasons:
reasons.append("market_passed_all_gates")
consistency_reasons = [
str(reason)
for reason in row.get("consistency_reasons", [])
if reason
]
if consistency_reasons:
reasons.extend(consistency_reasons)
reasons = list(dict.fromkeys(reasons))
# ── V2 Quant: Edge-based grading (replaces play_score bands) ──
if not playable:
grade = "PASS"
stake_units = 0.0
elif ev_edge > 0.10:
grade = "A"
# V2 Quant: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll)
stake_units = self._kelly_stake(prob, odd)
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A")
elif ev_edge > 0.05:
grade = "B"
stake_units = self._kelly_stake(prob, odd)
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B")
elif ev_edge > 0.02:
grade = "C"
stake_units = self._kelly_stake(prob, odd)
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_C")
else:
# Passes all V20+ gates but no mathematical edge over bookie
grade = "C"
stake_units = 0.25 # minimum stake (conservative)
reasons.append("no_ev_edge_minimum_stake")
out = dict(row)
out.update(
{
"raw_confidence": round(raw_conf, 1),
"calibrated_confidence": round(calibrated_conf, 1),
"min_required_confidence": round(min_conf, 1),
"min_required_play_score": round(min_play_score, 1),
"min_required_edge": round(min_edge, 4),
"edge": round(ev_edge, 4),
"implied_prob": round(implied_prob, 4),
"ev_edge": round(ev_edge, 4),
"odds_reliability": round(odds_rel, 4),
"play_score": round(play_score, 1),
"playable": playable,
"bet_grade": grade,
"stake_units": stake_units,
"decision_reasons": reasons[:5],
},
)
return out
@staticmethod
def _kelly_stake(true_prob: float, decimal_odds: float) -> float:
"""
Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll).
Full Kelly: f* = ((b × p) - q) / b
where b = odds - 1, p = true_prob, q = 1 - p
Quarter-Kelly reduces variance and ruin risk on noisy sports data.
Returns stake in units, capped at 3.0.
"""
if decimal_odds <= 1.0 or true_prob <= 0.0 or true_prob >= 1.0:
return 0.25 # minimum fallback
b = decimal_odds - 1.0
p = true_prob
q = 1.0 - p
f_star = ((b * p) - q) / b
if f_star <= 0.0:
return 0.25 # minimum fallback
kelly_fraction = 0.25 # quarter-Kelly
bankroll_units = 10.0
stake = f_star * kelly_fraction * bankroll_units
stake = min(stake, 3.0) # cap
return round(max(0.25, stake), 1)
@staticmethod
def _to_bet_summary_item(row: Dict[str, Any]) -> Dict[str, Any]:
return {
"market": row.get("market"),
"pick": row.get("pick"),
"raw_confidence": row.get("raw_confidence", row.get("confidence")),
"calibrated_confidence": row.get("calibrated_confidence", row.get("confidence")),
"bet_grade": row.get("bet_grade", "PASS"),
"playable": bool(row.get("playable")),
"stake_units": float(row.get("stake_units", 0.0)),
"play_score": row.get("play_score", 0.0),
"ev_edge": row.get("ev_edge", row.get("edge", 0.0)),
"implied_prob": row.get("implied_prob", 0.0),
"odds_reliability": row.get("odds_reliability", 0.35),
"odds": row.get("odds", 0.0),
"reasons": row.get("decision_reasons", []),
}
def _compute_data_quality(self, data: MatchData) -> Dict[str, Any]:
if str(data.sport or "football").lower() == "basketball":
return self._compute_basketball_data_quality(data)
flags: List[str] = []
ms_keys = ("ms_h", "ms_d", "ms_a")
has_ms = all(k in data.odds_data for k in ms_keys)
has_market_depth = any(k not in ms_keys for k in data.odds_data.keys())
is_default_ms = (
abs(float(data.odds_data.get("ms_h", 0.0)) - self.DEFAULT_MS_H) < 1e-6 and
abs(float(data.odds_data.get("ms_d", 0.0)) - self.DEFAULT_MS_D) < 1e-6 and
abs(float(data.odds_data.get("ms_a", 0.0)) - self.DEFAULT_MS_A) < 1e-6
)
has_real_ms = has_ms and (has_market_depth or (not is_default_ms))
odds_score = 1.0 if has_real_ms else (0.6 if has_ms else 0.4)
if odds_score < 1.0:
flags.append("missing_full_ms_odds")
home_n = len(data.home_lineup or [])
away_n = len(data.away_lineup or [])
lineup_score = min(home_n, away_n) / 11.0 if min(home_n, away_n) > 0 else 0.0
if data.lineup_source == "probable_xi":
lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0)))
lineup_score *= max(0.45, min(0.88, lineup_conf))
flags.append("lineup_probable_not_confirmed")
if lineup_conf < 0.65:
flags.append("lineup_projection_low_confidence")
elif data.lineup_source == "none":
flags.append("lineup_unavailable")
if lineup_score < 0.7:
flags.append("lineup_incomplete")
ref_score = 1.0 if data.referee_name else 0.6
if not data.referee_name:
flags.append("missing_referee")
total_score = (odds_score * 0.45) + (lineup_score * 0.45) + (ref_score * 0.10)
if total_score >= 0.8:
label = "HIGH"
elif total_score >= 0.55:
label = "MEDIUM"
else:
label = "LOW"
return {
"label": label,
"score": round(total_score, 3),
"home_lineup_count": home_n,
"away_lineup_count": away_n,
"lineup_source": data.lineup_source,
"lineup_confidence": round(float(getattr(data, "lineup_confidence", 0.0) or 0.0), 3),
"flags": flags,
}
def _compute_basketball_data_quality(self, data: MatchData) -> Dict[str, Any]:
flags: List[str] = []
has_ml = float(data.odds_data.get("ml_h", 0.0)) > 1.0 and float(data.odds_data.get("ml_a", 0.0)) > 1.0
has_total = (
float(data.odds_data.get("tot_line", 0.0)) > 0.0
and float(data.odds_data.get("tot_o", 0.0)) > 1.0
and float(data.odds_data.get("tot_u", 0.0)) > 1.0
)
has_spread = (
"spread_home_line" in data.odds_data
and float(data.odds_data.get("spread_h", 0.0)) > 1.0
and float(data.odds_data.get("spread_a", 0.0)) > 1.0
)
odds_components = [has_ml, has_total, has_spread]
odds_score = sum(1.0 for x in odds_components if x) / 3.0
if not has_ml:
flags.append("missing_moneyline_odds")
if not has_total:
flags.append("missing_total_odds")
if not has_spread:
flags.append("missing_spread_odds")
# Basketball live lineup/referee coverage is structurally lower in this project.
# Keep neutral baseline and rely mostly on odds depth.
lineup_score = 0.7
ref_score = 0.7
total_score = (odds_score * 0.75) + (lineup_score * 0.15) + (ref_score * 0.10)
if total_score >= 0.75:
label = "HIGH"
elif total_score >= 0.52:
label = "MEDIUM"
else:
label = "LOW"
return {
"label": label,
"score": round(total_score, 3),
"home_lineup_count": len(data.home_lineup or []),
"away_lineup_count": len(data.away_lineup or []),
"lineup_source": data.lineup_source,
"flags": flags,
}
def _build_reasoning_factors(
self,
data: MatchData,
prediction: FullMatchPrediction,
quality: Dict[str, Any],
) -> List[str]:
factors: List[str] = []
if prediction.odds_confidence >= prediction.team_confidence:
factors.append("market_signal_dominant")
else:
factors.append("team_form_signal_dominant")
if prediction.player_confidence >= 60:
factors.append("lineup_signal_strong")
elif not data.home_lineup or not data.away_lineup:
factors.append("lineup_signal_weak")
if data.lineup_source == "probable_xi":
factors.append("lineup_probable_xi_used")
if prediction.is_surprise_risk:
factors.append("upset_risk_detected")
if quality["label"] == "LOW":
factors.append("limited_data_confidence")
if prediction.risk_warnings:
factors.extend([f"risk:{w}" for w in prediction.risk_warnings[:2]])
return factors
@staticmethod
def _to_float(value: Any, default: float) -> float:
try:
if value is None:
return default
return float(value)
except Exception:
return default
@staticmethod
def _normalize_text(value: Any) -> str:
text = str(value or "").casefold().replace("", "i")
return " ".join(text.split())
def _selection_value(
self,
selections: Dict[str, Any],
aliases: Tuple[str, ...],
default: float,
) -> float:
if not isinstance(selections, dict):
return default
normalized_aliases = {self._normalize_text(alias) for alias in aliases}
for key, value in selections.items():
key_norm = self._normalize_text(key)
if key_norm in normalized_aliases:
return self._to_float(value, default)
# Secondary match for entries like "2,5 Üst" or "Toplam Alt"
for key, value in selections.items():
key_norm = self._normalize_text(key)
if any(alias in key_norm for alias in normalized_aliases):
return self._to_float(value, default)
return default
def _parse_json_dict(self, payload: Any) -> Optional[Dict[str, Any]]:
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
return None
return payload if isinstance(payload, dict) else None
def get_daily_bankers(self, count: int = 3) -> List[Dict[str, Any]]:
"""
Identifies the safest, highest value bets for the next 24 hours.
"""
now_ms = int(time.time() * 1000)
horizon_ms = now_ms + (24 * 60 * 60 * 1000)
with psycopg2.connect(self.dsn) as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT m.id, m.match_name, m.mst_utc
FROM matches m
WHERE m.mst_utc >= %s AND m.mst_utc <= %s
AND m.status = 'NS'
AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id)
ORDER BY m.mst_utc ASC
LIMIT 50
""", (now_ms, horizon_ms))
matches = cur.fetchall()
potential_bankers = []
print(f"🔍 Scanning {len(matches)} upcoming matches for Bankers...")
for match in matches:
try:
data = self._load_match_data(match['id'])
if data is None: continue
result = self.analyze_match(match['id'])
if result and 'main_pick' in result:
pick = result['main_pick']
conf = pick.get('calibrated_confidence', pick.get('confidence', 0))
odds = pick.get('odds', 0)
market = pick.get('market', '')
pick_name = pick.get('pick', '')
# Banker Criteria: High Confidence (>75%) AND Decent Odds (>1.30)
if conf >= 75.0 and odds >= 1.30:
score = conf * (odds - 1.0)
potential_bankers.append({
"match_id": match['id'],
"match_name": match['match_name'] or f"{data.home_team_name} vs {data.away_team_name}",
"league": data.league_name,
"pick": f"{market} - {pick_name}",
"confidence": conf,
"odds": odds,
"value_score": score
})
except Exception:
pass
potential_bankers.sort(key=lambda x: x['value_score'], reverse=True)
return potential_bankers[:count]
_orchestrator: Optional[SingleMatchOrchestrator] = None
def get_single_match_orchestrator() -> SingleMatchOrchestrator:
global _orchestrator
if _orchestrator is None:
_orchestrator = SingleMatchOrchestrator()
return _orchestrator