1283 lines
49 KiB
Python
1283 lines
49 KiB
Python
"""
|
||
V20 Ensemble Beast - Main Predictor
|
||
Combines 4 prediction engines with surprise detection.
|
||
|
||
This is the primary interface for V20 predictions.
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import math
|
||
import json
|
||
import pickle
|
||
import time
|
||
import psycopg2
|
||
import pandas as pd
|
||
from typing import Dict, List, Optional, Tuple, Any
|
||
from dataclasses import dataclass, field
|
||
|
||
# Add paths
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
from core.engines.team_predictor import get_team_predictor
|
||
from core.engines.player_predictor import get_player_predictor
|
||
from core.engines.odds_predictor import get_odds_predictor
|
||
from core.engines.referee_predictor import get_referee_predictor
|
||
from features.upset_engine import get_upset_engine
|
||
from features.upset_engine_v2 import get_upset_engine_v2
|
||
from features.feature_adapter import get_feature_adapter
|
||
from utils.top_leagues import load_top_league_ids
|
||
from data.db import get_clean_dsn
|
||
import xgboost as xgb
|
||
from models.calibration import Calibrator
|
||
|
||
# New Config & Calculators
|
||
from config.config_loader import get_config
|
||
from core.calculators.base_calculator import CalculationContext
|
||
from core.calculators.match_result_calculator import MatchResultCalculator
|
||
from core.calculators.over_under_calculator import OverUnderCalculator
|
||
from core.calculators.half_time_calculator import HalfTimeCalculator
|
||
from core.calculators.score_calculator import ScoreCalculator, ScorePrediction
|
||
from core.calculators.other_markets_calculator import OtherMarketsCalculator
|
||
from core.calculators.risk_assessor import RiskAssessor
|
||
from core.calculators.bet_recommender import BetRecommender
|
||
|
||
|
||
class _BoosterModelAdapter:
|
||
"""Adapter to provide predict_proba interface for raw xgboost.Booster models."""
|
||
|
||
def __init__(self, booster: xgb.Booster):
|
||
self._booster = booster
|
||
|
||
def predict_proba(self, features: pd.DataFrame):
|
||
dmat = xgb.DMatrix(features)
|
||
preds = self._booster.predict(dmat)
|
||
if len(preds.shape) == 1:
|
||
# binary: return [P(class0), P(class1)]
|
||
return [[float(1.0 - p), float(p)] for p in preds]
|
||
# multiclass: already (n, k)
|
||
return preds
|
||
|
||
|
||
@dataclass
|
||
class MarketPrediction:
|
||
"""Prediction for a single betting market."""
|
||
market_type: str
|
||
pick: str
|
||
probability: float
|
||
confidence: float
|
||
odds: float = 0.0
|
||
is_recommended: bool = False
|
||
is_value_bet: bool = False
|
||
edge: float = 0.0 # Expected edge over market
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"market_type": self.market_type,
|
||
"pick": self.pick,
|
||
"probability": round(self.probability * 100, 1),
|
||
"confidence": round(self.confidence, 1),
|
||
"odds": self.odds,
|
||
"is_recommended": self.is_recommended,
|
||
"is_value_bet": self.is_value_bet,
|
||
"edge": round(self.edge, 1)
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class FullMatchPrediction:
|
||
"""Complete prediction for a match with ALL markets."""
|
||
match_id: str
|
||
home_team: str
|
||
away_team: str
|
||
match_date: str = ""
|
||
|
||
# === MAÇ SONUCU (1X2) ===
|
||
ms_home_prob: float = 0.33
|
||
ms_draw_prob: float = 0.33
|
||
ms_away_prob: float = 0.33
|
||
ms_pick: str = ""
|
||
ms_confidence: float = 0.0
|
||
|
||
# === ÇİFTE ŞANS ===
|
||
dc_1x_prob: float = 0.66
|
||
dc_x2_prob: float = 0.66
|
||
dc_12_prob: float = 0.66
|
||
dc_pick: str = ""
|
||
dc_confidence: float = 0.0
|
||
|
||
# === ALT/ÜST GOLLER ===
|
||
# 1.5
|
||
over_15_prob: float = 0.70
|
||
under_15_prob: float = 0.30
|
||
ou15_pick: str = ""
|
||
ou15_confidence: float = 0.0
|
||
|
||
# 2.5
|
||
over_25_prob: float = 0.50
|
||
under_25_prob: float = 0.50
|
||
ou25_pick: str = ""
|
||
ou25_confidence: float = 0.0
|
||
|
||
# 3.5
|
||
over_35_prob: float = 0.30
|
||
under_35_prob: float = 0.70
|
||
ou35_pick: str = ""
|
||
ou35_confidence: float = 0.0
|
||
|
||
# === KARŞILIKLI GOL (BTTS) ===
|
||
btts_yes_prob: float = 0.50
|
||
btts_no_prob: float = 0.50
|
||
btts_pick: str = ""
|
||
btts_confidence: float = 0.0
|
||
|
||
# === İLK YARI SONUCU ===
|
||
ht_home_prob: float = 0.30
|
||
ht_draw_prob: float = 0.40
|
||
ht_away_prob: float = 0.30
|
||
ht_pick: str = ""
|
||
ht_confidence: float = 0.0
|
||
|
||
# === SKOR TAHMİNLERİ ===
|
||
score: ScorePrediction = None
|
||
predicted_ft_score: str = "1-1"
|
||
predicted_ht_score: str = "0-0"
|
||
ft_scores_top5: List[Dict] = field(default_factory=list)
|
||
|
||
# === xG (Expected Goals) ===
|
||
home_xg: float = 1.3
|
||
away_xg: float = 1.1
|
||
total_xg: float = 2.4
|
||
|
||
# === RISK DEĞERLENDİRMESİ ===
|
||
risk_level: str = "MEDIUM" # LOW, MEDIUM, HIGH, EXTREME
|
||
risk_score: float = 0.0
|
||
is_surprise_risk: bool = False
|
||
surprise_type: str = ""
|
||
risk_warnings: List[str] = field(default_factory=list)
|
||
ht_ft_probs: Dict[str, float] = field(default_factory=dict)
|
||
|
||
# === GLM-5 SÜRPRİZ SKORU ===
|
||
upset_score: int = 0 # 0-100 arası sürpriz skoru
|
||
upset_level: str = "LOW" # LOW, MEDIUM, HIGH, EXTREME
|
||
upset_reasons: List[str] = field(default_factory=list)
|
||
|
||
# === ENGINE KATKILARI ===
|
||
team_confidence: float = 0.0
|
||
player_confidence: float = 0.0
|
||
odds_confidence: float = 0.0
|
||
referee_confidence: float = 0.0
|
||
|
||
# === KORNER & KART & DİĞER ===
|
||
total_corners_pred: float = 9.5
|
||
corner_pick: str = "9.5 Üst"
|
||
|
||
total_cards_pred: float = 4.5
|
||
card_pick: str = "4.5 Alt"
|
||
cards_over_prob: float = 0.50
|
||
cards_under_prob: float = 0.50
|
||
cards_confidence: float = 0.0
|
||
|
||
handicap_pick: str = ""
|
||
handicap_home_prob: float = 0.33
|
||
handicap_draw_prob: float = 0.34
|
||
handicap_away_prob: float = 0.33
|
||
handicap_confidence: float = 0.0
|
||
|
||
ht_over_05_prob: float = 0.65
|
||
ht_under_05_prob: float = 0.35
|
||
ht_over_15_prob: float = 0.30
|
||
ht_under_15_prob: float = 0.70
|
||
ht_ou_pick: str = "İY 0.5 Üst"
|
||
ht_ou15_pick: str = "İY 1.5 Alt"
|
||
|
||
odd_even_pick: str = "Çift"
|
||
odd_prob: float = 0.50 # Tek olasılığı
|
||
even_prob: float = 0.50 # Çift olasılığı
|
||
|
||
# === TAVSİYELER (RECOMMENDATIONS) ===
|
||
best_bet: Optional[MarketPrediction] = None
|
||
recommended_bets: List[MarketPrediction] = field(default_factory=list)
|
||
alternative_bet: Optional[MarketPrediction] = None
|
||
expert_recommendation: Dict[str, Any] = field(default_factory=dict)
|
||
|
||
# === DETAILED ANALYSIS ===
|
||
analysis_details: Dict[str, Any] = field(default_factory=dict)
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"match_info": {
|
||
"match_id": self.match_id,
|
||
"home_team": self.home_team,
|
||
"away_team": self.away_team,
|
||
"match_date": self.match_date
|
||
},
|
||
"predictions": {
|
||
"match_result": {
|
||
"1": round(self.ms_home_prob * 100, 1),
|
||
"X": round(self.ms_draw_prob * 100, 1),
|
||
"2": round(self.ms_away_prob * 100, 1),
|
||
"pick": self.ms_pick,
|
||
"confidence": round(self.ms_confidence, 1)
|
||
},
|
||
"double_chance": {
|
||
"1X": round(self.dc_1x_prob * 100, 1),
|
||
"X2": round(self.dc_x2_prob * 100, 1),
|
||
"12": round(self.dc_12_prob * 100, 1),
|
||
"pick": self.dc_pick,
|
||
"confidence": round(self.dc_confidence, 1)
|
||
},
|
||
"over_under": {
|
||
"1.5": {
|
||
"over": round(self.over_15_prob * 100, 1),
|
||
"under": round(self.under_15_prob * 100, 1),
|
||
"pick": self.ou15_pick,
|
||
"confidence": round(self.ou15_confidence, 1)
|
||
},
|
||
"2.5": {
|
||
"over": round(self.over_25_prob * 100, 1),
|
||
"under": round(self.under_25_prob * 100, 1),
|
||
"pick": self.ou25_pick,
|
||
"confidence": round(self.ou25_confidence, 1)
|
||
},
|
||
"3.5": {
|
||
"over": round(self.over_35_prob * 100, 1),
|
||
"under": round(self.under_35_prob * 100, 1),
|
||
"pick": self.ou35_pick,
|
||
"confidence": round(self.ou35_confidence, 1)
|
||
}
|
||
},
|
||
"btts": {
|
||
"yes": round(self.btts_yes_prob * 100, 1),
|
||
"no": round(self.btts_no_prob * 100, 1),
|
||
"pick": self.btts_pick,
|
||
"confidence": round(self.btts_confidence, 1)
|
||
},
|
||
"first_half": {
|
||
"1": round(self.ht_home_prob * 100, 1),
|
||
"X": round(self.ht_draw_prob * 100, 1),
|
||
"2": round(self.ht_away_prob * 100, 1),
|
||
"pick": self.ht_pick,
|
||
"confidence": round(self.ht_confidence, 1),
|
||
"over_under_05": {
|
||
"over": round(self.ht_over_05_prob * 100, 1),
|
||
"under": round(self.ht_under_05_prob * 100, 1),
|
||
"pick": self.ht_ou_pick
|
||
},
|
||
"over_under_15": {
|
||
"over": round(self.ht_over_15_prob * 100, 1),
|
||
"under": round(self.ht_under_15_prob * 100, 1),
|
||
"pick": self.ht_ou15_pick
|
||
}
|
||
},
|
||
"scores": {
|
||
"predicted_ft": self.predicted_ft_score,
|
||
"predicted_ht": self.predicted_ht_score,
|
||
"top_5_ft_scores": self.ft_scores_top5
|
||
},
|
||
"others": {
|
||
"handicap": {
|
||
"pick": self.handicap_pick,
|
||
"confidence": round(self.handicap_confidence, 1),
|
||
"home": round(self.handicap_home_prob * 100, 1),
|
||
"draw": round(self.handicap_draw_prob * 100, 1),
|
||
"away": round(self.handicap_away_prob * 100, 1)
|
||
},
|
||
"corners": {
|
||
"total": round(self.total_corners_pred, 1),
|
||
"pick": self.corner_pick
|
||
},
|
||
"cards": {
|
||
"total": round(self.total_cards_pred, 1),
|
||
"pick": self.card_pick,
|
||
"confidence": round(self.cards_confidence, 1),
|
||
"over": round(self.cards_over_prob * 100, 1),
|
||
"under": round(self.cards_under_prob * 100, 1)
|
||
},
|
||
"odd_even": {
|
||
"pick": self.odd_even_pick,
|
||
"tek": round(self.odd_prob * 100, 1),
|
||
"cift": round(self.even_prob * 100, 1)
|
||
}
|
||
},
|
||
"xg": {
|
||
"home": round(self.home_xg, 2),
|
||
"away": round(self.away_xg, 2),
|
||
"total": round(self.total_xg, 2)
|
||
}
|
||
},
|
||
"risk": {
|
||
"level": self.risk_level,
|
||
"score": round(self.risk_score, 1),
|
||
"is_surprise_risk": self.is_surprise_risk,
|
||
"surprise_type": self.surprise_type,
|
||
"ht_ft_probs": {k: round(v * 100, 1) for k, v in self.ht_ft_probs.items()} if self.ht_ft_probs else {},
|
||
"warnings": self.risk_warnings
|
||
},
|
||
"upset_analysis": {
|
||
"score": self.upset_score,
|
||
"level": self.upset_level,
|
||
"reasons": self.upset_reasons
|
||
},
|
||
"engine_breakdown": {
|
||
"team_engine": round(self.team_confidence, 1),
|
||
"player_engine": round(self.player_confidence, 1),
|
||
"odds_engine": round(self.odds_confidence, 1),
|
||
"referee_engine": round(self.referee_confidence, 1)
|
||
},
|
||
"recommendations": {
|
||
"best_bet": self.best_bet.to_dict() if self.best_bet else None,
|
||
"all_recommended": [b.to_dict() for b in self.recommended_bets] if self.recommended_bets else [],
|
||
"alternative_bet": self.alternative_bet.to_dict() if self.alternative_bet else None
|
||
},
|
||
"analysis_details": self.analysis_details
|
||
}
|
||
|
||
|
||
class V20EnsemblePredictor:
|
||
HTFT_LABELS = ("1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2")
|
||
# Neutral defaults when MS odds are missing: avoid synthetic home-favorite bias.
|
||
DEFAULT_MS_H = 2.65
|
||
DEFAULT_MS_D = 3.20
|
||
DEFAULT_MS_A = 2.65
|
||
FOOTBALL_TOP_PRIOR = (
|
||
0.263760,
|
||
0.051786,
|
||
0.022942,
|
||
0.150168,
|
||
0.157798,
|
||
0.106064,
|
||
0.027622,
|
||
0.051226,
|
||
0.168634,
|
||
)
|
||
FOOTBALL_NON_TOP_PRIOR = (
|
||
0.265113,
|
||
0.048306,
|
||
0.020399,
|
||
0.147020,
|
||
0.152383,
|
||
0.113075,
|
||
0.026542,
|
||
0.046356,
|
||
0.180805,
|
||
)
|
||
# Top-league football priors conditioned on favorite side from MS (1X2) odds.
|
||
# Label order follows HTFT_LABELS.
|
||
FOOTBALL_TOP_PRIOR_HOME_FAV = (
|
||
0.321707,
|
||
0.054165,
|
||
0.017952,
|
||
0.179729,
|
||
0.161674,
|
||
0.078991,
|
||
0.031186,
|
||
0.047394,
|
||
0.107201,
|
||
)
|
||
FOOTBALL_TOP_PRIOR_AWAY_FAV = (
|
||
0.130654,
|
||
0.049139,
|
||
0.033754,
|
||
0.081975,
|
||
0.156142,
|
||
0.167164,
|
||
0.020207,
|
||
0.058324,
|
||
0.302641,
|
||
)
|
||
FOOTBALL_TOP_PRIOR_BALANCED = (
|
||
0.169429,
|
||
0.052486,
|
||
0.028545,
|
||
0.144567,
|
||
0.209024,
|
||
0.116943,
|
||
0.026703,
|
||
0.053407,
|
||
0.198895,
|
||
)
|
||
|
||
def __init__(self):
|
||
print("🚀 Initializing V20 Ensemble Beast...")
|
||
self.config = get_config()
|
||
|
||
# Engines
|
||
self.team_engine = get_team_predictor()
|
||
self.player_engine = get_player_predictor()
|
||
self.odds_engine = get_odds_predictor()
|
||
self.referee_engine = get_referee_predictor()
|
||
self.upset_engine = get_upset_engine()
|
||
self.upset_engine_v2 = get_upset_engine_v2() # GLM-5 enhanced
|
||
|
||
# Calculators
|
||
print("⚙️ Loading market calculators...")
|
||
self.match_result_calc = MatchResultCalculator(self.config)
|
||
self.over_under_calc = OverUnderCalculator(self.config)
|
||
self.half_time_calc = HalfTimeCalculator(self.config)
|
||
self.score_calc = ScoreCalculator(self.config)
|
||
print(" ✅ Score Calculator (XGBoost FT+HT) loaded")
|
||
self.other_markets_calc = OtherMarketsCalculator(self.config)
|
||
self.risk_assessor = RiskAssessor(self.config)
|
||
self.bet_recommender = BetRecommender(self.config)
|
||
|
||
# Expert Recommender (New Logic)
|
||
from core.calculators.expert_recommender import ExpertRecommender
|
||
self.expert_recommender = ExpertRecommender(self.config)
|
||
|
||
# XGBoost Integration
|
||
print("🤖 Loading XGBoost models...")
|
||
self.feature_adapter = get_feature_adapter()
|
||
self.calibrator = Calibrator()
|
||
self.xgb_models = {}
|
||
self.top_league_ids = load_top_league_ids()
|
||
print(f"📋 Loaded {len(self.top_league_ids)} top leagues for HT/FT tuning")
|
||
self.db_dsn = get_clean_dsn()
|
||
self.league_htft_prior_cache: Dict[Tuple[str, str], Optional[Tuple[float, ...]]] = {}
|
||
|
||
xgb_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "xgboost")
|
||
model_files = {
|
||
"ms": "xgb_ms",
|
||
"ou25": "xgb_ou25",
|
||
"btts": "xgb_btts",
|
||
"ht_ft": "xgb_ht_ft",
|
||
"ht_result": "xgb_ht_result",
|
||
"ht_ou05": "xgb_ht_ou05",
|
||
"ht_ou15": "xgb_ht_ou15",
|
||
"odd_even": "xgb_odd_even",
|
||
"ou15": "xgb_ou15",
|
||
"ou35": "xgb_ou35",
|
||
"handicap_ms": "xgb_handicap_ms",
|
||
"cards_ou45": "xgb_cards_ou45",
|
||
}
|
||
|
||
only_keys = os.getenv("XGB_MODEL_KEYS", "").strip()
|
||
if only_keys:
|
||
selected_keys = {k.strip().lower() for k in only_keys.split(",") if k.strip()}
|
||
model_files = {k: v for k, v in model_files.items() if k in selected_keys}
|
||
if model_files:
|
||
print(f"ℹ️ XGB_MODEL_KEYS active -> loading only: {', '.join(sorted(model_files.keys()))}")
|
||
else:
|
||
print("⚠️ XGB_MODEL_KEYS set but no valid keys matched. Loading none.")
|
||
|
||
for key, base_name in model_files.items():
|
||
print(f" ⏳ Loading {key} from {base_name}.pkl/.json...", flush=True)
|
||
model, src, err = self._load_xgb_model(xgb_dir, base_name)
|
||
if model is not None:
|
||
self.xgb_models[key] = model
|
||
print(f" ✅ Loaded {key} ({src})")
|
||
elif err:
|
||
print(f" ⚠️ Failed to load {base_name}: {err}")
|
||
else:
|
||
print(f" ⚠️ Model not found: {base_name}.pkl or {base_name}.json")
|
||
|
||
print("✅ V20 Ensemble Beast ready!")
|
||
|
||
@staticmethod
|
||
def _load_xgb_model(xgb_dir: str, base_name: str):
|
||
pkl_path = os.path.join(xgb_dir, f"{base_name}.pkl")
|
||
json_path = os.path.join(xgb_dir, f"{base_name}.json")
|
||
|
||
if os.path.exists(pkl_path):
|
||
started = time.perf_counter()
|
||
with open(pkl_path, "rb") as f:
|
||
model = pickle.load(f)
|
||
elapsed = time.perf_counter() - started
|
||
return model, f"pkl {elapsed:.2f}s", None
|
||
|
||
if os.path.exists(json_path):
|
||
started = time.perf_counter()
|
||
# Preferred path: sklearn wrapper with predict_proba
|
||
try:
|
||
model = xgb.XGBClassifier()
|
||
model.load_model(json_path)
|
||
elapsed = time.perf_counter() - started
|
||
return model, f"json {elapsed:.2f}s", None
|
||
except Exception:
|
||
# Fallback: raw Booster + adapter
|
||
try:
|
||
booster = xgb.Booster()
|
||
booster.load_model(json_path)
|
||
model = _BoosterModelAdapter(booster)
|
||
elapsed = time.perf_counter() - started
|
||
return model, f"json/booster {elapsed:.2f}s", None
|
||
except Exception as e:
|
||
return None, "", e
|
||
|
||
return None, "", None
|
||
|
||
@staticmethod
|
||
def _safe_odd(value: Any) -> float:
|
||
try:
|
||
odd = float(value)
|
||
return odd if odd > 1.01 else 0.0
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
@staticmethod
|
||
def _align_features(features: pd.DataFrame, model) -> pd.DataFrame:
|
||
"""Align DataFrame columns to the model's expected feature set.
|
||
|
||
Supports:
|
||
- sklearn wrappers (XGBClassifier / LGBMClassifier) → feature_names_in_
|
||
- raw xgboost.Booster → feature_names
|
||
- _BoosterModelAdapter → _booster.feature_names
|
||
|
||
If the model doesn't expose feature names, returns the DataFrame as-is.
|
||
"""
|
||
expected: Optional[List[str]] = None
|
||
|
||
# 1. sklearn wrapper (XGBClassifier, LGBMClassifier, CalibratedClassifierCV)
|
||
if hasattr(model, 'feature_names_in_'):
|
||
expected = list(model.feature_names_in_)
|
||
# 2. _BoosterModelAdapter
|
||
elif hasattr(model, '_booster') and hasattr(model._booster, 'feature_names'):
|
||
expected = model._booster.feature_names
|
||
# 3. raw xgboost.Booster
|
||
elif hasattr(model, 'feature_names') and model.feature_names:
|
||
expected = list(model.feature_names)
|
||
|
||
if expected is None:
|
||
return features
|
||
|
||
# Only keep columns that the model expects (order preserved)
|
||
available = [col for col in expected if col in features.columns]
|
||
if len(available) < len(expected):
|
||
missing = set(expected) - set(available)
|
||
print(f"⚠️ Feature alignment: {len(missing)} missing features filled with 0: {sorted(missing)[:5]}{'...' if len(missing) > 5 else ''}")
|
||
# Add missing columns with 0 (safe neutral default)
|
||
for col in expected:
|
||
if col not in features.columns:
|
||
features = features.copy()
|
||
features[col] = 0.0
|
||
|
||
return features[expected]
|
||
|
||
def _favorite_profile_from_odds(self, odds_data: Dict[str, float]) -> Tuple[str, float]:
|
||
"""
|
||
Returns (favorite_side, gap_to_second_favorite).
|
||
favorite_side: H, A, D, or U (unknown)
|
||
"""
|
||
ms_h = self._safe_odd((odds_data or {}).get("ms_h"))
|
||
ms_d = self._safe_odd((odds_data or {}).get("ms_d"))
|
||
ms_a = self._safe_odd((odds_data or {}).get("ms_a"))
|
||
|
||
candidates = [(side, odd) for side, odd in (("H", ms_h), ("D", ms_d), ("A", ms_a)) if odd > 0.0]
|
||
if len(candidates) < 2:
|
||
return "U", 0.0
|
||
|
||
candidates.sort(key=lambda item: item[1])
|
||
favorite_side, favorite_odd = candidates[0]
|
||
second_odd = candidates[1][1]
|
||
return favorite_side, max(0.0, second_odd - favorite_odd)
|
||
|
||
def _favorite_side_from_ms_odds(
|
||
self,
|
||
odds_data: Dict[str, float],
|
||
) -> str:
|
||
"""
|
||
Returns side from MS home/away odds only:
|
||
- H: home favorite
|
||
- A: away favorite
|
||
- B: balanced (home and away near-equal)
|
||
- U: unknown
|
||
"""
|
||
ms_h = self._safe_odd((odds_data or {}).get("ms_h"))
|
||
ms_a = self._safe_odd((odds_data or {}).get("ms_a"))
|
||
if ms_h <= 0.0 or ms_a <= 0.0:
|
||
return "U"
|
||
|
||
balance_gap = float(self.config.get("risk.htft_favorite_balance_gap", 0.20))
|
||
if abs(ms_h - ms_a) <= balance_gap:
|
||
return "B"
|
||
return "H" if ms_h < ms_a else "A"
|
||
|
||
def _get_top_odds_conditioned_prior(
|
||
self,
|
||
odds_data: Dict[str, float],
|
||
) -> Optional[Tuple[float, ...]]:
|
||
side = self._favorite_side_from_ms_odds(odds_data)
|
||
if side == "H":
|
||
return self.FOOTBALL_TOP_PRIOR_HOME_FAV
|
||
if side == "A":
|
||
return self.FOOTBALL_TOP_PRIOR_AWAY_FAV
|
||
if side == "B":
|
||
return self.FOOTBALL_TOP_PRIOR_BALANCED
|
||
return None
|
||
|
||
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 _get_htft_league_prior(
|
||
self,
|
||
league_id: Optional[str],
|
||
sport: str,
|
||
) -> Optional[Tuple[float, ...]]:
|
||
sport_key = (sport or "").lower().strip()
|
||
if sport_key != "football" or not league_id:
|
||
return None
|
||
|
||
cache_key = (sport_key, str(league_id))
|
||
if cache_key in self.league_htft_prior_cache:
|
||
return self.league_htft_prior_cache[cache_key]
|
||
|
||
min_samples = int(self.config.get("risk.htft_prior_min_matches", 300))
|
||
combo_counts = {label: 0 for label in self.HTFT_LABELS}
|
||
try:
|
||
with psycopg2.connect(self.db_dsn) as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"""
|
||
WITH base AS (
|
||
SELECT
|
||
CASE WHEN ht_score_home > ht_score_away THEN '1'
|
||
WHEN ht_score_home = ht_score_away THEN 'X'
|
||
ELSE '2' END AS ht,
|
||
CASE WHEN score_home > score_away THEN '1'
|
||
WHEN score_home = score_away THEN 'X'
|
||
ELSE '2' END AS ft
|
||
FROM matches
|
||
WHERE status = 'FT'
|
||
AND sport = %s
|
||
AND league_id = %s
|
||
AND ht_score_home IS NOT NULL
|
||
AND ht_score_away IS NOT NULL
|
||
AND score_home IS NOT NULL
|
||
AND score_away IS NOT NULL
|
||
)
|
||
SELECT ht || '/' || ft AS combo, COUNT(*)::bigint AS n
|
||
FROM base
|
||
GROUP BY combo
|
||
""",
|
||
(sport_key, str(league_id)),
|
||
)
|
||
rows = cur.fetchall()
|
||
except Exception:
|
||
self.league_htft_prior_cache[cache_key] = None
|
||
return None
|
||
|
||
total = 0
|
||
for combo, n in rows:
|
||
if combo in combo_counts:
|
||
combo_counts[combo] = int(n)
|
||
total += int(n)
|
||
|
||
if total < min_samples:
|
||
self.league_htft_prior_cache[cache_key] = None
|
||
return None
|
||
|
||
prior = tuple(combo_counts[label] / total for label in self.HTFT_LABELS)
|
||
self.league_htft_prior_cache[cache_key] = prior
|
||
return prior
|
||
|
||
def _postprocess_htft_probs(
|
||
self,
|
||
raw_probs: List[float],
|
||
odds_data: Optional[Dict[str, float]] = None,
|
||
sport: str = "football",
|
||
is_top_league: bool = False,
|
||
league_id: Optional[str] = None,
|
||
) -> List[float]:
|
||
"""
|
||
Stabilize HT/FT class probabilities.
|
||
|
||
Why:
|
||
- HT/FT reversals (1/2, 2/1) are rare and can be overestimated.
|
||
- We preserve ranking signal but make absolute probabilities conservative.
|
||
"""
|
||
probs = [max(1e-9, float(p)) for p in raw_probs[:9]]
|
||
if len(probs) != 9:
|
||
return [1.0 / 9.0] * 9
|
||
|
||
# Global calibration pass for HT/FT market.
|
||
probs = [self.calibrator.calibrate("ht_ft", p) for p in probs]
|
||
|
||
sport_key = (sport or "football").lower().strip()
|
||
|
||
# Temperature > 1.0 flattens over-confident distributions.
|
||
if sport_key == "basketball":
|
||
if is_top_league:
|
||
temperature = float(
|
||
self.config.get("risk.htft_temperature_basketball_top", self.config.get("risk.htft_temperature_basketball", 1.08)),
|
||
)
|
||
else:
|
||
temperature = float(
|
||
self.config.get("risk.htft_temperature_basketball_non_top", 1.15),
|
||
)
|
||
else:
|
||
if is_top_league:
|
||
temperature = float(
|
||
self.config.get("risk.htft_temperature_top", self.config.get("risk.htft_temperature", 1.25)),
|
||
)
|
||
else:
|
||
temperature = float(
|
||
self.config.get("risk.htft_temperature_non_top", 1.35),
|
||
)
|
||
if temperature > 1.0:
|
||
inv_t = 1.0 / temperature
|
||
probs = [p**inv_t for p in probs]
|
||
|
||
# Extra damping for reversal classes: 1/2 (idx 2), 2/1 (idx 6).
|
||
if is_top_league:
|
||
base_reversal_multiplier = float(
|
||
self.config.get("risk.htft_reversal_multiplier_top", self.config.get("risk.htft_reversal_multiplier", 0.60)),
|
||
)
|
||
favorite_reversal_multiplier = float(
|
||
self.config.get(
|
||
"risk.htft_reversal_multiplier_favorite_top",
|
||
self.config.get("risk.htft_reversal_multiplier_favorite", 0.72),
|
||
),
|
||
)
|
||
underdog_reversal_multiplier = float(
|
||
self.config.get(
|
||
"risk.htft_reversal_multiplier_underdog_top",
|
||
self.config.get("risk.htft_reversal_multiplier_underdog", 0.45),
|
||
),
|
||
)
|
||
basketball_reversal_multiplier = float(
|
||
self.config.get(
|
||
"risk.htft_reversal_multiplier_basketball_top",
|
||
self.config.get("risk.htft_reversal_multiplier_basketball", 0.90),
|
||
),
|
||
)
|
||
else:
|
||
base_reversal_multiplier = float(self.config.get("risk.htft_reversal_multiplier_non_top", 0.45))
|
||
favorite_reversal_multiplier = float(
|
||
self.config.get("risk.htft_reversal_multiplier_favorite_non_top", 0.55),
|
||
)
|
||
underdog_reversal_multiplier = float(
|
||
self.config.get("risk.htft_reversal_multiplier_underdog_non_top", 0.30),
|
||
)
|
||
basketball_reversal_multiplier = float(
|
||
self.config.get("risk.htft_reversal_multiplier_basketball_non_top", 0.75),
|
||
)
|
||
gap_medium = float(self.config.get("risk.htft_reversal_gap_medium", 0.50))
|
||
gap_strong = float(self.config.get("risk.htft_reversal_gap_strong", 1.00))
|
||
|
||
favorite_side, favorite_gap = self._favorite_profile_from_odds(odds_data or {})
|
||
|
||
def _reversal_multiplier(winner_side: str) -> float:
|
||
if sport_key == "basketball":
|
||
return basketball_reversal_multiplier
|
||
|
||
multiplier = base_reversal_multiplier
|
||
if favorite_side in ("H", "A"):
|
||
multiplier = (
|
||
favorite_reversal_multiplier
|
||
if winner_side == favorite_side
|
||
else underdog_reversal_multiplier
|
||
)
|
||
|
||
# If market heavily favors one side, penalize underdog-reversal harder.
|
||
if winner_side != favorite_side and favorite_gap >= gap_strong:
|
||
multiplier *= 0.80
|
||
elif winner_side != favorite_side and favorite_gap >= gap_medium:
|
||
multiplier *= 0.90
|
||
|
||
return max(0.20, min(1.10, multiplier))
|
||
|
||
# 1/2 => winner is Away, 2/1 => winner is Home
|
||
probs[2] *= _reversal_multiplier("A")
|
||
probs[6] *= _reversal_multiplier("H")
|
||
|
||
# Prior blend for football (league-specific if sufficient sample size).
|
||
if sport_key == "football":
|
||
league_prior = self._get_htft_league_prior(league_id=league_id, sport=sport_key)
|
||
if league_prior is not None:
|
||
prior = league_prior
|
||
blend = float(self.config.get("risk.htft_prior_blend_league", 0.65))
|
||
else:
|
||
prior = self.FOOTBALL_TOP_PRIOR if is_top_league else self.FOOTBALL_NON_TOP_PRIOR
|
||
blend = float(
|
||
self.config.get(
|
||
"risk.htft_prior_blend_top" if is_top_league else "risk.htft_prior_blend_non_top",
|
||
0.50 if is_top_league else 0.58,
|
||
),
|
||
)
|
||
|
||
if is_top_league:
|
||
side_prior = self._get_top_odds_conditioned_prior(odds_data or {})
|
||
if side_prior is not None:
|
||
if league_prior is not None:
|
||
odds_prior_blend = float(
|
||
self.config.get("risk.htft_prior_odds_blend_top_with_league", 0.22),
|
||
)
|
||
else:
|
||
odds_prior_blend = float(
|
||
self.config.get("risk.htft_prior_odds_blend_top", 0.35),
|
||
)
|
||
odds_prior_blend = max(0.0, min(0.80, odds_prior_blend))
|
||
prior = tuple(
|
||
((1.0 - odds_prior_blend) * prior[idx]) + (odds_prior_blend * side_prior[idx])
|
||
for idx in range(9)
|
||
)
|
||
|
||
blend = max(0.0, min(0.95, blend))
|
||
probs = [((1.0 - blend) * p) + (blend * prior[idx]) for idx, p in enumerate(probs)]
|
||
|
||
# Hard cap reversal classes by prior factor to avoid unrealistic spikes.
|
||
cap_factor = float(self.config.get("risk.htft_reversal_cap_factor", 2.3))
|
||
cap_factor = max(1.0, cap_factor)
|
||
for idx in (2, 6):
|
||
cap_val = prior[idx] * cap_factor
|
||
if probs[idx] > cap_val:
|
||
probs[idx] = cap_val
|
||
|
||
total = sum(probs)
|
||
if total <= 0:
|
||
return [1.0 / 9.0] * 9
|
||
|
||
return [p / total for p in probs]
|
||
|
||
def predict(self,
|
||
match_id: str,
|
||
home_team_id: str,
|
||
away_team_id: str,
|
||
home_team_name: str,
|
||
away_team_name: str,
|
||
match_date_ms: int,
|
||
odds_data: Dict[str, float] = None,
|
||
home_lineup: List[str] = None,
|
||
away_lineup: List[str] = None,
|
||
referee_name: str = None,
|
||
home_goals_avg: float = 1.5,
|
||
home_conceded_avg: float = 1.2,
|
||
away_goals_avg: float = 1.2,
|
||
away_conceded_avg: float = 1.4,
|
||
home_position: int = 10,
|
||
away_position: int = 10,
|
||
league_name: str = "",
|
||
league_id: str = None,
|
||
sport: str = "football",
|
||
sidelined_data: Dict = None) -> FullMatchPrediction:
|
||
"""
|
||
Generate complete V20 ensemble prediction.
|
||
|
||
Returns FullMatchPrediction with ALL markets.
|
||
"""
|
||
|
||
# Default odds if not provided
|
||
if odds_data is None:
|
||
odds_data = {
|
||
"ms_h": self.DEFAULT_MS_H,
|
||
"ms_d": self.DEFAULT_MS_D,
|
||
"ms_a": self.DEFAULT_MS_A,
|
||
}
|
||
|
||
# === 1. COLLECT ALL ENGINE PREDICTIONS ===
|
||
|
||
team_pred = self.team_engine.predict(
|
||
home_team_id=home_team_id,
|
||
away_team_id=away_team_id,
|
||
match_date_ms=match_date_ms,
|
||
home_team_name=home_team_name,
|
||
away_team_name=away_team_name
|
||
)
|
||
|
||
player_pred = self.player_engine.predict(
|
||
match_id=match_id,
|
||
home_team_id=home_team_id,
|
||
away_team_id=away_team_id,
|
||
home_lineup=home_lineup,
|
||
away_lineup=away_lineup,
|
||
sidelined_data=sidelined_data
|
||
)
|
||
|
||
odds_pred = self.odds_engine.predict(
|
||
odds_data=odds_data,
|
||
home_goals_avg=home_goals_avg,
|
||
home_conceded_avg=home_conceded_avg,
|
||
away_goals_avg=away_goals_avg,
|
||
away_conceded_avg=away_conceded_avg
|
||
)
|
||
|
||
referee_pred = self.referee_engine.predict(
|
||
match_id=match_id,
|
||
referee_name=referee_name,
|
||
league_id=league_id
|
||
)
|
||
|
||
upset_factors = self.upset_engine.calculate_upset_potential(
|
||
home_team_name=home_team_name,
|
||
home_team_id=home_team_id,
|
||
away_team_name=away_team_name,
|
||
league_name=league_name,
|
||
home_position=home_position,
|
||
away_position=away_position,
|
||
match_date_ms=match_date_ms
|
||
)
|
||
|
||
# GLM-5 Enhanced Upset Detection v2
|
||
# Determine favorite from odds
|
||
favorite_side = "home"
|
||
favorite_odds = odds_data.get("ms_h", 2.0) if odds_data else 2.0
|
||
if odds_data:
|
||
ms_h = odds_data.get("ms_h", 999)
|
||
ms_a = odds_data.get("ms_a", 999)
|
||
if ms_a < ms_h:
|
||
favorite_side = "away"
|
||
favorite_odds = ms_a
|
||
elif ms_h < ms_a:
|
||
favorite_side = "home"
|
||
favorite_odds = ms_h
|
||
else:
|
||
favorite_side = "draw"
|
||
favorite_odds = odds_data.get("ms_d", 3.0)
|
||
|
||
upset_factors_v2 = self.upset_engine_v2.calculate_upset_potential(
|
||
home_team_name=home_team_name,
|
||
home_team_id=home_team_id,
|
||
away_team_name=away_team_name,
|
||
league_name=league_name,
|
||
home_position=home_position,
|
||
away_position=away_position,
|
||
match_date_ms=match_date_ms,
|
||
odds_data=odds_data,
|
||
referee_name=referee_name,
|
||
home_form_score=team_pred.home_form_score if hasattr(team_pred, 'home_form_score') else 50.0,
|
||
away_form_score=team_pred.away_form_score if hasattr(team_pred, 'away_form_score') else 50.0,
|
||
favorite_side=favorite_side,
|
||
favorite_odds=favorite_odds
|
||
)
|
||
|
||
# === 2. DYNAMIC ENGINE WEIGHTS ===
|
||
w_team = self.config.get("engine_weights.team", 0.30)
|
||
w_player = self.config.get("engine_weights.player", 0.25)
|
||
w_odds = self.config.get("engine_weights.odds", 0.30)
|
||
w_referee = self.config.get("engine_weights.referee", 0.15)
|
||
|
||
# Redistribution Logic
|
||
if not player_pred.lineup_available:
|
||
min_w = self.config.get("engine_weights.min_weight", 0.05)
|
||
surplus = w_player - min_w
|
||
w_player = min_w
|
||
w_team += surplus * self.config.get("weight_redistribution.player_missing_to_team", 0.5)
|
||
w_odds += surplus * self.config.get("weight_redistribution.player_missing_to_odds", 0.5)
|
||
|
||
min_ref_matches = self.config.get("weight_redistribution.referee_min_matches", 5)
|
||
if referee_pred.matches_officiated < min_ref_matches:
|
||
min_w = self.config.get("engine_weights.min_weight", 0.05)
|
||
surplus = w_referee - min_w
|
||
w_referee = min_w
|
||
w_team += surplus * self.config.get("weight_redistribution.referee_missing_to_team", 0.4)
|
||
w_odds += surplus * self.config.get("weight_redistribution.referee_missing_to_odds", 0.6)
|
||
|
||
# Normalize
|
||
w_total = w_team + w_player + w_odds + w_referee
|
||
weights = {
|
||
"team": w_team / w_total,
|
||
"player": w_player / w_total,
|
||
"odds": w_odds / w_total,
|
||
"referee": w_referee / w_total
|
||
}
|
||
|
||
# Get Modifiers
|
||
player_mods = self.player_engine.get_1x2_modifier(player_pred)
|
||
referee_mods = self.referee_engine.get_modifiers(referee_pred)
|
||
|
||
# Calculate xG (Used by multiple calculators)
|
||
home_xg = (team_pred.home_xg + odds_pred.poisson_home_xg) / 2
|
||
away_xg = (team_pred.away_xg + odds_pred.poisson_away_xg) / 2
|
||
|
||
# === 3. CREATE CONTEXT ===
|
||
ctx = CalculationContext(
|
||
team_pred=team_pred,
|
||
player_pred=player_pred,
|
||
odds_pred=odds_pred,
|
||
referee_pred=referee_pred,
|
||
upset_factors=upset_factors,
|
||
weights=weights,
|
||
player_mods=player_mods,
|
||
referee_mods=referee_mods,
|
||
match_id=match_id,
|
||
home_team_name=home_team_name,
|
||
away_team_name=away_team_name,
|
||
odds_data=odds_data,
|
||
home_xg=home_xg,
|
||
away_xg=away_xg,
|
||
total_xg=home_xg + away_xg,
|
||
league_id=league_id,
|
||
sport=(sport or "football").lower().strip(),
|
||
is_top_league=self._is_top_league(league_id),
|
||
)
|
||
|
||
# === 4. XGBOOST INFERENCE ===
|
||
try:
|
||
# Prepare features (1 row DataFrame)
|
||
xgb_features = self.feature_adapter.get_features(ctx)
|
||
|
||
# Predict — per-model feature alignment
|
||
for key, model in self.xgb_models.items():
|
||
try:
|
||
model_features = self._align_features(xgb_features, model)
|
||
raw_pred = model.predict_proba(model_features)
|
||
except Exception as model_err:
|
||
print(f"⚠️ XGBoost {key} inference failed: {model_err}")
|
||
continue
|
||
|
||
# Handle multi-class (MS, HT_RESULT, HT/FT) vs binary
|
||
if key in ("ms", "ht_result"):
|
||
# raw_pred is (1, 3)
|
||
probs = raw_pred[0] # [Home, Draw, Away]
|
||
ctx.xgboost_preds[key] = {
|
||
"home": float(probs[0]),
|
||
"draw": float(probs[1]),
|
||
"away": float(probs[2])
|
||
}
|
||
elif key == "handicap_ms":
|
||
probs = raw_pred[0] # [H1, HX, H2]
|
||
ctx.xgboost_preds[key] = {
|
||
"h1": float(probs[0]),
|
||
"hx": float(probs[1]),
|
||
"h2": float(probs[2])
|
||
}
|
||
elif key == "ht_ft":
|
||
# raw_pred is (1, 9)
|
||
raw_probs = [float(p) for p in raw_pred[0]]
|
||
probs = self._postprocess_htft_probs(
|
||
raw_probs,
|
||
odds_data=odds_data,
|
||
sport=sport,
|
||
is_top_league=ctx.is_top_league,
|
||
league_id=league_id,
|
||
)
|
||
ctx.xgboost_preds[key] = {
|
||
label: float(probs[idx]) for idx, label in enumerate(self.HTFT_LABELS)
|
||
}
|
||
# Keep raw vector for optional calculators/debug consumers.
|
||
ctx.xgboost_preds["ht_ft_raw"] = raw_probs
|
||
else:
|
||
# Binary (OU/BTTS) - index 1 is the positive class probability
|
||
prob = float(raw_pred[0][1])
|
||
ctx.xgboost_preds[key] = prob
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ XGBoost Inference Failed: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
# === 5. RUN CALCULATORS ===
|
||
ms_result = self.match_result_calc.calculate(ctx)
|
||
ou_result = self.over_under_calc.calculate(ctx)
|
||
ht_result = self.half_time_calc.calculate(ctx)
|
||
score_result = self.score_calc.calculate(ctx, ms_result)
|
||
other_result = self.other_markets_calc.calculate(ctx, ms_result)
|
||
risk_result = self.risk_assessor.calculate(ctx, ms_result)
|
||
|
||
# Use Reconciled Result
|
||
final_ms = score_result.reconciled_ms if score_result.reconciled_ms else ms_result
|
||
|
||
# Expert Recommendation (New Logic)
|
||
expert_result = self.expert_recommender.calculate(ctx, final_ms, ou_result, risk_result)
|
||
expert_data = {}
|
||
if expert_result:
|
||
expert_data = {
|
||
"main_pick": expert_result.main_pick,
|
||
"safe_alternative": expert_result.safe_alternative,
|
||
"value_picks": expert_result.value_picks,
|
||
"surprise_picks": expert_result.surprise_picks,
|
||
"market_summary": expert_result.market_summary
|
||
}
|
||
|
||
# Update context with risk info for recommender
|
||
ctx.risk_level = risk_result.risk_level
|
||
ctx.is_surprise = risk_result.is_surprise_risk
|
||
|
||
rec_result = self.bet_recommender.calculate(ctx, final_ms, ou_result, risk_result)
|
||
|
||
# === 5. ASSEMBLE PREDICTION ===
|
||
|
||
# Map MarketPredictionDTO to internal MarketPrediction
|
||
def _map_dto(dto):
|
||
if not dto: return None
|
||
return MarketPrediction(
|
||
market_type=dto.market_type,
|
||
pick=dto.pick,
|
||
probability=dto.probability,
|
||
confidence=dto.confidence,
|
||
odds=dto.odds,
|
||
is_recommended=dto.is_recommended,
|
||
is_value_bet=dto.is_value_bet,
|
||
edge=dto.edge
|
||
)
|
||
|
||
best_bet = _map_dto(rec_result.best_bet)
|
||
alt_bet = _map_dto(rec_result.alternative_bet)
|
||
recommended = [_map_dto(r) for r in rec_result.recommended_bets]
|
||
|
||
# Analysis Details
|
||
analysis_details = {
|
||
"home_form": f"Form Score: {round(0.5 + team_pred.form_advantage/2, 2)}",
|
||
"away_form": f"Form Score: {round(0.5 - team_pred.form_advantage/2, 2)}",
|
||
"key_players_missing": self._get_missing_desc(player_pred),
|
||
"referee_notes": f"{referee_name}: {round(referee_pred.avg_yellow_cards, 1)} Yellow Cards/Avg",
|
||
"market_trend": "Market data analyzed"
|
||
}
|
||
|
||
return FullMatchPrediction(
|
||
match_id=match_id,
|
||
home_team=home_team_name,
|
||
away_team=away_team_name,
|
||
|
||
# Match Result (Using Reconciled Final MS)
|
||
ms_home_prob=final_ms.ms_home_prob,
|
||
ms_draw_prob=final_ms.ms_draw_prob,
|
||
ms_away_prob=final_ms.ms_away_prob,
|
||
ms_pick=final_ms.ms_pick,
|
||
ms_confidence=final_ms.ms_confidence,
|
||
|
||
# Double Chance (Using Reconciled Final MS)
|
||
dc_1x_prob=final_ms.dc_1x_prob,
|
||
dc_x2_prob=final_ms.dc_x2_prob,
|
||
dc_12_prob=final_ms.dc_12_prob,
|
||
dc_pick=final_ms.dc_pick,
|
||
dc_confidence=final_ms.dc_confidence,
|
||
|
||
# Over/Under
|
||
over_15_prob=ou_result.over_15_prob,
|
||
under_15_prob=ou_result.under_15_prob,
|
||
ou15_pick=ou_result.ou15_pick,
|
||
ou15_confidence=ou_result.ou15_confidence,
|
||
|
||
over_25_prob=ou_result.over_25_prob,
|
||
under_25_prob=ou_result.under_25_prob,
|
||
ou25_pick=ou_result.ou25_pick,
|
||
ou25_confidence=ou_result.ou25_confidence,
|
||
|
||
over_35_prob=ou_result.over_35_prob,
|
||
under_35_prob=ou_result.under_35_prob,
|
||
ou35_pick=ou_result.ou35_pick,
|
||
ou35_confidence=ou_result.ou35_confidence,
|
||
|
||
# BTTS
|
||
btts_yes_prob=ou_result.btts_yes_prob,
|
||
btts_no_prob=ou_result.btts_no_prob,
|
||
btts_pick=ou_result.btts_pick,
|
||
btts_confidence=ou_result.btts_confidence,
|
||
|
||
# Half Time
|
||
ht_home_prob=ht_result.ht_home_prob,
|
||
ht_draw_prob=ht_result.ht_draw_prob,
|
||
ht_away_prob=ht_result.ht_away_prob,
|
||
ht_pick=ht_result.ht_pick,
|
||
ht_confidence=ht_result.ht_confidence,
|
||
|
||
# Score
|
||
score=score_result,
|
||
|
||
# HT O/U
|
||
ht_over_05_prob=ht_result.ht_over_05_prob,
|
||
ht_under_05_prob=ht_result.ht_under_05_prob,
|
||
ht_over_15_prob=ht_result.ht_over_15_prob,
|
||
ht_under_15_prob=ht_result.ht_under_15_prob,
|
||
ht_ou_pick=ht_result.ht_ou_pick,
|
||
ht_ou15_pick=ht_result.ht_ou15_pick,
|
||
|
||
# Scores (Reconciled check usually happens in ScoreCalc)
|
||
predicted_ft_score=score_result.predicted_ft_score,
|
||
predicted_ht_score=score_result.predicted_ht_score,
|
||
ft_scores_top5=score_result.ft_scores_top5,
|
||
|
||
# xG
|
||
home_xg=home_xg,
|
||
away_xg=away_xg,
|
||
total_xg=home_xg + away_xg,
|
||
|
||
# Others
|
||
total_corners_pred=other_result.total_corners_pred,
|
||
corner_pick=other_result.corner_pick,
|
||
total_cards_pred=other_result.total_cards_pred,
|
||
card_pick=other_result.card_pick,
|
||
cards_over_prob=other_result.cards_over_prob,
|
||
cards_under_prob=other_result.cards_under_prob,
|
||
cards_confidence=other_result.cards_confidence,
|
||
handicap_pick=other_result.handicap_pick,
|
||
handicap_home_prob=other_result.handicap_home_prob,
|
||
handicap_draw_prob=other_result.handicap_draw_prob,
|
||
handicap_away_prob=other_result.handicap_away_prob,
|
||
handicap_confidence=other_result.handicap_confidence,
|
||
odd_even_pick=other_result.odd_even_pick,
|
||
odd_prob=other_result.odd_prob,
|
||
even_prob=other_result.even_prob,
|
||
|
||
# Risk
|
||
risk_level=risk_result.risk_level,
|
||
risk_score=risk_result.risk_score,
|
||
is_surprise_risk=risk_result.is_surprise_risk,
|
||
surprise_type=risk_result.surprise_type,
|
||
ht_ft_probs=ctx.xgboost_preds.get("ht_ft", {}),
|
||
analysis_details=analysis_details,
|
||
risk_warnings=risk_result.risk_warnings,
|
||
|
||
# GLM-5 Sürpriz Skoru
|
||
upset_score=upset_factors_v2.upset_score,
|
||
upset_level=upset_factors_v2.upset_level,
|
||
upset_reasons=upset_factors_v2.reasoning,
|
||
|
||
# Engines
|
||
team_confidence=team_pred.confidence,
|
||
player_confidence=player_pred.confidence,
|
||
odds_confidence=odds_pred.confidence,
|
||
referee_confidence=referee_pred.confidence,
|
||
|
||
# Recs
|
||
best_bet=best_bet,
|
||
recommended_bets=recommended,
|
||
alternative_bet=alt_bet,
|
||
|
||
# Expert Recommendation (New)
|
||
expert_recommendation=expert_data
|
||
)
|
||
|
||
def _get_missing_desc(self, player_pred) -> List[str]:
|
||
if not player_pred.lineup_available:
|
||
return ["Lineups not confirmed"]
|
||
|
||
missing = []
|
||
if player_pred.home_missing_impact > 0.1:
|
||
missing.append(f"Home missing impact: {int(player_pred.home_missing_impact*100)}%")
|
||
if player_pred.away_missing_impact > 0.1:
|
||
missing.append(f"Away missing impact: {int(player_pred.away_missing_impact*100)}%")
|
||
|
||
return missing if missing else ["No significant missing players"]
|
||
|
||
|
||
# Singleton
|
||
_predictor: Optional[V20EnsemblePredictor] = None
|
||
|
||
|
||
def get_v20_predictor() -> V20EnsemblePredictor:
|
||
global _predictor
|
||
if _predictor is None:
|
||
_predictor = V20EnsemblePredictor()
|
||
return _predictor
|
||
|
||
|
||
if __name__ == "__main__":
|
||
predictor = get_v20_predictor()
|
||
|
||
print("\\n🧪 V20 Ensemble Beast Test")
|
||
print("=" * 60)
|
||
|
||
result = predictor.predict(
|
||
match_id="test_match",
|
||
home_team_id="test_home",
|
||
away_team_id="test_away",
|
||
home_team_name="Beşiktaş",
|
||
away_team_name="Galatasaray",
|
||
match_date_ms=1707393600000,
|
||
odds_data={
|
||
"ms_h": 2.50,
|
||
"ms_d": 3.20,
|
||
"ms_a": 2.80,
|
||
"ou25_o": 1.85
|
||
},
|
||
home_position=3,
|
||
away_position=1,
|
||
league_name="Süper Lig"
|
||
)
|
||
|
||
print(json.dumps(result.to_dict(), indent=2, ensure_ascii=False))
|