Initial commit with 2026 World Cup Quant Platform core modules and CI/CD

This commit is contained in:
QuantBot
2026-06-13 23:18:18 +08:00
commit 073abf98c1
155 changed files with 19539 additions and 0 deletions

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,95 @@
"""量化分析模組匯總。"""
from .engine import calculate_value_bet, PoissonPredictor, adjust_away_defense_for_altitude
from .ev_calculator import calculate_expected_value
from .feature_engineering import MatchFeatureExtractor, MatchFeatureVector
from .kelly import KellyResult, calculate_kelly_fraction
from .ml_inference import XGBoostPredictor, XGBoostPrediction
from .player_props import (
PlayerPropsProfile,
PlayerPropsSimulationResult,
PropMetric,
evaluate_top_edge,
simulate_player_prop_probability,
)
from .ml_ensemble import (
FEATURE_COLUMNS,
EnsembleModelArtifact,
build_default_ensemble_artifact,
calculate_model_edges,
model_predict_probabilities,
normalize_feature_payload,
train_match_outcome_ensemble,
)
from .backtesting import BacktestTradeRecord, StrategyFilter, filter_trades, run_flat_stake_backtest
from .poisson_model import PoissonMatchPredictor
from .referee_analyzer import calculate_cards_ev
from .environment_model import adjust_team_strength_for_environment
from .referee_weather import MatchConditionSignal, evaluate_match_conditions
from .rlm import ReverseLineMovementAlert, evaluate_reverse_line_movement
from .proof_of_yield import LedgerSummary, ProofOfYieldStore, ProofYieldRecord, compute_clv, compute_pnl
from .player_props_sim import PlayerPropsDistribution, evaluate_prop_bet, simulate_player_stats
from .sgp_engine import calculate_joint_probability, find_sgp_value
from .portfolio_analyzer import analyze_user_leaks
from .hedging_calculator import calculate_hedge_amount
from .daily_card_generator import generate_daily_card
from .vig_remover import (
calculate_overround,
compare_bookmaker_true_prob,
prob_to_decimal_odds,
remove_margin_basic,
remove_margin_shin,
)
__all__ = [
'KellyResult',
'BacktestTradeRecord',
'PropMetric',
'calculate_expected_value',
'calculate_value_bet',
'calculate_kelly_fraction',
'evaluate_top_edge',
'PoissonPredictor',
'PlayerPropsProfile',
'PlayerPropsSimulationResult',
'PoissonMatchPredictor',
'adjust_away_defense_for_altitude',
'adjust_team_strength_for_environment',
'filter_trades',
'run_flat_stake_backtest',
'simulate_player_prop_probability',
'StrategyFilter',
'FEATURE_COLUMNS',
'build_default_ensemble_artifact',
'calculate_model_edges',
'model_predict_probabilities',
'normalize_feature_payload',
'train_match_outcome_ensemble',
'MatchConditionSignal',
'evaluate_match_conditions',
'ReverseLineMovementAlert',
'evaluate_reverse_line_movement',
'LedgerSummary',
'ProofOfYieldStore',
'ProofYieldRecord',
'compute_clv',
'compute_pnl',
'MatchFeatureExtractor',
'MatchFeatureVector',
'XGBoostPredictor',
'XGBoostPrediction',
'PlayerPropsDistribution',
'simulate_player_stats',
'evaluate_prop_bet',
'calculate_joint_probability',
'find_sgp_value',
'calculate_cards_ev',
'calculate_overround',
'remove_margin_basic',
'remove_margin_shin',
'prob_to_decimal_odds',
'compare_bookmaker_true_prob',
'analyze_user_leaks',
'calculate_hedge_amount',
'generate_daily_card',
]

View File

