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, # type: ignore[override] 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 )