Initial commit with 2026 World Cup Quant Platform core modules and CI/CD
This commit is contained in:
112
platform/backend/app/analytics/engine.py
Normal file
112
platform/backend/app/analytics/engine.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""量化投注引擎(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',
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user