Caloogy 文章
English

向量化回测 vs 事件驱动回测

日期
2025 年 9 月 1 日
系列
量化
阅读
3 分钟
主题
QuantQuant devBacktestVectorized

一句话版本

回测框架有两种基本写法:

  • 向量化:把整段历史数据当作一张大表,用数组运算一次性算出所有信号、仓位和收益。写得快、跑得快,适合研究阶段快速筛选想法;
  • 事件驱动:模拟真实时间的流逝,一根 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 自律 结构上避免
复杂逻辑 难 自然
实盘复用 难 同一份代码
适用 研究筛选 精细回测、模拟盘、实盘