一句话版本
回测框架有两种基本写法:
- 向量化:把整段历史数据当作一张大表,用数组运算一次性算出所有信号、仓位和收益。写得快、跑得快,适合研究阶段快速筛选想法;
- 事件驱动:模拟真实时间的流逝,一根 K 线(或一个 tick、一个订单回报)一个事件地推进,策略只能看到"过去"。写起来慢、跑起来慢,但结构上不可能偷看未来,也更容易模拟成交、成本、风控,并且同一份策略代码可以直接接入实盘。
本文用两种方式实现同一个策略,验证结果完全一致,再讨论什么时候必须用事件驱动。
免责
本文仅为技术科普,不构成任何投资建议。文中代码使用模拟数据。
第一层:通俗理解
1.1 看录像 vs 现场直播
| 向量化 | 事件驱动 | |
|---|---|---|
| 比喻 | 拿到整场比赛的录像,可以随意快进、倒退、一次看全局 | 现场直播,只能看到已经发生的 |
| 风险 | 一不小心就看到了"后面的剧情"(前视偏差) | 结构上看不到未来 |
| 速度 | 极快:一行数组运算处理几十年数据 | 慢:要逐个事件循环 |
| 真实度 | 难以模拟复杂的成交逻辑 | 可以模拟订单、部分成交、撤单、风控 |
1.2 研究流程中的位置
第二层:两种实现
2.1 向量化
import numpy as np, pandas as pd
rng = np.random.default_rng(11)
n = 500
price = pd.Series(100 * np.exp(np.cumsum(rng.normal(0.0003, 0.012, n))))
def vectorized(price, fast=10, slow=30):
sig = (price.rolling(fast).mean() > price.rolling(slow).mean()).astype(float)
pos = sig.shift(1).fillna(0) # 昨天收盘的信号,今天持有
return (pos * price.pct_change().fillna(0)).add(1).cumprod()
整个回测只有三行。关键是 shift(1):忘了它,就是回测的七宗罪里演示的前视偏差。
2.2 事件驱动
class Strategy:
def __init__(self, fast=10, slow=30):
self.fast, self.slow, self.hist = fast, slow, []
def on_bar(self, close):
self.hist.append(close)
if len(self.hist) < self.slow:
return 0.0
f = np.mean(self.hist[-self.fast:]); s = np.mean(self.hist[-self.slow:])
return 1.0 if f > s else 0.0 # 目标仓位
class Broker:
def __init__(self):
self.pos, self.equity, self.last = 0.0, 1.0, None
def on_price(self, close):
if self.last is not None:
self.equity *= 1 + self.pos * (close / self.last - 1)
self.last = close
def set_target(self, target):
self.pos = target # 简化:按收盘价立即成交、无成本
def event_driven(price):
strat, broker, curve = Strategy(), Broker(), []
for close in price: # 一根一根 K 线推进
broker.on_price(close) # 先用旧仓位结算今天的盈亏
broker.set_target(strat.on_bar(close)) # 再根据今天收盘算出明天的仓位
curve.append(broker.equity)
return pd.Series(curve)
v, e = vectorized(price), event_driven(price)
print("向量化终值:", round(v.iloc[-1], 6), " 事件驱动终值:", round(e.iloc[-1], 6),
" 最大差异:", f"{(v - e).abs().max():.2e}")
输出:
向量化终值: 1.102555 事件驱动终值: 1.102555 最大差异: 0.00e+00
两种写法结果完全一致。注意事件驱动版本里,on_price(结算)一定在 set_target(下单)之前:这个顺序就是"不偷看未来"的结构保证,策略对象 Strategy 也只保存了过去的价格。
一个实用技巧:像这样用两种独立的方式实现同一个策略、比对结果,是发现回测 bug 的好办法(参见《多源交叉验证》 里"独立实现"的思路)。
第三层:什么时候必须用事件驱动
3.1 向量化很难处理的情况
| 情况 | 原因 |
|---|---|
| 路径依赖的逻辑 | 止损、止盈、移动止损:今天的仓位取决于持仓以来的最高价,需要逐步计算 |
| 资金和仓位约束 | 可用资金不足时只能部分下单;保证金和强平 |
| 订单类型 | 限价单是否成交取决于之后的价格走势;部分成交、撤单 |
| 多资产相互影响 | 组合层面的风控:一个资产亏损导致其他资产减仓 |
| 成本依赖于状态 | 市场冲击取决于当时的成交量和下单量(见《交易成本建模》) |
| 风控闸门 | 每笔订单都要过一遍交易前检查(见《交易前风控与熔断开关》) |
| 实盘一致性 | 希望回测和实盘使用同一份策略代码 |
3.2 一个典型的事件驱动架构
关键设计:
- 数据源可替换:回测时回放历史,实盘时接交易所,策略代码不变;
- 模拟撮合要保守:限价单只有在价格穿过限价时才成交,而不是"触及"就成交;按成交量比例限制成交数量;
- 时间戳严格:所有事件按时间排序,同一时刻的事件顺序要有明确规则;
- 可复现:随机数种子、数据版本、代码版本都要记录。
3.3 混合用法
实际中常见的做法:
- 研究阶段用向量化,几秒钟跑完几百个变体,用于淘汰明显不行的想法;
- 对少数候选策略,用事件驱动做精细回测,加入真实的成本、约束和风控;
- 两者结果出入很大时,通常意味着向量化版本漏掉了某个现实约束,或者有前视偏差。
3.4 常见开源框架
| 类型 | 例子 |
|---|---|
| 向量化 | 直接用 pandas / NumPy;vectorbt |
| 事件驱动 | Backtrader、Zipline(及其社区维护版本)、NautilusTrader、LEAN(QuantConnect) |
选框架时重点看:是否支持你需要的资产和订单类型、数据接口是否方便、回测和实盘能否共用代码、社区是否活跃。
总结
| 向量化 | 事件驱动 | |
|---|---|---|
| 速度 | 极快 | 慢 |
| 前视风险 | 高,需靠 shift 自律 |
结构上避免 |
| 复杂逻辑 | 难 | 自然 |
| 实盘复用 | 难 | 同一份代码 |
| 适用 | 研究筛选 | 精细回测、模拟盘、实盘 |