""" 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