Files
2026FIFAWorldCup/platform/backend/app/analytics/player_props.py

164 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""球員道具盤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',
]