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