714 lines
32 KiB
Python
Executable File
714 lines
32 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 pickle
|
||
import pandas as pd
|
||
import numpy as np
|
||
from collections import defaultdict
|
||
from typing import Any, Dict, List, Optional, Set, Tuple, overload
|
||
|
||
import psycopg2
|
||
from psycopg2.extras import RealDictCursor
|
||
|
||
from data.db import get_clean_dsn
|
||
from schemas.prediction import FullMatchPrediction
|
||
from schemas.match_data import MatchData
|
||
from models.v25_ensemble import V25Predictor, get_v25_predictor
|
||
try:
|
||
from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge
|
||
except ImportError:
|
||
class V27Predictor:
|
||
def __init__(self): self.models = {}
|
||
def load_models(self): return False
|
||
def predict_all(self, features): return {}
|
||
def compute_divergence(*args, **kwargs):
|
||
return {}
|
||
def compute_value_edge(*args, **kwargs):
|
||
return {}
|
||
from features.odds_band_analyzer import OddsBandAnalyzer
|
||
try:
|
||
from models.basketball_v25 import (
|
||
BasketballMatchPrediction,
|
||
get_basketball_v25_predictor,
|
||
)
|
||
except ImportError:
|
||
BasketballMatchPrediction = Any
|
||
def get_basketball_v25_predictor() -> Any:
|
||
raise ImportError("Basketball predictor is not available")
|
||
from core.engines.player_predictor import PlayerPrediction, get_player_predictor
|
||
from services.feature_enrichment import FeatureEnrichmentService
|
||
from services.betting_brain import BettingBrain
|
||
from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine
|
||
from services.match_commentary import generate_match_commentary
|
||
from utils.top_leagues import load_top_league_ids
|
||
from utils.league_reliability import load_league_reliability
|
||
from config.config_loader import build_threshold_dict, get_threshold_default, get_config
|
||
from models.calibration import get_calibrator
|
||
|
||
# Refactor note (post-V28):
|
||
# The original 5786-line monolith was split into focused mixin modules under
|
||
# services/orchestrator/. This file is the slim composition layer plus the
|
||
# main entry points (__init__, analyze_match, predictor lifecycle).
|
||
from services.orchestrator import (
|
||
DataLoaderMixin,
|
||
FeatureBuilderMixin,
|
||
PredictionMixin,
|
||
BasketballMixin,
|
||
UpperBrainMixin,
|
||
HtmsMixin,
|
||
CouponMixin,
|
||
ReversalMixin,
|
||
MarketBoardMixin,
|
||
UtilsMixin,
|
||
)
|
||
|
||
|
||
# MRO note: the original file contained two `_safe_float` definitions where
|
||
# the second silently overrode the first per Python class-scope rules.
|
||
# Both are preserved in UtilsMixin in their original order, so the override
|
||
# behaviour is identical.
|
||
class SingleMatchOrchestrator(
|
||
DataLoaderMixin,
|
||
FeatureBuilderMixin,
|
||
PredictionMixin,
|
||
BasketballMixin,
|
||
UpperBrainMixin,
|
||
HtmsMixin,
|
||
CouponMixin,
|
||
ReversalMixin,
|
||
MarketBoardMixin,
|
||
UtilsMixin,
|
||
):
|
||
"""Main V20+ application service used by API endpoints."""
|
||
_cfg = get_config()
|
||
DEFAULT_MS_H: float = float(_cfg.get('model_ensemble.default_ms_odds.home', 2.65))
|
||
DEFAULT_MS_D: float = float(_cfg.get('model_ensemble.default_ms_odds.draw', 3.20))
|
||
DEFAULT_MS_A: float = float(_cfg.get('model_ensemble.default_ms_odds.away', 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._v27: Optional[V27Predictor] = None
|
||
self.basketball_predictor: Optional[Any] = None
|
||
self.dsn = get_clean_dsn()
|
||
self.engine_mode = str(os.getenv("AI_ENGINE_MODE", "v28-pro-max")).strip().lower()
|
||
self.top_league_ids = load_top_league_ids()
|
||
self.league_reliability = load_league_reliability()
|
||
self.enrichment = FeatureEnrichmentService()
|
||
self.odds_band_analyzer = OddsBandAnalyzer()
|
||
# ── Market Thresholds (loaded from config/market_thresholds.json) ──
|
||
# All values are centralized in a single JSON file for easy tuning
|
||
# without code changes. See config/market_thresholds.json for details.
|
||
self.market_calibration: Dict[str, float] = build_threshold_dict("calibration")
|
||
self.market_min_conf: Dict[str, float] = build_threshold_dict("min_conf")
|
||
self.market_min_play_score: Dict[str, float] = build_threshold_dict("min_play_score")
|
||
self.market_min_edge: Dict[str, float] = build_threshold_dict("min_edge")
|
||
self.odds_band_min_sample: Dict[str, float] = build_threshold_dict("odds_band_min_sample")
|
||
self.odds_band_min_edge: Dict[str, float] = build_threshold_dict("odds_band_min_edge")
|
||
|
||
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 not hasattr(self, "v26_shadow_engine") or self.v26_shadow_engine is None:
|
||
self.v26_shadow_engine = get_v26_shadow_engine()
|
||
return self.v26_shadow_engine
|
||
|
||
def _get_v27_predictor(self) -> Optional[V27Predictor]:
|
||
"""Non-fatal V27 loader — returns None if models can't load."""
|
||
if V27Predictor is None:
|
||
return None
|
||
if getattr(self, "_v27", None) is not None:
|
||
return self._v27
|
||
try:
|
||
pred = V27Predictor()
|
||
if pred.load_models():
|
||
self._v27 = pred
|
||
print(f"[V27] ✅ Predictor loaded: {sum(len(v) for v in pred.models.values())} models")
|
||
return self._v27
|
||
except Exception as e:
|
||
print(f"[V27] ⚠ Load failed (non-fatal): {e}")
|
||
self._v27 = None
|
||
return None
|
||
|
||
def _get_basketball_predictor(self) -> Any:
|
||
if self.basketball_predictor is None:
|
||
self.basketball_predictor = get_basketball_v25_predictor()
|
||
return self.basketball_predictor
|
||
|
||
def analyze_match(self, match_id: str) -> Optional[Dict[str, Any]]:
|
||
data = self._load_match_data(match_id)
|
||
if data is None:
|
||
return None
|
||
|
||
# ── Pre-Match Simulation Mode ────────────────────────────
|
||
# Force all matches (live and finished) into pre-match state so the
|
||
# engine purely predicts based on pre-match odds and context, ignoring
|
||
# current live scores and preventing live state penalties.
|
||
_status_upper = str(data.status or "").upper()
|
||
if _status_upper not in {"NS", "POSTPONED", "CANC", "ABD"}:
|
||
data.status = "NS"
|
||
data.state = "preGame"
|
||
data.current_score_home = None
|
||
data.current_score_away = None
|
||
|
||
sport_key = str(data.sport or "football").lower()
|
||
if sport_key == "basketball":
|
||
prediction = self._get_basketball_predictor().predict(
|
||
match_id=data.match_id,
|
||
home_team_id=data.home_team_id,
|
||
away_team_id=data.away_team_id,
|
||
home_team_name=data.home_team_name,
|
||
away_team_name=data.away_team_name,
|
||
match_date_ms=data.match_date_ms,
|
||
league_id=data.league_id,
|
||
league_name=data.league_name,
|
||
odds_data=data.odds_data,
|
||
sidelined_data=data.sidelined_data,
|
||
)
|
||
return self._build_basketball_prediction_package(data, prediction)
|
||
|
||
features = self._build_v25_features(data)
|
||
# ── DEBUG: log critical feature values to diagnose absurd predictions ──
|
||
_debug_keys = [
|
||
'home_overall_elo', 'away_overall_elo', 'elo_diff',
|
||
'odds_ms_h', 'odds_ms_d', 'odds_ms_a',
|
||
'implied_home', 'implied_draw', 'implied_away',
|
||
'home_goals_avg', 'away_goals_avg',
|
||
'home_conceded_avg', 'away_conceded_avg',
|
||
'home_momentum_score', 'away_momentum_score',
|
||
'home_squad_quality', 'away_squad_quality',
|
||
]
|
||
print("── [DEBUG] Feature values for model input ──")
|
||
for _dk in _debug_keys:
|
||
print(f" {_dk}: {features.get(_dk, 'MISSING')}")
|
||
print(f" Total features: {len(features)}")
|
||
print("── [DEBUG END] ──")
|
||
v25_signal = self._get_v25_signal(data, features)
|
||
prediction = self._build_v25_prediction(data, features, v25_signal)
|
||
base_package = self._build_prediction_package(data, prediction, v25_signal)
|
||
|
||
# ── V27 Dual-Engine Divergence ──────────────────────────────
|
||
v27_predictor = self._get_v27_predictor()
|
||
if v27_predictor is not None:
|
||
try:
|
||
v27_preds = v27_predictor.predict_all(features)
|
||
|
||
# MS divergence
|
||
v27_ms = v27_preds.get("ms")
|
||
if v27_ms:
|
||
v25_ms_probs = {
|
||
"home": prediction.ms_home_prob,
|
||
"draw": prediction.ms_draw_prob,
|
||
"away": prediction.ms_away_prob,
|
||
}
|
||
ms_divergence = compute_divergence(v25_ms_probs, v27_ms)
|
||
ms_odds = {
|
||
"home": float((data.odds_data or {}).get("ms_h", 0)),
|
||
"draw": float((data.odds_data or {}).get("ms_d", 0)),
|
||
"away": float((data.odds_data or {}).get("ms_a", 0)),
|
||
}
|
||
ms_value = compute_value_edge(v25_ms_probs, v27_ms, ms_odds)
|
||
else:
|
||
ms_divergence = {}
|
||
ms_value = {}
|
||
|
||
# OU25 divergence
|
||
v27_ou25 = v27_preds.get("ou25")
|
||
if v27_ou25:
|
||
v25_ou25_probs = {
|
||
"under": prediction.under_25_prob,
|
||
"over": prediction.over_25_prob,
|
||
}
|
||
ou25_divergence = compute_divergence(v25_ou25_probs, v27_ou25)
|
||
ou25_odds = {
|
||
"under": float((data.odds_data or {}).get("ou25_u", 0)),
|
||
"over": float((data.odds_data or {}).get("ou25_o", 0)),
|
||
}
|
||
ou25_value = compute_value_edge(v25_ou25_probs, v27_ou25, ou25_odds)
|
||
else:
|
||
ou25_divergence = {}
|
||
ou25_value = {}
|
||
|
||
# ── V28 Odds-Band Historical Performance ─────────────
|
||
odds_band_ms_home = {
|
||
"win_rate": features.get("home_band_ms_win_rate", 0.33),
|
||
"draw_rate": features.get("home_band_ms_draw_rate", 0.33),
|
||
"loss_rate": features.get("home_band_ms_loss_rate", 0.34),
|
||
"sample": features.get("home_band_ms_sample", 0),
|
||
"avg_goals_scored": features.get("home_band_ms_avg_goals_scored", 1.3),
|
||
"avg_goals_conceded": features.get("home_band_ms_avg_goals_conceded", 1.1),
|
||
}
|
||
odds_band_ms_away = {
|
||
"win_rate": features.get("away_band_ms_win_rate", 0.33),
|
||
"draw_rate": features.get("away_band_ms_draw_rate", 0.33),
|
||
"loss_rate": features.get("away_band_ms_loss_rate", 0.34),
|
||
"sample": features.get("away_band_ms_sample", 0),
|
||
"avg_goals_scored": features.get("away_band_ms_avg_goals_scored", 1.3),
|
||
"avg_goals_conceded": features.get("away_band_ms_avg_goals_conceded", 1.1),
|
||
}
|
||
odds_band_ou25 = {
|
||
"over_rate": features.get("band_ou25_over_rate", 0.50),
|
||
"under_rate": features.get("band_ou25_under_rate", 0.50),
|
||
"avg_total_goals": features.get("band_ou25_avg_total_goals", 2.5),
|
||
"sample": features.get("band_ou25_sample", 0),
|
||
}
|
||
odds_band_ou15 = {
|
||
"over_rate": features.get("band_ou15_over_rate", 0.65),
|
||
"under_rate": features.get("band_ou15_under_rate", 0.35),
|
||
"avg_total_goals": features.get("band_ou15_avg_total_goals", 2.5),
|
||
"sample": features.get("band_ou15_sample", 0),
|
||
}
|
||
odds_band_ou35 = {
|
||
"over_rate": features.get("band_ou35_over_rate", 0.35),
|
||
"under_rate": features.get("band_ou35_under_rate", 0.65),
|
||
"avg_total_goals": features.get("band_ou35_avg_total_goals", 2.5),
|
||
"sample": features.get("band_ou35_sample", 0),
|
||
}
|
||
odds_band_btts = {
|
||
"yes_rate": features.get("band_btts_yes_rate", 0.50),
|
||
"no_rate": features.get("band_btts_no_rate", 0.50),
|
||
"sample": features.get("band_btts_sample", 0),
|
||
}
|
||
odds_band_dc = {
|
||
"1x_rate": features.get("band_dc_1x_rate", 0.60),
|
||
"x2_rate": features.get("band_dc_x2_rate", 0.60),
|
||
"12_rate": features.get("band_dc_12_rate", 0.67),
|
||
"1x_sample": features.get("band_dc_1x_sample", 0),
|
||
"x2_sample": features.get("band_dc_x2_sample", 0),
|
||
"12_sample": features.get("band_dc_12_sample", 0),
|
||
}
|
||
odds_band_ht_home = {
|
||
"win_rate": features.get("home_band_ht_win_rate", 0.33),
|
||
"draw_rate": features.get("home_band_ht_draw_rate", 0.40),
|
||
"loss_rate": features.get("home_band_ht_loss_rate", 0.27),
|
||
"sample": features.get("home_band_ht_sample", 0),
|
||
}
|
||
odds_band_ht_away = {
|
||
"win_rate": features.get("away_band_ht_win_rate", 0.33),
|
||
"draw_rate": features.get("away_band_ht_draw_rate", 0.40),
|
||
"loss_rate": features.get("away_band_ht_loss_rate", 0.27),
|
||
"sample": features.get("away_band_ht_sample", 0),
|
||
}
|
||
odds_band_ht_ou05 = {
|
||
"over_rate": features.get("band_ht_ou05_over_rate", 0.50),
|
||
"under_rate": features.get("band_ht_ou05_under_rate", 0.50),
|
||
"sample": features.get("band_ht_ou05_sample", 0),
|
||
}
|
||
odds_band_ht_ou15 = {
|
||
"over_rate": features.get("band_ht_ou15_over_rate", 0.35),
|
||
"under_rate": features.get("band_ht_ou15_under_rate", 0.65),
|
||
"sample": features.get("band_ht_ou15_sample", 0),
|
||
}
|
||
odds_band_oe = {
|
||
"odd_rate": features.get("band_oe_odd_rate", 0.50),
|
||
"even_rate": features.get("band_oe_even_rate", 0.50),
|
||
"sample": features.get("band_oe_sample", 0),
|
||
}
|
||
|
||
# Cards (Kart) band — hakem + takım profili
|
||
odds_band_cards = {
|
||
"referee_avg": features.get("band_cards_referee_avg", 0.0),
|
||
"referee_over_rate": features.get("band_cards_referee_over_rate", 0.50),
|
||
"referee_sample": features.get("band_cards_referee_sample", 0),
|
||
"team_avg": features.get("band_cards_team_avg", 0.0),
|
||
"team_over_rate": features.get("band_cards_team_over_rate", 0.50),
|
||
"team_sample": features.get("band_cards_team_sample", 0),
|
||
"combined_over_rate": features.get("band_cards_combined_over_rate", 0.50),
|
||
"sample": features.get("band_cards_sample", 0),
|
||
}
|
||
|
||
# HTFT (İY/MS) 9 combination rates
|
||
odds_band_htft = {}
|
||
for combo in ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22"):
|
||
odds_band_htft[combo] = {
|
||
"rate": features.get(f"band_htft_{combo}_rate", 0.11),
|
||
"sample": features.get(f"band_htft_{combo}_sample", 0),
|
||
}
|
||
|
||
# ── Triple Value Detection ────────────────────────────
|
||
ms_odds = {
|
||
"home": float((data.odds_data or {}).get("ms_h", 0)),
|
||
"draw": float((data.odds_data or {}).get("ms_d", 0)),
|
||
"away": float((data.odds_data or {}).get("ms_a", 0)),
|
||
}
|
||
triple_value = {}
|
||
for outcome_key, band_key, odds_key in [
|
||
("home", "home", "home"),
|
||
("away", "away", "away"),
|
||
]:
|
||
v27_prob = (v27_ms or {}).get(outcome_key, 0)
|
||
band_rate = (odds_band_ms_home if band_key == "home"
|
||
else odds_band_ms_away)["win_rate"]
|
||
mkt_odds = ms_odds.get(odds_key, 0)
|
||
implied_prob = (1.0 / mkt_odds) if mkt_odds > 1.0 else 0.33
|
||
|
||
combined_prob = (v27_prob + band_rate) / 2.0 if v27_prob > 0 else band_rate
|
||
edge = combined_prob - implied_prob
|
||
band_sample = (odds_band_ms_home if band_key == "home"
|
||
else odds_band_ms_away)["sample"]
|
||
|
||
v27_confirms = v27_prob > implied_prob
|
||
band_confirms = band_rate > implied_prob
|
||
confirmation_count = sum([v27_confirms, band_confirms])
|
||
|
||
triple_value[outcome_key] = {
|
||
"v27_prob": round(v27_prob, 4),
|
||
"band_rate": round(band_rate, 4),
|
||
"implied_prob": round(implied_prob, 4),
|
||
"combined_prob": round(combined_prob, 4),
|
||
"edge": round(edge, 4),
|
||
"band_sample": band_sample,
|
||
"confirmations": confirmation_count,
|
||
"is_value": (
|
||
confirmation_count >= 2
|
||
and edge > 0.05
|
||
and band_sample >= 8
|
||
),
|
||
}
|
||
|
||
# OU25 triple value
|
||
ou25_over_odds = float((data.odds_data or {}).get("ou25_o", 0))
|
||
v27_ou25_over = (v27_ou25 or {}).get("over", 0) if v27_ou25 else 0
|
||
ou25_band_rate = odds_band_ou25["over_rate"]
|
||
ou25_implied = (1.0 / ou25_over_odds) if ou25_over_odds > 1.0 else 0.50
|
||
ou25_combined = (v27_ou25_over + ou25_band_rate) / 2.0 if v27_ou25_over > 0 else ou25_band_rate
|
||
ou25_edge = ou25_combined - ou25_implied
|
||
ou25_v27_confirms = v27_ou25_over > ou25_implied
|
||
ou25_band_confirms = ou25_band_rate > ou25_implied
|
||
ou25_conf_count = sum([ou25_v27_confirms, ou25_band_confirms])
|
||
|
||
triple_value["ou25_over"] = {
|
||
"v27_prob": round(v27_ou25_over, 4),
|
||
"band_rate": round(ou25_band_rate, 4),
|
||
"implied_prob": round(ou25_implied, 4),
|
||
"combined_prob": round(ou25_combined, 4),
|
||
"edge": round(ou25_edge, 4),
|
||
"band_sample": odds_band_ou25["sample"],
|
||
"confirmations": ou25_conf_count,
|
||
"is_value": (
|
||
ou25_conf_count >= 2
|
||
and ou25_edge > 0.05
|
||
and odds_band_ou25["sample"] >= 8
|
||
),
|
||
}
|
||
|
||
# BTTS triple value — now with V27 BTTS model
|
||
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']
|
||
|
||
# V27 BTTS model prediction (if available)
|
||
v27_btts = v27_preds.get('btts')
|
||
v27_btts_yes = (v27_btts or {}).get('yes', 0) if v27_btts else 0
|
||
|
||
if v27_btts_yes > 0:
|
||
btts_combined = (v27_btts_yes + btts_band_rate) / 2.0
|
||
else:
|
||
btts_combined = btts_band_rate
|
||
btts_edge = btts_combined - btts_implied
|
||
btts_band_confirms = btts_band_rate > btts_implied
|
||
btts_v27_confirms = v27_btts_yes > btts_implied if v27_btts_yes > 0 else False
|
||
btts_conf_count = sum([btts_v27_confirms, btts_band_confirms])
|
||
|
||
# BTTS divergence (V25 vs V27)
|
||
v25_btts_probs = {
|
||
'no': 1.0 - prediction.btts_yes_prob,
|
||
'yes': prediction.btts_yes_prob,
|
||
}
|
||
btts_divergence = compute_divergence(v25_btts_probs, v27_btts) if v27_btts else {}
|
||
btts_odds = {
|
||
'yes': float((data.odds_data or {}).get('btts_y', 0)),
|
||
'no': float((data.odds_data or {}).get('btts_n', 0)),
|
||
}
|
||
btts_value_edge = compute_value_edge(
|
||
v25_btts_probs, v27_btts, btts_odds,
|
||
) if v27_btts else {}
|
||
|
||
# DC divergence (derived from V27 MS probs)
|
||
v27_dc = v27_preds.get('dc')
|
||
dc_divergence = {}
|
||
dc_value_edge = {}
|
||
if v27_dc:
|
||
v25_dc_probs = {
|
||
'1x': prediction.ms_home_prob + prediction.ms_draw_prob,
|
||
'x2': prediction.ms_draw_prob + prediction.ms_away_prob,
|
||
'12': prediction.ms_home_prob + prediction.ms_away_prob,
|
||
}
|
||
dc_divergence = compute_divergence(v25_dc_probs, v27_dc)
|
||
dc_odds = {
|
||
'1x': float((data.odds_data or {}).get('dc_1x', 0)),
|
||
'x2': float((data.odds_data or {}).get('dc_x2', 0)),
|
||
'12': float((data.odds_data or {}).get('dc_12', 0)),
|
||
}
|
||
dc_value_edge = compute_value_edge(v25_dc_probs, v27_dc, dc_odds)
|
||
|
||
triple_value['btts_yes'] = {
|
||
'v27_prob': round(v27_btts_yes, 4),
|
||
'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': btts_conf_count,
|
||
'is_value': (
|
||
btts_conf_count >= 2
|
||
and btts_edge > 0.05
|
||
and odds_band_btts['sample'] >= 8
|
||
) if v27_btts_yes > 0 else (
|
||
btts_band_confirms
|
||
and btts_edge > 0.05
|
||
and odds_band_btts['sample'] >= 8
|
||
),
|
||
}
|
||
|
||
_odds_data = data.odds_data or {}
|
||
def _band_value(label, band_rate, odds_key, sample):
|
||
o = float(_odds_data.get(odds_key, 0))
|
||
imp = (1.0 / o) if o > 1.0 else 0.50
|
||
e = band_rate - imp
|
||
conf = band_rate > imp
|
||
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 {},
|
||
"btts": v27_btts or {},
|
||
"dc": v27_dc or {},
|
||
},
|
||
"divergence": {
|
||
"ms": ms_divergence,
|
||
"ou25": ou25_divergence,
|
||
"btts": btts_divergence,
|
||
"dc": dc_divergence,
|
||
},
|
||
"value_edge": {
|
||
"ms": ms_value,
|
||
"ou25": ou25_value,
|
||
"btts": btts_value_edge,
|
||
"dc": dc_value_edge,
|
||
},
|
||
"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.__getitem__)
|
||
v25_best_map = {"1": "home", "X": "draw", "2": "away"}
|
||
v25_best_mapped = v25_best_map.get(prediction.ms_pick, "")
|
||
if v27_best == v25_best_mapped:
|
||
# Engines agree → boost confidence by up to 5%
|
||
boost = min(5.0, abs(ms_divergence.get(v27_best, 0)) * 50)
|
||
# Additional boost if odds-band also confirms
|
||
band_val = triple_value.get(v25_best_mapped, {})
|
||
if band_val.get("is_value"):
|
||
boost = min(8.0, boost + 3.0) # Triple confirmation extra boost
|
||
prediction.ms_confidence = min(95.0, prediction.ms_confidence + boost)
|
||
market_board = base_package.get("market_board")
|
||
if isinstance(market_board, dict) and isinstance(market_board.get("MS"), dict):
|
||
market_board["MS"]["confidence"] = round(float(prediction.ms_confidence), 1)
|
||
base_package["v27_engine"]["consensus"] = "AGREE"
|
||
else:
|
||
base_package["v27_engine"]["consensus"] = "DISAGREE"
|
||
|
||
# Update analysis details
|
||
base_package.setdefault("analysis_details", {})
|
||
base_package["analysis_details"]["dual_engine"] = True
|
||
base_package["analysis_details"]["v27_loaded"] = True
|
||
base_package["analysis_details"]["odds_band_loaded"] = True
|
||
except Exception as e:
|
||
print(f"[V27] ⚠ Prediction failed (non-fatal): {e}")
|
||
base_package.setdefault("analysis_details", {})
|
||
base_package["analysis_details"]["v27_loaded"] = False
|
||
|
||
base_package = self._apply_upper_brain_guards(base_package)
|
||
|
||
# ── Match Commentary: human-readable summary ──────────────
|
||
try:
|
||
base_package["match_commentary"] = generate_match_commentary(base_package)
|
||
except Exception as e:
|
||
print(f"[Commentary] ⚠ Generation failed (non-fatal): {e}")
|
||
base_package["match_commentary"] = None
|
||
|
||
mode = str(getattr(self, "engine_mode", "v28-pro-max") or "v28-pro-max").lower()
|
||
if mode not in {"v25", "v26", "dual", "v28", "v28-pro-max"}:
|
||
mode = "v25"
|
||
|
||
quality = base_package.get("data_quality", self._compute_data_quality(data))
|
||
shadow_package = self._get_v26_shadow_engine().build_package(
|
||
data=data,
|
||
prediction=prediction,
|
||
v25_signal=v25_signal,
|
||
quality=quality,
|
||
)
|
||
|
||
if mode == "v26":
|
||
shadow_package["match_commentary"] = base_package.get("match_commentary")
|
||
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
|
||
|
||
|
||
_orchestrator: Optional[SingleMatchOrchestrator] = None
|
||
|
||
|
||
def get_single_match_orchestrator() -> SingleMatchOrchestrator:
|
||
global _orchestrator
|
||
if _orchestrator is None:
|
||
_orchestrator = SingleMatchOrchestrator()
|
||
return _orchestrator
|