164 lines
4.6 KiB
Python
164 lines
4.6 KiB
Python
"""球員道具盤(Player Props)量化引擎。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Any, Literal
|
||
|
||
import numpy as np
|
||
|
||
|
||
PropMetric = Literal['shots', 'shots_on_target', 'passes']
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PlayerPropsProfile:
|
||
"""球員與對位環境的道具盤參考參數。"""
|
||
|
||
player_id: str
|
||
metric: PropMetric
|
||
baseline_mean: float
|
||
match_minutes: int = 90
|
||
team_attack_factor: float = 1.0
|
||
opponent_defence_factor: float = 1.0
|
||
weather_fatigue_factor: float = 1.0
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PlayerPropsSimulationResult:
|
||
"""單個道具盤的模擬輸出。"""
|
||
|
||
metric: PropMetric
|
||
line: float
|
||
over_probability: float
|
||
under_probability: float
|
||
expected_count: float
|
||
p5: float
|
||
p50: float
|
||
p95: float
|
||
simulation_runs: int
|
||
|
||
def to_dict(self) -> dict[str, float | int | str]:
|
||
return {
|
||
'metric': self.metric,
|
||
'line': self.line,
|
||
'over_probability': self.over_probability,
|
||
'under_probability': self.under_probability,
|
||
'expected_count': self.expected_count,
|
||
'p5': self.p5,
|
||
'p50': self.p50,
|
||
'p95': self.p95,
|
||
'simulation_runs': self.simulation_runs,
|
||
}
|
||
|
||
|
||
def _apply_context_multiplier(profile: PlayerPropsProfile) -> float:
|
||
"""依據球員對位環境組合出單場事件期望值修正係數。"""
|
||
|
||
multipliers = [
|
||
max(0.1, profile.team_attack_factor),
|
||
1 / max(0.5, profile.opponent_defence_factor),
|
||
max(0.6, profile.weather_fatigue_factor),
|
||
]
|
||
return float(np.prod(multipliers))
|
||
|
||
|
||
def _metric_seed_variance(profile: PlayerPropsProfile) -> float:
|
||
"""使用不同維度的離散程度(sigma)以保留球員特徵差異。"""
|
||
|
||
if profile.metric == 'passes':
|
||
return 0.45
|
||
if profile.metric == 'shots_on_target':
|
||
return 0.22
|
||
return 0.30
|
||
|
||
|
||
def simulate_player_prop_probability(
|
||
profile: PlayerPropsProfile,
|
||
*,
|
||
line: float,
|
||
simulations: int = 10000,
|
||
rng: np.random.Generator | None = None,
|
||
) -> PlayerPropsSimulationResult:
|
||
"""用蒙地卡羅法計算球員道具盤超過盤口線的機率。"""
|
||
|
||
if line <= 0:
|
||
raise ValueError('line 必須為正數')
|
||
if simulations <= 100:
|
||
raise ValueError('simulations 最少需要 100 次')
|
||
|
||
generator = rng or np.random.default_rng()
|
||
|
||
minute_ratio = profile.match_minutes / 90
|
||
base = profile.baseline_mean * minute_ratio
|
||
adjusted_mean = max(0.05, base * _apply_context_multiplier(profile))
|
||
|
||
# 以 Gamma-Poisson 混合近似捕捉波動,避免單純 Poisson 太過平滑。
|
||
gamma_shape = max(0.5, 1.0 / (_metric_seed_variance(profile) ** 2))
|
||
gamma_scale = adjusted_mean / gamma_shape
|
||
intensity = generator.gamma(gamma_shape, gamma_scale, size=simulations)
|
||
|
||
counts = generator.poisson(intensity).astype(float)
|
||
|
||
over_count = int(np.sum(counts > line))
|
||
over_probability = over_count / simulations
|
||
under_probability = 1 - over_probability
|
||
|
||
expected_count = float(np.mean(counts))
|
||
p5, p50, p95 = [float(np.percentile(counts, q)) for q in (5, 50, 95)]
|
||
|
||
return PlayerPropsSimulationResult(
|
||
metric=profile.metric,
|
||
line=line,
|
||
over_probability=round(over_probability, 6),
|
||
under_probability=round(under_probability, 6),
|
||
expected_count=round(expected_count, 3),
|
||
p5=p5,
|
||
p50=p50,
|
||
p95=p95,
|
||
simulation_runs=simulations,
|
||
)
|
||
|
||
|
||
def evaluate_top_edge(
|
||
profile: PlayerPropsProfile,
|
||
bookmaker_over_odds: float,
|
||
*,
|
||
line: float,
|
||
simulations: int = 10000,
|
||
stake: float = 1.0,
|
||
) -> dict[str, Any]:
|
||
"""回傳道具盤 EV 與建議邊際,供前端高邊際卡片使用。"""
|
||
|
||
result = simulate_player_prop_probability(profile, line=line, simulations=simulations)
|
||
|
||
if bookmaker_over_odds <= 1:
|
||
raise ValueError('bookmaker_over_odds 必須大於 1')
|
||
|
||
# EV 計算以 "賭 over" 為例。
|
||
win_profit = (bookmaker_over_odds - 1) * stake
|
||
loss = stake
|
||
ev = result.over_probability * win_profit - (1 - result.over_probability) * loss
|
||
|
||
edge = ev / stake
|
||
top_edge = edge > 0.08
|
||
|
||
return {
|
||
**result.to_dict(),
|
||
'edge': round(edge, 6),
|
||
'top_edge': top_edge,
|
||
'bookmaker_over_odds': bookmaker_over_odds,
|
||
'implied_prob': round(1 / bookmaker_over_odds, 6),
|
||
'recommended_stake_hint': round(max(0.0, edge * stake * 0.4), 2),
|
||
}
|
||
|
||
|
||
__all__ = [
|
||
'PropMetric',
|
||
'PlayerPropsProfile',
|
||
'PlayerPropsSimulationResult',
|
||
'evaluate_top_edge',
|
||
'simulate_player_prop_probability',
|
||
]
|
||
|