Files
trade/engine/backtest/engine.py
T
Rekey 4da520c14b feat(engine): 添加事件驱动回测引擎
- backtest/engine.py: 事件驱动回测引擎核心,支持 K 线推进/订单撮合/权益曲线
- backtest/models.py: 回测数据模型(订单/成交/持仓/账户快照)
- backtest/README.md: 回测模块使用说明
- backtest/STRATEGY.md: 策略开发指南与最佳实践
- backtest/TIMEFRAME_COMPARISON*.md: 多周期回测对比分析报告
2026-06-12 10:26:53 +08:00

479 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
回测引擎核心 — 事件驱动的历史回测
逐根 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