@@ -0,0 +1,181 @@
"""自訂策略回測引擎。"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class BacktestTradeRecord:
"""單筆策略投注歷史資料。"""
trade_id: str
settled_at: datetime
odds: float
is_win: bool
stake: float = 100.0
altitude_meters: int | None = None
handicap: float | None = None
weather: str | None = None
recent_form_win_rate: float | None = None
market_type: str = '1x2'
selection: str = 'home'
@dataclass(frozen=True)
class StrategyFilter:
"""回測條件(前端 JSON 可直接對映)。"""
weather: str | None = None
altitude_min_meters: int | None = None
altitude_max_meters: int | None = None
handicap_min: float | None = None
handicap_max: float | None = None
recent_win_rate_min: float | None = None
recent_win_rate_max: float | None = None
market_types: list[str] | None = None
start_at: datetime | None = None
end_at: datetime | None = None
def _match_filter(record: BacktestTradeRecord, condition: StrategyFilter) -> bool:
"""判斷單筆交易是否符合使用者條件。"""
if condition.weather and (record.weather or '').lower() != condition.weather.lower():
return False
if condition.altitude_min_meters is not None and (
record.altitude_meters is None or record.altitude_meters < condition.altitude_min_meters
):
return False
if condition.altitude_max_meters is not None and (
record.altitude_meters is None or record.altitude_meters > condition.altitude_max_meters
):
return False
if condition.handicap_min is not None and (
record.handicap is None or record.handicap < condition.handicap_min
):
return False
if condition.handicap_max is not None and (
record.handicap is None or record.handicap > condition.handicap_max
):
return False
if condition.recent_win_rate_min is not None and (
record.recent_form_win_rate is None or record.recent_form_win_rate < condition.recent_win_rate_min
):
return False
if condition.recent_win_rate_max is not None and (
record.recent_form_win_rate is None or record.recent_form_win_rate > condition.recent_win_rate_max
):
return False
if condition.market_types and record.market_type not in condition.market_types:
return False
if condition.start_at is not None and record.settled_at < condition.start_at:
return False
if condition.end_at is not None and record.settled_at > condition.end_at:
return False
return True
def filter_trades(
trades: list[BacktestTradeRecord],
condition: StrategyFilter,
) -> list[BacktestTradeRecord]:
"""回傳符合條件的策略明細子集合。"""
return [t for t in trades if _match_filter(t, condition)]
def compute_max_drawdown(equity_curve: list[float]) -> float:
"""計算最大回撤(百分比)。"""
if not equity_curve:
return 0.0
peak = equity_curve[0]
max_drawdown = 0.0
for value in equity_curve:
if value > peak:
peak = value
continue
drawdown = (peak - value) / peak if peak else 0.0
max_drawdown = max(max_drawdown, drawdown)
return round(max_drawdown * 100, 4)
def run_flat_stake_backtest(
trades: list[BacktestTradeRecord],
initial_capital: float = 10000,
) -> dict[str, float | int | list[dict[str, float | str]]]:
"""固定單注本金Flat betting回測。
回傳:
- trade_count總注單數
- hit_count中獎注數
- win_rate中獎率
- final_capital最終資金
- net_profit淨利潤
- roi_percentROI
- max_drawdown_percent最大回撤百分比
- equity_curve資產曲線
"""
if initial_capital <= 0:
raise ValueError('initial_capital 必須大於 0')
if not trades:
return {
'trade_count': 0,
'hit_count': 0,
'win_rate': 0.0,
'final_capital': initial_capital,
'net_profit': 0.0,
'roi_percent': 0.0,
'max_drawdown_percent': 0.0,
'equity_curve': [{'ts': datetime.utcnow().isoformat() + 'Z', 'capital': initial_capital}],
}
# 確保輸入依賴的時序,回測才有金融合理性
ordered = sorted(trades, key=lambda row: row.settled_at)
equity = float(initial_capital)
equity_curve: list[dict[str, float | str]] = [
{'ts': ordered[0].settled_at.isoformat(), 'capital': equity},
]
hit = 0
total_stake = 0.0
for trade in ordered:
if trade.odds <= 1:
raise ValueError(f'賠率錯誤 trade={trade.trade_id}, odds={trade.odds}')
stake = trade.stake
profit = stake * (trade.odds - 1) if trade.is_win else -stake
equity += profit
total_stake += stake
if trade.is_win:
hit += 1
equity_curve.append({'ts': trade.settled_at.isoformat(), 'capital': equity})
if total_stake <= 0:
roi = 0.0
else:
roi = (equity - initial_capital) / total_stake * 100
win_rate = round(hit / len(ordered) * 100, 4) if ordered else 0.0
return {
'trade_count': len(ordered),
'hit_count': hit,
'win_rate': win_rate,
'final_capital': round(equity, 4),
'net_profit': round(equity - initial_capital, 4),
'roi_percent': round(roi, 4),
'max_drawdown_percent': compute_max_drawdown([float(point['capital']) for point in equity_curve]),
'equity_curve': equity_curve,
}
__all__ = [
'BacktestTradeRecord',
'StrategyFilter',
'filter_trades',
'run_flat_stake_backtest',
]

View File

@@ -0,0 +1,188 @@
"""每日智能注單生成器Daily Smart Card"""
from __future__ import annotations
from typing import Any
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _ev_percent(true_prob: float, decimal_odds: float) -> float:
if decimal_odds <= 1:
return 0.0
implied = 1.0 / decimal_odds
# EV = P*(odds-1) - (1-P)*1
return ((true_prob * (decimal_odds - 1.0)) - (1.0 - true_prob)) * 100
def _guess_stage(match_index: int) -> str:
return '小組賽' if match_index <= 48 else '淘汰賽'
def generate_daily_card(target_date: str, matches: list[dict[str, Any]]) -> dict[str, Any]:
"""
依賽事快照回傳 4 大區塊策略建議(安全單關、搏冷、高勝率串關、同場串關)。
回傳的格式會被前端 /daily-card 與手機版報表一致消化。
"""
safe_singles: list[dict[str, Any]] = []
high_risk_singles: list[dict[str, Any]] = []
safe_parlays: list[dict[str, Any]] = []
sgp_lotteries: list[dict[str, Any]] = []
total_unit = 0.0
for idx, match in enumerate(matches):
match_id = str(match.get('match_id', f'fallback-{idx+1}'))
home_team = str(match.get('home_team', '主隊'))
away_team = str(match.get('away_team', '客隊'))
odds_home = _safe_float(match.get('odds_home'), default=0)
odds_away = _safe_float(match.get('odds_away'), default=0)
# 用 xG 或開盤機率估算真實機率,若無資料則回退到 0.5
home_xg = _safe_float(match.get('home_xg'), default=1.0)
away_xg = _safe_float(match.get('away_xg'), default=1.0)
xg_sum = max(home_xg + away_xg, 0.01)
true_home_prob = home_xg / xg_sum
stage = _guess_stage(idx + 1)
# 安全單關:偏向高勝率市場
if odds_home > 1 and true_home_prob > 0.55:
ev = _ev_percent(true_home_prob, odds_home)
if ev > 3:
safe_unit = 1.8
total_unit += safe_unit
safe_singles.append(
{
'match_id': match_id,
'match_label': f'{home_team} vs {away_team}',
'market_type': '亞洲讓球',
'selection': f'{home_team} -0.25',
'target_odds': round(odds_home, 2),
'win_prob': round(true_home_prob * 100, 2),
'ev_percent': round(ev, 2),
'stake_units': round(safe_unit, 2),
'recommendation': 'SAFE_SINGLE',
'rationale': '高勝率 + 正EV適合作為核心穩健下注。',
},
)
# 高風險搏冷:低勝率但盤口偏高且 EV 過濾
away_true = 1.0 - true_home_prob
if odds_away > 1 and away_true < 0.35:
ev = _ev_percent(away_true, odds_away)
if ev > 8:
high_risk_unit = 0.35
total_unit += high_risk_unit
high_risk_singles.append(
{
'match_id': match_id,
'match_label': f'{home_team} vs {away_team}',
'market_type': '大小球',
'selection': f'{away_team} 不敗',
'target_odds': round(odds_away, 2),
'win_prob': round(away_true * 100, 2),
'ev_percent': round(ev, 2),
'stake_units': round(high_risk_unit, 2),
'recommendation': 'HIGH_RISK_SINGLE',
'rationale': '冷門高賠率,只有在高勝率組合中保留小倉位。',
},
)
# 2 串 1 串關:選取高勝率的兩個 SAFE 單關,若連乘機率符合條件
if len(safe_singles) >= 2:
legs = safe_singles[:2]
combined_odds = 1.0
combined_prob = 1.0
for leg in legs:
combined_odds *= leg['target_odds']
combined_prob *= leg['win_prob'] / 100
if combined_prob >= 0.28: # 高勝率門檻(保守)
ev = _ev_percent(combined_prob, combined_odds)
if ev > 2:
stake_units = 1.0
total_unit += stake_units
safe_parlays.append(
{
'match_id': 'PARLAY-SAFE',
'match_label': ' + '.join(item['match_label'] for item in legs),
'market_type': '跨場串關',
'selection': '2串1 安全組合',
'legs': [
{
'match_id': item['match_id'],
'selection': item['selection'],
'odds': item['target_odds'],
}
for item in legs
],
'target_odds': round(combined_odds, 2),
'win_prob': round(combined_prob * 100, 2),
'ev_percent': round(ev, 2),
'stake_units': round(stake_units, 2),
'recommendation': 'SAFE_PARLAY',
'rationale': '同風險組合加總,目標追求高穩健率 + 控制回撤。',
'match_stage': _guess_stage(1),
},
)
# 同場 SGP取出 1 個安全 + 1 個搏冷,形成關聯爆擊模板
if safe_singles and high_risk_singles:
s = safe_singles[0]
h = high_risk_singles[0]
combo_odds = s['target_odds'] * h['target_odds']
combo_prob = (s['win_prob'] / 100) * (h['win_prob'] / 100)
if combo_prob > 0:
ev = _ev_percent(combo_prob, combo_odds)
sgp_lotteries.append(
{
'match_id': s['match_id'],
'match_label': f"{s['match_label']}【同場】",
'market_type': 'SGP',
'selection': f"{s['selection']} + {h['selection']}",
'target_odds': round(combo_odds, 2),
'win_prob': round(combo_prob * 100, 2),
'ev_percent': round(ev, 2),
'stake_units': 0.5,
'recommendation': 'SGP_LOTTERY',
'rationale': '同場串關需監控相關性,避免同向風險重疊。',
'legs': [
{'match_id': s['match_id'], 'selection': s['selection'], 'odds': s['target_odds']},
{'match_id': h['match_id'], 'selection': h['selection'], 'odds': h['target_odds']},
],
'match_stage': _guess_stage(1),
},
)
return {
'date': target_date,
'total_daily_unit_recommendation': round(total_unit, 2),
'summary': (
'系統以當日賽程、赔率變動、xG 進攻強度與場次權重回填,'
'優先輸出高穩定性單關與可控風險的串關建議。'
),
'safe_singles': safe_singles,
'high_risk_singles': high_risk_singles,
'safe_parlays': safe_parlays,
'sgp_lotteries': sgp_lotteries,
'matched_matches': len(matches),
'stage_distribution': {
'小組賽': min(len(matches), 48),
'淘汰賽': max(0, len(matches) - 48),
},
}

View File

@@ -0,0 +1,112 @@
"""量化投注引擎EV、泊松預測、海拔修正"""
from __future__ import annotations
import math
import numpy as np
import pandas as pd
from scipy.stats import poisson
def calculate_value_bet(true_prob: float, decimal_odds: float, *, stake: float = 1.0) -> tuple[float, bool]:
"""計算期望值EV並判斷是否屬於 Value Bet。
EV 計算EV = (勝率 * 利潤) - (敗率 * 本金)
其中利潤 = decimal_odds - 1。
Returns
-------
ev_pct: float
以本金為基底的 EV 百分比EV / stake
is_value_bet: bool
當 EV > 0.033%)回傳 True。
"""
prob = float(true_prob)
odds = float(decimal_odds)
if not 0 <= prob <= 1 or odds <= 1 or stake <= 0:
return 0.0, False
profit = odds - 1
ev = prob * profit - (1 - prob) * stake
ev_pct = ev / stake
return round(ev_pct, 6), ev_pct > 0.03
class PoissonPredictor:
"""球員進球分佈預測器2x2 進球建模)。"""
def __init__(
self,
home_attack: float,
home_defense: float,
away_attack: float,
away_defense: float,
league_avg_goals: float,
) -> None:
self.home_attack = float(home_attack)
self.home_defense = float(home_defense)
self.away_attack = float(away_attack)
self.away_defense = float(away_defense)
self.league_avg_goals = float(league_avg_goals)
# 以攻守乘積估算 λ,並限制在合理範圍避免極端值發散。
home_lambda = league_avg_goals * (self.home_attack / max(self.away_defense, 0.01))
away_lambda = league_avg_goals * (self.away_attack / max(self.home_defense, 0.01))
self.home_lambda = float(np.clip(home_lambda, 0.02, 6.5))
self.away_lambda = float(np.clip(away_lambda, 0.02, 6.5))
def predict_exact_score(self, home_goals: int, away_goals: int) -> float:
"""回傳指定波膽home_goals, away_goals發生機率。"""
p_home = poisson.pmf(home_goals, self.home_lambda)
p_away = poisson.pmf(away_goals, self.away_lambda)
return float(p_home * p_away)
def predict_over_under_prob(self, line: float = 2.5, max_goals: int = 10) -> tuple[float, float]:
"""回傳under, over機率。"""
goals = pd.MultiIndex.from_product(
[range(max_goals + 1), range(max_goals + 1)],
names=['home', 'away'],
).to_frame(index=False)
def joint_prob(r: pd.Series) -> float:
return float(poisson.pmf(r['home'], self.home_lambda) * poisson.pmf(r['away'], self.away_lambda))
probs = goals.apply(joint_prob, axis=1)
total_goals = goals['home'] + goals['away']
under = float(probs[total_goals <= line].sum())
over = float(probs[total_goals > line].sum())
return under, over
def adjust_away_defense_for_altitude(
base_defense_rating: float,
venue_altitude_meters: float,
*,
is_second_half: bool,
penalty_factor: float = 0.35,
) -> float:
"""高海拔下修正客隊防守能力。
當場地海拔高於 1500m 且處於下半場,套用對數懲罰,
代表客隊在氧氣濃度降低下體能下降導致防守效率衰退。
"""
base = float(base_defense_rating)
if venue_altitude_meters <= 1500 or not is_second_half:
return base
# 以 log(1 + altitude/1000) 做平滑遞增函式,避免低海拔時劇烈改變。
altitude_penalty = penalty_factor * math.log1p(venue_altitude_meters / 1000)
return base * (1 - min(max(altitude_penalty, 0), 0.45))
__all__ = [
'calculate_value_bet',
'PoissonPredictor',
'adjust_away_defense_for_altitude',
]

View File

@@ -0,0 +1,43 @@
"""比賽環境衰減模型(高海拔與高溫)。"""
from __future__ import annotations
import math
def adjust_team_strength_for_environment(
base_strength: float,
venue_altitude: float,
venue_heat_index: float,
is_second_half: bool,
team_acclimatized: bool,
) -> float:
"""調整球隊能力值,反映環境壓力。
- 海拔 > 1500m 且球隊未適應,第二節時套用疲勞衰退。
- 熱指數Heat Index越高衰退越明顯。
"""
if base_strength < 0:
raise ValueError('base_strength 必須大於等於 0')
adjusted = float(base_strength)
if not is_second_half:
return adjusted
altitude_penalty = 0.0
heat_penalty = 0.0
if not team_acclimatized and venue_altitude >= 1500:
# 以對數遞增1500m 為轉折3000m 接近上限。
altitude_factor = math.log1p((venue_altitude - 1500.0) / 300.0)
altitude_penalty = 0.025 + 0.045 * min(altitude_factor, 2.8)
# 熱指數高於 30逐步加入疲勞因子超過 38 非常明顯。
if venue_heat_index > 30:
heat_excess = min(max(venue_heat_index - 30.0, 0.0), 30.0)
heat_penalty = 0.0012 * heat_excess
total_penalty = altitude_penalty + heat_penalty
adjusted *= max(0.2, 1.0 - total_penalty)
return adjusted

View File

@@ -0,0 +1,63 @@
"""EV期望值運算模組。
本模組提供最基本、可復用的賠率價值判斷邏輯:給定真實勝率與小數賠率,計算期望值與是否為優勢投注。
"""
from __future__ import annotations
from typing import Any
def calculate_expected_value(
true_win_prob: float,
decimal_odds: float,
stake: float = 100.0,
suggested_kelly_fraction: float | None = None,
) -> dict[str, Any]:
"""計算期望值EV並回傳報價建議。
Parameters
----------
true_win_prob:
模型估計的真實勝率,必須在 0 到 1 之間。
decimal_odds:
小數制賠率,必須大於 1否則不具可投注意義
stake:
本次下注本金;同時也是 EV 百分比的基準。
suggested_kelly_fraction:
由外部凱利公式模組預留的建議資金比例;若未提供則回傳 None。
Returns
-------
dict
{
'ev_value': 實際 EV 金額,
'ev_percentage': EV / stake * 100,
'is_value_bet': 當 EV% 大於 3% 時為 True,
'suggested_kelly_fraction': 傳入值或 None
}
"""
if not 0.0 <= true_win_prob <= 1.0:
raise ValueError('true_win_prob 必須介於 0 到 1 之間')
if decimal_odds <= 1:
raise ValueError('decimal_odds 必須大於 1')
if stake <= 0:
raise ValueError('stake 必須大於 0')
win_prob = float(true_win_prob)
odds = float(decimal_odds)
stake_amount = float(stake)
profit_when_win = odds - 1.0
lose_prob = 1.0 - win_prob
ev = win_prob * profit_when_win * stake_amount - lose_prob * stake_amount
ev_percentage = ev / stake_amount * 100
return {
'ev_value': round(ev, 6),
'ev_percentage': round(ev_percentage, 4),
'is_value_bet': ev_percentage > 3.0,
'suggested_kelly_fraction': suggested_kelly_fraction,
}

View File

@@ -0,0 +1,163 @@
"""進階特徵工程:從資料庫抽取多維比賽特徵。"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from math import radians, sin, cos, asin, sqrt
from typing import Iterable
from sqlalchemy import and_, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import Match, Team
@dataclass(frozen=True)
class MatchFeatureVector:
rest_days_advantage: float
travel_distance_km: float
recent_5_xg_diff: float
elo_rating_diff: float
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Haversine 地球大圓距離(公里)。"""
R = 6371.0
dlat = radians(lat2 - lat1)
dlon = radians(lon2 - lon1)
a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
return 2 * R * asin(min(1.0, sqrt(a)))
class MatchFeatureExtractor:
"""抽取並生成賽前特徵。"""
def __init__(
self,
session_factory,
*,
team_locations: dict[str, tuple[float, float]] | None = None,
) -> None:
self.session_factory = session_factory
# 可選:{team_id: (lat, lon)},若缺資料則 fallback 為 0 距離。
self.team_locations = team_locations or {}
async def _previous_match(self, session: AsyncSession, team_id: str, match_time: datetime) -> Match | None:
stmt = (
select(Match)
.where(
and_(
(Match.home_team_id == team_id) | (Match.away_team_id == team_id),
Match.match_time_utc < match_time,
Match.home_xg.is_not(None),
Match.away_xg.is_not(None),
),
)
.order_by(desc(Match.match_time_utc))
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def _recent_xg_series(self, session: AsyncSession, team_id: str, as_of_match_id: str, count: int = 5) -> list[float]:
stmt = (
select(Match)
.where(
(Match.home_team_id == team_id) | (Match.away_team_id == team_id),
Match.home_xg.is_not(None),
Match.away_xg.is_not(None),
Match.id != as_of_match_id,
)
.order_by(desc(Match.match_time_utc))
.limit(count)
)
result = await session.execute(stmt)
rows = result.scalars().all()
out: list[float] = []
for row in rows:
home_xg = float(row.home_xg or 0.0)
away_xg = float(row.away_xg or 0.0)
out.append(home_xg)
out.append(away_xg)
return out[:count]
async def extract_features(self, match_id: str) -> MatchFeatureVector:
"""產生四個關鍵特徵。
1) rest_days_advantage
2) travel_distance_km
3) recent_5_xg_diff
4) elo_rating_diff
"""
async with self.session_factory() as session: # type: ignore[assignment]
current_match = await session.get(Match, match_id)
if current_match is None:
raise ValueError(f'找不到 match_id={match_id}')
home_team = await session.get(Team, current_match.home_team_id)
away_team = await session.get(Team, current_match.away_team_id)
if home_team is None or away_team is None:
raise ValueError('比賽球隊資料不完整')
home_prev = await self._previous_match(session, home_team.id, current_match.match_time_utc)
away_prev = await self._previous_match(session, away_team.id, current_match.match_time_utc)
rest_home = (
(current_match.match_time_utc - home_prev.match_time_utc).days
if home_prev is not None
else 0
)
rest_away = (
(current_match.match_time_utc - away_prev.match_time_utc).days
if away_prev is not None
else 0
)
travel_distance = self._distance_between_teams(home_team.id, away_team.id)
home_xg = await self._recent_xg_series(session, home_team.id, current_match.id)
away_xg = await self._recent_xg_series(session, away_team.id, current_match.id)
recent_diff = sum(home_xg[:5]) / max(len(home_xg[:5]) or 1, 1) - sum(away_xg[:5]) / max(
len(away_xg[:5]) or 1,
1,
)
home_elo = float(home_team.current_elo_rating or 1500)
away_elo = float(away_team.current_elo_rating or 1500)
return MatchFeatureVector(
rest_days_advantage=float(rest_home - rest_away),
travel_distance_km=float(travel_distance),
recent_5_xg_diff=float(recent_diff),
elo_rating_diff=float(home_elo - away_elo),
)
def _distance_between_teams(self, home_team_id: str, away_team_id: str) -> float:
home_loc = self.team_locations.get(home_team_id)
away_loc = self.team_locations.get(away_team_id)
if home_loc is None or away_loc is None:
return 0.0
return float(_haversine_km(home_loc[0], home_loc[1], away_loc[0], away_loc[1]))
@staticmethod
def to_model_payload(features: MatchFeatureVector, columns: Iterable[str] | None = None) -> dict:
"""輸出可直接餵進 XGBoost 的特徵字典。"""
payload = {
'rest_days_advantage': features.rest_days_advantage,
'travel_distance_km': features.travel_distance_km,
'recent_5_xg_diff': features.recent_5_xg_diff,
'elo_rating_diff': features.elo_rating_diff,
}
if columns is None:
return payload
cols = list(columns)
return {c: float(payload[c]) for c in cols if c in payload}

View File

@@ -0,0 +1,39 @@
"""串關動態對沖Dynamic Hedging計算器。"""
from __future__ import annotations
def calculate_hedge_amount(
original_stake: float,
parlay_total_odds: float,
final_leg_hedge_odds: float,
) -> dict[str, float]:
"""
在 1 場或 2/3 場連贏快到最終局,計算對沖下注金額。
將原始串關保本化:
目標是「串關全過的淨利」與「對沖走向中的淨利」在最後同值。
設原始串關到位後保底淨利 = S * (O_parlay - 1)
對沖選項淨利 = H * (O_hedge - 1)
求 H * (O_hedge - 1) = S * (O_parlay - 1) - H
=> H = (S * (O_parlay - 1)) / O_hedge
"""
if original_stake <= 0:
raise ValueError('original_stake 必須大於 0')
if parlay_total_odds <= 1:
raise ValueError('parlay_total_odds 必須大於 1')
if final_leg_hedge_odds <= 1:
raise ValueError('final_leg_hedge_odds 必須大於 1')
expected_parlay_net = original_stake * (parlay_total_odds - 1)
hedge_stake = expected_parlay_net / final_leg_hedge_odds
profit_after_hedge = hedge_stake * (final_leg_hedge_odds - 1)
return {
'hedge_stake': round(hedge_stake, 4),
'locked_profit': round(profit_after_hedge, 4),
'parlay_net_after_hedge_if_win': round(expected_parlay_net - hedge_stake, 4),
'hedge_net_if_win': round(profit_after_hedge, 4),
}

View File

@@ -0,0 +1,64 @@
"""凱利準則Kelly Criterion工具。"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class KellyResult:
"""凱利投注建議結果。"""
decimal_odds: float
win_probability: float
raw_kelly_fraction: float
fractional_kelly_factor: float
risk_tolerance_factor: float
final_fraction: float
stake_fraction: float
def calculate_kelly_fraction(
decimal_odds: float,
true_prob: float,
*,
bankroll: float,
fractional_kelly_factor: float = 1.0,
risk_tolerance_factor: float = 1.0,
) -> KellyResult:
"""依凱利準則估算下注比例與建議金額。
凱利公式:
f* = (b * p - q) / b
其中 b = odds - 1p 為勝率q = 1 - p。
"""
if decimal_odds <= 1:
raise ValueError('decimal_odds 必須大於 1')
if bankroll <= 0:
raise ValueError('bankroll 必須大於 0')
if not 0 <= true_prob <= 1:
raise ValueError('true_prob 需介於 0 到 1')
if not 0 <= fractional_kelly_factor <= 5:
raise ValueError('fractional_kelly_factor 須介於 0 到 5')
if not 0 <= risk_tolerance_factor <= 2:
raise ValueError('risk_tolerance_factor 須介於 0 到 2')
b = decimal_odds - 1
raw_kelly = (b * true_prob - (1 - true_prob)) / b
final_fraction = raw_kelly * fractional_kelly_factor * risk_tolerance_factor
# 保守處理避免負值與超過總資金比例100%)的極端輸出。
final_fraction = max(0.0, min(final_fraction, 1.0))
return KellyResult(
decimal_odds=decimal_odds,
win_probability=true_prob,
raw_kelly_fraction=raw_kelly,
fractional_kelly_factor=fractional_kelly_factor,
risk_tolerance_factor=risk_tolerance_factor,
final_fraction=final_fraction,
stake_fraction=final_fraction,
)
__all__ = ['KellyResult', 'calculate_kelly_fraction']

