113 lines
3.6 KiB
Python
113 lines
3.6 KiB
Python
"""量化投注引擎(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',
|
||
]
|
||
|