This commit is contained in:
Executable
+8
@@ -0,0 +1,8 @@
|
||||
from .base_calculator import BaseCalculator, CalculationContext
|
||||
from .match_result_calculator import MatchResultCalculator
|
||||
from .over_under_calculator import OverUnderCalculator
|
||||
from .half_time_calculator import HalfTimeCalculator
|
||||
from .score_calculator import ScoreCalculator
|
||||
from .other_markets_calculator import OtherMarketsCalculator
|
||||
from .risk_assessor import RiskAssessor
|
||||
from .bet_recommender import BetRecommender, MarketPredictionDTO
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Base classes and context dataclass for all calculators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalculationContext:
|
||||
"""Context object holding all inputs for calculators."""
|
||||
|
||||
team_pred: Any
|
||||
player_pred: Any
|
||||
odds_pred: Any
|
||||
referee_pred: Any
|
||||
upset_factors: Any
|
||||
|
||||
weights: dict[str, float]
|
||||
player_mods: dict[str, float]
|
||||
referee_mods: dict[str, float]
|
||||
|
||||
match_id: str
|
||||
home_team_name: str
|
||||
away_team_name: str
|
||||
|
||||
odds_data: dict[str, float]
|
||||
home_xg: float
|
||||
away_xg: float
|
||||
total_xg: float
|
||||
|
||||
league_id: str | None = None
|
||||
sport: str = "football"
|
||||
is_top_league: bool = False
|
||||
|
||||
# Risk info (populated later)
|
||||
risk_level: str = "MEDIUM"
|
||||
is_surprise: bool = False
|
||||
|
||||
# XGBoost Predictions (New)
|
||||
xgboost_preds: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
class BaseCalculator:
|
||||
"""Base class for all market calculators."""
|
||||
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config
|
||||
|
||||
def calculate(self, ctx: CalculationContext) -> dict[str, Any]:
|
||||
raise NotImplementedError("Subclasses must implement calculate()")
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Any
|
||||
from .base_calculator import BaseCalculator, CalculationContext
|
||||
from .match_result_calculator import MatchResultPrediction
|
||||
from .over_under_calculator import OverUnderPrediction
|
||||
from .risk_assessor import RiskAnalysis
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketPredictionDTO:
|
||||
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
|
||||
is_skip: bool = False # NEW: If model is unsure, mark as skip
|
||||
|
||||
@dataclass
|
||||
class RecommendationResult:
|
||||
best_bet: Optional[MarketPredictionDTO]
|
||||
recommended_bets: List[MarketPredictionDTO]
|
||||
alternative_bet: Optional[MarketPredictionDTO]
|
||||
value_bets: List[MarketPredictionDTO]
|
||||
skipped_bets: List[MarketPredictionDTO] # NEW: Track what we decided NOT to predict
|
||||
|
||||
|
||||
class BetRecommender(BaseCalculator):
|
||||
def calculate(self,
|
||||
ctx: CalculationContext,
|
||||
ms_res: MatchResultPrediction,
|
||||
ou_res: OverUnderPrediction,
|
||||
risk: RiskAnalysis) -> RecommendationResult:
|
||||
|
||||
odds_data = ctx.odds_data
|
||||
|
||||
# Market-Specific Minimum Confidence Thresholds (Hard Gates)
|
||||
# Below these, we say "I don't know" (SKIP)
|
||||
min_conf_thresholds = {
|
||||
"MS": 45.0, # 3-way is hard, need at least 45%
|
||||
"ÇŞ": 40.0, # Double chance is safer, but still need 40%
|
||||
"1.5 Üst/Alt": 50.0,
|
||||
"2.5 Üst/Alt": 45.0,
|
||||
"3.5 Üst/Alt": 45.0,
|
||||
"BTTS": 45.0,
|
||||
"HT": 40.0,
|
||||
}
|
||||
|
||||
# Prepare candidates
|
||||
markets = [
|
||||
MarketPredictionDTO("MS", ms_res.ms_pick,
|
||||
ms_res.ms_home_prob if ms_res.ms_pick == "1" else (ms_res.ms_away_prob if ms_res.ms_pick == "2" else ms_res.ms_draw_prob),
|
||||
ms_res.ms_confidence,
|
||||
odds_data.get(f"ms_{ms_res.ms_pick.lower()}", 0)),
|
||||
|
||||
MarketPredictionDTO("ÇŞ", ms_res.dc_pick,
|
||||
ms_res.dc_1x_prob if ms_res.dc_pick == "1X" else (ms_res.dc_x2_prob if ms_res.dc_pick == "X2" else ms_res.dc_12_prob),
|
||||
ms_res.dc_confidence,
|
||||
odds_data.get(f"dc_{ms_res.dc_pick.lower()}", 0)),
|
||||
|
||||
MarketPredictionDTO("1.5 Üst/Alt", ou_res.ou15_pick,
|
||||
ou_res.over_15_prob if "Üst" in ou_res.ou15_pick else ou_res.under_15_prob,
|
||||
ou_res.ou15_confidence, 0),
|
||||
|
||||
MarketPredictionDTO("2.5 Üst/Alt", ou_res.ou25_pick,
|
||||
ou_res.over_25_prob if "Üst" in ou_res.ou25_pick else ou_res.under_25_prob,
|
||||
ou_res.ou25_confidence,
|
||||
odds_data.get("ou25_o" if "Üst" in ou_res.ou25_pick else "ou25_u", 0)),
|
||||
|
||||
MarketPredictionDTO("3.5 Üst/Alt", ou_res.ou35_pick,
|
||||
ou_res.over_35_prob if "Üst" in ou_res.ou35_pick else ou_res.under_35_prob,
|
||||
ou_res.ou35_confidence, 0),
|
||||
|
||||
MarketPredictionDTO("BTTS", ou_res.btts_pick,
|
||||
ou_res.btts_yes_prob if "Var" in ou_res.btts_pick else ou_res.btts_no_prob,
|
||||
ou_res.btts_confidence,
|
||||
odds_data.get("btts_y" if "Var" in ou_res.btts_pick else "btts_n", 0)),
|
||||
]
|
||||
|
||||
# Market weights from config (historical accuracy weighting)
|
||||
market_weights = self.config.get("recommendations.market_weights", {})
|
||||
default_weight = 1.0
|
||||
|
||||
safe_markets = set(self.config.get("recommendations.safe_markets", ["ÇŞ", "1.5 Üst/Alt"]))
|
||||
risk_level = risk.risk_level
|
||||
|
||||
# Confidence calibration (backtest-derived accuracy scaling)
|
||||
market_accuracy = self.config.get("recommendations.market_accuracy", {})
|
||||
baseline_accuracy = self.config.get("recommendations.baseline_accuracy", 65.0)
|
||||
|
||||
def _calibrated_confidence(m):
|
||||
"""Scale raw confidence by market's historical accuracy ratio."""
|
||||
accuracy = market_accuracy.get(m.market_type, baseline_accuracy) if isinstance(market_accuracy, dict) else baseline_accuracy
|
||||
ratio = accuracy / baseline_accuracy
|
||||
return m.confidence * ratio
|
||||
|
||||
def _score(m):
|
||||
mw = market_weights.get(m.market_type, default_weight) if isinstance(market_weights, dict) else default_weight
|
||||
|
||||
# 1. Base Score: calibrated confidence * market weight
|
||||
cal_conf = _calibrated_confidence(m)
|
||||
score = cal_conf * mw
|
||||
|
||||
# 2. Value/Edge Bonus
|
||||
odds_val = m.odds if m.odds is not None else 0.0
|
||||
if odds_val > 0:
|
||||
implied = 1.0 / odds_val
|
||||
edge = (m.probability - implied) * 100
|
||||
if edge > 0:
|
||||
score += edge * 4.0
|
||||
|
||||
# 3. Risk adjustment
|
||||
if risk_level in ("HIGH", "EXTREME"):
|
||||
if m.market_type in safe_markets:
|
||||
score *= self.config.get("recommendations.risk_safe_boost", 1.2)
|
||||
elif m.market_type == "MS":
|
||||
score *= self.config.get("recommendations.risk_ms_penalty_high", 0.5)
|
||||
else:
|
||||
score *= self.config.get("recommendations.risk_other_penalty", 0.7)
|
||||
elif risk_level == "MEDIUM":
|
||||
if m.market_type == "MS":
|
||||
score *= self.config.get("recommendations.risk_ms_penalty_medium", 0.8)
|
||||
|
||||
# 4. Extreme Confidence Bonus
|
||||
if cal_conf > 80:
|
||||
score *= 1.15
|
||||
|
||||
return score
|
||||
|
||||
recommended = []
|
||||
value_bets = []
|
||||
skipped_bets = []
|
||||
|
||||
conf_thr = self.config.get("recommendations.confidence_threshold", 60)
|
||||
|
||||
val_min = self.config.get("recommendations.value_confidence_min", 45) # Increased from 30
|
||||
val_max = self.config.get("recommendations.value_confidence_max", 60)
|
||||
val_margin = self.config.get("recommendations.value_edge_margin", 0.03) # Increased from 0.02
|
||||
val_upgrade = self.config.get("recommendations.value_upgrade_edge", 5.0)
|
||||
|
||||
for m in markets:
|
||||
# --- SKIP LOGIC (Hard Gate) ---
|
||||
# 1. Confidence is below market threshold
|
||||
min_conf = min_conf_thresholds.get(m.market_type, 45.0)
|
||||
if m.confidence < min_conf:
|
||||
m.is_skip = True
|
||||
skipped_bets.append(m)
|
||||
continue
|
||||
|
||||
# 2. Negative Value Edge (Odds are too low for our probability)
|
||||
if m.odds > 0:
|
||||
implied = 1.0 / m.odds
|
||||
edge = m.probability - implied
|
||||
# If our prob is significantly lower than implied (negative edge > 3%), SKIP
|
||||
if edge < -0.03:
|
||||
m.is_skip = True
|
||||
skipped_bets.append(m)
|
||||
continue
|
||||
|
||||
# --- PROCESS BET ---
|
||||
# 1. Regular recommended
|
||||
if m.confidence >= conf_thr:
|
||||
m.is_recommended = True
|
||||
recommended.append(m)
|
||||
|
||||
# 2. Value bet logic
|
||||
if m.confidence is not None and val_min <= m.confidence <= val_max and m.odds > 0:
|
||||
implied = 1.0 / m.odds
|
||||
if m.probability > (implied + val_margin):
|
||||
m.is_value_bet = True
|
||||
m.edge = (m.probability - implied) * 100
|
||||
|
||||
if m.edge > val_upgrade:
|
||||
m.is_recommended = True
|
||||
recommended.append(m)
|
||||
else:
|
||||
value_bets.append(m)
|
||||
|
||||
# Best bet (from recommended only)
|
||||
best_bet = None
|
||||
if recommended:
|
||||
# Re-sort only recommended markets to find the best one
|
||||
valid_markets = [m for m in markets if not m.is_skip and m.is_recommended]
|
||||
if valid_markets:
|
||||
valid_markets.sort(key=_score, reverse=True)
|
||||
best_bet = valid_markets[0]
|
||||
best_bet.is_recommended = True
|
||||
|
||||
# Alternative bet
|
||||
alternative = None
|
||||
if risk.is_surprise_risk and ms_res.ms_pick in ["1", "2"]:
|
||||
# Check if alternative is not skipped
|
||||
alt_candidate = MarketPredictionDTO(
|
||||
"2.5 Üst/Alt", ou_res.ou25_pick,
|
||||
ou_res.over_25_prob if "Üst" in ou_res.ou25_pick else ou_res.under_25_prob,
|
||||
ou_res.ou25_confidence,
|
||||
odds_data.get("ou25_o" if "Üst" in ou_res.ou25_pick else "ou25_u", 0)
|
||||
)
|
||||
if alt_candidate.confidence >= min_conf_thresholds.get("2.5 Üst/Alt", 45.0):
|
||||
alternative = alt_candidate
|
||||
|
||||
return RecommendationResult(
|
||||
best_bet=best_bet,
|
||||
recommended_bets=recommended,
|
||||
alternative_bet=alternative,
|
||||
value_bets=value_bets,
|
||||
skipped_bets=skipped_bets
|
||||
)
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
def calc_confidence_3way(top_prob: float) -> float:
|
||||
"""Returns the true win probability percentage (e.g. 0.45 -> 45.0)."""
|
||||
return max(0, min(99.0, top_prob * 100))
|
||||
|
||||
def calc_confidence_2way(prob: float) -> float:
|
||||
"""Returns the true win probability percentage for the favored side."""
|
||||
# Find the probability of the >0.5 side
|
||||
win_prob = prob if prob >= 0.5 else (1.0 - prob)
|
||||
return max(0, min(99.0, win_prob * 100))
|
||||
|
||||
def calc_confidence_dc(top_prob: float) -> float:
|
||||
"""Returns the true win probability percentage for double chance."""
|
||||
return max(0, min(99.0, top_prob * 100))
|
||||
|
||||
def calc_confidence_3way_with_agreement(top_prob: float, agreement_ratio: float,
|
||||
boost: float = 1.05, penalty: float = 0.95) -> float:
|
||||
"""
|
||||
Returns the true win probability percentage, slightly adjusted by engine consensus.
|
||||
|
||||
Args:
|
||||
top_prob: highest probability among options
|
||||
agreement_ratio: 0.0 to 1.0 — how many engines agree on the pick
|
||||
"""
|
||||
base = calc_confidence_3way(top_prob)
|
||||
|
||||
# Slight nudge rather than massive swing, to keep it feeling like a true probability
|
||||
if agreement_ratio >= 0.75:
|
||||
return min(99.0, base * boost)
|
||||
elif agreement_ratio <= 0.25:
|
||||
return max(0.0, base * penalty)
|
||||
|
||||
return base
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Expert Recommendation Engine (Senior Level)
|
||||
============================================
|
||||
Evaluates ALL markets, classifies by risk, and ensures NO "empty" recommendations.
|
||||
Prioritizes user safety by clearly labeling risk levels.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Any, Dict
|
||||
from .base_calculator import BaseCalculator, CalculationContext
|
||||
from .match_result_calculator import MatchResultPrediction
|
||||
from .over_under_calculator import OverUnderPrediction
|
||||
from .risk_assessor import RiskAnalysis
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpertPick:
|
||||
market_type: str
|
||||
pick: str
|
||||
probability: float
|
||||
confidence: float
|
||||
odds: float
|
||||
edge: float # Expected value percentage
|
||||
|
||||
# Risk Classification
|
||||
risk_level: str # SAFE, MEDIUM, RISKY, SURPRISE
|
||||
reasoning: str # Why this pick? (e.g., "High xG support", "Value detected")
|
||||
|
||||
@dataclass
|
||||
class ExpertResult:
|
||||
main_pick: ExpertPick
|
||||
safe_alternative: Optional[ExpertPick]
|
||||
value_picks: List[ExpertPick]
|
||||
surprise_picks: List[ExpertPick]
|
||||
market_summary: Dict[str, float] # {market: probability}
|
||||
|
||||
|
||||
class ExpertRecommender(BaseCalculator):
|
||||
def calculate(self,
|
||||
ctx: CalculationContext,
|
||||
ms_res: MatchResultPrediction,
|
||||
ou_res: OverUnderPrediction,
|
||||
risk: RiskAnalysis) -> ExpertResult:
|
||||
|
||||
odds_data = ctx.odds_data
|
||||
all_picks: List[ExpertPick] = []
|
||||
|
||||
# ─── 1. Helper to Evaluate Pick ───
|
||||
def evaluate(market: str, pick: str, prob: float, odd_key: str):
|
||||
odd_val = float(odds_data.get(odd_key, 0))
|
||||
# If odd is missing/low, estimate it via probability (Kelly-ish estimation)
|
||||
if odd_val <= 1.01:
|
||||
odd_val = round(1.0 / (prob + 0.05), 2) # Conservative estimation
|
||||
reasoning = "Derived (No market odd)"
|
||||
else:
|
||||
reasoning = "Market Confirmed"
|
||||
|
||||
implied = 1.0 / odd_val
|
||||
edge = (prob - implied) * 100
|
||||
|
||||
# ─── Risk Classification ───
|
||||
if prob >= 0.75 and odd_val <= 1.45:
|
||||
level = "SAFE"
|
||||
elif edge > 5.0:
|
||||
level = "VALUE"
|
||||
elif odd_val >= 2.50 and prob >= 0.35:
|
||||
level = "SURPRISE"
|
||||
else:
|
||||
level = "MEDIUM"
|
||||
|
||||
all_picks.append(ExpertPick(
|
||||
market_type=market, pick=pick, probability=prob,
|
||||
confidence=prob * 100, odds=odd_val, edge=edge,
|
||||
risk_level=level, reasoning=reasoning
|
||||
))
|
||||
|
||||
# ─── 2. Evaluate All Major Markets ───
|
||||
# MS
|
||||
evaluate("MS", ms_res.ms_pick,
|
||||
ms_res.ms_home_prob if ms_res.ms_pick == "1" else (ms_res.ms_away_prob if ms_res.ms_pick == "2" else ms_res.ms_draw_prob),
|
||||
f"ms_{ms_res.ms_pick.lower()}")
|
||||
|
||||
# Double Chance
|
||||
evaluate("DC", ms_res.dc_pick,
|
||||
ms_res.dc_1x_prob if ms_res.dc_pick == "1X" else (ms_res.dc_x2_prob if ms_res.dc_pick == "X2" else ms_res.dc_12_prob),
|
||||
f"dc_{ms_res.dc_pick.lower()}")
|
||||
|
||||
# OU25
|
||||
evaluate("OU25", ou_res.ou25_pick,
|
||||
ou_res.over_25_prob if "Üst" in ou_res.ou25_pick else ou_res.under_25_prob,
|
||||
"ou25_o" if "Üst" in ou_res.ou25_pick else "ou25_u")
|
||||
|
||||
# BTTS
|
||||
evaluate("BTTS", ou_res.btts_pick,
|
||||
ou_res.btts_yes_prob if "Var" in ou_res.btts_pick else ou_res.btts_no_prob,
|
||||
"btts_y" if "Var" in ou_res.btts_pick else "btts_n")
|
||||
|
||||
# OU15
|
||||
evaluate("OU15", ou_res.ou15_pick,
|
||||
ou_res.over_15_prob if "Üst" in ou_res.ou15_pick else ou_res.under_15_prob,
|
||||
"ou15_o" if "Üst" in ou_res.ou15_pick else "ou15_u")
|
||||
|
||||
# ─── 3. Sort and Select ───
|
||||
# Sort by a mix of Confidence and Edge
|
||||
all_picks.sort(key=lambda p: (p.probability * 0.6) + (max(0, p.edge/100) * 0.4), reverse=True)
|
||||
|
||||
main = all_picks[0]
|
||||
|
||||
# Find Safe Alternative (if main isn't Safe)
|
||||
safe_alt = next((p for p in all_picks if p.risk_level == "SAFE"), None)
|
||||
if safe_alt == main: safe_alt = None
|
||||
|
||||
value_picks = [p for p in all_picks if p.risk_level == "VALUE" and p != main]
|
||||
surprise_picks = [p for p in all_picks if p.risk_level == "SURPRISE"]
|
||||
|
||||
# Market Summary for UI
|
||||
market_summary = {
|
||||
"MS_Home": ms_res.ms_home_prob,
|
||||
"MS_Draw": ms_res.ms_draw_prob,
|
||||
"MS_Away": ms_res.ms_away_prob,
|
||||
"OU25_Over": ou_res.over_25_prob,
|
||||
"BTTS_Yes": ou_res.btts_yes_prob
|
||||
}
|
||||
|
||||
return ExpertResult(
|
||||
main_pick=main,
|
||||
safe_alternative=safe_alt,
|
||||
value_picks=value_picks,
|
||||
surprise_picks=surprise_picks,
|
||||
market_summary=market_summary
|
||||
)
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from .base_calculator import BaseCalculator, CalculationContext
|
||||
from .confidence import calc_confidence_3way, calc_confidence_2way
|
||||
|
||||
|
||||
@dataclass
|
||||
class HalfTimePrediction:
|
||||
ht_home_prob: float
|
||||
ht_draw_prob: float
|
||||
ht_away_prob: float
|
||||
ht_pick: str
|
||||
ht_confidence: float
|
||||
|
||||
ht_over_05_prob: float
|
||||
ht_under_05_prob: float
|
||||
ht_over_15_prob: float
|
||||
ht_under_15_prob: float
|
||||
ht_ou_pick: str
|
||||
ht_ou15_pick: str
|
||||
|
||||
ht_home_xg: float
|
||||
ht_away_xg: float
|
||||
|
||||
|
||||
class HalfTimeCalculator(BaseCalculator):
|
||||
|
||||
def _poisson_pmf(self, k, lam):
|
||||
"""Poisson probability mass function."""
|
||||
if lam <= 0:
|
||||
return 1.0 if k == 0 else 0.0
|
||||
return (lam ** k) * math.exp(-lam) / math.factorial(k)
|
||||
|
||||
def calculate(self, ctx: CalculationContext) -> HalfTimePrediction:
|
||||
team_pred = ctx.team_pred
|
||||
odds_pred = ctx.odds_pred
|
||||
|
||||
# Config
|
||||
ft_to_ht_ratio = self.config.get("half_time.ft_to_ht_ratio", 0.42)
|
||||
grid_max = self.config.get("half_time.poisson_grid_max", 5)
|
||||
draw_floor = self.config.get("half_time.ht_draw_floor", 0.35)
|
||||
low_xg_thr = self.config.get("half_time.low_xg_threshold", 2.0)
|
||||
low_xg_adj = self.config.get("half_time.low_xg_ratio_adjust", 0.85)
|
||||
|
||||
# FT xG (blended team + odds)
|
||||
ft_home_xg = (team_pred.home_xg + odds_pred.poisson_home_xg) / 2
|
||||
ft_away_xg = (team_pred.away_xg + odds_pred.poisson_away_xg) / 2
|
||||
total_ft_xg = ft_home_xg + ft_away_xg
|
||||
|
||||
# Dynamic HT ratio: düşük xG maçlarda ratio'yu küçült
|
||||
# Çünkü düşük gollü maçlarda ilk yarıda gol olma ihtimali daha da düşük
|
||||
effective_ratio = ft_to_ht_ratio
|
||||
if total_ft_xg < low_xg_thr:
|
||||
effective_ratio *= low_xg_adj
|
||||
|
||||
# HT xG
|
||||
ht_home_xg = ft_home_xg * effective_ratio
|
||||
ht_away_xg = ft_away_xg * effective_ratio
|
||||
ht_total_xg = ht_home_xg + ht_away_xg
|
||||
|
||||
# Compute HT 1X2 via bivariate Poisson grid
|
||||
ht_home = 0.0
|
||||
ht_away = 0.0
|
||||
ht_draw = 0.0
|
||||
|
||||
# Also compute O/U while iterating
|
||||
total_goals_prob = {}
|
||||
|
||||
for i in range(grid_max):
|
||||
for j in range(grid_max):
|
||||
p = self._poisson_pmf(i, ht_home_xg) * self._poisson_pmf(j, ht_away_xg)
|
||||
if i > j:
|
||||
ht_home += p
|
||||
elif i < j:
|
||||
ht_away += p
|
||||
else:
|
||||
ht_draw += p
|
||||
|
||||
total = i + j
|
||||
total_goals_prob[total] = total_goals_prob.get(total, 0.0) + p
|
||||
|
||||
# Draw floor: düşük xG maçlarda beraberlik olasılığını minimum seviyeye çek
|
||||
if ht_draw < draw_floor:
|
||||
deficit = draw_floor - ht_draw
|
||||
ht_draw = draw_floor
|
||||
# Deficit'i home ve away'den orantılı düş
|
||||
total_ha = ht_home + ht_away
|
||||
if total_ha > 0:
|
||||
ht_home -= deficit * (ht_home / total_ha)
|
||||
ht_away -= deficit * (ht_away / total_ha)
|
||||
|
||||
# Normalize
|
||||
total_prob = ht_home + ht_draw + ht_away
|
||||
if total_prob > 0:
|
||||
ht_home /= total_prob
|
||||
ht_draw /= total_prob
|
||||
ht_away /= total_prob
|
||||
|
||||
# XGBoost Integration (HT 1X2 and HT/FT Models)
|
||||
w_xgb = self.config.get("xgboost.weight_ht", 0.60)
|
||||
xgb_ht_home, xgb_ht_draw, xgb_ht_away = None, None, None
|
||||
|
||||
if "ht_result" in ctx.xgboost_preds:
|
||||
probs = ctx.xgboost_preds["ht_result"]
|
||||
xgb_ht_home, xgb_ht_draw, xgb_ht_away = probs["home"], probs["draw"], probs["away"]
|
||||
elif "ht_ft" in ctx.xgboost_preds:
|
||||
# Fallback to HT/FT marginals
|
||||
htft_payload = ctx.xgboost_preds.get("ht_ft", {})
|
||||
probs = None
|
||||
if isinstance(htft_payload, dict):
|
||||
labels = ("1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2")
|
||||
if all(label in htft_payload for label in labels):
|
||||
probs = [float(htft_payload[label]) for label in labels]
|
||||
|
||||
if probs is None:
|
||||
probs = ctx.xgboost_preds.get("ht_ft_raw")
|
||||
if probs is not None and len(probs) == 9:
|
||||
xgb_ht_home = sum(probs[0:3])
|
||||
xgb_ht_draw = sum(probs[3:6])
|
||||
xgb_ht_away = sum(probs[6:9])
|
||||
|
||||
if xgb_ht_home is not None:
|
||||
ht_home = ht_home * (1 - w_xgb) + xgb_ht_home * w_xgb
|
||||
ht_draw = ht_draw * (1 - w_xgb) + xgb_ht_draw * w_xgb
|
||||
ht_away = ht_away * (1 - w_xgb) + xgb_ht_away * w_xgb
|
||||
|
||||
# Re-normalize
|
||||
total = ht_home + ht_draw + ht_away
|
||||
ht_home /= total
|
||||
ht_draw /= total
|
||||
ht_away /= total
|
||||
|
||||
# HT O/U 0.5
|
||||
ht_over_05 = 1.0 - math.exp(-ht_total_xg)
|
||||
if "ht_ou05" in ctx.xgboost_preds:
|
||||
w_xgb = self.config.get("xgboost.weight_ou", 0.60)
|
||||
xgb_ht_over_05 = float(ctx.xgboost_preds["ht_ou05"])
|
||||
ht_over_05 = ht_over_05 * (1 - w_xgb) + xgb_ht_over_05 * w_xgb
|
||||
|
||||
ht_over_05_min = self.config.get("half_time.ht_over_05_min", 0.20)
|
||||
ht_over_05_max = self.config.get("half_time.ht_over_05_max", 0.95)
|
||||
ht_over_05 = max(ht_over_05_min, min(ht_over_05_max, ht_over_05))
|
||||
|
||||
# HT O/U 1.5
|
||||
# P(total >= 2) = 1 - P(0) - P(1)
|
||||
ht_over_15 = sum(p for g, p in total_goals_prob.items() if g >= 2)
|
||||
if "ht_ou15" in ctx.xgboost_preds:
|
||||
w_xgb = self.config.get("xgboost.weight_ou", 0.60)
|
||||
xgb_ht_over_15 = float(ctx.xgboost_preds["ht_ou15"])
|
||||
ht_over_15 = ht_over_15 * (1 - w_xgb) + xgb_ht_over_15 * w_xgb
|
||||
|
||||
ht_over_15 = max(0.02, min(0.95, ht_over_15))
|
||||
|
||||
# Picks
|
||||
ht_probs = [(ht_home, "İY 1"), (ht_draw, "İY X"), (ht_away, "İY 2")]
|
||||
ht_sorted = sorted(ht_probs, key=lambda x: x[0], reverse=True)
|
||||
ht_pick = ht_sorted[0][1]
|
||||
ht_confidence = calc_confidence_3way(ht_sorted[0][0])
|
||||
|
||||
# HT O/U picks
|
||||
ht_ou_thr = self.config.get("half_time.ht_ou_threshold", 0.55)
|
||||
ht_ou_pick = "İY 0.5 Üst" if ht_over_05 > ht_ou_thr else "İY 0.5 Alt"
|
||||
ht_ou15_pick = "İY 1.5 Üst" if ht_over_15 > 0.45 else "İY 1.5 Alt"
|
||||
|
||||
return HalfTimePrediction(
|
||||
ht_home_prob=ht_home,
|
||||
ht_draw_prob=ht_draw,
|
||||
ht_away_prob=ht_away,
|
||||
ht_pick=ht_pick,
|
||||
ht_confidence=ht_confidence,
|
||||
ht_over_05_prob=ht_over_05,
|
||||
ht_under_05_prob=1.0 - ht_over_05,
|
||||
ht_over_15_prob=ht_over_15,
|
||||
ht_under_15_prob=1.0 - ht_over_15,
|
||||
ht_ou_pick=ht_ou_pick,
|
||||
ht_ou15_pick=ht_ou15_pick,
|
||||
ht_home_xg=ht_home_xg,
|
||||
ht_away_xg=ht_away_xg
|
||||
)
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Any, List
|
||||
from .base_calculator import BaseCalculator, CalculationContext
|
||||
from .confidence import calc_confidence_3way_with_agreement, calc_confidence_dc
|
||||
|
||||
@dataclass
|
||||
class MatchResultPrediction:
|
||||
ms_home_prob: float
|
||||
ms_draw_prob: float
|
||||
ms_away_prob: float
|
||||
ms_pick: str
|
||||
ms_confidence: float
|
||||
|
||||
dc_1x_prob: float
|
||||
dc_x2_prob: float
|
||||
dc_12_prob: float
|
||||
dc_pick: str
|
||||
dc_confidence: float
|
||||
|
||||
class MatchResultCalculator(BaseCalculator):
|
||||
|
||||
def _get_engine_winner(self, home_prob: float, draw_prob: float, away_prob: float) -> str:
|
||||
"""Determine which outcome an engine favors."""
|
||||
probs = {"1": home_prob, "X": draw_prob, "2": away_prob}
|
||||
return max(probs, key=probs.get)
|
||||
|
||||
def calculate(self, ctx: CalculationContext) -> MatchResultPrediction:
|
||||
# Weights
|
||||
w_team = ctx.weights["team"]
|
||||
w_player = ctx.weights["player"]
|
||||
w_odds = ctx.weights["odds"]
|
||||
w_referee = ctx.weights["referee"]
|
||||
|
||||
# Engine predictions
|
||||
team_pred = ctx.team_pred
|
||||
odds_pred = ctx.odds_pred
|
||||
player_mods = ctx.player_mods
|
||||
referee_mods = ctx.referee_mods
|
||||
|
||||
# Weighted ensemble for 1X2
|
||||
ms_home = (
|
||||
team_pred.home_win_prob * w_team +
|
||||
odds_pred.market_home_prob * w_odds +
|
||||
team_pred.home_win_prob * player_mods["home_modifier"] * w_player +
|
||||
odds_pred.market_home_prob * referee_mods["home_modifier"] * w_referee
|
||||
)
|
||||
|
||||
ms_away = (
|
||||
team_pred.away_win_prob * w_team +
|
||||
odds_pred.market_away_prob * w_odds +
|
||||
team_pred.away_win_prob * player_mods["away_modifier"] * w_player +
|
||||
odds_pred.market_away_prob / referee_mods["home_modifier"] * w_referee
|
||||
)
|
||||
|
||||
ms_draw = 1.0 - ms_home - ms_away
|
||||
|
||||
# XGBoost Integration
|
||||
if "ms" in ctx.xgboost_preds:
|
||||
xgb_probs = ctx.xgboost_preds["ms"]
|
||||
w_xgb = self.config.get("xgboost.weight_ms", 0.70)
|
||||
w_heuristic = 1.0 - w_xgb
|
||||
|
||||
ms_home = ms_home * w_heuristic + xgb_probs["home"] * w_xgb
|
||||
ms_draw = ms_draw * w_heuristic + xgb_probs["draw"] * w_xgb
|
||||
ms_away = ms_away * w_heuristic + xgb_probs["away"] * w_xgb
|
||||
|
||||
# Re-normalize
|
||||
total = ms_home + ms_draw + ms_away
|
||||
ms_home /= total
|
||||
ms_draw /= total
|
||||
ms_away /= total
|
||||
|
||||
# Min draw probability clamping
|
||||
min_draw = self.config.get("match_result.min_draw_prob", 0.15)
|
||||
if ms_draw < min_draw:
|
||||
ms_draw = min_draw
|
||||
total = ms_home + ms_away + ms_draw
|
||||
ms_home /= total
|
||||
ms_away /= total
|
||||
ms_draw /= total
|
||||
|
||||
# Double Chance
|
||||
dc_1x = ms_home + ms_draw
|
||||
dc_x2 = ms_draw + ms_away
|
||||
dc_12 = ms_home + ms_away
|
||||
|
||||
# MS pick
|
||||
ms_probs = [(ms_home, "1"), (ms_draw, "X"), (ms_away, "2")]
|
||||
ms_sorted = sorted(ms_probs, key=lambda x: x[0], reverse=True)
|
||||
ms_pick = ms_sorted[0][1]
|
||||
|
||||
# === ENGINE AGREEMENT ===
|
||||
# Determine each engine's winner and calculate agreement ratio
|
||||
team_winner = self._get_engine_winner(
|
||||
team_pred.home_win_prob, team_pred.draw_prob, team_pred.away_win_prob
|
||||
)
|
||||
odds_winner = self._get_engine_winner(
|
||||
odds_pred.market_home_prob, odds_pred.market_draw_prob, odds_pred.market_away_prob
|
||||
)
|
||||
|
||||
# Player-modified: team probs * player modifiers
|
||||
player_adj_home = team_pred.home_win_prob * player_mods["home_modifier"]
|
||||
player_adj_away = team_pred.away_win_prob * player_mods["away_modifier"]
|
||||
player_adj_draw = max(0.01, 1.0 - player_adj_home - player_adj_away)
|
||||
player_winner = self._get_engine_winner(player_adj_home, player_adj_draw, player_adj_away)
|
||||
|
||||
# Referee-modified: odds probs * referee modifiers
|
||||
ref_adj_home = odds_pred.market_home_prob * referee_mods["home_modifier"]
|
||||
ref_adj_away = odds_pred.market_away_prob / referee_mods["home_modifier"]
|
||||
ref_adj_draw = max(0.01, 1.0 - ref_adj_home - ref_adj_away)
|
||||
referee_winner = self._get_engine_winner(ref_adj_home, ref_adj_draw, ref_adj_away)
|
||||
|
||||
# Count how many engines agree with final pick
|
||||
engines = [team_winner, odds_winner, player_winner, referee_winner]
|
||||
agreement_count = sum(1 for e in engines if e == ms_pick)
|
||||
agreement_ratio = agreement_count / len(engines)
|
||||
|
||||
# Confidence with agreement
|
||||
boost = self.config.get("confidence.agreement_boost", 1.3)
|
||||
penalty = self.config.get("confidence.disagreement_penalty", 0.7)
|
||||
ms_confidence = calc_confidence_3way_with_agreement(
|
||||
ms_sorted[0][0], agreement_ratio, boost, penalty
|
||||
)
|
||||
|
||||
# DC pick
|
||||
dc_probs = [(dc_1x, "1X"), (dc_x2, "X2"), (dc_12, "12")]
|
||||
dc_sorted = sorted(dc_probs, key=lambda x: x[0], reverse=True)
|
||||
dc_pick = dc_sorted[0][1]
|
||||
dc_confidence = calc_confidence_dc(dc_sorted[0][0])
|
||||
|
||||
return MatchResultPrediction(
|
||||
ms_home_prob=ms_home,
|
||||
ms_draw_prob=ms_draw,
|
||||
ms_away_prob=ms_away,
|
||||
ms_pick=ms_pick,
|
||||
ms_confidence=ms_confidence,
|
||||
dc_1x_prob=dc_1x,
|
||||
dc_x2_prob=dc_x2,
|
||||
dc_12_prob=dc_12,
|
||||
dc_pick=dc_pick,
|
||||
dc_confidence=dc_confidence
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Tuple
|
||||
|
||||
@dataclass
|
||||
class AnomalyResult:
|
||||
is_anomaly: bool
|
||||
side: str = ""
|
||||
severity: float = 0.0
|
||||
reason: str = ""
|
||||
|
||||
class OddsAnomalyDetector:
|
||||
"""
|
||||
Detects mismatches between bookmaker odds and underlying team metrics.
|
||||
A 'Bookmaker Trap' is when a team has very low odds (heavy favorite)
|
||||
but their xG/defense metrics are surprisingly poor.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
self.config = config
|
||||
|
||||
# Thresholds
|
||||
self.fav_odds_threshold = self.config.get("anomaly.fav_odds_threshold", 1.75)
|
||||
self.min_xg_for_fav = self.config.get("anomaly.min_xg_for_fav", 1.25)
|
||||
self.max_conceded_for_fav = self.config.get("anomaly.max_conceded_for_fav", 1.30)
|
||||
self.opp_min_xg_threat = self.config.get("anomaly.opp_min_xg_threat", 1.10)
|
||||
|
||||
def detect_trap(self,
|
||||
odds_data: Dict[str, float],
|
||||
home_xg: float,
|
||||
away_xg: float,
|
||||
home_conceded_avg: float,
|
||||
away_conceded_avg: float) -> tuple[bool, AnomalyResult]:
|
||||
"""
|
||||
Check if the match is a potential odds trap.
|
||||
Returns: (has_trap, AnomalyResult)
|
||||
"""
|
||||
ms_h = odds_data.get("ms_h", 0.0)
|
||||
ms_a = odds_data.get("ms_a", 0.0)
|
||||
|
||||
# Check Home Favorite Trap
|
||||
if 1.0 < ms_h <= self.fav_odds_threshold:
|
||||
# Home is favored. Check metrics.
|
||||
if home_xg < self.min_xg_for_fav and (away_xg > self.opp_min_xg_threat or home_conceded_avg > self.max_conceded_for_fav):
|
||||
severity = (self.fav_odds_threshold - ms_h) + (self.min_xg_for_fav - home_xg)
|
||||
reason = f"🚨 ODDS ANOMALY (TRAP): Home odds ({ms_h}) suspiciously low despite poor metrics (xG: {round(home_xg, 2)}, Conceded: {round(home_conceded_avg, 2)})"
|
||||
return True, AnomalyResult(True, "H", min(10.0, severity * 2), reason)
|
||||
|
||||
# Check Away Favorite Trap
|
||||
if 1.0 < ms_a <= self.fav_odds_threshold:
|
||||
# Away is favored. Check metrics
|
||||
if away_xg < self.min_xg_for_fav and (home_xg > self.opp_min_xg_threat or away_conceded_avg > self.max_conceded_for_fav):
|
||||
severity = (self.fav_odds_threshold - ms_a) + (self.min_xg_for_fav - away_xg)
|
||||
reason = f"🚨 ODDS ANOMALY (TRAP): Away odds ({ms_a}) suspiciously low despite poor metrics (xG: {round(away_xg, 2)}, Conceded: {round(away_conceded_avg, 2)})"
|
||||
return True, AnomalyResult(True, "A", min(10.0, severity * 2), reason)
|
||||
|
||||
return False, AnomalyResult(False)
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
|
||||
from .base_calculator import BaseCalculator, CalculationContext
|
||||
from .match_result_calculator import MatchResultPrediction
|
||||
|
||||
|
||||
@dataclass
|
||||
class OtherMarketsPrediction:
|
||||
total_corners_pred: float
|
||||
corner_pick: str | None
|
||||
|
||||
total_cards_pred: float
|
||||
card_pick: str
|
||||
cards_over_prob: float
|
||||
cards_under_prob: float
|
||||
cards_confidence: float
|
||||
|
||||
handicap_pick: str
|
||||
handicap_home_prob: float
|
||||
handicap_draw_prob: float
|
||||
handicap_away_prob: float
|
||||
handicap_confidence: float
|
||||
|
||||
odd_even_pick: str
|
||||
odd_prob: float
|
||||
even_prob: float
|
||||
|
||||
|
||||
class OtherMarketsCalculator(BaseCalculator):
|
||||
def calculate(
|
||||
self,
|
||||
ctx: CalculationContext,
|
||||
ms_result: MatchResultPrediction,
|
||||
) -> OtherMarketsPrediction:
|
||||
if "handicap_ms" in ctx.xgboost_preds:
|
||||
handicap_payload = ctx.xgboost_preds["handicap_ms"]
|
||||
handicap_home_prob = float(handicap_payload.get("h1", 0.33))
|
||||
handicap_draw_prob = float(handicap_payload.get("hx", 0.34))
|
||||
handicap_away_prob = float(handicap_payload.get("h2", 0.33))
|
||||
else:
|
||||
xg_diff = ctx.home_xg - ctx.away_xg
|
||||
threshold = float(self.config.get("handicap.xg_diff_threshold", 1.2))
|
||||
if xg_diff > threshold:
|
||||
handicap_home_prob, handicap_draw_prob, handicap_away_prob = 0.58, 0.24, 0.18
|
||||
elif xg_diff < -threshold:
|
||||
handicap_home_prob, handicap_draw_prob, handicap_away_prob = 0.18, 0.24, 0.58
|
||||
else:
|
||||
handicap_home_prob, handicap_draw_prob, handicap_away_prob = 0.28, 0.44, 0.28
|
||||
|
||||
handicap_confidence = max(
|
||||
handicap_home_prob,
|
||||
handicap_draw_prob,
|
||||
handicap_away_prob,
|
||||
) * 100.0
|
||||
if handicap_home_prob >= handicap_draw_prob and handicap_home_prob >= handicap_away_prob:
|
||||
handicap_pick = "H 1 (Ev -1)"
|
||||
elif handicap_away_prob >= handicap_home_prob and handicap_away_prob >= handicap_draw_prob:
|
||||
handicap_pick = "H 2 (Dep -1)"
|
||||
else:
|
||||
handicap_pick = "H 0 (Beraberlik)"
|
||||
|
||||
total_corners = 0.0
|
||||
corner_pick = None
|
||||
|
||||
card_line = float(self.config.get("cards.line", 4.5))
|
||||
if "cards_ou45" in ctx.xgboost_preds:
|
||||
cards_over_prob = float(ctx.xgboost_preds["cards_ou45"])
|
||||
total_cards = 5.0 if cards_over_prob > 0.50 else 3.5
|
||||
else:
|
||||
referee_average = float(ctx.referee_pred.avg_yellow_cards)
|
||||
match_heat = 1.0
|
||||
is_derby = bool(
|
||||
ctx.upset_factors.reasoning
|
||||
and "DERBY" in str(ctx.upset_factors.reasoning[0]),
|
||||
)
|
||||
if is_derby:
|
||||
match_heat = float(self.config.get("cards.derby_heat_factor", 1.3))
|
||||
total_cards = referee_average * match_heat
|
||||
delta = total_cards - card_line
|
||||
cards_over_prob = 1.0 / (1.0 + math.exp(-delta * 0.9))
|
||||
|
||||
cards_over_prob = max(0.02, min(0.98, cards_over_prob))
|
||||
cards_under_prob = 1.0 - cards_over_prob
|
||||
cards_confidence = max(cards_over_prob, cards_under_prob) * 100.0
|
||||
card_pick = f"{card_line} Ust" if cards_over_prob > 0.50 else f"{card_line} Alt"
|
||||
|
||||
lambda_total = ctx.total_xg
|
||||
even_prob = math.exp(-lambda_total) * math.cosh(lambda_total)
|
||||
if "odd_even" in ctx.xgboost_preds:
|
||||
xgb_weight = float(self.config.get("xgboost.weight_ou", 0.60))
|
||||
xgb_even_prob = float(ctx.xgboost_preds["odd_even"])
|
||||
even_prob = even_prob * (1 - xgb_weight) + xgb_even_prob * xgb_weight
|
||||
|
||||
even_prob = max(0.02, min(0.98, even_prob))
|
||||
odd_prob = 1.0 - even_prob
|
||||
odd_even_pick = "Cift" if even_prob > 0.5 else "Tek"
|
||||
|
||||
return OtherMarketsPrediction(
|
||||
total_corners_pred=total_corners,
|
||||
corner_pick=corner_pick,
|
||||
total_cards_pred=total_cards,
|
||||
card_pick=card_pick,
|
||||
cards_over_prob=cards_over_prob,
|
||||
cards_under_prob=cards_under_prob,
|
||||
cards_confidence=cards_confidence,
|
||||
handicap_pick=handicap_pick,
|
||||
handicap_home_prob=handicap_home_prob,
|
||||
handicap_draw_prob=handicap_draw_prob,
|
||||
handicap_away_prob=handicap_away_prob,
|
||||
handicap_confidence=handicap_confidence,
|
||||
odd_even_pick=odd_even_pick,
|
||||
odd_prob=odd_prob,
|
||||
even_prob=even_prob,
|
||||
)
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from .base_calculator import BaseCalculator, CalculationContext
|
||||
from .confidence import calc_confidence_2way
|
||||
|
||||
@dataclass
|
||||
class OverUnderPrediction:
|
||||
over_15_prob: float
|
||||
under_15_prob: float
|
||||
ou15_pick: str
|
||||
ou15_confidence: float
|
||||
|
||||
over_25_prob: float
|
||||
under_25_prob: float
|
||||
ou25_pick: str
|
||||
ou25_confidence: float
|
||||
|
||||
over_35_prob: float
|
||||
under_35_prob: float
|
||||
ou35_pick: str
|
||||
ou35_confidence: float
|
||||
|
||||
btts_yes_prob: float
|
||||
btts_no_prob: float
|
||||
btts_pick: str
|
||||
btts_confidence: float
|
||||
|
||||
|
||||
class OverUnderCalculator(BaseCalculator):
|
||||
|
||||
def _poisson_pmf(self, k: int, lam: float) -> float:
|
||||
if lam <= 0:
|
||||
return 1.0 if k == 0 else 0.0
|
||||
return (lam ** k) * math.exp(-lam) / math.factorial(k)
|
||||
|
||||
def _poisson_ou_probs(self, home_xg: float, away_xg: float, grid_max: int = 6):
|
||||
"""Bivariate Poisson grid → O/U probabilities."""
|
||||
total_goals_prob = {} # total_goals → cumulative probability
|
||||
|
||||
for i in range(grid_max):
|
||||
for j in range(grid_max):
|
||||
p = self._poisson_pmf(i, home_xg) * self._poisson_pmf(j, away_xg)
|
||||
total = i + j
|
||||
total_goals_prob[total] = total_goals_prob.get(total, 0.0) + p
|
||||
|
||||
# Cumulative
|
||||
over_15 = sum(p for g, p in total_goals_prob.items() if g >= 2)
|
||||
over_25 = sum(p for g, p in total_goals_prob.items() if g >= 3)
|
||||
over_35 = sum(p for g, p in total_goals_prob.items() if g >= 4)
|
||||
|
||||
# BTTS: P(home >= 1) * P(away >= 1)
|
||||
p_home_0 = self._poisson_pmf(0, home_xg)
|
||||
p_away_0 = self._poisson_pmf(0, away_xg)
|
||||
btts_yes = (1 - p_home_0) * (1 - p_away_0)
|
||||
|
||||
return over_15, over_25, over_35, btts_yes
|
||||
|
||||
def calculate(self, ctx: CalculationContext) -> OverUnderPrediction:
|
||||
odds_pred = ctx.odds_pred
|
||||
referee_mods = ctx.referee_mods
|
||||
|
||||
# Config
|
||||
prob_min = self.config.get("over_under.prob_min", 0.02)
|
||||
prob_max = self.config.get("over_under.prob_max", 0.98)
|
||||
blend_w = self.config.get("over_under.poisson_blend_weight", 0.4)
|
||||
grid_max = self.config.get("over_under.poisson_grid_max", 6)
|
||||
|
||||
ou15_thr = self.config.get("over_under.ou15_threshold", 0.55)
|
||||
ou25_thr = self.config.get("over_under.ou25_threshold", 0.52)
|
||||
ou35_thr = self.config.get("over_under.ou35_threshold", 0.48)
|
||||
btts_thr = self.config.get("over_under.btts_threshold", 0.58)
|
||||
|
||||
# 1. Poisson-based O/U from context xG (team + odds average)
|
||||
p_over_15, p_over_25, p_over_35, p_btts = self._poisson_ou_probs(
|
||||
ctx.home_xg, ctx.away_xg, int(grid_max)
|
||||
)
|
||||
|
||||
# 2. Odds-based O/U (from odds engine Poisson)
|
||||
o_over_15 = odds_pred.over_15_prob
|
||||
o_over_25 = odds_pred.over_25_prob
|
||||
o_over_35 = odds_pred.over_35_prob
|
||||
o_btts = odds_pred.btts_yes_prob
|
||||
|
||||
# 3. Blend: poisson xG + odds Poisson
|
||||
# Odds engine already uses Poisson internally, so keep blend weight low
|
||||
# to avoid double-counting. Use majority odds weight for established markets.
|
||||
over_15 = p_over_15 * blend_w + o_over_15 * (1 - blend_w)
|
||||
over_25 = p_over_25 * blend_w + o_over_25 * (1 - blend_w)
|
||||
over_35 = p_over_35 * blend_w + o_over_35 * (1 - blend_w)
|
||||
|
||||
# BTTS: keep primarily from odds engine (it was 63.6% accurate before)
|
||||
# Only a small Poisson contribution to cross-validate
|
||||
btts_blend = min(blend_w, 0.2)
|
||||
btts_yes = p_btts * btts_blend + o_btts * (1 - btts_blend)
|
||||
|
||||
# XGBoost Integration (High Weight)
|
||||
w_xgb = self.config.get("xgboost.weight_ou", 0.70)
|
||||
|
||||
if "ou25" in ctx.xgboost_preds:
|
||||
over_25 = over_25 * (1 - w_xgb) + ctx.xgboost_preds["ou25"] * w_xgb
|
||||
|
||||
if "ou15" in ctx.xgboost_preds:
|
||||
over_15 = over_15 * (1 - w_xgb) + ctx.xgboost_preds["ou15"] * w_xgb
|
||||
|
||||
if "ou35" in ctx.xgboost_preds:
|
||||
over_35 = over_35 * (1 - w_xgb) + ctx.xgboost_preds["ou35"] * w_xgb
|
||||
|
||||
# BTTS: lower XGBoost weight (was 0.70) — Poisson/odds fundamentals matter more
|
||||
w_xgb_btts = self.config.get("xgboost.weight_btts", 0.45)
|
||||
if "btts" in ctx.xgboost_preds:
|
||||
btts_yes = btts_yes * (1 - w_xgb_btts) + ctx.xgboost_preds["btts"] * w_xgb_btts
|
||||
|
||||
# 4. Referee modifier (only applied to goal totals, not BTTS)
|
||||
ou_mod = referee_mods.get("over_25_modifier", 1.0)
|
||||
over_15 *= ou_mod
|
||||
over_25 *= ou_mod
|
||||
over_35 *= ou_mod
|
||||
|
||||
# 5. Clamp
|
||||
over_15 = max(prob_min, min(prob_max, over_15))
|
||||
over_25 = max(prob_min, min(prob_max, over_25))
|
||||
over_35 = max(prob_min, min(prob_max, over_35))
|
||||
btts_yes = max(prob_min, min(prob_max, btts_yes))
|
||||
|
||||
# Picks & Confidence
|
||||
ou15_pick = "Üst 1.5" if over_15 > ou15_thr else "Alt 1.5"
|
||||
ou15_conf = calc_confidence_2way(over_15)
|
||||
|
||||
ou25_pick = "Üst 2.5" if over_25 > ou25_thr else "Alt 2.5"
|
||||
ou25_conf = calc_confidence_2way(over_25)
|
||||
|
||||
ou35_pick = "Üst 3.5" if over_35 > ou35_thr else "Alt 3.5"
|
||||
ou35_conf = calc_confidence_2way(over_35)
|
||||
|
||||
btts_pick = "KG Var" if btts_yes > btts_thr else "KG Yok"
|
||||
btts_conf = calc_confidence_2way(btts_yes)
|
||||
|
||||
# --- SAFE BTTS PENALTY (v2 — tighter thresholds) ---
|
||||
# Penalize BTTS confidence when fundamentals don't strongly support the pick.
|
||||
try:
|
||||
home_conceded = ctx.team_pred.raw_features.get("home_conceded_avg", 1.0)
|
||||
away_conceded = ctx.team_pred.raw_features.get("away_conceded_avg", 1.0)
|
||||
|
||||
if btts_pick == "KG Var":
|
||||
# "Var" needs BOTH teams to score → requires strong attack OR leaky defense
|
||||
# Penalty if either xG is low AND defenses are solid
|
||||
weak_attack = ctx.home_xg < 1.30 or ctx.away_xg < 1.15
|
||||
solid_defense = home_conceded < 1.15 or away_conceded < 1.15
|
||||
if weak_attack and solid_defense:
|
||||
btts_conf *= 0.3
|
||||
else: # KG Yok
|
||||
# "Yok" needs at least one team to fail scoring
|
||||
# Penalty if both have good xG AND both defenses are leaky
|
||||
if ctx.home_xg >= 1.30 and ctx.away_xg >= 1.15 and home_conceded >= 1.20 and away_conceded >= 1.20:
|
||||
btts_conf *= 0.3
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Safe BTTS Check Error: {e}")
|
||||
pass
|
||||
|
||||
|
||||
return OverUnderPrediction(
|
||||
over_15_prob=over_15, under_15_prob=1-over_15,
|
||||
ou15_pick=ou15_pick, ou15_confidence=ou15_conf,
|
||||
|
||||
over_25_prob=over_25, under_25_prob=1-over_25,
|
||||
ou25_pick=ou25_pick, ou25_confidence=ou25_conf,
|
||||
|
||||
over_35_prob=over_35, under_35_prob=1-over_35,
|
||||
ou35_pick=ou35_pick, ou35_confidence=ou35_conf,
|
||||
|
||||
btts_yes_prob=btts_yes, btts_no_prob=1-btts_yes,
|
||||
btts_pick=btts_pick, btts_confidence=btts_conf
|
||||
)
|
||||
Executable
+278
@@ -0,0 +1,278 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from .base_calculator import BaseCalculator, CalculationContext
|
||||
from .odds_anomaly_detector import OddsAnomalyDetector
|
||||
|
||||
@dataclass
|
||||
class RiskAnalysis:
|
||||
risk_score: float
|
||||
risk_level: str
|
||||
is_surprise_risk: bool
|
||||
reasons: List[str] = field(default_factory=list)
|
||||
surprise_type: str = ""
|
||||
risk_warnings: List[str] = field(default_factory=list)
|
||||
|
||||
class RiskAssessor(BaseCalculator):
|
||||
"""
|
||||
Assesses risk level of the match based on context and predictions.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
super().__init__(config)
|
||||
self.anomaly_detector = OddsAnomalyDetector(config)
|
||||
|
||||
@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
|
||||
|
||||
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 _dynamic_reversal_threshold(
|
||||
self,
|
||||
ctx: CalculationContext,
|
||||
top_label: str,
|
||||
) -> float:
|
||||
"""
|
||||
Dynamic threshold for reversal surprise flags.
|
||||
Lower threshold => easier to trigger surprise.
|
||||
"""
|
||||
base_threshold = float(self.config.get("risk.surprise_threshold", 0.20))
|
||||
sport_key = (ctx.sport or "football").lower().strip()
|
||||
is_top_league = bool(getattr(ctx, "is_top_league", False))
|
||||
|
||||
if not is_top_league:
|
||||
base_threshold = float(
|
||||
self.config.get("risk.surprise_threshold_non_top", base_threshold + 0.04),
|
||||
)
|
||||
|
||||
if sport_key == "basketball":
|
||||
if is_top_league:
|
||||
return float(
|
||||
self.config.get("risk.surprise_threshold_basketball_top", self.config.get("risk.surprise_threshold_basketball", 0.30)),
|
||||
)
|
||||
return float(
|
||||
self.config.get("risk.surprise_threshold_basketball_non_top", 0.34),
|
||||
)
|
||||
|
||||
if top_label not in ("1/2", "2/1"):
|
||||
return base_threshold
|
||||
|
||||
winner_side = "A" if top_label == "1/2" else "H"
|
||||
favorite_side, gap = self._favorite_profile_from_odds(ctx.odds_data)
|
||||
|
||||
if is_top_league:
|
||||
favorite_winner_threshold = float(
|
||||
self.config.get(
|
||||
"risk.surprise_threshold_favorite_reversal_top",
|
||||
self.config.get("risk.surprise_threshold_favorite_reversal", 0.26),
|
||||
),
|
||||
)
|
||||
underdog_winner_threshold = float(
|
||||
self.config.get(
|
||||
"risk.surprise_threshold_underdog_reversal_top",
|
||||
self.config.get("risk.surprise_threshold_underdog_reversal", 0.20),
|
||||
),
|
||||
)
|
||||
else:
|
||||
favorite_winner_threshold = float(
|
||||
self.config.get("risk.surprise_threshold_favorite_reversal_non_top", 0.30),
|
||||
)
|
||||
underdog_winner_threshold = float(
|
||||
self.config.get("risk.surprise_threshold_underdog_reversal_non_top", 0.24),
|
||||
)
|
||||
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))
|
||||
|
||||
if favorite_side in ("H", "A"):
|
||||
threshold = (
|
||||
favorite_winner_threshold
|
||||
if winner_side == favorite_side
|
||||
else underdog_winner_threshold
|
||||
)
|
||||
if winner_side != favorite_side and gap >= gap_strong:
|
||||
threshold += 0.03
|
||||
elif winner_side != favorite_side and gap >= gap_medium:
|
||||
threshold += 0.015
|
||||
return threshold
|
||||
|
||||
return base_threshold
|
||||
|
||||
def calculate(self, ctx: CalculationContext, ms_result=None) -> RiskAnalysis:
|
||||
"""
|
||||
Wrapper for assess_risk to match BaseCalculator interface but with extra arg.
|
||||
"""
|
||||
return self.assess_risk(ctx)
|
||||
|
||||
def assess_risk(self, ctx: CalculationContext) -> RiskAnalysis:
|
||||
"""
|
||||
Calculate risk score and level.
|
||||
Returns RiskAnalysis object.
|
||||
"""
|
||||
score = 5.0
|
||||
reasons = []
|
||||
is_surprise = ctx.is_surprise
|
||||
surprise_type = ""
|
||||
|
||||
# 1. League deviation (from UpsetEngine)
|
||||
if ctx.is_surprise:
|
||||
score += 2.0
|
||||
reasons.append("High Upset Potential detected by UpsetEngine")
|
||||
|
||||
# 1.5 Odds Anomaly Detection
|
||||
try:
|
||||
home_conceded = ctx.team_pred.raw_features.get("home_conceded_avg", 1.0)
|
||||
away_conceded = ctx.team_pred.raw_features.get("away_conceded_avg", 1.0)
|
||||
|
||||
has_anomaly, anomaly_res = self.anomaly_detector.detect_trap(
|
||||
ctx.odds_data,
|
||||
ctx.home_xg,
|
||||
ctx.away_xg,
|
||||
home_conceded,
|
||||
away_conceded
|
||||
)
|
||||
|
||||
if has_anomaly:
|
||||
is_surprise = True
|
||||
score += anomaly_res.severity + 2.0
|
||||
surprise_type = "Bookmaker Trap"
|
||||
reasons.append(anomaly_res.reason)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Odds Anomaly Detection Error: {e}")
|
||||
pass
|
||||
|
||||
# 2. HT/FT Surprise Hunter (XGBoost)
|
||||
# We look for 1/2 (idx 2) and 2/1 (idx 6) from the V20 HT/FT model
|
||||
if "ht_ft" in ctx.xgboost_preds:
|
||||
ht_ft = ctx.xgboost_preds["ht_ft"]
|
||||
valid_items = [(k, float(v)) for k, v in ht_ft.items() if isinstance(v, (int, float))]
|
||||
if valid_items:
|
||||
ranked = sorted(valid_items, key=lambda item: item[1], reverse=True)
|
||||
top_label, top_prob = ranked[0]
|
||||
second_prob = ranked[1][1] if len(ranked) > 1 else 0.0
|
||||
top_gap = top_prob - second_prob
|
||||
|
||||
threshold = self._dynamic_reversal_threshold(ctx, top_label)
|
||||
if getattr(ctx, "is_top_league", False):
|
||||
min_gap = float(self.config.get("risk.surprise_min_top_gap_top", self.config.get("risk.surprise_min_top_gap", 0.02)))
|
||||
else:
|
||||
min_gap = float(self.config.get("risk.surprise_min_top_gap_non_top", 0.03))
|
||||
|
||||
# Trigger surprise only when reversal class is:
|
||||
# - top HT/FT outcome
|
||||
# - above dynamic threshold
|
||||
# - separated from second class with a minimum gap
|
||||
if top_label in ("1/2", "2/1") and top_prob > threshold and top_gap > min_gap:
|
||||
is_surprise = True
|
||||
score += 3.0
|
||||
surprise_type = f"{top_label} Reversal"
|
||||
reasons.append(
|
||||
f"🔥 Surprise Hunter: {top_label} potential ({round(top_prob*100, 1)}%, gap {round(top_gap*100, 1)}pp)"
|
||||
)
|
||||
|
||||
# NEW: Potential Upset Alert - even if reversal is not the top prediction
|
||||
# This catches cases like Bayern vs Augsburg where 1/2 was only 2% but it happened
|
||||
favorite_side, gap = self._favorite_profile_from_odds(ctx.odds_data)
|
||||
|
||||
# Get reversal probabilities
|
||||
prob_12 = float(ht_ft.get("1/2", 0))
|
||||
prob_21 = float(ht_ft.get("2/1", 0))
|
||||
|
||||
# DYNAMIC threshold based on odds - stronger favorite = lower threshold
|
||||
# When home odds are 1.30, even 1% reversal probability is significant
|
||||
base_threshold = float(self.config.get("risk.upset_alert_threshold", 0.05))
|
||||
|
||||
# Calculate dynamic threshold based on favorite strength
|
||||
if favorite_side == "H":
|
||||
home_odds = float(ctx.odds_data.get("ms_h", 2.0))
|
||||
# Stronger favorite (lower odds) = lower threshold
|
||||
# 1.20 odds -> 0.01 threshold, 1.50 odds -> 0.03 threshold, 2.0+ odds -> base threshold
|
||||
if home_odds <= 1.25:
|
||||
dynamic_threshold = 0.01 # 1% - extremely strong favorite
|
||||
elif home_odds <= 1.40:
|
||||
dynamic_threshold = 0.015 # 1.5% - very strong favorite
|
||||
elif home_odds <= 1.60:
|
||||
dynamic_threshold = 0.02 # 2% - strong favorite
|
||||
elif home_odds < 2.00:
|
||||
dynamic_threshold = 0.03 # 3% - moderate favorite
|
||||
else:
|
||||
dynamic_threshold = base_threshold
|
||||
elif favorite_side == "A":
|
||||
away_odds = float(ctx.odds_data.get("ms_a", 2.0))
|
||||
if away_odds <= 1.25:
|
||||
dynamic_threshold = 0.01
|
||||
elif away_odds <= 1.40:
|
||||
dynamic_threshold = 0.015
|
||||
elif away_odds <= 1.60:
|
||||
dynamic_threshold = 0.02
|
||||
elif away_odds < 2.00:
|
||||
dynamic_threshold = 0.03
|
||||
else:
|
||||
dynamic_threshold = base_threshold
|
||||
else:
|
||||
dynamic_threshold = base_threshold
|
||||
|
||||
# Check for potential upset based on favorite
|
||||
if favorite_side == "H" and prob_12 > dynamic_threshold:
|
||||
# Home favorite, but 1/2 (home leads HT, away wins FT) has potential
|
||||
is_surprise = True
|
||||
score += 2.0
|
||||
surprise_type = "1/2 Potential Upset"
|
||||
reasons.append(
|
||||
f"⚠️ UPSET ALERT: Home favorite ({ctx.odds_data.get('ms_h', 'N/A')}) but 1/2 reversal risk ({round(prob_12*100, 1)}% > {round(dynamic_threshold*100, 1)}% threshold)"
|
||||
)
|
||||
elif favorite_side == "A" and prob_21 > dynamic_threshold:
|
||||
# Away favorite, but 2/1 (away leads HT, home wins FT) has potential
|
||||
is_surprise = True
|
||||
score += 2.0
|
||||
surprise_type = "2/1 Potential Upset"
|
||||
reasons.append(
|
||||
f"⚠️ UPSET ALERT: Away favorite ({ctx.odds_data.get('ms_a', 'N/A')}) but 2/1 reversal risk ({round(prob_21*100, 1)}% > {round(dynamic_threshold*100, 1)}% threshold)"
|
||||
)
|
||||
elif gap > 0.5 and (prob_12 > dynamic_threshold or prob_21 > dynamic_threshold):
|
||||
# Strong favorite (big odds gap) with any reversal potential
|
||||
reversal_type = "1/2" if prob_12 > prob_21 else "2/1"
|
||||
reversal_prob = max(prob_12, prob_21)
|
||||
is_surprise = True
|
||||
score += 1.5
|
||||
surprise_type = f"{reversal_type} Potential Upset"
|
||||
reasons.append(
|
||||
f"⚠️ UPSET ALERT: Strong favorite (gap {round(gap, 2)}) with {reversal_type} risk ({round(reversal_prob*100, 1)}%)"
|
||||
)
|
||||
|
||||
# Determine level
|
||||
if score < 4.0:
|
||||
level = "LOW"
|
||||
elif score < 7.0:
|
||||
level = "MEDIUM"
|
||||
elif score < 9.0:
|
||||
level = "HIGH"
|
||||
else:
|
||||
level = "EXTREME"
|
||||
|
||||
return RiskAnalysis(
|
||||
risk_score=score,
|
||||
risk_level=level,
|
||||
is_surprise_risk=is_surprise,
|
||||
surprise_type=surprise_type,
|
||||
reasons=reasons
|
||||
)
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import os
|
||||
import pickle
|
||||
import pandas as pd
|
||||
import xgboost as xgb
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict, Tuple
|
||||
import math
|
||||
from .base_calculator import BaseCalculator, CalculationContext
|
||||
from .confidence import calc_confidence_3way, calc_confidence_dc
|
||||
from .match_result_calculator import MatchResultPrediction
|
||||
|
||||
@dataclass
|
||||
class ScorePrediction:
|
||||
predicted_ft_score: str
|
||||
predicted_ht_score: str
|
||||
ft_scores_top5: List[Dict]
|
||||
|
||||
# Reconciled MS/DC predictions (can be updated here)
|
||||
reconciled_ms: MatchResultPrediction = None
|
||||
|
||||
class ScoreCalculator(BaseCalculator):
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
super().__init__(config)
|
||||
self.xgb_home = None
|
||||
self.xgb_away = None
|
||||
self.xgb_ht_home = None
|
||||
self.xgb_ht_away = None
|
||||
self.scaler = None # If used
|
||||
self.features = []
|
||||
self._load_model()
|
||||
|
||||
def _load_model(self):
|
||||
try:
|
||||
model_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "models", "xgb_score.pkl")
|
||||
if os.path.exists(model_path):
|
||||
with open(model_path, "rb") as f:
|
||||
data = pickle.load(f)
|
||||
# Handle both dictionary and direct model formats (just in case)
|
||||
if isinstance(data, dict):
|
||||
self.xgb_home = data.get("home_model")
|
||||
self.xgb_away = data.get("away_model")
|
||||
self.xgb_ht_home = data.get("ht_home_model")
|
||||
self.xgb_ht_away = data.get("ht_away_model")
|
||||
self.features = data.get("features", [])
|
||||
else:
|
||||
print("⚠️ Unexpected XGB score model format.")
|
||||
print("✅ XGBoost Score Model loaded.")
|
||||
else:
|
||||
print(f"⚠️ XGBoost Score Model not found at {model_path}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading XGBoost Score Model: {e}")
|
||||
|
||||
def _poisson_pmf(self, k, lam):
|
||||
"""Poisson probability mass function."""
|
||||
if lam <= 0:
|
||||
return 1.0 if k == 0 else 0.0
|
||||
return (lam ** k) * math.exp(-lam) / math.factorial(k)
|
||||
|
||||
def calculate(self, ctx: CalculationContext, ms_result: MatchResultPrediction) -> ScorePrediction:
|
||||
# Default Lambdas (fallback)
|
||||
lambda_home = max(0.5, ctx.home_xg)
|
||||
lambda_away = max(0.5, ctx.away_xg)
|
||||
|
||||
# --- XGBOOST PREDICTION ---
|
||||
if self.xgb_home and self.xgb_away and hasattr(ctx.team_pred, "raw_features"):
|
||||
try:
|
||||
# 1. Prepare Features
|
||||
# We need to map ctx data to self.features list columns
|
||||
raw = ctx.team_pred.raw_features
|
||||
odds = ctx.odds_data or {}
|
||||
|
||||
# Use unified feature adapter for exact 56-feature sync
|
||||
from features.feature_adapter import get_feature_adapter
|
||||
df_input = get_feature_adapter().get_features(ctx)
|
||||
|
||||
# Predict FT
|
||||
pred_h = self.xgb_home.predict(df_input)[0]
|
||||
pred_a = self.xgb_away.predict(df_input)[0]
|
||||
|
||||
# Predict HT (if available)
|
||||
if self.xgb_ht_home and self.xgb_ht_away:
|
||||
pred_ht_h = self.xgb_ht_home.predict(df_input)[0]
|
||||
pred_ht_a = self.xgb_ht_away.predict(df_input)[0]
|
||||
|
||||
# Clamp HT predictions (min 0, and shouldn't exceed FT in logic, but models are independent)
|
||||
# We trust the model but ensure sanity (HT <= FT is hard to enforce without joint training, but usually holds)
|
||||
ht_h_val = max(0.0, float(pred_ht_h))
|
||||
ht_a_val = max(0.0, float(pred_ht_a))
|
||||
|
||||
predicted_ht = f"{round(ht_h_val)}-{round(ht_a_val)}"
|
||||
else:
|
||||
# Fallback if HT models missing
|
||||
ht_h_val = max(0.0, float(pred_h) * 0.42)
|
||||
ht_a_val = max(0.0, float(pred_a) * 0.42)
|
||||
predicted_ht = f"{round(ht_h_val)}-{round(ht_a_val)}"
|
||||
|
||||
# Update lambdas with ML predictions
|
||||
lambda_home = max(0.1, min(6.0, float(pred_h)))
|
||||
lambda_away = max(0.1, min(6.0, float(pred_a)))
|
||||
|
||||
# Store raw XGB preds in context
|
||||
ctx.xgboost_preds["score"] = {
|
||||
"home": lambda_home,
|
||||
"away": lambda_away,
|
||||
"ht_home": ht_h_val,
|
||||
"ht_away": ht_a_val
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ XGBoost Score Prediction failed: {e}. Falling back to Poisson xG.")
|
||||
# Fallback to current simple logic if ML fails
|
||||
predicted_ht = f"{round(lambda_home * 0.42)}-{round(lambda_away * 0.42)}"
|
||||
|
||||
# --- POISSON GRID GENERATION ---
|
||||
# Now use lambda_home/away (either ML or fallback) to generate grid
|
||||
score_probs = {}
|
||||
grid_max = self.config.get("score.poisson_grid_max", 7)
|
||||
|
||||
for i in range(grid_max):
|
||||
for j in range(grid_max):
|
||||
p = self._poisson_pmf(i, lambda_home) * self._poisson_pmf(j, lambda_away)
|
||||
score_probs[f"{i}-{j}"] = round(p * 100, 2)
|
||||
|
||||
sorted_scores = sorted(score_probs.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
# --- DERIVE MS PROBS FROM SCORES (CONSISTENCY CHECK) ---
|
||||
poisson_ms_home = sum(p for s, p in score_probs.items()
|
||||
for h, a in [s.split("-")] if int(h) > int(a))
|
||||
poisson_ms_away = sum(p for s, p in score_probs.items()
|
||||
for h, a in [s.split("-")] if int(h) < int(a))
|
||||
poisson_ms_draw = sum(p for s, p in score_probs.items()
|
||||
for h, a in [s.split("-")] if int(h) == int(a))
|
||||
|
||||
# Normalize
|
||||
poisson_total = poisson_ms_home + poisson_ms_away + poisson_ms_draw
|
||||
if poisson_total > 0:
|
||||
poisson_ms_home /= poisson_total
|
||||
poisson_ms_away /= poisson_total
|
||||
poisson_ms_draw /= poisson_total
|
||||
|
||||
# --- HYBRID RECONCILIATION ---
|
||||
|
||||
threshold = self.config.get("score.ms_confidence_threshold", 15.0)
|
||||
reconciled_result = ms_result
|
||||
|
||||
# If original confidence is low, trust new Score Model more
|
||||
if ms_result.ms_confidence < threshold:
|
||||
poisson_probs = [(poisson_ms_home, "1"), (poisson_ms_draw, "X"), (poisson_ms_away, "2")]
|
||||
poisson_sorted = sorted(poisson_probs, key=lambda x: x[0], reverse=True)
|
||||
|
||||
new_ms_pick = poisson_sorted[0][1]
|
||||
new_ms_conf = calc_confidence_3way(poisson_sorted[0][0])
|
||||
|
||||
# Recalculate DC
|
||||
dc_1x = poisson_ms_home + poisson_ms_draw
|
||||
dc_x2 = poisson_ms_draw + poisson_ms_away
|
||||
dc_12 = poisson_ms_home + poisson_ms_away
|
||||
|
||||
dc_probs = [(dc_1x, "1X"), (dc_x2, "X2"), (dc_12, "12")]
|
||||
dc_sorted = sorted(dc_probs, key=lambda x: x[0], reverse=True)
|
||||
new_dc_pick = dc_sorted[0][1]
|
||||
new_dc_conf = calc_confidence_dc(dc_sorted[0][0])
|
||||
|
||||
reconciled_result = MatchResultPrediction(
|
||||
ms_home_prob=poisson_ms_home,
|
||||
ms_draw_prob=poisson_ms_draw,
|
||||
ms_away_prob=poisson_ms_away,
|
||||
ms_pick=new_ms_pick,
|
||||
ms_confidence=new_ms_conf,
|
||||
dc_1x_prob=dc_1x,
|
||||
dc_x2_prob=dc_x2,
|
||||
dc_12_prob=dc_12,
|
||||
dc_pick=new_dc_pick,
|
||||
dc_confidence=new_dc_conf
|
||||
)
|
||||
|
||||
# Select best score that matches MS Pick
|
||||
# NEW LOGIC: We trust XGBoost/Poisson top score over generic MS Pick if MS Confidence is low.
|
||||
# Otherwise, we filter the grid to match the MS pick.
|
||||
ms_pick = reconciled_result.ms_pick
|
||||
|
||||
def _score_matches_ms(score_str, pick):
|
||||
h, a = map(int, score_str.split("-"))
|
||||
if pick == "1": return h > a
|
||||
if pick == "2": return h < a
|
||||
return h == a
|
||||
|
||||
matching_scores = [(s, p) for s, p in sorted_scores if _score_matches_ms(s, ms_pick)]
|
||||
|
||||
# Primary Prediction Strategy:
|
||||
# If MS pick is highly confident, enforce it.
|
||||
# But if the absolute best score in the grid contradicts it and has a high probability (e.g. >10%), trust the score model directly.
|
||||
top_overall_score, top_overall_prob = sorted_scores[0]
|
||||
|
||||
if matching_scores and not (top_overall_prob > 12.0 and not _score_matches_ms(top_overall_score, ms_pick)):
|
||||
predicted_ft = matching_scores[0][0]
|
||||
else:
|
||||
predicted_ft = top_overall_score
|
||||
|
||||
# If we didn't calculate HT via ML (exception case), do it now
|
||||
if 'predicted_ht' not in locals():
|
||||
ft_to_ht = self.config.get("half_time.ft_to_ht_ratio", 0.42)
|
||||
ht_h = round(lambda_home * ft_to_ht)
|
||||
ht_a = round(lambda_away * ft_to_ht)
|
||||
predicted_ht = f"{ht_h}-{ht_a}"
|
||||
|
||||
# --- CONSISTENCY CHECK ---
|
||||
# Ensure HT score <= FT score
|
||||
try:
|
||||
ft_h, ft_a = map(int, predicted_ft.split("-"))
|
||||
ht_h, ht_a = map(int, predicted_ht.split("-"))
|
||||
|
||||
# Clamp HT values
|
||||
ht_h = min(ht_h, ft_h)
|
||||
ht_a = min(ht_a, ft_a)
|
||||
|
||||
predicted_ht = f"{ht_h}-{ht_a}"
|
||||
except ValueError:
|
||||
pass # Malformed score string, ignore correction
|
||||
|
||||
ft_scores = [{"score": s, "prob": p} for s, p in sorted_scores[:5]]
|
||||
|
||||
return ScorePrediction(
|
||||
predicted_ft_score=predicted_ft,
|
||||
predicted_ht_score=predicted_ht,
|
||||
ft_scores_top5=ft_scores,
|
||||
reconciled_ms=reconciled_result
|
||||
)
|
||||
Reference in New Issue
Block a user