View File

@@ -0,0 +1,435 @@
"""機器學習賽果預測引擎Ensemble"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping
from uuid import uuid4
import numpy as np
import pandas as pd
try:
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
except Exception: # pragma: no cover - 缺少 scikit-learn 時的 fallback
GradientBoostingClassifier = None
train_test_split = None
FEATURE_COLUMNS = ('rest_days_advantage', 'travel_distance_km', 'recent_5_xg_diff')
OUTCOMES = ('home', 'draw', 'away')
def _sigmoid(value: float) -> float:
return 1.0 / (1.0 + np.exp(-value))
def _softmax(values: np.ndarray) -> np.ndarray:
shifted = values - np.max(values)
exp_values = np.exp(shifted)
return exp_values / exp_values.sum()
@dataclass(frozen=True)
class EnsembleModelArtifact:
"""已訓練的 ML 模組與中繼資料。"""
model: Any
feature_columns: tuple[str, ...]
model_id: str
training_size: int
is_fallback: bool
training_accuracy: float | None = None
class _FallbackMatchModel:
"""缺少 ML 套件時的保底模型(規則式)。"""
feature_columns = FEATURE_COLUMNS
def predict_proba(self, row_df: pd.DataFrame) -> np.ndarray:
if row_df.empty:
return np.zeros((0, 3))
x = row_df[self.feature_columns].to_numpy(float)
raw_scores = []
for rest_days_advantage, travel_distance_km, recent_5_xg_diff in x:
home_score = 0.6 + rest_days_advantage * 0.022 + recent_5_xg_diff * 0.34 - travel_distance_km * 0.0012
draw_score = 0.30 - abs(rest_days_advantage) * 0.015 - abs(recent_5_xg_diff) * 0.22
away_score = 0.1 - rest_days_advantage * 0.022 - recent_5_xg_diff * 0.34 + travel_distance_km * 0.0012
scores = np.array(
[
_sigmoid(home_score),
_sigmoid(draw_score) * 0.9,
_sigmoid(away_score),
],
dtype=float,
)
raw_scores.append(_softmax(scores))
return np.vstack(raw_scores)
def _as_float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def normalize_feature_payload(payload: Mapping[str, Any]) -> dict[str, float]:
"""從前端或資料庫欄位,萃取核心三大特徵。"""
home_rest = _as_float(payload.get('home_rest_days'))
away_rest = _as_float(payload.get('away_rest_days'))
home_travel = _as_float(payload.get('home_travel_distance_km'))
away_travel = _as_float(payload.get('away_travel_distance_km'))
recent_home = _as_float(payload.get('recent_5_xg_home'))
recent_away = _as_float(payload.get('recent_5_xg_away'))
return {
'home_rest_days': home_rest,
'away_rest_days': away_rest,
'home_travel_distance_km': home_travel,
'away_travel_distance_km': away_travel,
'recent_5_xg_home': recent_home,
'recent_5_xg_away': recent_away,
'rest_days_advantage': home_rest - away_rest,
'travel_distance_km': home_travel - away_travel,
'recent_5_xg_diff': recent_home - recent_away,
}
def _validation_frame(rows: list[Mapping[str, Any]]) -> pd.DataFrame:
if len(rows) < 5:
raise ValueError('訓練樣本少於 5 筆,無法完成穩定訓練')
frame = pd.DataFrame(rows)
required_fields = set(FEATURE_COLUMNS) | {'match_result'}
missing = required_fields - set(frame.columns)
if missing:
raise ValueError(f'訓練資料缺欄位:{sorted(missing)}')
frame = frame.copy()
frame[list(FEATURE_COLUMNS)] = frame[list(FEATURE_COLUMNS)].astype(float).fillna(0.0)
frame['match_result'] = frame['match_result'].str.lower().str.strip()
unknown = set(frame['match_result']) - set(OUTCOMES)
if unknown:
raise ValueError(f'未知賽果標籤:{sorted(unknown)},僅支援 {OUTCOMES}')
return frame
def build_default_ml_training_rows() -> list[dict[str, float | str]]:
"""建立保底訓練樣本(當環境無法即時取得外部訓練資料時)。"""
return [
{
'home_rest_days': 4,
'away_rest_days': 3,
'home_travel_distance_km': 520,
'away_travel_distance_km': 1100,
'recent_5_xg_home': 1.8,
'recent_5_xg_away': 1.0,
'rest_days_advantage': 1,
'travel_distance_km': -580,
'recent_5_xg_diff': 0.8,
'match_result': 'home',
},
{
'home_rest_days': 2,
'away_rest_days': 5,
'home_travel_distance_km': 220,
'away_travel_distance_km': 780,
'recent_5_xg_home': 1.1,
'recent_5_xg_away': 1.7,
'rest_days_advantage': -3,
'travel_distance_km': -560,
'recent_5_xg_diff': -0.6,
'match_result': 'away',
},
{
'home_rest_days': 6,
'away_rest_days': 4,
'home_travel_distance_km': 120,
'away_travel_distance_km': 960,
'recent_5_xg_home': 2.3,
'recent_5_xg_away': 1.8,
'rest_days_advantage': 2,
'travel_distance_km': -840,
'recent_5_xg_diff': 0.5,
'match_result': 'home',
},
{
'home_rest_days': 3,
'away_rest_days': 3,
'home_travel_distance_km': 900,
'away_travel_distance_km': 900,
'recent_5_xg_home': 1.2,
'recent_5_xg_away': 1.3,
'rest_days_advantage': 0,
'travel_distance_km': 0,
'recent_5_xg_diff': -0.1,
'match_result': 'draw',
},
{
'home_rest_days': 8,
'away_rest_days': 2,
'home_travel_distance_km': 350,
'away_travel_distance_km': 700,
'recent_5_xg_home': 2.0,
'recent_5_xg_away': 1.2,
'rest_days_advantage': 6,
'travel_distance_km': -350,
'recent_5_xg_diff': 0.8,
'match_result': 'home',
},
{
'home_rest_days': 1,
'away_rest_days': 2,
'home_travel_distance_km': 1600,
'away_travel_distance_km': 2500,
'recent_5_xg_home': 1.4,
'recent_5_xg_away': 2.1,
'rest_days_advantage': -1,
'travel_distance_km': -900,
'recent_5_xg_diff': -0.7,
'match_result': 'away',
},
{
'home_rest_days': 5,
'away_rest_days': 5,
'home_travel_distance_km': 700,
'away_travel_distance_km': 700,
'recent_5_xg_home': 1.9,
'recent_5_xg_away': 1.9,
'rest_days_advantage': 0,
'travel_distance_km': 0,
'recent_5_xg_diff': 0.0,
'match_result': 'draw',
},
{
'home_rest_days': 9,
'away_rest_days': 3,
'home_travel_distance_km': 400,
'away_travel_distance_km': 300,
'recent_5_xg_home': 2.4,
'recent_5_xg_away': 1.1,
'rest_days_advantage': 6,
'travel_distance_km': 100,
'recent_5_xg_diff': 1.3,
'match_result': 'home',
},
{
'home_rest_days': 2,
'away_rest_days': 7,
'home_travel_distance_km': 1800,
'away_travel_distance_km': 250,
'recent_5_xg_home': 1.0,
'recent_5_xg_away': 1.5,
'rest_days_advantage': -5,
'travel_distance_km': 1550,
'recent_5_xg_diff': -0.5,
'match_result': 'away',
},
{
'home_rest_days': 4,
'away_rest_days': 4,
'home_travel_distance_km': 500,
'away_travel_distance_km': 500,
'recent_5_xg_home': 1.6,
'recent_5_xg_away': 1.4,
'rest_days_advantage': 0,
'travel_distance_km': 0,
'recent_5_xg_diff': 0.2,
'match_result': 'home',
},
{
'home_rest_days': 6,
'away_rest_days': 1,
'home_travel_distance_km': 300,
'away_travel_distance_km': 1200,
'recent_5_xg_home': 2.8,
'recent_5_xg_away': 0.8,
'rest_days_advantage': 5,
'travel_distance_km': -900,
'recent_5_xg_diff': 2.0,
'match_result': 'home',
},
{
'home_rest_days': 2,
'away_rest_days': 6,
'home_travel_distance_km': 1000,
'away_travel_distance_km': 200,
'recent_5_xg_home': 1.0,
'recent_5_xg_away': 2.6,
'rest_days_advantage': -4,
'travel_distance_km': 800,
'recent_5_xg_diff': -1.6,
'match_result': 'away',
},
{
'home_rest_days': 7,
'away_rest_days': 7,
'home_travel_distance_km': 650,
'away_travel_distance_km': 650,
'recent_5_xg_home': 1.8,
'recent_5_xg_away': 1.8,
'rest_days_advantage': 0,
'travel_distance_km': 0,
'recent_5_xg_diff': 0.0,
'match_result': 'draw',
},
{
'home_rest_days': 3,
'away_rest_days': 1,
'home_travel_distance_km': 260,
'away_travel_distance_km': 900,
'recent_5_xg_home': 2.1,
'recent_5_xg_away': 1.6,
'rest_days_advantage': 2,
'travel_distance_km': -640,
'recent_5_xg_diff': 0.5,
'match_result': 'home',
},
{
'home_rest_days': 0,
'away_rest_days': 5,
'home_travel_distance_km': 1500,
'away_travel_distance_km': 150,
'recent_5_xg_home': 1.2,
'recent_5_xg_away': 2.0,
'rest_days_advantage': -5,
'travel_distance_km': 1350,
'recent_5_xg_diff': -0.8,
'match_result': 'away',
},
{
'home_rest_days': 5,
'away_rest_days': 2,
'home_travel_distance_km': 300,
'away_travel_distance_km': 300,
'recent_5_xg_home': 2.2,
'recent_5_xg_away': 1.1,
'rest_days_advantage': 3,
'travel_distance_km': 0,
'recent_5_xg_diff': 1.1,
'match_result': 'home',
},
{
'home_rest_days': 4,
'away_rest_days': 8,
'home_travel_distance_km': 450,
'away_travel_distance_km': 980,
'recent_5_xg_home': 1.5,
'recent_5_xg_away': 2.4,
'rest_days_advantage': -4,
'travel_distance_km': -530,
'recent_5_xg_diff': -0.9,
'match_result': 'away',
},
]
def train_match_outcome_ensemble(
training_rows: list[Mapping[str, Any]],
*,
model_id: str | None = None,
) -> EnsembleModelArtifact:
"""訓練 1X2 賽果 Ensemble無法使用 sklearn 時自動回退規則模型)。"""
normalized = [_normalize_training_row(row) for row in training_rows]
frame = _validation_frame(normalized)
x = frame[list(FEATURE_COLUMNS)]
y = frame['match_result'].map({'home': 0, 'draw': 1, 'away': 2})
if len(frame) < 24 or GradientBoostingClassifier is None or train_test_split is None:
return EnsembleModelArtifact(
model=_FallbackMatchModel(),
feature_columns=FEATURE_COLUMNS,
model_id=model_id or uuid4().hex,
training_size=len(frame),
is_fallback=True,
training_accuracy=None,
)
x_train, x_val, y_train, y_val = train_test_split(
x,
y,
test_size=min(0.3, max(0.15, 1 - (30 / len(frame)))),
random_state=17,
stratify=y,
)
model = GradientBoostingClassifier(
random_state=17,
n_estimators=220,
max_depth=3,
learning_rate=0.06,
)
model.fit(x_train, y_train)
accuracy = float(model.score(x_val, y_val)) if len(set(y_val)) > 1 else None
return EnsembleModelArtifact(
model=model,
feature_columns=FEATURE_COLUMNS,
model_id=model_id or uuid4().hex,
training_size=len(frame),
is_fallback=False,
training_accuracy=accuracy,
)
def _normalize_training_row(row: Mapping[str, Any]) -> dict[str, float | str]:
normalized = normalize_feature_payload(row)
if 'match_result' not in row:
raise ValueError('訓練資料缺少 match_result')
normalized['match_result'] = str(row['match_result']).strip().lower()
return normalized
def build_default_ensemble_artifact() -> EnsembleModelArtifact:
"""建立系統預設模型(含 fallback"""
return train_match_outcome_ensemble(build_default_ml_training_rows(), model_id='default')
def model_predict_probabilities(
artifact: EnsembleModelArtifact,
features: Mapping[str, Any],
) -> dict[str, float]:
"""回傳 home/draw/away 的機率。"""
normalized = normalize_feature_payload(features)
feature_frame = pd.DataFrame([normalized], columns=artifact.feature_columns)
probs = artifact.model.predict_proba(feature_frame)[0]
return {
'home': float(probs[0]),
'draw': float(probs[1]),
'away': float(probs[2]),
}
def calculate_model_edges(
predicted: dict[str, float],
implied: dict[str, float],
) -> dict[str, dict[str, float | bool]]:
"""比較模型機率與莊家隱含機率,標示 Strong Buy。"""
edges: dict[str, dict[str, float | bool]] = {}
for key in OUTCOMES:
p = float(predicted.get(key, 0))
i = float(implied.get(key, 0))
edge = p - i
edges[key] = {
'model_prob': round(p, 6),
'implied_prob': round(i, 6),
'edge': round(edge, 6),
'strong_buy': edge >= 0.04,
}
return edges

View File

@@ -0,0 +1,99 @@
"""XGBoost 推論 API 套件。"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
from xgboost import Booster, DMatrix
@dataclass(frozen=True)
class XGBoostPrediction:
home_win: float
draw: float
away_win: float
def _safe_probability(x: float) -> float:
return float(max(0.0, min(1.0, x)))
class XGBoostPredictor:
"""XGBoost 預測器:輸入特徵 => 輸出 1x2 機率。"""
def __init__(
self,
model_path: str | None = None,
*,
feature_columns: list[str] | None = None,
) -> None:
self.feature_columns = feature_columns or []
self.model_path = model_path
self.model = self._load_model(model_path) if model_path else None
def _load_model(self, model_path: str | None) -> Booster | None:
if not model_path:
return None
path = Path(model_path)
if not path.exists():
return None
model = Booster()
model.load_model(str(path))
return model
def predict_match_outcome(self, features: dict[str, float]) -> dict[str, float]:
"""輸出主勝/平/客勝機率。"""
if self.model is None:
# fallback: 均分
return {'home': 1 / 3, 'draw': 1 / 3, 'away': 1 / 3}
ordered_values = [float(features.get(col, 0.0)) for col in self.feature_columns]
dmatrix = DMatrix(np.array([ordered_values]), feature_names=self.feature_columns)
probs = self.model.predict(dmatrix)
if probs.ndim == 1:
probs = probs.reshape(1, -1)
arr = probs[0]
if arr.size < 3:
raise ValueError('模型輸出維度不足 3')
raw = np.array(arr[:3], dtype=float)
raw = np.maximum(raw, 0.0)
s = raw.sum()
if s <= 0:
raise ValueError('模型輸出總和異常為 0')
norm = raw / s
return {'home': _safe_probability(norm[0]), 'draw': _safe_probability(norm[1]), 'away': _safe_probability(norm[2])}
def find_model_edge(
self,
ml_probs: dict[str, float],
bookmaker_implied_probs: dict[str, float],
) -> list[dict[str, Any]]:
"""回傳模型超越莊家 4% 以上的投注選項。"""
mapping = [('home', 'home'), ('draw', 'draw'), ('away', 'away')]
outputs: list[dict[str, Any]] = []
for model_key, book_key in mapping:
ml_v = float(ml_probs.get(model_key, 0.0))
book_v = float(bookmaker_implied_probs.get(book_key, 0.0))
edge = ml_v - book_v
if edge >= 0.04:
outputs.append(
{
'selection': model_key,
'ml_prob': round(ml_v, 6),
'bookmaker_implied_prob': round(book_v, 6),
'edge': round(edge, 6),
'label': 'Strong Buy',
},
)
return outputs

View File

@@ -0,0 +1,163 @@
"""球員道具盤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',
]

View 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']),
}

View File

@@ -0,0 +1,113 @@
"""Poisson 分佈賽果預測模組。"""
from __future__ import annotations
import numpy as np
from scipy.stats import poisson
class PoissonMatchPredictor:
"""基於攻守強度的雙方進球機率預測器。"""
def __init__(
self,
home_attack_strength: float,
home_defense_strength: float,
away_attack_strength: float,
away_defense_strength: float,
league_avg_home_goals: float,
) -> None:
for value, name in [
(home_attack_strength, 'home_attack_strength'),
(home_defense_strength, 'home_defense_strength'),
(away_attack_strength, 'away_attack_strength'),
(away_defense_strength, 'away_defense_strength'),
(league_avg_home_goals, 'league_avg_home_goals'),
]:
if value <= 0:
raise ValueError(f'{name} 必須大於 0')
self.home_attack_strength = float(home_attack_strength)
self.home_defense_strength = float(home_defense_strength)
self.away_attack_strength = float(away_attack_strength)
self.away_defense_strength = float(away_defense_strength)
self.league_avg_home_goals = float(league_avg_home_goals)
def calculate_expected_goals(self) -> tuple[float, float]:
"""根據攻守強度與聯盟均值估算預期進球數(λ 值)。
使用比值校正避免極端值放大風險:
- 主隊 λ = 聯盟主場均值 × (主攻 / 客守)
- 客隊 λ = 聯盟客場均值 × (客攻 / 主守)
"""
league_avg_away_goals = self.league_avg_home_goals * 0.95
home_lambda = self.league_avg_home_goals * (self.home_attack_strength / self.away_defense_strength)
away_lambda = league_avg_away_goals * (self.away_attack_strength / self.home_defense_strength)
home_lambda = max(0.01, min(home_lambda, 8.0))
away_lambda = max(0.01, min(away_lambda, 8.0))
return home_lambda, away_lambda
def predict_exact_score_matrix(self, max_goals: int = 5) -> np.ndarray:
"""輸出 0~max_goals 間所有比分組合的機率矩陣。
回傳 shape = (max_goals+1, max_goals+1)
index [i,j] 代表主隊 i 球、客隊 j 球的機率。
"""
if max_goals < 0:
raise ValueError('max_goals 必須大於等於 0')
home_lambda, away_lambda = self.calculate_expected_goals()
goals = np.arange(max_goals + 1)
home_prob = poisson.pmf(goals, home_lambda)
away_prob = poisson.pmf(goals, away_lambda)
matrix = np.outer(home_prob, away_prob)
matrix = matrix.astype(float)
matrix /= matrix.sum() if matrix.sum() > 0 else 1.0
return matrix
def predict_1x2_probabilities(self) -> dict[str, float]:
"""由波膽矩陣匯總 1x2主勝/平/客勝)機率。"""
matrix = self.predict_exact_score_matrix(max_goals=8)
draw = float(np.trace(matrix))
home_win = float(np.tril(matrix, -1).sum())
away_win = float(np.triu(matrix, 1).sum())
total = home_win + draw + away_win
if total <= 0:
return {'home_win': 0.0, 'draw': 0.0, 'away_win': 0.0}
return {
'home_win': home_win / total,
'draw': draw / total,
'away_win': away_win / total,
}
def predict_over_under_prob(self, line: float = 2.5, max_goals: int = 8) -> tuple[float, float]:
"""回傳Under 機率, Over 機率)。"""
if line < 0:
raise ValueError('line 必須大於等於 0')
matrix = self.predict_exact_score_matrix(max_goals=max_goals)
goals = np.arange(max_goals + 1)
home, away = np.meshgrid(goals, goals)
total = home + away
under_mask = total <= line
under = float(matrix[under_mask].sum())
over = float(matrix[~under_mask].sum())
normalizer = under + over
if normalizer <= 0:
return 0.0, 0.0
return under / normalizer, over / normalizer

View File

@@ -0,0 +1,241 @@
"""個人投注弱點分析Betting Leaks引擎。
將使用者歷史注單做群組化彙總,找出長期導致虧損的下注模式。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
def _safe_float(value: Any, default: float | None = None) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return default
def _safe_int(value: Any, default: int | None = None) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return default
def _to_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {'1', 'true', 't', 'yes', 'y'}
if isinstance(value, (int, float)):
return value not in {0}
return default
def _odds_bucket(odds: float | None, step: float = 0.5) -> str:
if odds is None or odds <= 0:
return 'N/A'
if odds <= 1:
return '1.00-1.50'
bucket_start = ((odds - 1) // step) * step + 1
bucket_end = bucket_start + step
return f'{bucket_start:.2f}-{bucket_end:.2f}'
def _calculate_pnl(stake: float, is_win: bool, closing_odds: float | None, recommended_odds: float | None) -> float:
"""依下注結果與收盤賠率計算實際 P/L。"""
effective_odds = closing_odds
if effective_odds is None or effective_odds <= 1:
effective_odds = recommended_odds
if effective_odds is None or effective_odds <= 1 or stake <= 0:
return 0.0
if is_win:
return stake * (effective_odds - 1)
return -stake
def _calculate_clv(recommended_odds: float | None, closing_odds: float | None) -> float | None:
if recommended_odds is None or closing_odds is None:
return None
if recommended_odds <= 0 or closing_odds <= 0:
return None
return (recommended_odds / closing_odds - 1) * 100
@dataclass(frozen=True)
class LeakageCluster:
market_type: str
bet_type: str
odds_bucket: str
match_stage: str
bet_count: int
total_stake: float
closed_count: int
win_count: int
total_pnl: float
avg_clv_percent: float
roi_percent: float
hit_rate_percent: float
status: str
def as_dict(self) -> dict[str, Any]:
return {
'market_type': self.market_type,
'bet_type': self.bet_type,
'odds_bucket': self.odds_bucket,
'match_stage': self.match_stage,
'bet_count': self.bet_count,
'total_stake': self.total_stake,
'closed_count': self.closed_count,
'win_count': self.win_count,
'total_pnl': self.total_pnl,
'avg_clv_percent': self.avg_clv_percent,
'roi_percent': self.roi_percent,
'hit_rate_percent': self.hit_rate_percent,
'status': self.status,
}
@dataclass(frozen=True)
class HardTruth:
title: str
message: str
cluster: dict[str, Any]
def analyze_user_leaks(user_bets: list[dict[str, Any]]) -> dict[str, Any]:
"""分析使用者注單中的高頻虧損模式,回傳風險群組與漏點警告。"""
raw_bets = user_bets if isinstance(user_bets, list) else []
grouped: dict[tuple[str, str, str, str], dict[str, Any]] = {}
for raw in raw_bets:
if not isinstance(raw, dict):
continue
market_type = str(raw.get('market_type', 'unknown')).strip() or 'unknown'
is_single = raw.get('parlay_type') in (None, 'single', '', 'single_bet')
bet_type = 'single' if is_single else 'parlay'
odds = _safe_float(raw.get('odds'))
stake = _safe_float(raw.get('stake'))
if stake is None or stake <= 0:
continue
match_stage = str(raw.get('match_stage', raw.get('stage', 'unknown'))).strip() or 'unknown'
odds_band = _odds_bucket(odds)
key = (market_type, bet_type, odds_band, match_stage)
entry = grouped.setdefault(
key,
{
'bet_count': 0,
'total_stake': 0.0,
'closed_count': 0,
'win_count': 0,
'total_pnl': 0.0,
'clv_values': [] as list[float],
},
)
entry['bet_count'] += 1
entry['total_stake'] += stake
is_settled = _to_bool(raw.get('is_settled'), default=False)
if not is_settled:
continue
is_win = _to_bool(raw.get('is_win'))
if is_win:
entry['win_count'] += 1
entry['closed_count'] += 1
closing_odds = _safe_float(raw.get('closing_odds'))
recommended_odds = odds or _safe_float(raw.get('recommended_odds'))
pnl = _calculate_pnl(
stake=stake,
is_win=is_win,
closing_odds=closing_odds,
recommended_odds=recommended_odds,
)
entry['total_pnl'] += pnl
clv = _calculate_clv(recommended_odds, closing_odds)
if clv is not None:
entry['clv_values'].append(clv)
total_bets = sum(v['bet_count'] for v in grouped.values())
settled_bets = sum(v['closed_count'] for v in grouped.values())
total_stake = sum(v['total_stake'] for v in grouped.values())
total_pnl = sum(v['total_pnl'] for v in grouped.values())
total_win = sum(v['win_count'] for v in grouped.values())
clusters: list[LeakageCluster] = []
hard_truths: list[HardTruth] = []
for (market_type, bet_type, odds_bucket, match_stage), row in grouped.items():
bet_count = int(row['bet_count'])
closed_count = int(row['closed_count'])
total_stake_group = float(row['total_stake'])
total_pnl_group = float(row['total_pnl'])
roi = (total_pnl_group / total_stake_group * 100) if total_stake_group > 0 else 0.0
win_rate = (row['win_count'] / closed_count * 100) if closed_count > 0 else 0.0
avg_clv = (sum(row['clv_values']) / len(row['clv_values'])) if row['clv_values'] else 0.0
status = 'OK'
if bet_count > 20 and roi < -10:
status = 'CRITICAL_LEAK'
hard_truths.append(
HardTruth(
title='嚴重漏財點',
message=(
f'{match_stage} / {bet_type} / {market_type} / {odds_bucket} 的下注次數 {bet_count} 場,'
f'ROI {roi:.2f}%,請先降低此區塊投注比例。'
),
cluster={
'market_type': market_type,
'bet_type': bet_type,
'odds_bucket': odds_bucket,
'match_stage': match_stage,
},
).__dict__,
)
clusters.append(
LeakageCluster(
market_type=market_type,
bet_type=bet_type,
odds_bucket=odds_bucket,
match_stage=match_stage,
bet_count=bet_count,
total_stake=round(total_stake_group, 2),
closed_count=closed_count,
win_count=row['win_count'],
total_pnl=round(total_pnl_group, 2),
avg_clv_percent=round(avg_clv, 4),
roi_percent=round(roi, 4),
hit_rate_percent=round(win_rate, 2),
status=status,
),
)
clusters.sort(key=lambda c: c.roi_percent)
overall_roi = (total_pnl / total_stake * 100) if total_stake > 0 else 0.0
overall_hit_rate = (total_win / settled_bets * 100) if settled_bets > 0 else 0.0
return {
'total_bet_count': total_bets,
'settled_bet_count': settled_bets,
'total_stake': round(total_stake, 2),
'total_pnl': round(total_pnl, 2),
'overall_roi_percent': round(overall_roi, 4),
'overall_hit_rate_percent': round(overall_hit_rate, 2),
'clusters': [c.as_dict() for c in clusters],
'hard_truths': [h.__dict__ for h in hard_truths],
}

View File

@@ -0,0 +1,162 @@
"""公開獲利帳本Proof of Yield模組。"""
from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
from typing import Any
from uuid import uuid4
from datetime import datetime
def _as_float(value: Any, *, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
@dataclass(frozen=True)
class ProofYieldRecord:
recommendation_id: str
match_id: str
market_type: str
selection: str
stake: float
recommended_odds: float
closing_odds: float | None
is_win: bool
settled_at: str
clv_ratio: float | None
clv_percent: float | None
pnl: float
created_at: str
def compute_clv(recommended_odds: float, closing_odds: float) -> float:
"""CLV = (推薦賠率 / 收盤賠率) - 1。"""
if recommended_odds <= 0 or closing_odds <= 0:
raise ValueError('推薦賠率與收盤賠率都必須大於 0')
return (recommended_odds / closing_odds) - 1
def compute_pnl(stake: float, is_win: bool, closing_odds: float | None) -> float:
if closing_odds is None or stake <= 0:
return 0.0
return stake * (closing_odds - 1) if is_win else -stake
@dataclass(frozen=True)
class LedgerSummary:
total_recommendations: int
hit_count: int
win_rate_percent: float
total_stake: float
total_pnl: float
roi_percent: float
avg_clv_percent: float
class ProofOfYieldStore:
"""本地持久化透明帳本(先以 JSON 做可追溯快啟動)。"""
def __init__(self, file_path: str | None = None) -> None:
self.path = Path(file_path or 'data/proof_of_yield_ledger.json')
self.path.parent.mkdir(parents=True, exist_ok=True)
def _load(self) -> list[dict[str, Any]]:
if not self.path.exists():
return []
raw = self.path.read_text(encoding='utf-8')
if not raw.strip():
return []
parsed = json.loads(raw)
if not isinstance(parsed, list):
return []
return parsed
def _save(self, rows: list[dict[str, Any]]) -> None:
self.path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding='utf-8')
def upsert_settlements(self, items: list[dict[str, Any]]) -> list[ProofYieldRecord]:
current = self._load()
idx = {row['recommendation_id']: i for i, row in enumerate(current)}
for item in items:
recommendation_id = str(item.get('recommendation_id') or uuid4().hex)
stake = _as_float(item.get('stake'), default=100.0)
recommended_odds = _as_float(item.get('recommended_odds'))
closing_odds = item.get('closing_odds')
is_win = bool(item.get('is_win', False))
closing = _as_float(closing_odds) if closing_odds is not None else None
clv = None
clv_pct = None
if closing is not None and recommended_odds > 0:
clv = compute_clv(recommended_odds, closing)
clv_pct = clv * 100
pnl = compute_pnl(stake, is_win, closing)
record = {
'recommendation_id': recommendation_id,
'match_id': str(item.get('match_id', 'UNKNOWN')),
'market_type': str(item.get('market_type', '1x2')),
'selection': str(item.get('selection', 'home')),
'stake': round(stake, 4),
'recommended_odds': round(recommended_odds, 6),
'closing_odds': round(closing, 6) if closing is not None else None,
'is_win': is_win,
'settled_at': str(item.get('settled_at') or datetime.utcnow().isoformat()),
'clv_ratio': round(clv, 6) if clv is not None else None,
'clv_percent': round(clv_pct, 4) if clv_pct is not None else None,
'pnl': round(pnl, 4),
'created_at': str(item.get('created_at') or datetime.utcnow().isoformat()),
}
if recommendation_id in idx:
current[idx[recommendation_id]] = record
else:
current.append(record)
self._save(current)
return [ProofYieldRecord(**row) for row in current]
def query_ledger(self, *, limit: int = 200) -> list[ProofYieldRecord]:
rows = sorted(self._load(), key=lambda row: row.get('created_at', ''), reverse=True)
return [ProofYieldRecord(**row) for row in rows[:limit]]
@staticmethod
def summarize(records: list[ProofYieldRecord]) -> LedgerSummary:
total = len(records)
if total == 0:
return LedgerSummary(
total_recommendations=0,
hit_count=0,
win_rate_percent=0.0,
total_stake=0.0,
total_pnl=0.0,
roi_percent=0.0,
avg_clv_percent=0.0,
)
hit = sum(1 for row in records if row.is_win)
total_stake = sum(row.stake for row in records)
total_pnl = sum(row.pnl for row in records)
clv_values = [row.clv_percent for row in records if row.clv_percent is not None]
avg_clv = sum(clv_values) / len(clv_values) if clv_values else 0.0
roi = (total_pnl / total_stake) * 100 if total_stake > 0 else 0.0
win_rate = (hit / total) * 100
return LedgerSummary(
total_recommendations=total,
hit_count=hit,
win_rate_percent=round(win_rate, 4),
total_stake=round(total_stake, 4),
total_pnl=round(total_pnl, 4),
roi_percent=round(roi, 4),
avg_clv_percent=round(avg_clv, 4),
)

View File

@@ -0,0 +1,53 @@
"""裁判尺度分析器。"""
from __future__ import annotations
from typing import Dict
from .ev_calculator import calculate_expected_value
def calculate_cards_ev(
referee_stats: dict,
match_tension_index: float,
bookmaker_card_line: float,
bookmaker_odds: float,
) -> dict[str, float | bool | str]:
"""判斷裁判/對手張力對紅黃牌盤口的偏差與價值。
依據裁判最近場次平均黃牌數與比賽張力(衝突度)估算
本場真實牌數,並與莊家 O/U 盤口比較。
"""
if bookmaker_odds <= 1:
raise ValueError('bookmaker_odds 必須大於 1')
if bookmaker_card_line <= 0:
raise ValueError('bookmaker_card_line 必須大於 0')
if not 0 <= match_tension_index <= 1:
raise ValueError('match_tension_index 必須在 0~1')
avg_cards = float(referee_stats.get('avg_yellow_cards', 0.0) or 0.0)
penalties_per_game = float(referee_stats.get('penalties_per_game', 0.0) or 0.0)
strictness_index = 20.0 + avg_cards * 1.9 + penalties_per_game * 2.5
# 綜合壓力補正,將裁判嚴厲度與球隊/賽事張力轉為預測牌數。
expected_cards = max(
0.5,
strictness_index * (0.45 + 0.55 * max(0.0, min(match_tension_index, 1.0))),
)
true_prob = min(1.0, max(0.0, expected_cards / (bookmaker_card_line * 1.4)))
implied_prob = 1.0 / bookmaker_odds
edge = true_prob - implied_prob
ev = calculate_expected_value(true_prob, bookmaker_odds, stake=100.0)
return {
'strictness_index': round(strictness_index, 3),
'expected_total_cards': round(expected_cards, 3),
'true_prob': round(true_prob, 4),
'implied_prob': round(implied_prob, 4),
'edge_percent': round(edge * 100, 3),
'is_value_bet': ev['is_value_bet'],
'ev_percentage': ev['ev_percentage'],
}

View File

@@ -0,0 +1,131 @@
"""裁判與天候條件量化模組。"""
from __future__ import annotations
from dataclasses import dataclass
def calculate_referee_strictness_index(
avg_yellow_cards: float,
penalties_per_game: float,
) -> float:
"""裁判嚴厲度指標0-100"""
yellow = max(0.0, min(avg_yellow_cards, 8.0)) / 8.0
penalties = max(0.0, min(penalties_per_game, 2.5)) / 2.5
return round(yellow * 55 + penalties * 45, 4)
def detect_cards_pressure_signal(
strictness_index: float,
cards_ou_line: float,
) -> bool:
"""當裁判嚴格且莊家的卡數 O/U 開得偏低時,判斷為可能的逆風盤口。"""
return strictness_index >= 80 and cards_ou_line <= 4.5
def estimate_heat_index(ambient_temp_c: float, humidity_pct: float) -> float:
"""簡化的 Heat Index攝氏"""
t = max(-60.0, min(60.0, ambient_temp_c))
rh = max(0.0, min(100.0, humidity_pct))
hi = (
-8.784695
+ 1.61139411 * t
+ 2.338549 * rh
- 0.14611605 * t * rh
- 0.012308094 * t * t
- 0.016424828 * rh * rh
+ 0.002211732 * t * t * rh
+ 0.00072546 * t * rh * rh
- 0.000003582 * t * t * rh * rh
)
return round(max(0.0, hi), 4)
@dataclass(frozen=True)
class MatchConditionSignal:
strictness_index: float
heat_index: float
cards_pressure_alert: bool
cards_ou_line: float
second_half_home_attack: float
second_half_away_attack: float
second_half_under_recommendation: bool
attacker_direction: str
def adjust_attack_for_heat_and_altitude(
base_attack: float,
*,
heat_index: float,
is_second_half: bool,
venue_altitude_meters: float | None = None,
) -> float:
"""極端環境下的下半場攻擊效率修正。"""
if not is_second_half:
return round(float(base_attack), 6)
heat_penalty = max(0.0, heat_index - 28.0) / 120.0 # 每 1.2 度約降 1%
altitude_penalty = 0.0
if venue_altitude_meters and venue_altitude_meters > 1500:
altitude_penalty = min(0.22, (venue_altitude_meters - 1500) / 8000.0)
factor = max(0.6, 1 - heat_penalty - altitude_penalty)
return round(float(base_attack * factor), 6)
def evaluate_match_conditions(
*,
avg_yellow_cards: float,
penalties_per_game: float,
cards_ou_line: float,
temp_c: float,
humidity_pct: float,
venue_altitude_meters: int,
home_second_half_attack: float,
away_second_half_attack: float,
) -> MatchConditionSignal:
"""整合裁判與天候對下半場盤口與進攻效率的衝擊。"""
strictness_index = calculate_referee_strictness_index(avg_yellow_cards, penalties_per_game)
heat_index = estimate_heat_index(temp_c, humidity_pct)
adjusted_home = adjust_attack_for_heat_and_altitude(
home_second_half_attack,
heat_index=heat_index,
is_second_half=True,
venue_altitude_meters=venue_altitude_meters,
)
adjusted_away = adjust_attack_for_heat_and_altitude(
away_second_half_attack,
heat_index=heat_index,
is_second_half=True,
venue_altitude_meters=venue_altitude_meters,
)
cards_pressure = detect_cards_pressure_signal(strictness_index, cards_ou_line)
high_heat = heat_index >= 32.0
heat_pressure_delta = home_second_half_attack + away_second_half_attack
second_half_under = high_heat and (adjusted_home + adjusted_away) <= heat_pressure_delta * 0.95
if adjusted_home > adjusted_away:
attacker_direction = '上場勢優勢偏向主隊'
elif adjusted_home < adjusted_away:
attacker_direction = '上場勢優勢偏向客隊'
else:
attacker_direction = '攻勢對稱'
return MatchConditionSignal(
strictness_index=strictness_index,
heat_index=heat_index,
cards_pressure_alert=cards_pressure,
cards_ou_line=cards_ou_line,
second_half_home_attack=adjusted_home,
second_half_away_attack=adjusted_away,
second_half_under_recommendation=second_half_under,
attacker_direction=attacker_direction,
)

View File

@@ -0,0 +1,70 @@
"""反向盤口移動RLM偵測模組。"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class ReverseLineMovementAlert:
match_id: str
market_type: str
selection: str
opening_odds: float
current_odds: float
ticket_pct: float
handle_pct: float
odds_change_pct: float
smart_money_to: str
is_triggered: bool
triggered_at: datetime
rationale: str
def evaluate_reverse_line_movement(
match_id: str,
market_type: str,
selection: str,
*,
opening_odds: float,
current_odds: float,
ticket_pct: float,
handle_pct: float,
ticket_threshold: float = 70.0,
odds_change_threshold: float = 0.05,
) -> ReverseLineMovementAlert:
"""依條件判斷是否出現反向盤口。"""
if opening_odds <= 0:
odds_pct = 0.0
else:
odds_pct = round((current_odds - opening_odds) / opening_odds, 6)
is_triggered = (
ticket_pct > ticket_threshold
and odds_pct > odds_change_threshold
and handle_pct < ticket_pct
)
smart_money_to = selection if handle_pct > ticket_pct else '對側'
rationale = (
f'散戶 {ticket_pct:.1f}% 追捧卻資金 {handle_pct:.1f}%\n'
f'盤口由 {opening_odds:.2f} 上升到 {current_odds:.2f}'
)
return ReverseLineMovementAlert(
match_id=match_id,
market_type=market_type,
selection=selection,
opening_odds=opening_odds,
current_odds=current_odds,
ticket_pct=ticket_pct,
handle_pct=handle_pct,
odds_change_pct=round(odds_pct * 100, 4),
smart_money_to=smart_money_to,
is_triggered=is_triggered,
triggered_at=datetime.utcnow(),
rationale=rationale,
)

View File

@@ -0,0 +1,71 @@
from typing import List, Dict
import math
class SGPCorrelationEngine:
"""
同場串關 (Same Game Parlay) 關聯性與價值探測引擎
"""
@staticmethod
def calculate_joint_probability(prob_A: float, prob_B: float, correlation_coeff: float) -> float:
"""
計算兩個事件的聯合機率 (考慮相關係數)。
使用簡化的二元正態分佈/Copula近似邏輯。
:param prob_A: 事件 A 獨立發生的真實機率
:param prob_B: 事件 B 獨立發生的真實機率
:param correlation_coeff: 相關係數 (-1.0 到 1.0)
"""
if not (-1.0 <= correlation_coeff <= 1.0):
raise ValueError("相關係數必須介於 -1.0 與 1.0 之間")
# 獨立發生的聯合機率
independent_joint_prob = prob_A * prob_B
# 理論最大與最小邊界
max_joint_prob = min(prob_A, prob_B)
min_joint_prob = max(0.0, prob_A + prob_B - 1.0)
if correlation_coeff == 0:
return independent_joint_prob
elif correlation_coeff > 0:
# 正相關:聯合機率向 max_joint_prob 靠攏
return independent_joint_prob + correlation_coeff * (max_joint_prob - independent_joint_prob)
else:
# 負相關:聯合機率向 min_joint_prob 靠攏
return independent_joint_prob + abs(correlation_coeff) * (min_joint_prob - independent_joint_prob)
@staticmethod
def find_sgp_value(events: List[Dict], bookmaker_sgp_odds: float) -> Dict:
"""
評估 SGP 注單是否具備正期望值。
events 範例: [{'prob': 0.6}, {'prob': 0.4}] 且需自帶兩兩相關係數矩陣 (此處簡化為平均相關性)
"""
if len(events) < 2:
raise ValueError("SGP 必須至少包含兩個事件")
# 假設外部特徵工程已經給出了這組事件的平均正相關係數 (例如 0.4)
# 實務上會透過更複雜的 Monte Carlo 計算,此為展示核心邏輯
avg_correlation = events[0].get('correlation_with_others', 0.0)
current_joint_prob = events[0]['prob']
for i in range(1, len(events)):
current_joint_prob = SGPCorrelationEngine.calculate_joint_probability(
current_joint_prob,
events[i]['prob'],
avg_correlation
)
# 計算莊家隱含機率
implied_prob = 1.0 / bookmaker_sgp_odds
# 計算 EV
ev_percentage = (current_joint_prob * bookmaker_sgp_odds) - 1.0
is_profitable = ev_percentage > 0.05 # 設定 5% 的 EV 門檻
return {
"true_joint_probability": round(current_joint_prob, 4),
"bookmaker_implied_probability": round(implied_prob, 4),
"ev_percentage": round(ev_percentage, 4),
"is_profitable_sgp": is_profitable,
"fair_odds": round(1.0 / current_joint_prob, 2) if current_joint_prob > 0 else 0
}

View File

@@ -0,0 +1,135 @@
"""莊家抽水Vig去除工具。"""
from __future__ import annotations
from typing import Callable, List, Sequence
import numpy as np
from scipy.optimize import minimize_scalar
def calculate_overround(odds: Sequence[float]) -> float:
"""計算莊家總水位Overround
Overround = Σ(1 / odds_i)。
若結果 > 1 表示含有抽水。
"""
if not odds:
raise ValueError('odds 不可為空')
_odds = np.asarray(odds, dtype=float)
if np.any(_odds <= 1):
raise ValueError('賠率必須全部大於 1')
return float(np.sum(1.0 / _odds))
def remove_margin_basic(odds: Sequence[float]) -> List[float]:
"""等比例剝除抽水。
先轉換為 implied probability再除以 overround 讓機率總和為 1。
"""
implied = np.array([1.0 / x for x in odds], dtype=float)
overround = implied.sum()
if overround <= 0:
raise ValueError('無效 odds無法計算去水')
true_probs = implied / overround
return [float(x) for x in true_probs]
def _shin_objective(z: float, observed: np.ndarray) -> float:
"""Shin 模型中,透過 z 估計真實機率,使每個結果有一致修正。
模型假設:
q_i(z) = max((p_i - z/(k-1)) / (1 - k/(k-1)*z), 1e-12)
其中 q_i 為觀察值 implied probabilityp_i 為解構後真實機率。
透過約束 Σp_i=1 搜尋最小平方誤差。
"""
k = observed.size
if not 0.0 <= z < 1:
return 1e9
denom = 1.0 - k / max(k - 1, 1) * z
if denom <= 0:
return 1e9
raw = (observed - z / max(k - 1, 1)) / denom
raw = np.clip(raw, 1e-12, None)
normalized = raw / raw.sum()
return float(np.sum((normalized - observed / observed.sum()) ** 2))
def remove_margin_shin(odds: Sequence[float]) -> List[float]:
"""Shin 方法去水。
流程:
1) 觀察賠率轉 implied probability。
2) 用單參數 z 做最小化,推回一組更接近無套利的真實機率。
3) 回傳機率正規化結果。
"""
odds_array = np.asarray(odds, dtype=float)
if odds_array.size == 0:
raise ValueError('odds 不可為空')
if np.any(odds_array <= 1):
raise ValueError('賠率必須全部大於 1')
implied = 1.0 / odds_array
if implied.size == 2:
# 二元市場可直接利用近似閉式解,穩定性較佳
q1 = implied[0] / implied.sum()
q2 = implied[1] / implied.sum()
z = max(0.0, min(0.49, (q1 + q2 - 1.0) * 0.5))
else:
# 多項市場,使用數值搜尋
result = minimize_scalar(
_shin_objective,
args=(implied,),
bounds=(0.0, 0.49),
method='bounded',
)
z = float(result.x if result.success else 0.0)
k = implied.size
denom = 1.0 - k / max(k - 1, 1) * z
if denom <= 0:
return remove_margin_basic(odds)
raw = (implied - z / max(k - 1, 1)) / denom
raw = np.clip(raw, 1e-12, None)
true_prob = raw / raw.sum()
return [float(x) for x in true_prob]
def prob_to_decimal_odds(true_probs: Sequence[float]) -> List[float]:
"""真實機率轉換回無水賠率。
p 轉賠率公式odds = 1 / p。
"""
probs = np.asarray(true_probs, dtype=float)
if np.any(probs <= 0):
raise ValueError('機率需大於 0')
total = probs.sum()
if not np.isclose(total, 1.0, atol=1e-6):
probs = probs / total
return [round(float(1.0 / p), 4) for p in probs]
def compare_bookmaker_true_prob(
implied_odds: Sequence[float],
transform: Callable[[Sequence[float]], Sequence[float]] = remove_margin_shin,
) -> dict[str, list[float]]:
"""比對原始賠率與去水後真實賠率,可直接提供前端展示。"""
true_probs = transform(implied_odds)
return {
'implied_prob': [float(1.0 / x) for x in implied_odds],
'true_implied_prob': true_probs,
'true_decimal_odds': prob_to_decimal_odds(true_probs),
}

View File

@@ -0,0 +1,41 @@
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import RedirectResponse
import uuid
router = APIRouter()
# 模擬的 affiliate_clicks 記錄,實務上應寫入 TimescaleDB / PostgreSQL
# affiliate_clicks_db = []
@router.get("/api/v1/go/{bookmaker_id}")
async def affiliate_redirect(bookmaker_id: str, request: Request):
"""
動態聯盟行銷與防廣告攔截引擎
- Server-side redirect
- 紀錄點擊以計算 CR
"""
# 模擬博彩公司對應表與追蹤碼
bookmakers = {
"bet365": "https://www.bet365.com/?affiliate=QUANT2026",
"pinnacle": "https://www.pinnacle.com/?ref=QUANT2026",
"draftkings": "https://www.draftkings.com/?track=QUANT2026"
}
if bookmaker_id not in bookmakers:
raise HTTPException(status_code=404, detail="Bookmaker not found")
target_url = bookmakers[bookmaker_id]
# 記錄點擊資料 (User-Agent, IP, Timestamp, etc)
click_data = {
"click_id": str(uuid.uuid4()),
"bookmaker_id": bookmaker_id,
"user_agent": request.headers.get("user-agent", "unknown"),
"client_ip": request.client.host if request.client else "unknown"
}
# affiliate_clicks_db.append(click_data)
# print(f"Logged affiliate click: {click_data}")
return RedirectResponse(url=target_url, status_code=302)

View File

@@ -0,0 +1,49 @@
from datetime import date
from typing import List, Dict
class DailyCardGenerator:
"""
投資長級別的每日智能注單生成引擎 (Daily Smart Card)
"""
def __init__(self, db_session):
self.db = db_session
def generate_daily_card(self, target_date: date) -> Dict:
"""
掃描當日賽事,並將高價值投注分類打包
"""
# 模擬從資料庫與 EV 引擎取得的當日高價值清單
# 實務上會 join `matches` 與 `odds_history` 並即時套用 ev_calculator
raw_value_bets = self._fetch_todays_value_bets(target_date)
card = {
"date": target_date.isoformat(),
"briefing": "AI 賽況總評:淘汰賽階段防守強度升級,系統偵測到大量下半場小球的定價錯誤,建議重倉穩健單關,避開受讓盤。",
"total_suggested_units": 0.0,
"recommendations": {
"SAFE_SINGLE": [], # 穩健單關 (高勝率,正 EV)
"HIGH_RISK_SINGLE": [], # 高賠搏冷 (低勝率,超高 EV)
"SGP_LOTTERY": [] # 同場爆擊 (SGP)
}
}
for bet in raw_value_bets:
if bet['true_prob'] > 0.55 and bet['ev_percentage'] > 0.03:
bet['suggested_units'] = 1.5
card['recommendations']['SAFE_SINGLE'].append(bet)
card['total_suggested_units'] += 1.5
elif bet['true_prob'] < 0.35 and bet['ev_percentage'] > 0.08:
bet['suggested_units'] = 0.5
card['recommendations']['HIGH_RISK_SINGLE'].append(bet)
card['total_suggested_units'] += 0.5
return card
def _fetch_todays_value_bets(self, target_date: date) -> List[Dict]:
# 模擬資料
return [
{"match": "USA vs ENG", "selection": "Under 2.5", "odds": 1.95, "true_prob": 0.58, "ev_percentage": 0.131},
{"match": "MEX vs ARG", "selection": "MEX Win", "odds": 4.20, "true_prob": 0.28, "ev_percentage": 0.176}
]

View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, Request
from pydantic import BaseModel
import time
router = APIRouter()
class TelegramUpdate(BaseModel):
update_id: int
message: dict = None
@router.post("/api/v1/telegram/webhook")
async def telegram_webhook(update: TelegramUpdate):
"""
VIP 私董會互動式 Telegram 機器人 Webhook
"""
if not update.message or "text" not in update.message:
return {"status": "ok"}
text = update.message["text"].strip()
chat_id = update.message["chat"]["id"]
# 模擬 !sgp [主隊] [客隊]
if text.startswith("!sgp"):
parts = text.split()
if len(parts) == 3:
home, away = parts[1], parts[2]
# 這裡應該呼叫 SGPCorrelationEngine
response_text = f"📊 [SGP 蒙地卡羅運算完成]\n賽事: {home} vs {away}\n推薦串關: {home} 勝 + 總進球數大於 2.5\nEV: +6.5%\n機率: 45%"
else:
response_text = "❌ 指令錯誤,正確格式: !sgp [主隊] [客隊]"
# 模擬 !ev
elif text.startswith("!ev"):
# 這裡應該從 EV 引擎抓取 Top 3
response_text = "🔥 [全市場 Top 3 正期望值盤口]\n1. USA vs ENG - Under 2.5 (EV: +13.1%)\n2. MEX vs ARG - MEX Win (EV: +17.6%)\n3. FRA vs BRA - FRA Win (EV: +6.5%)"
else:
response_text = "未知指令。可用指令: !sgp [主隊] [客隊], !ev"
# 實務上這裡會呼叫 Telegram Bot API 傳送訊息
# send_message_to_telegram(chat_id, response_text)
print(f"Telegram Bot Reply to {chat_id}: {response_text}")
return {"status": "ok"}

View File

@@ -0,0 +1,19 @@
"""資料模型套件。"""
from .base import Base, get_engine, get_session_factory, SessionFactory
from .models import Bookmaker, Match, MatchStatus, OddsHistory, SmartMoneyFlow, Team, Venue
__all__ = [
'Base',
'Bookmaker',
'Match',
'MatchStatus',
'OddsHistory',
'SmartMoneyFlow',
'Team',
'Venue',
'get_engine',
'get_session_factory',
'SessionFactory',
]

View File

@@ -0,0 +1,26 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
import os
class Base(DeclarativeBase):
"""Project ORM base model."""
DATABASE_URL = os.getenv('DATABASE_URL', 'postgresql+asyncpg://fifa_user:change_me@fifa2026-postgres:5432/fifa2026')
def get_engine(database_url: str = DATABASE_URL):
"""Create asynchronous SQLAlchemy engine for production use."""
return create_async_engine(database_url, echo=False, pool_pre_ping=True)
def get_session_factory(database_url: str = DATABASE_URL):
"""Create session factory for async query operations."""
engine = get_engine(database_url)
return async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
SessionFactory = get_session_factory()

View File

@@ -0,0 +1,199 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
class MatchStatus(str, Enum):
PRE_MATCH = 'pre-match'
IN_PLAY = 'in-play'
FINISHED = 'finished'
class Venue(Base):
"""球場主資料:海拔與時區是 2026 世界盃關鍵參數。"""
__tablename__ = 'venues'
id: Mapped[str] = mapped_column(String(36), primary_key=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
city: Mapped[str] = mapped_column(String(120), nullable=False)
country: Mapped[str] = mapped_column(String(120), nullable=False)
altitude_meters: Mapped[int | None] = mapped_column(Integer, nullable=True)
timezone: Mapped[str] = mapped_column(String(80), nullable=False)
matches: Mapped[list[Match]] = relationship('Match', back_populates='venue', lazy='raise')
class Team(Base):
"""球隊主表,保留排名與 Elo 給量化模型做能力修正。"""
__tablename__ = 'teams'
id: Mapped[str] = mapped_column(String(36), primary_key=True)
name: Mapped[str] = mapped_column(String(140), nullable=False, unique=True)
fifa_rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
current_elo_rating: Mapped[float | None] = mapped_column(Float, nullable=True)
group_name: Mapped[str | None] = mapped_column(String(10), nullable=True)
home_matches: Mapped[list[Match]] = relationship(
'Match',
foreign_keys='Match.home_team_id',
back_populates='home_team',
)
away_matches: Mapped[list[Match]] = relationship(
'Match',
foreign_keys='Match.away_team_id',
back_populates='away_team',
)
class Bookmaker(Base):
"""莊家主檔。"""
__tablename__ = 'bookmakers'
id: Mapped[str] = mapped_column(String(36), primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
odds_rows: Mapped[list[OddsHistory]] = relationship('OddsHistory', back_populates='bookmaker')
class Match(Base):
"""賽事基本結構,儲存 UTC 時間、場地與賽前 xG。"""
__tablename__ = 'matches'
id: Mapped[str] = mapped_column(String(64), primary_key=True)
home_team_id: Mapped[str] = mapped_column(ForeignKey('teams.id'), nullable=False)
away_team_id: Mapped[str] = mapped_column(ForeignKey('teams.id'), nullable=False)
venue_id: Mapped[str] = mapped_column(ForeignKey('venues.id'), nullable=False)
match_time_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
status: Mapped[MatchStatus] = mapped_column(
SAEnum(MatchStatus, name='match_status', native_enum=False),
default=MatchStatus.PRE_MATCH,
)
home_xg: Mapped[float | None] = mapped_column(Float, nullable=True)
away_xg: Mapped[float | None] = mapped_column(Float, nullable=True)
home_team: Mapped[Team] = relationship('Team', foreign_keys=[home_team_id], back_populates='home_matches')
away_team: Mapped[Team] = relationship('Team', foreign_keys=[away_team_id], back_populates='away_matches')
venue: Mapped[Venue] = relationship('Venue', back_populates='matches')
odds_history: Mapped[list[OddsHistory]] = relationship('OddsHistory', back_populates='match')
recommendations: Mapped[list['ValueBetRecommendation']] = relationship(
'ValueBetRecommendation',
back_populates='match',
cascade='all, delete-orphan',
)
class OddsHistory(Base):
"""時間序列賠率表(待轉為 TimescaleDB Hypertable"""
__tablename__ = 'odds_history'
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
match_id: Mapped[str] = mapped_column(ForeignKey('matches.id'), nullable=False, index=True)
bookmaker_id: Mapped[str] = mapped_column(ForeignKey('bookmakers.id'), nullable=False, index=True)
market_type: Mapped[str] = mapped_column(String(30), nullable=False)
selection: Mapped[str] = mapped_column(String(30), nullable=False)
decimal_odds: Mapped[float] = mapped_column(Float, nullable=False)
implied_probability: Mapped[float] = mapped_column(Float, nullable=False)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
match: Mapped[Match] = relationship('Match', back_populates='odds_history')
bookmaker: Mapped[Bookmaker] = relationship('Bookmaker', back_populates='odds_rows')
class SmartMoneyFlow(Base):
"""聰明錢流向快照表。"""
__tablename__ = 'smart_money_flow'
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
match_id: Mapped[str] = mapped_column(ForeignKey('matches.id'), nullable=False, index=True)
market_type: Mapped[str] = mapped_column(String(30), nullable=False)
selection: Mapped[str] = mapped_column(String(30), nullable=False)
ticket_pct: Mapped[float] = mapped_column(Float, nullable=False)
handle_pct: Mapped[float] = mapped_column(Float, nullable=False)
sharp_indicator: Mapped[bool] = mapped_column(Boolean, nullable=False)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
class ValueBetRecommendation(Base):
"""可驗證獲利帳本紀錄(公開透明)。"""
__tablename__ = 'value_bet_recommendations'
id: Mapped[str] = mapped_column(String(64), primary_key=True)
match_id: Mapped[str] = mapped_column(ForeignKey('matches.id'), nullable=False, index=True)
market_type: Mapped[str] = mapped_column(String(30), nullable=False)
selection: Mapped[str] = mapped_column(String(30), nullable=False)
stake: Mapped[float] = mapped_column(Float, nullable=False)
recommended_odds: Mapped[float] = mapped_column(Float, nullable=False)
closing_odds: Mapped[float] = mapped_column(Float, nullable=True)
is_win: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
settled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
clv_ratio: Mapped[float] = mapped_column(Float, nullable=True)
pnl: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
note: Mapped[str | None] = mapped_column(String(240), nullable=True)
match: Mapped[Match] = relationship('Match', back_populates='recommendations')
class AffiliateBookmaker(Base):
"""聯盟行銷博彩公司追蹤碼設定。"""
__tablename__ = 'affiliate_bookmakers'
id: Mapped[str] = mapped_column(String(36), primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
tracking_url: Mapped[str] = mapped_column(String(512), nullable=False)
commission_rate: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
class AffiliateClick(Base):
"""聯盟行銷跳轉點擊紀錄(防廣告攔截)。"""
__tablename__ = 'affiliate_clicks'
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
bookmaker_id: Mapped[str] = mapped_column(ForeignKey('affiliate_bookmakers.id'), nullable=False, index=True)
user_ip_hash: Mapped[str] = mapped_column(String(128), nullable=False)
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
referrer: Mapped[str | None] = mapped_column(String(512), nullable=True)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), index=True)
converted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class UserProfile(Base):
"""量化大神排行榜與跟單系統的用戶資料。"""
__tablename__ = 'user_profiles'
id: Mapped[str] = mapped_column(String(36), primary_key=True)
username: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
clv_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
roi_30d: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
sharp_rating: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
class CopyBet(Base):
"""一鍵跟單交易紀錄。"""
__tablename__ = 'copy_bets'
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
follower_id: Mapped[str] = mapped_column(ForeignKey('user_profiles.id'), nullable=False, index=True)
leader_id: Mapped[str] = mapped_column(ForeignKey('user_profiles.id'), nullable=False, index=True)
recommendation_id: Mapped[str] = mapped_column(ForeignKey('value_bet_recommendations.id'), nullable=False)
follower_stake: Mapped[float] = mapped_column(Float, nullable=False)
copied_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now())

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any, Dict, Mapping
from redis.asyncio import Redis
ARBITRAGE_LUA = r'''
local odds_json = ARGV[1]
local payload = cjson.decode(odds_json)
local grouped = {}
for _, row in ipairs(payload) do
local market = row.market_type
local selection = row.selection
local odds = tonumber(row.decimal_odds)
if market and selection and odds and odds > 0 then
if grouped[market] == nil then
grouped[market] = {}
end
if grouped[market][selection] == nil or odds > grouped[market][selection] then
grouped[market][selection] = odds
end
end
end
local out = {}
for market, selections in pairs(grouped) do
local prob_sum = 0
local count = 0
for _, odds in pairs(selections) do
prob_sum = prob_sum + (1 / odds)
count = count + 1
end
if count > 1 then
out[market] = {
has_arbitrage = (prob_sum < 1),
implied_total_probability = prob_sum,
edge = math.max(1 - prob_sum, 0),
best_odds = selections,
}
end
end
return cjson.encode(out)
'''
@dataclass(slots=True)
class MatchState:
"""賽中 Hash 快照欄位。"""
home_score: int
away_score: int
possession_home_pct: float
possession_away_pct: float
red_cards_home: int
red_cards_away: int
class MatchCacheManager:
"""賽事 Redis 快取層。
- live:{match_id}:odds 存 JSON即時賠率
- live:{match_id}:state 存 Hash包含比分、控球率、紅牌數
"""
def __init__(self, redis: Redis) -> None:
self.redis = redis
self._lua_sha: str | None = None
async def _ensure_lua(self) -> str:
if self._lua_sha is None:
self._lua_sha = await self.redis.script_load(ARBITRAGE_LUA)
return self._lua_sha
async def set_match_odds(
self,
match_id: str,
payload: list[dict[str, Any]],
*,
ttl_seconds: int = 45,
finished: bool = False,
) -> None:
key = f'live:{match_id}:odds'
value = json.dumps(payload, ensure_ascii=False)
ttl = 7200 if finished else ttl_seconds
await self.redis.set(name=key, value=value, ex=ttl)
async def get_match_odds(self, match_id: str) -> list[dict[str, Any]]:
key = f'live:{match_id}:odds'
raw = await self.redis.get(key)
if not raw:
return []
if isinstance(raw, bytes):
raw = raw.decode()
return json.loads(raw)
async def set_match_state(
self,
match_id: str,
state: MatchState | Mapping[str, Any],
*,
ttl_seconds: int = 7200,
) -> None:
key = f'live:{match_id}:state'
mapping = {
'home_score': state['home_score'] if isinstance(state, Mapping) else state.home_score,
'away_score': state['away_score'] if isinstance(state, Mapping) else state.away_score,
'possession_home_pct': state['possession_home_pct'] if isinstance(state, Mapping) else state.possession_home_pct,
'possession_away_pct': state['possession_away_pct'] if isinstance(state, Mapping) else state.possession_away_pct,
'red_cards_home': state['red_cards_home'] if isinstance(state, Mapping) else state.red_cards_home,
'red_cards_away': state['red_cards_away'] if isinstance(state, Mapping) else state.red_cards_away,
}
await self.redis.hset(name=key, mapping=mapping)
await self.redis.expire(key, ttl_seconds)
async def get_match_state(self, match_id: str) -> dict[str, str] | None:
key = f'live:{match_id}:state'
result = await self.redis.hgetall(key)
return {str(k): str(v) for k, v in result.items()} if result else None
async def calculate_arbitrage_for_match(self, match_id: str) -> dict[str, Any]:
odds = await self.get_match_odds(match_id)
if not odds:
return {}
sha = await self._ensure_lua()
result = await self.redis.evalsha(sha, 0, json.dumps(odds, ensure_ascii=False))
if isinstance(result, bytes):
result = result.decode()
if isinstance(result, str):
return json.loads(result)
return result

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
import asyncio
import json
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, Mapping
import aiohttp
from .cache import MatchCacheManager
TEAM_ALIAS_MAP = {
'USA': 'USMNT',
'United States': 'USMNT',
'United States of America': 'USMNT',
'USMNT': 'USMNT',
}
@dataclass(frozen=True)
class SourceOdds:
match_id: str
home_team: str
away_team: str
market_type: str
selection: str
decimal_odds: float
bookmaker: str
status: str = 'in-play'
def normalize_team_name(raw_name: str) -> str:
"""對齊來自不同博彩商的球隊名稱,返回標準化內部 ID。"""
normalized = raw_name.strip()
return TEAM_ALIAS_MAP.get(normalized, normalized)
class OddsIngestionWorker:
"""非同步抓取賽事賠率與推入 Redis 快取的 Worker。"""
def __init__(self, session: aiohttp.ClientSession, endpoint: str, api_key: str) -> None:
self.session = session
self.endpoint = endpoint
self.api_key = api_key
async def _request_with_backoff(self, url: str, *, max_attempts: int = 5) -> Mapping[str, Any]:
delay = 0.5
attempts = 0
while True:
attempts += 1
try:
async with self.session.get(url, timeout=20) as resp:
if resp.status == 429:
if attempts >= max_attempts:
text = await resp.text()
raise RuntimeError(f'HTTP 429 Too Many Requests: {text}')
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status >= 500:
if attempts >= max_attempts:
resp.raise_for_status()
await asyncio.sleep(delay)
delay *= 2
continue
resp.raise_for_status()
return await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
if attempts >= max_attempts:
raise RuntimeError(f'HTTP request failed: {exc!s}') from exc
await asyncio.sleep(delay)
delay *= 2
async def fetch_latest_matches(self) -> list[SourceOdds]:
params = {'api_key': self.api_key}
url = f'{self.endpoint}/v1/odds'
payload = await self._request_with_backoff(url)
items = payload.get('data', []) if isinstance(payload, Mapping) else []
normalized: list[SourceOdds] = []
for row in items:
try:
raw_home = row['home_team']
raw_away = row['away_team']
normalized.append(
SourceOdds(
match_id=str(row['match_id']),
home_team=normalize_team_name(str(raw_home)),
away_team=normalize_team_name(str(raw_away)),
market_type=str(row['market_type']),
selection=str(row['selection']),
decimal_odds=float(row['odds']),
bookmaker=str(row.get('bookmaker', 'unknown')),
status=str(row.get('status', 'in-play')),
),
)
except (KeyError, TypeError, ValueError):
continue
return normalized
async def sync_to_cache(
self,
cache: MatchCacheManager,
*,
ttl_seconds: int = 45,
) -> dict[str, int]:
"""抓取賽事即時賠率並更新 Redis 快取。"""
rows = await self.fetch_latest_matches()
payload_by_match: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
payload_by_match[row.match_id].append(
{
'match_id': row.match_id,
'home_team': row.home_team,
'away_team': row.away_team,
'market_type': row.market_type,
'selection': row.selection,
'decimal_odds': row.decimal_odds,
'bookmaker': row.bookmaker,
'status': row.status,
},
)
for match_id, rows_payload in payload_by_match.items():
finished = any(item['status'] == 'finished' for item in rows_payload)
await cache.set_match_odds(match_id, rows_payload, ttl_seconds=ttl_seconds, finished=finished)
return {match_id: len(payload_rows) for match_id, payload_rows in payload_by_match.items()}
async def run_once(
self,
cache: MatchCacheManager,
*,
ttl_seconds: int = 45,
) -> dict[str, int]:
"""單次輪詢流程(可給排程器或事件輪詢器呼叫)。"""
return await self.sync_to_cache(cache, ttl_seconds=ttl_seconds)
def to_cache_payload(rows: list[SourceOdds]) -> list[dict[str, Any]]:
"""將來源資料轉為 Redis 快取可存取結構。"""
return [
{
'match_id': row.match_id,
'home_team': row.home_team,
'away_team': row.away_team,
'market_type': row.market_type,
'selection': row.selection,
'decimal_odds': row.decimal_odds,
'bookmaker': row.bookmaker,
'status': row.status,
}
for row in rows
]
def serialize_error(error: Exception) -> str:
"""錯誤訊息格式化,供上層日誌與警報系統使用。"""
return json.dumps({'error': str(error), 'type': error.__class__.__name__})

1473
platform/backend/app/main.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
"""Redis 快取管理層(賠率與賽事快取)。"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Mapping
from redis.asyncio import Redis
ARBITRAGE_LUA = r'''
local odds_json = ARGV[1]
local payload = cjson.decode(odds_json)
local by_market = {}
for _, row in ipairs(payload) do
local market = row.market_type
local selection = row.selection
local odds = tonumber(row.decimal_odds)
local bookmaker = tostring(row.bookmaker or "")
if market and selection and odds and odds > 0 then
if by_market[market] == nil then
by_market[market] = {}
end
if by_market[market][selection] == nil or odds > by_market[market][selection].odds then
by_market[market][selection] = {odds = odds, bookmaker = bookmaker}
end
end
end
local out = {}
for market, selections in pairs(by_market) do
local inv = 0
local n = 0
for _, item in pairs(selections) do
inv = inv + (1 / item.odds)
n = n + 1
end
if n >= 2 then
out[market] = {
has_arbitrage = (inv < 1),
implied_total = inv,
best_odds = selections,
edge = math.max(1 - inv, 0)
}
end
end
return cjson.encode(out)
'''
@dataclass(slots=True)
class MatchState:
home_score: int
away_score: int
minute: int
possession_home: float
possession_away: float
red_cards_home: int
red_cards_away: int
class MatchCacheManager:
"""實作高頻快取:賠率 JSON + 賽事狀態 Hash。"""
def __init__(self, redis: Redis) -> None:
self.redis = redis
self._lua_sha: str | None = None
async def _ensure_lua(self) -> str:
if self._lua_sha is None:
self._lua_sha = await self.redis.script_load(ARBITRAGE_LUA)
return self._lua_sha
async def set_match_odds(
self,
match_id: str,
payload: list[dict[str, Any]],
*,
finished: bool = False,
) -> None:
key = f'live_odds:{match_id}'
ex = 7200 if finished else 30
await self.redis.set(key, json.dumps(payload, ensure_ascii=False), ex=ex)
async def get_match_odds(self, match_id: str) -> list[dict[str, Any]]:
key = f'live_odds:{match_id}'
raw = await self.redis.get(key)
if not raw:
return []
if isinstance(raw, bytes):
raw = raw.decode('utf-8')
return json.loads(raw)
async def set_match_state(
self,
match_id: str,
state: MatchState | Mapping[str, Any],
*,
finished: bool = False,
) -> None:
key = f'live_state:{match_id}'
mapping = {
'home_score': state['home_score'] if isinstance(state, Mapping) else state.home_score,
'away_score': state['away_score'] if isinstance(state, Mapping) else state.away_score,
'minute': state['minute'] if isinstance(state, Mapping) else state.minute,
'possession_home': state['possession_home'] if isinstance(state, Mapping) else state.possession_home,
'possession_away': state['possession_away'] if isinstance(state, Mapping) else state.possession_away,
'red_cards_home': state['red_cards_home'] if isinstance(state, Mapping) else state.red_cards_home,
'red_cards_away': state['red_cards_away'] if isinstance(state, Mapping) else state.red_cards_away,
}
await self.redis.hset(key, mapping=mapping)
await self.redis.expire(key, 7200 if finished else 60)
async def get_match_state(self, match_id: str) -> dict[str, str] | None:
key = f'live_state:{match_id}'
result = await self.redis.hgetall(key)
return {str(k): str(v) for k, v in result.items()} if result else None
async def calculate_arbitrage(self, match_id: str) -> dict[str, Any]:
odds = await self.get_match_odds(match_id)
if not odds:
return {}
sha = await self._ensure_lua()
result = await self.redis.evalsha(sha, 0, json.dumps(odds, ensure_ascii=False))
if isinstance(result, bytes):
result = result.decode()
if isinstance(result, str):
return json.loads(result)
return result