4da520c14b
- backtest/engine.py: 事件驱动回测引擎核心,支持 K 线推进/订单撮合/权益曲线 - backtest/models.py: 回测数据模型(订单/成交/持仓/账户快照) - backtest/README.md: 回测模块使用说明 - backtest/STRATEGY.md: 策略开发指南与最佳实践 - backtest/TIMEFRAME_COMPARISON*.md: 多周期回测对比分析报告
479 lines
17 KiB
Python
479 lines
17 KiB
Python
"""
|
||
回测引擎核心 — 事件驱动的历史回测
|
||
|
||
逐根 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
|