80 lines
2.6 KiB
Python
80 lines
2.6 KiB
Python
"""球員道具盤(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']),
|
||
}
|