This commit is contained in:
+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
|
||||
)
|
||||
Reference in New Issue
Block a user