自建量化回测引擎V2:从零搭建事件驱动回测框架,替代商业平台
为什么自建回测引擎
聚宽、优矿、米筐等平台很好用,但有三个问题:
- 数据限制:免费版不支持5分钟K线、不支持可转债分钟数据
- 策略泄露:策略代码运行在云端,你不确定是否被他人看到
- 成本:专业版年费数千到数万元
我们的解决方案是自建了一套49个Python文件、845KB代码的回测引擎V2,完全本地运行,支持5分钟K线回测、限价单订单簿模拟、动态流动性滑点。
更重要的是,在开发过程中我们发现了7个致命BUG——这些BUG让所有策略看起来都赚钱,修复后年化收益直接打了3-7折。
引擎架构
┌──────────────────────────────────────────────────────┐
│ 回测引擎V2 架构 │
├──────────────────────────────────────────────────────┤
│ │
│ BacktestEngineV2 (主编排器) │
│ ├── DataLoader 数据加载层 │
│ │ └── 5分钟K线 / 日线 / 流动性数据 │
│ │ │
│ ├── TransactionAccount 模拟券商账户 │
│ │ ├── Order 订单管理 │
│ │ ├── Position 持仓管理 │
│ │ ├── FillRecord 逐笔成交记录 │
│ │ └── TradeRecord 买卖配对记录 │
│ │ │
│ ├── ExecutionEngine 撮合引擎 │
│ │ ├── OrderBook 限价单订单簿 │
│ │ ├── IntraBarSimulator bar内布朗桥插值 │
│ │ └── LiquidityProfile 流动性画像 │
│ │ │
│ ├── GridStrategyV2 网格策略 │
│ │ └── GridLayerState 逐层网格状态 │
│ │ │
│ └── Report 报告生成 │
│ └── Walk-Forward验证 │
│ │
└──────────────────────────────────────────────────────┘
核心模块详解
1. 模拟券商账户(account.py)
这是最基础也最重要的模块——模拟真实券商账户的资金、持仓和费用。
class OrderSide(Enum):
BUY = "buy"
SELL = "sell"
class OrderType(Enum):
LIMIT = "limit" # 限价单
STOP_LIMIT = "stop_limit" # 止损限价单
MARKET = "market" # 市价单
class OrderStatus(Enum):
PENDING = "pending" # 挂单中
PARTIAL = "partial" # 部分成交
FILLED = "filled" # 全部成交
CANCELLED = "cancelled" # 已撤单
@dataclass
class Order:
id: int
code: str
side: OrderSide
type: OrderType = OrderType.LIMIT
price: float = 0.0 # 限价价格
stop_price: float = 0.0 # 止损触发价
quantity: int = 0 # 总数量
filled_quantity: int = 0 # 已成交数量
status: OrderStatus = OrderStatus.PENDING
fills: List[FillRecord] = field(default_factory=list)
slippage: float = 0.0 # 实际滑点
@dataclass
class Position:
code: str
quantity: int = 0 # 持有张数
avg_cost: float = 0.0 # 平均成本
total_buy_amount: float = 0.0 # 总买入金额
realized_pnl: float = 0.0 # 已实现盈亏
grid_layer: int = 0 # 当前网格层
buy_price: float = 0.0 # 买入价
sell_target: float = 0.0 # 目标卖出价
精确的费用模型
费用模型是回测真实性的关键。可转债的费用结构:
"""费用明细(可转债):
- 佣金: 万0.5 (免五)
- 经手费: 万0.004 (双向)
- 证管费: 万0.002 (双向)
- 印花税: 0 (可转债免)
- 过户费: 0 (可转债免)
"""
对比股票的费用:
"""费用明细(股票):
- 佣金: 万0.5 (最低5元)
- 印花税: 千1 (卖出单边)
- 过户费: 万0.1 (双边)
- 经手费: 万0.487 (双边)
- 证管费: 万0.2 (双边)
"""
最容易遗漏的是”最低5元佣金”。如果你的回测中每笔交易佣金只有0.5元(万0.5×1万),但实际券商最低收5元,那么小单交易的成本被低估了10倍。
2. 撮合引擎(execution.py)
这是引擎的技术核心——模拟真实交易所的限价单撮合逻辑。
流动性画像
不同标的的流动性差异巨大。我们根据日均成交额将标的分为5个流动性等级:
@dataclass
class LiquidityProfile:
"""流动性画像"""
code: str
avg_daily_amount: float = 0.0 # 日均成交额
avg_5min_amount: float = 0.0 # 平均5分钟成交额
liquidity_tier: int = 3 # 1(高)~5(低)
slippage_bp: float = 0.5 # 基础滑点(bp)
max_fill_ratio: float = 0.15 # 单笔最大成交量占比
@classmethod
def from_daily_amount(cls, code: str, daily_amount: float):
if daily_amount >= 1e9: # ≥ 10亿
tier, slippage, max_ratio = 1, 0.3, 0.25
elif daily_amount >= 5e8: # ≥ 5亿
tier, slippage, max_ratio = 2, 0.5, 0.20
elif daily_amount >= 1e8: # ≥ 1亿
tier, slippage, max_ratio = 3, 1.0, 0.15
elif daily_amount >= 3e7: # ≥ 3000万
tier, slippage, max_ratio = 4, 2.0, 0.15
else: # < 3000万(低流动性)
tier, slippage, max_ratio = 5, 5.0, 0.10
return cls(code=code, avg_daily_amount=daily_amount,
liquidity_tier=tier, slippage_bp=slippage,
max_fill_ratio=max_ratio)
| 流动性等级 | 日均成交额 | 基础滑点 | 单笔最大成交量占比 |
|---|---|---|---|
| Tier 1 | ≥10亿 | 0.3bp | 25% |
| Tier 2 | ≥5亿 | 0.5bp | 20% |
| Tier 3 | ≥1亿 | 1.0bp | 15% |
| Tier 4 | ≥3000万 | 2.0bp | 15% |
| Tier 5 | <3000万 | 5.0bp | 10% |
关键设计:你的订单不能吃掉整个bar的成交量。如果一个5分钟bar只成交了10万元,你的订单最多只能成交1.5万元(15%)。这防止了回测中出现”一笔交易吃掉整个市场”的不现实情况。
限价单订单簿
class OrderBook:
"""
限价单订单簿 - 模拟真实交易所撮合:
- 价格优先,同价格时间优先
- 支持部分成交
- 成交量约束
"""
def place_limit_order(self, order: Order) -> bool:
"""挂限价单到订单簿"""
book = self._buy_orders if order.side == OrderSide.BUY else self._sell_orders
if order.price not in book:
book[order.price] = []
book[order.price].append((order.id, order.quantity))
return True
def match_orders(self, bar_high, bar_low, bar_volume):
"""在当前bar内撮合订单"""
for price in sorted(self._buy_orders.keys(), reverse=True):
if price <= bar_high: # 买入限价 ≤ bar最高价 → 可成交
self._fill_at_price(price, bar_volume)
for price in sorted(self._sell_orders.keys()):
if price >= bar_low: # 卖出限价 ≥ bar最低价 → 可成交
self._fill_at_price(price, bar_volume)
3. Bar内模拟器
传统回测用OHLC判断是否触发限价单——如果限价买入价在[low, high]之间,就认为成交了。但这忽略了价格在bar内的路径。
我们用布朗桥(Brownian Bridge)插值模拟bar内价格路径:
class IntraBarSimulator:
"""
Bar内价格路径模拟器
在OHLC之间用布朗桥插值,生成更真实的bar内价格路径。
这对于网格策略至关重要——因为网格的触发完全取决于
价格是否精确触及网格线。
"""
def simulate_path(self, o, h, l, c, n_steps=12):
"""
在给定的OHLC之间生成n_steps个中间价格点。
使用约束布朗桥:
- 起点=open, 终点=close
- 路径必须触及high和low
- 中间路径随机但有约束
"""
prices = [o]
for i in range(1, n_steps):
t = i / n_steps
# 布朗桥线性插值 + 随机扰动
bridge = o + t * (c - o)
noise = self.rng.gauss(0, (h - l) * 0.15)
p = bridge + noise
p = max(l, min(h, p)) # 约束在[low, high]内
prices.append(p)
prices.append(c)
return prices
4. 网格策略引擎
@dataclass
class GridConfig:
"""网格策略配置"""
codes: List[str] # 标的列表
grid_pct: float = 0.003 # 网格间距 0.3%
layers: int = 5 # 网格层数
qty_per_layer: int = 30 # 每层买入张数
stop_loss_pct: float = 0.02 # 止损 2%
total_capital: float = 100000.0 # 总资金
max_positions_per_code: int = 5 # 单标的最大持仓层数
order_timeout_bars: int = 6 # 订单超时撤单
commission_rate: float = 0.00005 # 佣金万0.5
slippage_bp: float = 0.5 # 基础滑点
max_fill_ratio: float = 0.15 # 单笔最大成交量占比
dynamic_rebalance: bool = True # 动态调整网格间距
max_drawdown_pct: float = 0.0 # 最大回撤约束
网格策略的核心状态管理——每层网格独立挂单,而不是一次性全部触发:
@dataclass
class GridLayerState:
"""单层网格状态"""
layer: int # 层号 (1~N)
buy_price: float # 买入触发价
sell_target: float # 卖出目标价
buy_order_id: Optional[int] = None # 买入挂单ID
sell_order_id: Optional[int] = None # 卖出挂单ID
status: str = "ready" # ready/buying/bought/selling/sold
5. 回测编排流程
class BacktestEngineV2:
def run(self) -> dict:
"""执行完整回测"""
# 1. 获取流动性数据
liquidity_map = self.loader.get_liquidity_data(
self.config.codes, lookback_days=60
)
# 2. 加载历史5分钟K线
all_data = {}
for code in self.config.codes:
df = self.loader.load_5min_bars(code, self.start_date, self.end_date)
all_data[code] = df
# 3. 初始化账户和策略
self.account = TransactionAccount(
capital=self.config.total_capital,
commission_rate=self.config.commission_rate
)
self.strategy = GridStrategyV2(self.config)
# 4. 按时间顺序逐bar回放
all_timestamps = sorted(set().union(*[set(df.index) for df in all_data.values()]))
for ts in all_timestamps:
for code in self.config.codes:
if ts not in all_data[code].index:
continue
bar = all_data[code].loc[ts]
# 策略生成订单
orders = self.strategy.on_bar(code, bar, self.account)
# 订单进入订单簿
for order in orders:
self.order_book.place_limit_order(order)
# bar内撮合
self.order_book.match_orders(
bar['high'], bar['low'], bar['volume']
)
# 更新账户
self.account.update_positions(code, bar['close'])
# 5. 统计和报告
return self._generate_report()
7个致命BUG:那些让策略看起来很赚钱的错误
这是本文最重要的部分。在开发过程中,我们通过系统化诊断发现了7个BUG,这些BUG叠加后虚增年化收益20-40%。
BUG 1: Look-ahead Bias(前瞻偏差)🔴 高危
# ❌ 错误:用T日收盘价同时做信号和成交价
if df['close'][i] > df['close'][i-1]: # 信号
buy(code, price=df['close'][i]) # 成交
# ✅ 正确:T日信号 → T+1日开盘成交
if df['close'][i] > df['close'][i-1]: # T日信号
buy(code, price=df['open'][i+1]) # T+1开盘成交
影响:修复后年化降30-60%。因为你”看到”了收盘价才决定买入,但实际上收盘时你还没做决定。
BUG 2: 佣金无最低限制 🔴 高危
# ❌ 错误
commission = max(amount * 0.00005, 0) # 万0.5,无最低
# ✅ 正确
commission = max(amount * 0.00005, 5.0) # 万0.5,最低5元
影响:可转债一张约100元,买入10张=1000元,佣金应为5元(最低)。但错误代码只收0.05元,成本被低估100倍。
BUG 3: 交易成本低估3倍 🟡 中危
# ❌ 错误:单边0.1%
cost = 0.001
# ✅ 正确:双边含滑点约0.3%
cost = 0.003 # 佣金万0.5×2 + 印花税千1 + 滑点0.1%×2
BUG 4: 网格策略过拟合 🔴 高危
优化期(4月): 年化+1,800%, Sharpe 31.68, 回撤 0.00%
验证期(2月): 年化+2,616%, Sharpe 38.60, 回撤 0.00%
问题:5分钟K线 + 布朗桥插值 = 过度拟合微观结构
0.00%回撤在真实交易中不可能出现
BUG 5: 幸存者偏差 🟡 中危
退市/到期的可转债数据不完整,但被保留在回测池中。已退市的转债通常经历了大跌,排除它们会高估策略收益。
BUG 6: 年化计算错误 🟢 低危
# ❌ 错误:per-trade compounding
daily_return = 0.001
annual = (1 + daily_return) ** 250 - 1 # 假设每天交易
# ✅ 正确:资金曲线法
equity_curve = [100000]
for trade in trades:
equity_curve.append(equity_curve[-1] * (1 + trade.return_))
total_return = equity_curve[-1] / equity_curve[0] - 1
annual = (1 + total_return) ** (252 / n_days) - 1
BUG 7: 不复权数据 🟡 中危
使用不复权数据做回测,除权除息日的跳空缺口会产生虚假信号。例如一只股票10送10,价格从20元跳到10元,回测会误判为”暴跌50%“。
修复后的真实回测结果
修复全部BUG后,重新回测的对比:
| 策略 | BUG修复前 | BUG修复后 | 衰减 |
|---|---|---|---|
| 网格激进型 | 年化+262% | 年化+80%(估) | -70% |
| 网格均衡型 | 年化+194% | 年化+50%(估) | -74% |
| 双低轮动 | 年化+25% | 年化+15% | -40% |
| 低价轮动 | 年化+20% | 年化+12% | -40% |
修复后的最佳策略——低成交额+低价复合因子:
| 年份 | 年度收益 | 月胜率 |
|---|---|---|
| 2020 | +26.55% | 73% |
| 2021 | +55.31% | 82% |
| 2022 | +8.51% | 64% |
| 2023 | +1.80% | 70% |
| 2024 | +26.01% | 55% |
| 2025 | +76.16% | 100% |
| 6年平均 | +32.4% | 74% |
Walk-Forward验证
防止过拟合的终极武器——用优化期的参数在验证期测试:
| 策略 | 优化期年化 | 验证期年化 | 衰减比 | 结论 |
|---|---|---|---|---|
| 网格激进型 | +1,800% | +2,616% | 1.45 | ✅ 通过 |
| 网格均衡型 | +1,520% | +1,924% | 1.27 | ✅ 通过 |
| 网格稳健型 | +664% | +840% | 1.26 | ✅ 通过 |
衰减比>1表示验证期优于优化期(这在网格策略中是正常的,因为波动越大网格越赚钱)。但如果衰减比<0.5,说明策略严重过拟合。
Backtest Guardrails:11项强制检查
我们最终形成了一套11项回测检查清单,每次回测必须通过:
| 检查项 | 编号 | 说明 |
|---|---|---|
| 幸存者偏差 | B1 | 排除退市/停牌股票 |
| 交易日历 | B2 | 对齐实际交易日 |
| 数据排除 | B3 | 排除新股/ST/科创板 |
| T+1成交 | B4 | 信号close → 成交open_next |
| 交易成本 | B5 | 含佣金+印花税+滑点 |
| 退市处理 | B6 | 缺exit_price → 亏损80%退出 |
| 年化计算 | B7 | 资金曲线cumprod法 |
| 随机基线 | B8 | 30次随机选中位数对比 |
| 参数鲁棒 | B9 | 参数扰动测试 |
| 逐年表现 | B10 | 逐年收益不能只靠1-2年 |
| 样本外验证 | B11 | Walk-Forward验证 |
| 复权方式 | B12 | 明确前复权/后复权/不复权 |
与商业平台对比
| 维度 | 聚宽免费版 | 自建引擎V2 |
|---|---|---|
| 数据频率 | 日线 | 5分钟/日线/自定义 |
| 可转债数据 | 部分支持 | 988只全量支持 |
| 限价单模拟 | 简单OHLC判断 | 订单簿+成交量约束 |
| 滑点模型 | 固定滑点 | 5级流动性动态滑点 |
| 费用模型 | 基础 | 逐项精细(佣金/经手费/证管费/印花税) |
| Walk-Forward | 不支持 | 内置支持 |
| 随机基线 | 不支持 | 30次自动对比 |
| 数据隐私 | 云端运行 | 100%本地 |
| 成本 | 免费(有限制) | 0元(全功能) |
总结
自建回测引擎的最大收获不是省了平台费,而是通过理解每个细节,发现了7个让自己被骗的BUG。
如果你的回测显示年化+262%、Sharpe 25、回撤0.2%——不要兴奋,先检查是否有前瞻偏差。真实的Alpha来之不易,那些看起来太好的结果,几乎都是BUG。
回测引擎的代码量分布:
- 账户和费用模型:4,500行
- 撮合和执行引擎:6,600行
- 网格策略:4,100行
- 数据加载器:2,800行
- 回测编排器:4,200行
- 全策略横评框架:4,400行
- 其他辅助脚本:总计49个文件
推荐的自建路径:
- 先写最小可用版本——日线 + OHLC判断 + 固定滑点
- 加入精确费用模型(最低佣金!)
- 加入成交量约束和流动性分级
- 升级到分钟K线 + 订单簿
- 最后加入Walk-Forward验证和随机基线
本文所有代码和回测结果均来自实际运行的系统。BUG诊断报告基于2026年4月的系统审计。