211 lines
8.9 KiB
Python
Executable File
211 lines
8.9 KiB
Python
Executable File
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
|
|
)
|