182 lines
5.3 KiB
Python
182 lines
5.3 KiB
Python
"""自訂策略回測引擎。"""
|
||
|
||
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',
|
||
]
|