""" 回测引擎核心 — 事件驱动的历史回测 逐根 K 线推送给策略,模拟订单成交,跟踪资金曲线,计算绩效指标。 用法: from engine.backtest import BacktestEngine, BacktestConfig from engine.common.config import config bt_config = BacktestConfig( symbol="BTCUSDT", interval="1h", start_time=datetime(2025, 1, 1), end_time=datetime(2025, 6, 1), initial_capital=10000.0, ) engine = BacktestEngine(bt_config, db_config=config.db) result = await engine.run(MyStrategy, my_strategy_config) print(result.summary()) """ import asyncio from datetime import datetime, timezone from typing import Optional, Type from ..common.base import BaseStrategy, Signal, StrategyConfig from ..common.models import Kline from ..data.service import DataService from .models import BacktestConfig, BacktestMetrics, BacktestResult, BacktestTrade # ── 资金曲线采样间隔(用于减少内存,每隔 N 根 Bar 记录一次)── EQUITY_SAMPLE_INTERVAL = 1 # 每根都记录 class BacktestEngine: """事件驱动回测引擎 按时间顺序逐根 K 线推送给策略,模拟: - 订单成交(含手续费、滑点) - 持仓管理与盈亏计算 - 资金曲线追踪 - 绩效指标统计 信号在 K 线收盘生成,在下一根 K 线开盘时以「开盘价」执行, 避免使用已知收盘价的未来函数偏差。 """ def __init__(self, config: BacktestConfig, db_config=None): """ Args: config: 回测配置(交易对、周期、时间范围、资金等) db_config: 数据库连接配置(DBConfig 实例)。 如果不传,引擎将在 run() 内从 engine.common.config 自动加载。 """ self.config = config self._db_config = db_config # ── 投资组合状态 ── self._cash: float = config.initial_capital self._position: float = 0.0 self._avg_entry_price: float = 0.0 # ── 记录 ── self._trades: list[BacktestTrade] = [] self._equity: list[dict] = [] # ── 待执行信号(BUY 信号在下一根 Bar 开盘时执行)── self._pending_buy: Optional[Signal] = None # ================================================================ # 主入口 # ================================================================ async def run( self, strategy_cls: Type[BaseStrategy], strategy_config: StrategyConfig, ) -> BacktestResult: """执行回测。 流程: 1. 连接数据库并加载历史 K 线 2. 创建策略实例并调用 on_start() 3. 预热阶段:喂 warmup_bars 根 K 线 4. 主循环:逐根 K 线推给策略 → 模拟成交 → 更新资金曲线 5. 对剩余持仓按最后一根 K 线收盘价强制平仓 6. 调用策略 on_stop() 7. 计算绩效指标 Args: strategy_cls: 策略类(继承 BaseStrategy) strategy_config: 策略配置实例 Returns: BacktestResult: 包含交易记录、资金曲线和绩效指标 """ # 确保 strategy_config 与回测配置对齐 strategy_config.symbol = self.config.symbol strategy_config.exchange = self.config.exchange # 1. 连接数据库并加载数据 from ..common.config import config as app_config db_cfg = self._db_config or app_config.db ds = DataService(db_cfg) await ds.connect() try: klines = await ds.fetch_klines( symbol=self.config.symbol, interval=self.config.interval, start_time=self.config.start_time, end_time=self.config.end_time, limit=1_000_000, # 足够大的 limit,实际由 start/end 约束 ) if len(klines) < self.config.warmup_bars + 2: raise ValueError( f"数据不足:需要至少 {self.config.warmup_bars + 2} 根 K 线," f"实际只有 {len(klines)} 根" ) # 2. 创建策略实例 strategy = strategy_cls(strategy_config) await strategy.on_start() # 重置状态 self._cash = self.config.initial_capital self._position = 0.0 self._avg_entry_price = 0.0 self._trades = [] self._equity = [] self._pending_buy = None # 3. 预热阶段 warmup_end = self.config.warmup_bars for i in range(warmup_end): await strategy.on_kline(klines[i]) # 4. 主循环 for i in range(warmup_end, len(klines)): kline = klines[i] # 4a. 先执行上一根 bar 产生的待执行买单 if self._pending_buy is not None: self._execute_buy(self._pending_buy, kline) self._pending_buy = None # 4b. 推送 K 线给策略 signal = await strategy.on_kline(kline) # 4c. 处理信号 if signal is not None and signal.side == "SELL": self._execute_sell(signal, kline) elif signal is not None and signal.side == "BUY": # BUY 信号延迟到下一根 bar 执行,避免未来函数 self._pending_buy = signal # LIMIT / CANCEL 信号暂不支持 # 4d. 记录资金曲线 if i % EQUITY_SAMPLE_INTERVAL == 0: self._record_equity(kline) # 5. 对剩余持仓按最后一根 K 线收盘价强平 if self._position > 0 and len(klines) > 0: last_kline = klines[-1] self._execute_sell( Signal( symbol=self.config.symbol, side="SELL", signal_type="MARKET", quantity=self._position, confidence=1.0, reason="回测结束 — 强制平仓", timestamp=last_kline.open_time, ), last_kline, ) # 6. 停止策略 await strategy.on_stop() # 7. 计算指标 metrics = self._compute_metrics() return BacktestResult( config=self.config, strategy_config=strategy_config.model_dump(), metrics=metrics, trades=self._trades, equity_curve=self._equity, ) finally: await ds.close() async def run_batch( self, strategy_cls: Type[BaseStrategy], configs: list[StrategyConfig], ) -> list[BacktestResult]: """批量回测(并行执行多个策略配置)。 适用于参数扫描场景。 """ tasks = [ self.run(strategy_cls, cfg) for cfg in configs ] return await asyncio.gather(*tasks) # ================================================================ # 交易模拟 # ================================================================ def _execute_buy(self, signal: Signal, kline: Kline) -> None: """执行买入(在下一根 K 线的开盘价执行)""" # 执行价格 = 开盘价 + 滑点 exec_price = kline.open * (1 + self.config.slippage_pct) # 确定数量 qty = signal.quantity if qty is None: # 按最大仓位比例计算 max_notional = self._cash * signal.confidence qty = max_notional / exec_price # 取整到最小下单量 qty = self._round_qty(qty) # 检查最小下单量 if qty < self.config.min_order_qty: return notional = exec_price * qty commission = notional * self.config.commission_pct total_cost = notional + commission # 检查余额 if total_cost > self._cash: # 按可用资金重新计算可买数量 max_qty = (self._cash / (exec_price * (1 + self.config.commission_pct))) qty = self._round_qty(max_qty) if qty < self.config.min_order_qty: return notional = exec_price * qty commission = notional * self.config.commission_pct total_cost = notional + commission # 更新持仓 if self._position > 0: total_value = self._avg_entry_price * self._position + notional self._position += qty self._avg_entry_price = total_value / self._position if self._position > 0 else 0 else: self._position = qty self._avg_entry_price = exec_price self._cash -= total_cost # 记录交易 self._trades.append(BacktestTrade( timestamp=kline.open_time, symbol=self.config.symbol, side="BUY", price=exec_price, quantity=qty, notional=notional, commission=commission, slippage=exec_price - kline.open, reason=signal.reason, )) def _execute_sell(self, signal: Signal, kline: Kline) -> None: """执行卖出(在当前 K 线的收盘价执行)""" exec_price = kline.close * (1 - self.config.slippage_pct) # 确定数量 qty = signal.quantity if qty is None: qty = self._position # 全部卖出 qty = min(qty, self._position) # 不能超卖 qty = self._round_qty(qty) if qty < self.config.min_order_qty or self._position < self.config.min_order_qty: return notional = exec_price * qty commission = notional * self.config.commission_pct net_proceeds = notional - commission # 计算盈亏 pnl = (exec_price - self._avg_entry_price) * qty - commission # 更新持仓 self._position -= qty if self._position < self.config.min_order_qty: self._position = 0.0 self._avg_entry_price = 0.0 self._cash += net_proceeds # 记录交易 self._trades.append(BacktestTrade( timestamp=kline.open_time, symbol=self.config.symbol, side="SELL", price=exec_price, quantity=qty, notional=notional, commission=commission, slippage=kline.close - exec_price, pnl=pnl, reason=signal.reason, )) # ================================================================ # 资金曲线 # ================================================================ def _record_equity(self, kline: Kline) -> None: """记录当前时间点的权益和回撤""" equity = self._cash + self._position * kline.close # 计算回撤 if not self._equity: self._peak_equity = equity elif equity > self._peak_equity: self._peak_equity = equity drawdown = (equity - self._peak_equity) / self._peak_equity * 100 if self._peak_equity > 0 else 0.0 self._equity.append({ "timestamp": kline.open_time, "equity": equity, "drawdown": drawdown, "position": self._position, }) # ================================================================ # 绩效指标计算 # ================================================================ def _compute_metrics(self) -> BacktestMetrics: """从交易记录和资金曲线计算全部绩效指标""" if not self._equity: return BacktestMetrics() initial_capital = self.config.initial_capital final_equity = self._equity[-1]["equity"] # ── 总收益率 ── total_return_pct = (final_equity - initial_capital) / initial_capital * 100 # ── 年化收益率 ── first_ts = self._equity[0]["timestamp"] last_ts = self._equity[-1]["timestamp"] days = (last_ts - first_ts) / (1000 * 86400) if days > 0 and final_equity > 0 and initial_capital > 0: annual_return_pct = ((final_equity / initial_capital) ** (365 / days) - 1) * 100 else: annual_return_pct = 0.0 # ── 日收益率 → 夏普比率 ── daily_returns = self._compute_daily_returns() if len(daily_returns) > 1: import statistics mean_ret = statistics.mean(daily_returns) std_ret = statistics.stdev(daily_returns) if len(daily_returns) > 1 else 0.0 sharpe_ratio = (mean_ret / std_ret * (365 ** 0.5)) if std_ret > 0 else 0.0 else: sharpe_ratio = 0.0 # ── 最大回撤 & 回撤持续天数 ── max_drawdown_pct, max_dd_days = self._compute_max_drawdown() # ── 交易统计 ── sells = [t for t in self._trades if t.side == "SELL" and t.pnl is not None] total_trades = len(sells) if total_trades > 0: winners = [t for t in sells if t.pnl > 0] losers = [t for t in sells if t.pnl <= 0] win_rate = len(winners) / total_trades gross_profit = sum(t.pnl for t in winners) gross_loss = abs(sum(t.pnl for t in losers)) profit_factor = gross_profit / gross_loss if gross_loss > 0 else (gross_profit if gross_profit > 0 else 0.0) avg_trade_pnl = sum(t.pnl for t in sells) / total_trades best_trade_pnl = max(t.pnl for t in sells) worst_trade_pnl = min(t.pnl for t in sells) else: win_rate = 0.0 profit_factor = 0.0 avg_trade_pnl = 0.0 best_trade_pnl = 0.0 worst_trade_pnl = 0.0 # ── 卡尔玛比率 ── if max_drawdown_pct < 0: calmar_ratio = annual_return_pct / abs(max_drawdown_pct) else: calmar_ratio = 0.0 return BacktestMetrics( total_return_pct=total_return_pct, annual_return_pct=annual_return_pct, sharpe_ratio=sharpe_ratio, max_drawdown_pct=max_drawdown_pct, max_drawdown_duration_days=max_dd_days, win_rate=win_rate, profit_factor=profit_factor, total_trades=total_trades, avg_trade_pnl=avg_trade_pnl, best_trade_pnl=best_trade_pnl, worst_trade_pnl=worst_trade_pnl, calmar_ratio=calmar_ratio, final_equity=final_equity, ) def _compute_daily_returns(self) -> list[float]: """从资金曲线提取每日收益率序列""" if not self._equity: return [] # 按日期分组,取每日最后一根 bar 的权益 from collections import defaultdict daily: dict[str, float] = {} for point in self._equity: dt = datetime.fromtimestamp(point["timestamp"] / 1000, tz=timezone.utc) date_key = dt.strftime("%Y-%m-%d") daily[date_key] = point["equity"] sorted_dates = sorted(daily.keys()) returns = [] for i in range(1, len(sorted_dates)): prev = daily[sorted_dates[i - 1]] curr = daily[sorted_dates[i]] if prev > 0: returns.append((curr - prev) / prev) return returns def _compute_max_drawdown(self) -> tuple[float, int]: """计算最大回撤百分比和最大回撤持续天数""" if not self._equity: return 0.0, 0 peak = self._equity[0]["equity"] max_dd = 0.0 dd_start_idx = 0 max_dd_days = 0 for i, point in enumerate(self._equity): equity = point["equity"] if equity > peak: peak = equity dd_start_idx = i dd = (equity - peak) / peak * 100 if dd < max_dd: max_dd = dd # 计算从 peak 日期到当前的持续时间 peak_ts = self._equity[dd_start_idx]["timestamp"] curr_ts = point["timestamp"] dd_days = int((curr_ts - peak_ts) / (1000 * 86400)) if dd_days > max_dd_days: max_dd_days = dd_days return max_dd, max_dd_days # ================================================================ # 工具方法 # ================================================================ def _round_qty(self, qty: float, decimals: int = 8) -> float: """将数量向下取整到指定位数""" factor = 10 ** decimals return int(qty * factor) / factor