Initial commit with 2026 World Cup Quant Platform core modules and CI/CD
This commit is contained in:
79
platform/backend/app/analytics/player_props_sim.py
Normal file
79
platform/backend/app/analytics/player_props_sim.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""球員道具盤(Props)蒙地卡羅模擬模組。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlayerPropsDistribution:
|
||||
shots: np.ndarray
|
||||
shots_on_target: np.ndarray
|
||||
passes: np.ndarray
|
||||
|
||||
|
||||
def simulate_player_stats(
|
||||
player_metrics: dict,
|
||||
opponent_defense_metrics: dict,
|
||||
iterations: int = 10_000,
|
||||
) -> PlayerPropsDistribution:
|
||||
"""快速模擬球員事件次數分佈。"""
|
||||
|
||||
if iterations <= 0:
|
||||
raise ValueError('iterations 必須大於 0')
|
||||
|
||||
avg_touches = float(player_metrics.get('avg_touches', 45) or 0.0)
|
||||
base_shot_rate = float(player_metrics.get('shots_per_touch', 0.08) or 0.0)
|
||||
base_target_rate = float(player_metrics.get('shot_on_target_rate', 0.35) or 0.0)
|
||||
base_pass_rate = float(player_metrics.get('passes_per_touch', 0.65) or 0.0)
|
||||
|
||||
opp_pressure = float(opponent_defense_metrics.get('pressing_index', 1.0) or 1.0)
|
||||
opp_tackling = float(opponent_defense_metrics.get('marking_index', 1.0) or 1.0)
|
||||
|
||||
adj_touches = max(1.0, avg_touches * max(0.6, 1.0 / max(0.5, opp_pressure)))
|
||||
shot_lambda = adj_touches * base_shot_rate
|
||||
pass_lambda = adj_touches * base_pass_rate
|
||||
|
||||
rng = np.random.default_rng()
|
||||
shots = rng.poisson(lam=shot_lambda, size=iterations)
|
||||
passes = rng.poisson(lam=pass_lambda, size=iterations)
|
||||
|
||||
# 對方壓迫會降低射正率
|
||||
effective_target_rate = max(0.02, base_target_rate / max(opp_tackling, 0.3))
|
||||
shots_on_target = rng.binomial(shots, p=min(effective_target_rate, 0.99), size=iterations)
|
||||
|
||||
return PlayerPropsDistribution(shots=shots.astype(int), shots_on_target=shots_on_target.astype(int), passes=passes.astype(int))
|
||||
|
||||
|
||||
def evaluate_prop_bet(
|
||||
simulated_distribution: PlayerPropsDistribution,
|
||||
line: float,
|
||||
odds: float,
|
||||
) -> dict[str, float | bool]:
|
||||
"""從 10,000 次模擬結果計算超過盤口機率與 EV。"""
|
||||
|
||||
if odds <= 1:
|
||||
raise ValueError('odds 必須大於 1')
|
||||
if line < 0:
|
||||
raise ValueError('line 必須大於等於 0')
|
||||
|
||||
shots = simulated_distribution.shots
|
||||
if shots.size == 0:
|
||||
raise ValueError('distribution 為空')
|
||||
|
||||
probability_over = float((shots > line).mean())
|
||||
from .ev_calculator import calculate_expected_value
|
||||
|
||||
ev = calculate_expected_value(probability_over, odds)
|
||||
|
||||
return {
|
||||
'metric': 'shots',
|
||||
'line': line,
|
||||
'over_probability': round(probability_over, 6),
|
||||
'under_probability': round(1.0 - probability_over, 6),
|
||||
'implied_ev': ev['ev_value'],
|
||||
'ev_percentage': ev['ev_percentage'],
|
||||
'is_value_bet': bool(ev['is_value_bet']),
|
||||
}
|
||||
Reference in New Issue
Block a user