Source code for slickbet.model

"""
Betting model for calculating win/loss probabilities.

Implements a weighted scoring system over form, league position, home
advantage, H2H, bookmaker odds, goals, venue form, defense, momentum,
match statistics, expected goals (xG), and discipline/reliability.
Produces ``BetPrediction`` objects including draw risk and double-chance
probabilities.
"""

from dataclasses import dataclass
from enum import Enum

from slickbet.api import Match


[docs] class BetOutcome(Enum): """Possible betting outcomes.""" HOME_WIN = "home_win" AWAY_WIN = "away_win" DRAW = "draw" # Double Chance outcomes HOME_OR_DRAW = "home_or_draw" # 1X: Home doesn't lose AWAY_OR_DRAW = "away_or_draw" # X2: Away doesn't lose HOME_OR_AWAY = "home_or_away" # 12: No draw
[docs] @dataclass class DoubleChancePrediction: """ Represents double chance betting predictions for a match. Double chance allows betting on two outcomes at once (safer but lower odds). """ # 1X: Home wins OR Draw (Home doesn't lose) home_or_draw_prob: float = 0.0 # X2: Away wins OR Draw (Away doesn't lose) away_or_draw_prob: float = 0.0 # 12: Home wins OR Away wins (No draw) no_draw_prob: float = 0.0 @property def best_double_chance(self) -> tuple[str, float]: """ Return the best double chance bet and its probability. Returns ------- tuple[str, float] ``(label, probability)`` for the highest double-chance option. """ options = { "1X (Home or Draw)": self.home_or_draw_prob, "X2 (Away or Draw)": self.away_or_draw_prob, "12 (No Draw)": self.no_draw_prob, } best = max(options.items(), key=lambda x: x[1]) return best
[docs] @dataclass class BetPrediction: """ Represents a betting prediction for a match. """ match: Match recommended_outcome: BetOutcome probability: float # 0.0 to 1.0 confidence: float # 0.0 to 1.0 (how confident we are in the prediction) # Breakdown of factors (core) form_score: float position_score: float home_advantage_score: float h2h_score: float odds_score: float = 0.0 # NEW: Enhanced factors goal_score: float = 0.0 # Attack/defense strength differential venue_form_score: float = 0.0 # Home/away specific performance defense_score: float = 0.0 # Clean sheet and goals conceded differential momentum_score: float = 0.0 # First-half dominance and consistency match_stats_score: float = 0.0 # Match statistics averages (possession, attacks, shots) xg_score: float = 0.0 # Expected Goals (xG) differential reliability_score: float = 0.0 # Team reliability based on discipline (cards) # Draw risk assessment draw_risk: float = 0.0 # 0.0 to 1.0 (probability of draw) # Double chance predictions double_chance: DoubleChancePrediction | None = None # Reasoning reasoning: list[str] = None # type: ignore @property def expected_value(self) -> float: """ Calculate expected value assuming fair 2.0 odds. EV = (probability * potential_win) - (1 - probability) * stake Returns ------- float Expected value per unit stake at even money. """ # Assuming stake of 1 unit and odds of 2.0 (fair odds for 50%) return (self.probability * 1.0) - ((1 - self.probability) * 1.0) @property def match_stats_used(self) -> bool: """ Check if match statistics were used in this prediction. Returns ------- bool True if match statistics contributed to the prediction """ if not self.match.home_performance or not self.match.away_performance: return False # Match stats were used if both teams have at least 1 match with statistics # We check if statistics were available, not if the score is non-zero # (score can be 0.0 if teams are evenly matched, but stats were still used) home_stats = self.match.home_performance.matches_with_stats away_stats = self.match.away_performance.matches_with_stats # Statistics were used if we have data for both teams # The score being 0.0 just means teams are evenly matched, not that stats weren't used return home_stats >= 1 and away_stats >= 1
[docs] class BettingModel: """ Model for calculating betting probabilities based on match statistics. The model uses a weighted scoring system combining multiple factors. ENHANCED VERSION with 12 factors: - Core: Form, Position, Home Advantage, H2H, Odds - Goals: Attack/defense strength, Venue Form, Defense (clean sheets) - Momentum: First-half performance and consistency - Match Stats: Historical averages (possession, attacks, shots on target) - xG: Expected Goals differential (quality of chances created) - Reliability: Team discipline based on card counts (fewer cards = more reliable) Weights optimized based on 120-day backtest across 5 major leagues (704 matches): - Bundesliga: 81.3% accuracy (excl. draws) - Premier League: 78.2% accuracy (excl. draws) - Serie A: 74.8% accuracy (excl. draws) - Ligue 1: 70.9% accuracy (excl. draws) - La Liga: 68.9% accuracy (excl. draws) """ # Weights for different factors (should sum to 1.0) # Updated to include momentum metrics and match statistics from API # WEIGHTS = { # "defense": 0.05, # Clean sheets and defensive solidity # "form": 0.15, # Recent form - reliable predictor # "goals": 0.11, # Attack/defense strength differential # "h2h": 0.07, # Head-to-head - less reliable due to sample size # "home": 0.09, # Home advantage - consistent ~10% boost # "match_stats": 0.05, # Match statistics averages (possession, attacks, shots) # "momentum": 0.06, # First-half dominance and consistency # "odds": 0.17, # Bookmaker odds - highly predictive when available # "position": 0.19, # League standing - MOST predictive factor # "reliability": 0.04, # Team reliability based on discipline (cards) # "venue_form": 0.06, # Home/away specific performance # "xg": 0.05, # Expected Goals (xG) - quality of chances created # } # Copy of the weights from hyperparameter tuning WEIGHTS = { "defense": 0.1042, "form": 0.0333, "goals": 0.0855, "h2h": 0.0605, "home": 0.0945, "match_stats": 0.0706, "momentum": 0.1195, "odds": 0.0549, "position": 0.0474, "reliability": 0.0874, "venue_form": 0.2057, "xg": 0.0365, } # Home advantage baseline (home teams win ~46% of matches historically) HOME_ADVANTAGE = 0.12 # Minimum confidence threshold for recommendations MIN_CONFIDENCE = 0.55 # Draw risk threshold - matches with score close to 0 are draw-prone DRAW_RISK_THRESHOLD = 0.15 def __init__( self, form_weight: float | None = None, position_weight: float | None = None, home_weight: float | None = None, h2h_weight: float | None = None, odds_weight: float | None = None, goals_weight: float | None = None, venue_form_weight: float | None = None, defense_weight: float | None = None, momentum_weight: float | None = None, match_stats_weight: float | None = None, xg_weight: float | None = None, reliability_weight: float | None = None, min_confidence: float = 0.55, ): """ Initialize the betting model with custom weights. Parameters ---------- form_weight : float Weight for recent form factor position_weight : float Weight for league position factor home_weight : float Weight for home advantage factor h2h_weight : float Weight for head-to-head factor odds_weight : float Weight for bookmaker odds factor goals_weight : float Weight for attack/defense strength factor venue_form_weight : float Weight for home/away specific performance defense_weight : float Weight for clean sheet/defensive factor momentum_weight : float Weight for first-half dominance and consistency match_stats_weight : float Weight for match statistics averages (possession, attacks, shots) xg_weight : float Weight for Expected Goals (xG) differential reliability_weight : float Weight for team reliability based on discipline (cards) min_confidence : float Minimum confidence threshold for recommendations """ w = self.WEIGHTS form_weight = form_weight if form_weight is not None else w["form"] position_weight = position_weight if position_weight is not None else w["position"] home_weight = home_weight if home_weight is not None else w["home"] h2h_weight = h2h_weight if h2h_weight is not None else w["h2h"] odds_weight = odds_weight if odds_weight is not None else w["odds"] goals_weight = goals_weight if goals_weight is not None else w["goals"] venue_form_weight = venue_form_weight if venue_form_weight is not None else w["venue_form"] defense_weight = defense_weight if defense_weight is not None else w["defense"] momentum_weight = momentum_weight if momentum_weight is not None else w["momentum"] match_stats_weight = ( match_stats_weight if match_stats_weight is not None else w["match_stats"] ) xg_weight = xg_weight if xg_weight is not None else w["xg"] reliability_weight = ( reliability_weight if reliability_weight is not None else w["reliability"] ) total = ( form_weight + position_weight + home_weight + h2h_weight + odds_weight + goals_weight + venue_form_weight + defense_weight + momentum_weight + match_stats_weight + xg_weight + reliability_weight ) # Normalize weights to sum to 1.0 self.weights = { "form": form_weight / total, "position": position_weight / total, "home": home_weight / total, "h2h": h2h_weight / total, "odds": odds_weight / total, "goals": goals_weight / total, "venue_form": venue_form_weight / total, "defense": defense_weight / total, "momentum": momentum_weight / total, "match_stats": match_stats_weight / total, "xg": xg_weight / total, "reliability": reliability_weight / total, } self.min_confidence = min_confidence
[docs] def predict(self, match: Match) -> BetPrediction: """ Generate a betting prediction for a match. Parameters ---------- match : Match Match object with statistics Returns ------- BetPrediction BetPrediction with probabilities and recommendation """ reasoning = [] # Calculate individual factor scores (Core factors) form_score, form_reasons = self._calculate_form_score(match) reasoning.extend(form_reasons) position_score, position_reasons = self._calculate_position_score(match) reasoning.extend(position_reasons) home_score, home_reasons = self._calculate_home_advantage_score(match) reasoning.extend(home_reasons) h2h_score, h2h_reasons = self._calculate_h2h_score(match) reasoning.extend(h2h_reasons) odds_score, odds_reasons = self._calculate_odds_score(match) reasoning.extend(odds_reasons) # NEW: Calculate enhanced factors goal_score, goal_reasons = self._calculate_goal_score(match) reasoning.extend(goal_reasons) venue_form_score, venue_reasons = self._calculate_venue_form_score(match) reasoning.extend(venue_reasons) defense_score, defense_reasons = self._calculate_defense_score(match) reasoning.extend(defense_reasons) momentum_score, momentum_reasons = self._calculate_momentum_score(match) reasoning.extend(momentum_reasons) match_stats_score, match_stats_reasons = self._calculate_match_stats_score(match) reasoning.extend(match_stats_reasons) xg_score, xg_reasons = self._calculate_xg_score(match) reasoning.extend(xg_reasons) reliability_score, reliability_reasons = self._calculate_reliability_score(match) reasoning.extend(reliability_reasons) # Adaptive weighting for match statistics based on data quality # More weight when we have more matches with statistics match_stats_weight = self.weights["match_stats"] if match.home_performance and match.away_performance: # Scale weight based on average matches with stats (0.0 to 1.0) # Full weight at 5+ matches, reduced proportionally below that home_stats = match.home_performance.matches_with_stats away_stats = match.away_performance.matches_with_stats avg_matches_with_stats = (home_stats + away_stats) / 2.0 # Scale factor: 0.0 (no data) to 1.0 (5+ matches) data_quality_factor = min(avg_matches_with_stats / 5.0, 1.0) match_stats_weight = match_stats_weight * data_quality_factor # Adaptive weighting for xG when both teams have xG data xg_weight = self.weights["xg"] if match.home_performance and match.away_performance: if ( match.home_performance.avg_expected_goals <= 0 or match.away_performance.avg_expected_goals <= 0 ): xg_weight = 0.0 else: avg_matches = ( match.home_performance.matches_with_stats + match.away_performance.matches_with_stats ) / 2.0 xg_weight = xg_weight * min(avg_matches / 5.0, 1.0) # Calculate weighted composite score # Score > 0 favors home team, score < 0 favors away team composite_score = ( form_score * self.weights["form"] + position_score * self.weights["position"] + home_score * self.weights["home"] + h2h_score * self.weights["h2h"] + odds_score * self.weights["odds"] + goal_score * self.weights["goals"] + venue_form_score * self.weights["venue_form"] + defense_score * self.weights["defense"] + momentum_score * self.weights["momentum"] + match_stats_score * match_stats_weight + xg_score * xg_weight + reliability_score * self.weights["reliability"] ) # Convert score to probability using sigmoid-like transformation # Score range is roughly -1 to 1, map to probability 0.2 to 0.8 home_probability = self._score_to_probability(composite_score) away_probability = 1.0 - home_probability # Calculate draw risk based on how close the composite score is to 0 # Matches with evenly-matched teams are more likely to draw # Now also considers bookmaker draw odds and PPG proximity draw_risk = self._calculate_draw_risk(composite_score, form_score, position_score, match) if draw_risk > self.DRAW_RISK_THRESHOLD: reasoning.append(f"⚠️ High draw risk detected ({draw_risk:.1%})") # Calculate double chance probabilities # These assume draw probability = draw_risk (simplified model) draw_prob = draw_risk # Adjust home/away probs to account for draw adjusted_home = home_probability * (1 - draw_prob) adjusted_away = away_probability * (1 - draw_prob) double_chance = DoubleChancePrediction( home_or_draw_prob=adjusted_home + draw_prob, # 1X away_or_draw_prob=adjusted_away + draw_prob, # X2 no_draw_prob=adjusted_home + adjusted_away, # 12 (= 1 - draw_prob) ) best_dc, best_dc_prob = double_chance.best_double_chance reasoning.append(f"🎲 Best double chance: {best_dc} ({best_dc_prob:.1%})") # Determine recommended outcome and confidence if home_probability > away_probability: recommended = BetOutcome.HOME_WIN probability = home_probability # Reduce confidence if draw risk is high base_confidence = abs(home_probability - 0.5) * 2 confidence = base_confidence * (1.0 - draw_risk * 0.5) else: recommended = BetOutcome.AWAY_WIN probability = away_probability base_confidence = abs(away_probability - 0.5) * 2 confidence = base_confidence * (1.0 - draw_risk * 0.5) return BetPrediction( match=match, recommended_outcome=recommended, probability=probability, confidence=confidence, form_score=form_score, position_score=position_score, home_advantage_score=home_score, h2h_score=h2h_score, odds_score=odds_score, goal_score=goal_score, venue_form_score=venue_form_score, defense_score=defense_score, momentum_score=momentum_score, match_stats_score=match_stats_score, xg_score=xg_score, reliability_score=reliability_score, draw_risk=draw_risk, double_chance=double_chance, reasoning=reasoning, )
def _calculate_form_score(self, match: Match) -> tuple[float, list[str]]: """ Calculate score based on recent form. Parameters ---------- match : Match Match with ``home_form`` / ``away_form`` strings. Returns ------- tuple[float, list[str]] Tuple of (score, reasoning_list). Score range: -1.0 to 1.0 (positive favors home) """ reasons = [] home_form = match.home_form or "" away_form = match.away_form or "" if not home_form and not away_form: return 0.0, ["No form data available"] def form_to_points(form: str) -> float: """Convert form string to points (3 for W, 1 for D, 0 for L).""" if not form: return 0.0 points = sum(3 if r == "W" else (1 if r == "D" else 0) for r in form) max_points = len(form) * 3 return points / max_points if max_points > 0 else 0.0 home_points = form_to_points(home_form) away_points = form_to_points(away_form) # Score difference normalized to -1 to 1 score = home_points - away_points if home_form: reasons.append(f"Home form: {home_form} ({home_points:.2%} of max points)") if away_form: reasons.append(f"Away form: {away_form} ({away_points:.2%} of max points)") if score > 0.2: reasons.append("→ Home team in significantly better form") elif score < -0.2: reasons.append("→ Away team in significantly better form") return score, reasons def _calculate_position_score(self, match: Match) -> tuple[float, list[str]]: """ Calculate score based on league position. Parameters ---------- match : Match Match with ``home_position`` / ``away_position``. Returns ------- tuple[float, list[str]] Tuple of (score, reasoning_list). Score range: -1.0 to 1.0 (positive favors home) """ reasons = [] home_pos = match.home_position away_pos = match.away_position if home_pos is None or away_pos is None: return 0.0, ["No standings data available"] # Convert to int if they're strings try: home_pos = int(home_pos) away_pos = int(away_pos) except (ValueError, TypeError): return 0.0, ["Invalid standings data"] # Position difference (lower is better) # Normalize assuming max 20 teams in league pos_diff = (away_pos - home_pos) / 20.0 # Clamp to -1 to 1 score = max(-1.0, min(1.0, pos_diff)) reasons.append(f"Home position: {home_pos}, Away position: {away_pos}") if home_pos < away_pos: reasons.append(f"→ Home team {away_pos - home_pos} places higher") elif away_pos < home_pos: reasons.append(f"→ Away team {home_pos - away_pos} places higher") else: reasons.append("→ Teams at same position") return score, reasons def _calculate_home_advantage_score(self, match: Match) -> tuple[float, list[str]]: """ Calculate score based on home advantage. Parameters ---------- match : Match Match being scored (unused beyond signature consistency). Returns ------- tuple[float, list[str]] Tuple of (score, reasoning_list). Score range: 0.0 to 0.5 (always favors home) """ # Historical home advantage is significant in soccer # Home teams win about 46% of matches, away about 27%, draw about 27% score = self.HOME_ADVANTAGE * 2 # Normalize to contribute positively reasons = [f"Home advantage factor applied (+{self.HOME_ADVANTAGE:.0%} to home team)"] return score, reasons def _calculate_h2h_score(self, match: Match) -> tuple[float, list[str]]: """ Calculate score based on head-to-head record. Parameters ---------- match : Match Match with ``head_to_head`` dict when available. Returns ------- tuple[float, list[str]] Tuple of (score, reasoning_list). Score range: -1.0 to 1.0 (positive favors home) """ reasons = [] h2h = match.head_to_head if not h2h or h2h.get("matches_played", 0) == 0: return 0.0, ["No head-to-head data available"] total = h2h["matches_played"] home_wins = h2h["home_wins"] away_wins = h2h["away_wins"] draws = h2h["draws"] # Calculate win rate difference home_win_rate = home_wins / total away_win_rate = away_wins / total # Score based on win rate difference score = home_win_rate - away_win_rate reasons.append( f"H2H record (last {total}): " f"Home wins: {home_wins}, Away wins: {away_wins}, Draws: {draws}" ) if home_wins > away_wins: reasons.append("→ Home team dominates head-to-head") elif away_wins > home_wins: reasons.append("→ Away team dominates head-to-head") else: reasons.append("→ Even head-to-head record") return score, reasons def _calculate_odds_score(self, match: Match) -> tuple[float, list[str]]: """ Calculate score based on bookmaker odds. Bookmaker odds are highly predictive as they incorporate vast amounts of information and are adjusted based on betting patterns. Parameters ---------- match : Match Match with ``pre_odds`` when available. Returns ------- tuple[float, list[str]] Tuple of (score, reasoning_list). Score range: -1.0 to 1.0 (positive favors home) """ reasons = [] odds = match.pre_odds if not odds or not odds.has_odds: return 0.0, ["No odds data available"] home_odds = odds.home_win away_odds = odds.away_win if home_odds is None or away_odds is None: return 0.0, ["Incomplete odds data"] # Convert odds to implied probability # Implied prob = 1 / decimal_odds home_implied = 1.0 / home_odds if home_odds > 0 else 0.5 away_implied = 1.0 / away_odds if away_odds > 0 else 0.5 # Normalize (bookmaker margin means probs sum to >1) total_implied = home_implied + away_implied if total_implied > 0: home_prob = home_implied / total_implied away_prob = away_implied / total_implied else: home_prob = away_prob = 0.5 # Score is the probability difference score = home_prob - away_prob reasons.append( f"Odds: Home {home_odds:.2f}, Away {away_odds:.2f} " f"(implied: {home_prob:.1%} vs {away_prob:.1%})" ) if home_prob > 0.55: reasons.append("→ Bookmakers favor home team") elif away_prob > 0.55: reasons.append("→ Bookmakers favor away team") else: reasons.append("→ Bookmakers see evenly matched teams") return score, reasons def _calculate_goal_score(self, match: Match) -> tuple[float, list[str]]: """ Calculate score based on goal statistics (attack/defense strength). Teams that score more and concede less are stronger. This captures attacking threat and defensive stability. Parameters ---------- match : Match Match with ``home_performance`` / ``away_performance``. Returns ------- tuple[float, list[str]] Tuple of (score, reasoning_list). Score range: -1.0 to 1.0 (positive favors home) """ reasons = [] home_perf = match.home_performance away_perf = match.away_performance if not home_perf or not away_perf: return 0.0, [] if home_perf.matches_analyzed == 0 or away_perf.matches_analyzed == 0: return 0.0, [] # Compare goals per game (attack strength) home_attack = home_perf.goals_per_game away_attack = away_perf.goals_per_game # Compare goals conceded (defense - lower is better) home_defense = home_perf.goals_conceded_per_game away_defense = away_perf.goals_conceded_per_game # Attack differential (normalized by typical 1.5 goals/game) attack_diff = (home_attack - away_attack) / 1.5 # Defense differential (inverted - lower is better) defense_diff = (away_defense - home_defense) / 1.5 # Combined score (attack matters more than defense) score = attack_diff * 0.6 + defense_diff * 0.4 # Clamp to -1 to 1 score = max(-1.0, min(1.0, score)) reasons.append(f"⚽ Goals/game: Home {home_attack:.2f}, Away {away_attack:.2f}") reasons.append(f"🛡️ Conceded/game: Home {home_defense:.2f}, Away {away_defense:.2f}") if score > 0.15: reasons.append("→ Home team has stronger goal stats") elif score < -0.15: reasons.append("→ Away team has stronger goal stats") return score, reasons def _calculate_venue_form_score(self, match: Match) -> tuple[float, list[str]]: """ Calculate score based on home/away specific performance. Some teams perform very differently at home vs away. This captures venue-specific form. Parameters ---------- match : Match Match with venue-specific rates on performance stats. Returns ------- tuple[float, list[str]] Tuple of (score, reasoning_list). Score range: -1.0 to 1.0 (positive favors home) """ reasons = [] home_perf = match.home_performance away_perf = match.away_performance if not home_perf or not away_perf: return 0.0, [] # Home team's home win rate vs Away team's away win rate home_rate = home_perf.home_win_rate if home_perf.home_games > 0 else 0.0 away_rate = away_perf.away_win_rate if away_perf.away_games > 0 else 0.0 # Differential - positive favors home score = home_rate - away_rate if home_perf.home_games > 0 or away_perf.away_games > 0: reasons.append( f"🏠 Home win rate at home: {home_rate:.0%} | Away win rate away: {away_rate:.0%}" ) if score > 0.2: reasons.append("→ Home team excels at home; Away struggles away") elif score < -0.2: reasons.append("→ Away team strong on the road") return max(-1.0, min(1.0, score)), reasons def _calculate_defense_score(self, match: Match) -> tuple[float, list[str]]: """ Calculate score based on defensive solidity (clean sheets). Teams that keep clean sheets regularly are defensively strong. Parameters ---------- match : Match Match with clean-sheet rates on performance stats. Returns ------- tuple[float, list[str]] Tuple of (score, reasoning_list). Score range: -1.0 to 1.0 (positive favors home) """ reasons = [] home_perf = match.home_performance away_perf = match.away_performance if not home_perf or not away_perf: return 0.0, [] home_cs_rate = home_perf.clean_sheet_rate away_cs_rate = away_perf.clean_sheet_rate # Differential score = home_cs_rate - away_cs_rate if home_perf.matches_analyzed > 0 and away_perf.matches_analyzed > 0: reasons.append(f"🧤 Clean sheet rate: Home {home_cs_rate:.0%}, Away {away_cs_rate:.0%}") if home_cs_rate > 0.4: reasons.append("→ Home team defensively solid") if away_cs_rate > 0.4: reasons.append("→ Away team defensively solid") return max(-1.0, min(1.0, score)), reasons def _calculate_momentum_score(self, match: Match) -> tuple[float, list[str]]: """ Calculate score based on first-half dominance and consistency. Teams that consistently lead at half-time and don't collapse are more reliable. Based on: https://live-score-api.com/documentation/reference/27/getting-teams-last-matches Parameters ---------- match : Match Match with HT/win/comeback rates on performance stats. Returns ------- tuple[float, list[str]] Tuple of (score, reasoning_list). Score range: -1.0 to 1.0 (positive favors home) """ reasons = [] home_perf = match.home_performance away_perf = match.away_performance if not home_perf or not away_perf: return 0.0, [] if home_perf.matches_analyzed == 0 or away_perf.matches_analyzed == 0: return 0.0, [] # First half dominance: How often does team lead at HT? home_ht_lead = home_perf.ht_lead_rate away_ht_lead = away_perf.ht_lead_rate # Win rate (consistency) home_win_rate = home_perf.win_rate away_win_rate = away_perf.win_rate # Comeback ability (resilience when trailing) home_comeback = home_perf.comeback_rate away_comeback = away_perf.comeback_rate # Calculate momentum score: # - HT lead rate shows early dominance (40% weight) # - Win rate shows consistency (40% weight) # - Comeback rate shows resilience (20% weight) home_momentum = home_ht_lead * 0.4 + home_win_rate * 0.4 + home_comeback * 0.2 away_momentum = away_ht_lead * 0.4 + away_win_rate * 0.4 + away_comeback * 0.2 score = home_momentum - away_momentum # Add reasoning if significant if home_perf.ht_wins > 0 or away_perf.ht_wins > 0: reasons.append(f"⏱️ HT lead rate: Home {home_ht_lead:.0%}, Away {away_ht_lead:.0%}") if home_perf.wins > 0 or away_perf.wins > 0: reasons.append(f"📈 Win rate: Home {home_win_rate:.0%}, Away {away_win_rate:.0%}") if home_momentum > away_momentum + 0.1: reasons.append("→ Home team has stronger momentum") elif away_momentum > home_momentum + 0.1: reasons.append("→ Away team has stronger momentum") return max(-1.0, min(1.0, score)), reasons def _calculate_match_stats_score(self, match: Match) -> tuple[float, list[str]]: """ Calculate score based on match statistics averages. Teams with better possession, more attacks, and more shots on target are generally stronger and more likely to win. Based on historical match statistics from: https://live-score-api.com/documentation/reference/23/match-statistics Parameters ---------- match : Match Match with aggregated match-stat averages on performance. Returns ------- tuple[float, list[str]] Tuple of (score, reasoning_list). Score range: -1.0 to 1.0 (positive favors home) """ reasons = [] home_perf = match.home_performance away_perf = match.away_performance if not home_perf or not away_perf: return 0.0, [] # Need at least some matches with statistics to make meaningful comparison # Lowered to 1 to maximize coverage (statistics may not be available for all matches) # If no stats available, return 0.0 but log for debugging if home_perf.matches_with_stats < 1 or away_perf.matches_with_stats < 1: # Match statistics not available - this is common for older matches # The model will still work using other factors (form, position, etc.) return 0.0, [] # Add info about data availability reasons.append( f"📊 Statistics data: Home {home_perf.matches_with_stats} matches, " f"Away {away_perf.matches_with_stats} matches" ) # Calculate differentials (normalized to -1 to 1 range) # Possession: 0-100%, normalize by 100 possession_diff = (home_perf.avg_possession - away_perf.avg_possession) / 100.0 # Corners: typically 0-15 per game, normalize by 10 corners_diff = (home_perf.avg_corners - away_perf.avg_corners) / 10.0 # Attacks: typically 50-150 per game, normalize by 50 attacks_diff = (home_perf.avg_attacks - away_perf.avg_attacks) / 50.0 # Dangerous attacks: typically 20-80 per game, normalize by 30 dangerous_attacks_diff = ( home_perf.avg_dangerous_attacks - away_perf.avg_dangerous_attacks ) / 30.0 # Shots on target: typically 2-8 per game, normalize by 5 shots_on_target_diff = (home_perf.avg_shots_on_target - away_perf.avg_shots_on_target) / 5.0 # Goal Conversion Rate: goals per shot on target # Measures efficiency in turning opportunities into actual goals conversion_diff = 0.0 if home_perf.goal_conversion_rate > 0 and away_perf.goal_conversion_rate > 0: # Normalize by typical conversion rate (0.3 = 30%) conversion_diff = ( home_perf.goal_conversion_rate - away_perf.goal_conversion_rate ) / 0.3 # Shot Accuracy: shots on target / total shots # Higher shot accuracy indicates better chance quality shot_accuracy_diff = 0.0 if home_perf.shot_accuracy > 0 and away_perf.shot_accuracy > 0: # Normalize by typical shot accuracy (0.4 = 40%) shot_accuracy_diff = (home_perf.shot_accuracy - away_perf.shot_accuracy) / 0.4 # Weighted combination (xG/xGD moved to separate xg factor) # Weights scaled so remaining factors sum to 1.0 score = ( possession_diff * 0.32 + shots_on_target_diff * 0.21 + shot_accuracy_diff * 0.18 # Shot accuracy (quality) + conversion_diff * 0.14 # Goal conversion rate + dangerous_attacks_diff * 0.11 + attacks_diff * 0.03 + corners_diff * 0.01 ) # Clamp to -1 to 1 score = max(-1.0, min(1.0, score)) # Add reasoning (xG/xGD reasoning in _calculate_xg_score) # Expected Goals Against (xGA) - defensive metric if home_perf.avg_expected_goals_against > 0 and away_perf.avg_expected_goals_against > 0: reasons.append( f"🛡️ xGA (Expected Goals Against): Home {home_perf.avg_expected_goals_against:.2f} vs Away {away_perf.avg_expected_goals_against:.2f}" ) reasons.append( f"📊 Possession: Home {home_perf.avg_possession:.1f}% vs Away {away_perf.avg_possession:.1f}%" ) reasons.append( f"🎯 Shots on target: Home {home_perf.avg_shots_on_target:.1f} vs Away {away_perf.avg_shots_on_target:.1f}" ) # Shot Accuracy - quality indicator if home_perf.shot_accuracy > 0 and away_perf.shot_accuracy > 0: reasons.append( f"🎪 Shot Accuracy: Home {home_perf.shot_accuracy:.1%} vs Away {away_perf.shot_accuracy:.1%}" ) # Goal Conversion Rate - efficiency metric if home_perf.goal_conversion_rate > 0 and away_perf.goal_conversion_rate > 0: reasons.append( f"🎪 Conversion Rate: Home {home_perf.goal_conversion_rate:.1%} vs Away {away_perf.goal_conversion_rate:.1%}" ) reasons.append( f"⚡ Attacks: Home {home_perf.avg_attacks:.1f} vs Away {away_perf.avg_attacks:.1f}" ) if score > 0.15: reasons.append("→ Home team has superior match statistics") elif score < -0.15: reasons.append("→ Away team has superior match statistics") return score, reasons def _calculate_xg_score(self, match: Match) -> tuple[float, list[str]]: """ Calculate score based on Expected Goals (xG) differential. xG measures quality of chances created; higher xG indicates better chance creation. Used as a standalone factor with its own weight. Parameters ---------- match : Match Match with average xG on performance stats. Returns ------- tuple[float, list[str]] Tuple of (score, reasoning_list). Score range: -1.0 to 1.0 (positive favors home). """ reasons: list[str] = [] if not match.home_performance or not match.away_performance: return 0.0, reasons home_perf = match.home_performance away_perf = match.away_performance if home_perf.avg_expected_goals <= 0 and away_perf.avg_expected_goals <= 0: return 0.0, reasons # Normalize xG diff by typical range (e.g. 2.0 per game) xg_diff = (home_perf.avg_expected_goals - away_perf.avg_expected_goals) / 2.0 score = max(-1.0, min(1.0, xg_diff)) reasons.append( f"⚽ xG (Expected Goals): Home {home_perf.avg_expected_goals:.2f} vs Away {away_perf.avg_expected_goals:.2f}" ) if ( home_perf.avg_expected_goal_difference != 0 or away_perf.avg_expected_goal_difference != 0 ): reasons.append( f"📈 xGD: Home {home_perf.avg_expected_goal_difference:.2f} vs Away {away_perf.avg_expected_goal_difference:.2f}" ) if score > 0.15: reasons.append("→ Home team creates higher quality chances (xG)") elif score < -0.15: reasons.append("→ Away team creates higher quality chances (xG)") return score, reasons def _calculate_reliability_score(self, match: Match) -> tuple[float, list[str]]: """ Calculate score based on team reliability (discipline). Teams with fewer cards are more reliable and predictable. Cards indicate: - Discipline issues (yellow cards) - Player dismissals (red cards) = playing with fewer players - Aggressive/unpredictable play style - Higher risk of penalties and free kicks in dangerous areas Lower cards = higher reliability = more predictable outcomes. Parameters ---------- match : Match Match with reliability/card stats on performance. Returns ------- tuple[float, list[str]] Tuple of (score, reasoning_list). Score range: -1.0 to 1.0 (positive favors home) """ reasons = [] home_perf = match.home_performance away_perf = match.away_performance if not home_perf or not away_perf: return 0.0, [] # Use reliability_score from TeamPerformanceStats (0.0 to 1.0) # Higher reliability = more predictable = better for betting home_reliability = home_perf.reliability_score away_reliability = away_perf.reliability_score # Differential: positive favors home (home more reliable) score = home_reliability - away_reliability # Scale to -1.0 to 1.0 range # Reliability difference of 1.0 (perfect vs unreliable) = 1.0 score score = max(-1.0, min(1.0, score)) # Add reasoning reasons.append(f"🎯 Reliability: Home {home_reliability:.2f}, Away {away_reliability:.2f}") if home_perf.matches_with_stats > 0: reasons.append( f" Cards/game: Home {home_perf.avg_cards:.1f} " f"(Y: {home_perf.avg_yellow_cards:.1f}, " f"R: {home_perf.avg_red_cards:.1f})" ) if away_perf.matches_with_stats > 0: reasons.append( f" Cards/game: Away {away_perf.avg_cards:.1f} " f"(Y: {away_perf.avg_yellow_cards:.1f}, " f"R: {away_perf.avg_red_cards:.1f})" ) if score > 0.15: reasons.append("→ Home team more reliable (fewer cards)") elif score < -0.15: reasons.append("→ Away team more reliable (fewer cards)") else: reasons.append("→ Similar reliability levels") return score, reasons def _calculate_draw_risk( self, composite_score: float, form_score: float, position_score: float, match: Match | None = None, ) -> float: """ Calculate the risk of a match ending in a draw. Draws occur ~26% of the time in top leagues. They're more likely when: - Teams are evenly matched (composite score close to 0) - Both teams have similar form - Teams are close in the table - Bookmaker draw odds are low (high implied probability) - Teams have similar PPG (points per game) - Teams have high draw rates in recent matches Parameters ---------- composite_score : float Overall match score form_score : float Form differential position_score : float Position differential match : Match or None, optional Optional match object for odds and performance data Returns ------- float Draw risk probability (0.0 to 1.0) """ # Base draw rate in top leagues base_draw_rate = 0.26 # Calculate how evenly matched the teams are # Lower scores = more evenly matched = higher draw risk score_factor = 1.0 - min(abs(composite_score), 1.0) form_factor = 1.0 - min(abs(form_score), 1.0) position_factor = 1.0 - min(abs(position_score), 1.0) # NEW: Factor in bookmaker draw odds if available odds_draw_factor = 0.0 if match and match.pre_odds and match.pre_odds.draw: draw_odds = match.pre_odds.draw # Convert odds to implied probability # Lower odds = higher probability of draw # Typical draw odds: 3.0-4.0 (25-33% implied) if draw_odds > 0: implied_draw_prob = 1.0 / draw_odds # Scale: if implied > 30%, increase draw factor if implied_draw_prob > 0.30: odds_draw_factor = (implied_draw_prob - 0.26) * 2 # Boost for high draw odds elif implied_draw_prob < 0.22: odds_draw_factor = -0.1 # Reduce for low draw odds # NEW: Factor in PPG proximity ppg_factor = 0.0 if match and match.home_performance and match.away_performance: home_ppg = match.home_performance.points_per_game away_ppg = match.away_performance.points_per_game # If both teams have similar PPG, more likely to draw ppg_diff = abs(home_ppg - away_ppg) if ppg_diff < 0.5: # Very similar performance ppg_factor = 0.10 elif ppg_diff < 1.0: ppg_factor = 0.05 # NEW: Factor in team draw rates home_draws = match.home_performance.draws away_draws = match.away_performance.draws home_matches = match.home_performance.matches_analyzed away_matches = match.away_performance.matches_analyzed if home_matches > 0 and away_matches > 0: home_draw_rate = home_draws / home_matches away_draw_rate = away_draws / away_matches avg_draw_rate = (home_draw_rate + away_draw_rate) / 2 # If teams draw a lot, increase draw risk if avg_draw_rate > 0.30: ppg_factor += 0.08 # Weighted combination evenness = ( score_factor * 0.40 + form_factor * 0.20 + position_factor * 0.20 + max(0, odds_draw_factor) * 0.10 # Only positive contributions + ppg_factor * 0.10 ) # Scale draw risk: evenly matched games have up to 45% draw chance draw_risk = base_draw_rate + (evenness * 0.20) + odds_draw_factor * 0.5 return max(0.15, min(draw_risk, 0.55)) # Cap between 15-55% def _score_to_probability(self, score: float) -> float: """ Convert composite score to win probability. Uses a modified sigmoid function to map scores to probabilities. Optimized based on backtest analysis for better calibration. Parameters ---------- score : float Composite score (-1 to 1) Returns ------- float Probability (0.25 to 0.75, centered at 0.5) """ import math # Sigmoid transformation with scaling # k controls steepness - increased from 3.0 to 3.5 for more decisive predictions k = 3.5 # Raw sigmoid maps to 0-1 sigmoid = 1 / (1 + math.exp(-k * score)) # Scale to 0.25-0.75 range (slightly narrower to avoid overconfidence) # This better reflects that even strong favorites only win ~70-75% in soccer probability = 0.25 + (sigmoid * 0.50) return probability
[docs] def filter_confident_predictions( self, predictions: list[BetPrediction], min_confidence: float | None = None ) -> list[BetPrediction]: """ Filter predictions by minimum confidence threshold. Parameters ---------- predictions : list[BetPrediction] List of predictions to filter min_confidence : float or None, optional Minimum confidence (defaults to model's threshold) Returns ------- list[BetPrediction] Filtered list of predictions """ threshold = self.min_confidence if min_confidence is None else min_confidence return [p for p in predictions if p.confidence >= threshold]