4656 lines
202 KiB
Python
Executable File
4656 lines
202 KiB
Python
Executable File
"""
|
||
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 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
|
||
from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge
|
||
from features.odds_band_analyzer import OddsBandAnalyzer
|
||
from models.basketball_v25 import (
|
||
BasketballMatchPrediction,
|
||
get_basketball_v25_predictor,
|
||
)
|
||
from core.engines.player_predictor import PlayerPrediction, get_player_predictor
|
||
from services.feature_enrichment import FeatureEnrichmentService
|
||
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
|
||
|
||
|
||
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", "v25")).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 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 _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 zero-defaults on failure."""
|
||
defaults = {
|
||
'home_squad_quality': 0.0, 'away_squad_quality': 0.0, 'squad_diff': 0.0,
|
||
'home_key_players': 0.0, 'away_key_players': 0.0,
|
||
'home_missing_impact': 0.0, 'away_missing_impact': 0.0,
|
||
'home_goals_form': 0.0, 'away_goals_form': 0.0,
|
||
}
|
||
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,
|
||
)
|
||
return {
|
||
'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),
|
||
}
|
||
except Exception as e:
|
||
print(f"⚠️ Squad features failed: {e}")
|
||
return defaults
|
||
|
||
def _get_v25_signal(
|
||
self,
|
||
data: MatchData,
|
||
features: Optional[Dict[str, float]] = None,
|
||
) -> Dict[str, Any]:
|
||
v25 = self._get_v25_predictor()
|
||
feature_row = features or self._build_v25_features(data)
|
||
return v25.predict_market_bundle(
|
||
features=feature_row,
|
||
odds=self._sanitize_v25_odds(data.odds_data or {}),
|
||
)
|
||
|
||
@staticmethod
|
||
def _prob_map(signal: Optional[Dict[str, Any]], market: str, defaults: Dict[str, float]) -> Dict[str, float]:
|
||
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:
|
||
return dict(defaults)
|
||
out = {key: float(probs.get(key, value)) for key, value in defaults.items()}
|
||
total = sum(out.values())
|
||
if total <= 0:
|
||
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
|
||
|
||
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 = prediction.ms_home_prob - prediction.ms_away_prob
|
||
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_penalty = 12.0 if data.lineup_source == "none" else 7.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 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 ────────────────────────────
|
||
# For finished (FT/postGame) matches, strip live scores so the
|
||
# entire pipeline treats them as if they haven't kicked off yet.
|
||
# _is_live_match already returns False for FT, but this adds
|
||
# defense-in-depth against any code path that reads scores directly.
|
||
_status_upper = str(data.status or "").upper()
|
||
_state_upper = str(data.state or "").upper()
|
||
if _status_upper in {"FT", "FINISHED"} or _state_upper in {"POSTGAME", "POST_GAME"}:
|
||
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)
|
||
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)
|
||
base_package["prediction"]["ms_confidence"] = prediction.ms_confidence
|
||
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
|
||
|
||
mode = str(getattr(self, "engine_mode", "v25") or "v25").lower()
|
||
if mode not in {"v25", "v26", "dual"}:
|
||
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 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 = 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"),
|
||
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]:
|
||
live_lineups = row.get("lineups")
|
||
home, away = self._parse_lineups_json(live_lineups)
|
||
if (home and len(home) >= 9) and (away and len(away) >= 9):
|
||
return home, away, "confirmed_live"
|
||
|
||
# fallback 1: current match participation table
|
||
cur.execute(
|
||
"""
|
||
SELECT team_id, player_id
|
||
FROM match_player_participation
|
||
WHERE match_id = %s
|
||
AND is_starting = true
|
||
""",
|
||
(row["match_id"],),
|
||
)
|
||
home_id = str(row["home_team_id"])
|
||
away_id = str(row["away_team_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"
|
||
|
||
# fallback 2: probable XI from historical starts before match date
|
||
before_date_ms = int(row.get("match_date_ms") or 0)
|
||
used_probable = False
|
||
if not home:
|
||
home = self._build_probable_xi(cur, home_id, before_date_ms)
|
||
used_probable = used_probable or bool(home)
|
||
if not away:
|
||
away = self._build_probable_xi(cur, away_id, before_date_ms)
|
||
used_probable = used_probable or bool(away)
|
||
|
||
if used_probable:
|
||
return home, away, "probable_xi"
|
||
return home, away, "none"
|
||
|
||
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,
|
||
max_days: int = 30,
|
||
) -> Optional[List[str]]:
|
||
if not team_id:
|
||
return None
|
||
|
||
min_date_ms = max(0, before_date_ms - (max_days * 24 * 60 * 60 * 1000))
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
mpp.player_id,
|
||
COUNT(*) AS starts,
|
||
MAX(m.mst_utc) AS last_start_ms
|
||
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 m.status = 'FT'
|
||
AND m.mst_utc < %s
|
||
AND m.mst_utc >= %s
|
||
GROUP BY mpp.player_id
|
||
ORDER BY starts DESC, last_start_ms DESC
|
||
LIMIT 11
|
||
""",
|
||
(team_id, before_date_ms, min_date_ms),
|
||
)
|
||
rows = cur.fetchall()
|
||
if not rows:
|
||
return None
|
||
return [str(r["player_id"]) for r in rows]
|
||
|
||
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_penalty += 4.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_score *= 0.55
|
||
flags.append("lineup_probable_not_confirmed")
|
||
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,
|
||
"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̇", "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
|