first (part 2: other directories)
Deploy Iddaai Backend / build-and-deploy (push) Failing after 18s

This commit is contained in:
2026-04-16 15:11:25 +03:00
parent 7814e0bc6b
commit 2f0b85a0c7
203 changed files with 59989 additions and 0 deletions
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
)
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
)
+278
View File
@@ -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
View File
@@ -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
)