Files
iddaai-be/ai-engine/services/single_match_orchestrator.py
fahricansecer 94c7a4481a
Deploy Iddaai Backend / build-and-deploy (push) Successful in 37s
main
2026-05-17 02:17:22 +03:00

714 lines
32 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Single Match Orchestrator (V20+)
================================
Primary prediction orchestration for frontend/live match clicks and automation.
Design goals:
- One authoritative match package contract.
- Scenario-consistent market board from a single prediction output.
- Data quality and risk tagging for consumer UX.
"""
from __future__ import annotations
import json
import re
import time
import math
import os
import pickle
import pandas as pd
import numpy as np
from collections import defaultdict
from 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