Initial commit with 2026 World Cup Quant Platform core modules and CI/CD
This commit is contained in:
181
platform/backend/app/analytics/backtesting.py
Normal file
181
platform/backend/app/analytics/backtesting.py
Normal 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_percent:ROI
|
||||
- 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',
|
||||
]
|
||||
Reference in New Issue
Block a user