"""量化投注引擎(EV、泊松預測、海拔修正)。""" from __future__ import annotations import math import numpy as np import pandas as pd from scipy.stats import poisson def calculate_value_bet(true_prob: float, decimal_odds: float, *, stake: float = 1.0) -> tuple[float, bool]: """計算期望值(EV)並判斷是否屬於 Value Bet。 EV 計算:EV = (勝率 * 利潤) - (敗率 * 本金) 其中利潤 = decimal_odds - 1。 Returns ------- ev_pct: float 以本金為基底的 EV 百分比(EV / stake)。 is_value_bet: bool 當 EV > 0.03(3%)回傳 True。 """ prob = float(true_prob) odds = float(decimal_odds) if not 0 <= prob <= 1 or odds <= 1 or stake <= 0: return 0.0, False profit = odds - 1 ev = prob * profit - (1 - prob) * stake ev_pct = ev / stake return round(ev_pct, 6), ev_pct > 0.03 class PoissonPredictor: """球員進球分佈預測器(2x2 進球建模)。""" def __init__( self, home_attack: float, home_defense: float, away_attack: float, away_defense: float, league_avg_goals: float, ) -> None: self.home_attack = float(home_attack) self.home_defense = float(home_defense) self.away_attack = float(away_attack) self.away_defense = float(away_defense) self.league_avg_goals = float(league_avg_goals) # 以攻守乘積估算 λ,並限制在合理範圍避免極端值發散。 home_lambda = league_avg_goals * (self.home_attack / max(self.away_defense, 0.01)) away_lambda = league_avg_goals * (self.away_attack / max(self.home_defense, 0.01)) self.home_lambda = float(np.clip(home_lambda, 0.02, 6.5)) self.away_lambda = float(np.clip(away_lambda, 0.02, 6.5)) def predict_exact_score(self, home_goals: int, away_goals: int) -> float: """回傳指定波膽(home_goals, away_goals)發生機率。""" p_home = poisson.pmf(home_goals, self.home_lambda) p_away = poisson.pmf(away_goals, self.away_lambda) return float(p_home * p_away) def predict_over_under_prob(self, line: float = 2.5, max_goals: int = 10) -> tuple[float, float]: """回傳(under, over)機率。""" goals = pd.MultiIndex.from_product( [range(max_goals + 1), range(max_goals + 1)], names=['home', 'away'], ).to_frame(index=False) def joint_prob(r: pd.Series) -> float: return float(poisson.pmf(r['home'], self.home_lambda) * poisson.pmf(r['away'], self.away_lambda)) probs = goals.apply(joint_prob, axis=1) total_goals = goals['home'] + goals['away'] under = float(probs[total_goals <= line].sum()) over = float(probs[total_goals > line].sum()) return under, over def adjust_away_defense_for_altitude( base_defense_rating: float, venue_altitude_meters: float, *, is_second_half: bool, penalty_factor: float = 0.35, ) -> float: """高海拔下修正客隊防守能力。 當場地海拔高於 1500m 且處於下半場,套用對數懲罰, 代表客隊在氧氣濃度降低下體能下降導致防守效率衰退。 """ base = float(base_defense_rating) if venue_altitude_meters <= 1500 or not is_second_half: return base # 以 log(1 + altitude/1000) 做平滑遞增函式,避免低海拔時劇烈改變。 altitude_penalty = penalty_factor * math.log1p(venue_altitude_meters / 1000) return base * (1 - min(max(altitude_penalty, 0), 0.45)) __all__ = [ 'calculate_value_bet', 'PoissonPredictor', 'adjust_away_defense_for_altitude', ]