"""球員道具盤(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', ]