← 返回模块
4.5.1.1beta 可读 · 未来免费校验通过内容版本 2026-05-28

回测引擎结构:向量化与事件驱动

4.5.1 · 回测方法论 · 量化全流程

某周二,上海某 量化 私募 的策略评审会上。一位 研究员 把 5 日 动量 信号 的回测报告投到屏幕上:在 沪深300 ETF 510300 上从 2014-01-01 到 2023-12-31 的回测,扣费后年化 夏普比率 1.8。曲线穿过 2015 股灾、穿过 2018 中美贸易摩擦、穿过 2022 疫情 + 房地产 双杀,姿态优雅。投资 决策 委员会 的 风控 总监 摘下眼镜,问了一个问题:「这是 向量化 回测 还是 事件驱动 回测?」研究员答:「向量化,三行 pandas」。风控 总监 把笔记本合上:「下周二,事件驱动 重跑,再给我看」。一周后,同一条信号、同样的数据,事件驱动 引擎 跑出来的 夏普比率 是 0.4。1.4 的 落差 不是市场风格切换,是 向量化 代码 里 那个 教科书级 的 一根 K 线 前视 bug——结构上 看不见,结构上 不可能 出现 在 事件驱动 引擎 里。本课要教给你的,就是那种从架构上 杜绝 bug 的回测引擎结构,以及为什么 向量化 与 事件驱动 的 区别,就是「研究草稿」与「能上 实盘 配 资金 的 证据」之间的区别。

回测 是 什么——以及 不 是 什么

回测 是一种 仿真:把历史数据回放给策略决策规则和 成交 模拟器,得到一条历史 PnL 轨迹。它的输出不是对未来收益的预测,而是 证据——证明在引擎诚实、数据干净的前提下,这套策略 可能 历史上 work 过。三种策略评估模式必须严格区分:

1. backtest        — 离线 回测;基于历史数据的离线 仿真;本模块的主题
2. paper trading   — 仿真 交易;接入实时行情、模拟资金的 live 仿真;L4
3. shadow trading  — 影子 交易;接入实时行情、小额真实资金,与生产策略并行运行;L4

每一关必须按顺序通过——策略不能跳关。回测 是第一关,仿真 交易 是第二关,影子 交易 是第三关,实盘 是第四关。跳关 不是 敏捷,是 治理 失败。

回测 的 头条 交付物 不是 夏普比率 那一个数字,而是 回测 读出 报告(backtest readout report):一份至少六个章节的工件,供下游部署阶梯消费、供 投决会 签字。

i.   PnL 曲线                  — 累计 收益 时间序列
ii.  Sharpe ratio with confidence interval   — 夏普比率 含 置信区间
iii. 最大回撤                  — peak-to-trough loss + 回补 时间
iv.  turnover                  — 日均 换手率
v.   factor exposure breakdown — 在 4.3 因子模型 上的 回归 分解
vi.  reproducibility metadata  — git commit、数据 快照 ID、随机种子、run-id

规则是:单一的 头条 夏普 不是 回测 交付物——读出报告 才是。L4 会把六节扩展到十节的 完整 信誉 文件;L1 把六节当作下限即可。

五大 引擎 层

每一个经得起 投决会 推敲的回测引擎,无论是 Citadel / Two Sigma / Renaissance 内部的 C++/Python 混合 框架,还是开源的 Zipline(zipline-reloaded 社区维护版)/ Backtrader / Lean(QuantConnect)/ bt / vectorbt,也无论是国内的 RQAlpha 米筐 / Vnpy / Qlib(微软亚洲研究院),都按同样的五个层次、同样的严格顺序分离关注点:

1. data layer        — 时点 数据 接入;data.get(symbol, field, as_of=t)
2. signal layer      — 从 PIT data 到 目标持仓/目标权重 的 纯函数
3. portfolio layer   — 目标权重 + 约束 + 当前持仓 -> order list
4. fill simulator    — orders + bar data -> fills;可配置 成交 模型
5. PnL + accounting layer — fills + 持仓 + bar data -> 更新后持仓 + 现金 + 日 PnL

架构铁律:​​每一层都必须能够在隔离环境下、用上一层 mock 输出 单元测试​​。如果你不能在不运行整条策略的前提下单测 成交 模拟器,那么引擎耦合过高,前视 偏差 一定 会藏在 缝里。

data layer 是 时点 数据 流。价格、基本面、另类数据——每一行都带有一个 available_at 字段(数据对公众可见的时刻,不是数据所指的时刻)。引擎拒绝在时刻 t 提供 available_at > t 的行。契约是:每一次数据访问都走 data.get(symbol, field, as_of=t),只返回 available_at <= t 的行。这是 L2 进一步阐述的 PIT 纪律 的 结构性 根基。

signal layer 是策略决策函数。输入是 PIT 数据,输出是目标持仓或目标权重。契约是:信号函数 ——相同输入产出相同输出,无全局状态、无副作用。纯函数 可 单测;不纯的会通过隐藏状态漏 前视。

portfolio layer 把目标权重翻译成 order list。它执行 头寸 规模 规则(等权、波动率倒数加权、因子倾斜加权)、约束规则(只做多、单只上限、行业上限、GICS 行业 限制),并计算从当前持仓到目标持仓的 delta。契约是:组合函数 接受 (current_positions, target_weights, constraints),返回 一张 order list——不多、不少。

fill simulator 把订单变成成交。可配置参数:在哪根 K 线上成交(next-bar-open 是日频策略的现实默认;same-bar-close 除非显式针对 market-on-close 订单,否则就是 前视);成交量 占比 上限(order_size / bar_volume <= 10% 是常规安全上限);滑点 模型(基点 或 与 成交量 成 比例)。契约是:成交 模拟器 接受 order list 和一根行情 bar,返回 一张 fills list——其中一部分可能部分成交或未成交。

PnL + accounting layer 给持仓打盯市、算收益、跟现金账户、处理 dividends / splits、计提 borrow cost 和 transaction cost。契约是:账户函数 接受 (fills, current_positions, bar_data),返回 (updated_positions, cash, daily_pnl)。L2 在这个骨架上挂上 真实性(PIT 纪律、幸存者 偏差、成本、融券);L1 关心的就是 骨架 本身。

两种 引擎 范式

1. vectorized backtest    — 完整价格数组始终在 scope 内;比 event-driven 快 10-100 倍;
                            结构上 易出 前视;用于 原型 验证
2. event-driven backtest  — 事件按时间顺序流过队列;引擎只看得到当前事件之前的事件;
                            结构上 杜绝 前视;用于 上 投决会 的最终回测

​向量化 回测​​。策略就是 完整 价格 历史 上的 一次 向量 / DataFrame 操作。信号 是 一行表达式:signal = (close / close.shift(L) - 1);持仓 是 一次 平移:position = signal.shift(1);PnL 是 一次 点积:(position * close.pct_change()).sum()。整个回测三行 pandas。速度:比 事件驱动 快 10-100 倍——因为没有 Python 事件循环 开销。用途:研究 快速 迭代、在 大量 候选 信号 上 筛选、新 想法 的 第一刀。弱点:结构上 易出 前视——完整 价格 数组 在 每一步 都 在 scope 内,唯一阻挡策略读到未来的,就是 程序员 在正确位置上写 .shift(1) 的 自律。

​事件驱动 回测​​。事件 按 时间 顺序 流过 一条 队列。bar 事件 触发 signal 层;signal 事件 触发 portfolio 层;order 事件 流到 fill simulator;fills 流到 accounting;clock-tick 事件 推进 到下一根 K 线。引擎 一个 事件 一个 事件 处理,按时间顺序,只看得到当前事件之前的事件。速度:慢——Python 事件循环 拖累,通常比 vectorized 慢 10-100 倍。强项:结构上 杜绝 前视——信号 计算 的 时候 未来 的 K 线 还 没进 队列。用途:上 paper trading 之前的 信誉级 最终 回测。

决策口诀:​​向量化 用于 原型;事件驱动 用于 上 实盘​​。一只 策略 被 分配 资金 的 那个 夏普 数字 必须 来自 事件驱动 引擎。向量化 的 夏普 只是 研究 草稿。

经典 前视 bug

bug 模式在 pandas 里只要三行:

# BUG: signal uses close[t] -> one-bar look-ahead
signal = close / close.shift(5) - 1            # 第 t 行的 signal 用了 close[t]
position = signal                              # t 时刻的 持仓 = t 时刻的 signal
bar_return = close.pct_change()                # 第 t 行的收益 = close[t] / close[t-1] - 1
pnl = (position * bar_return).sum()
sharpe = pnl.mean() / pnl.std() * np.sqrt(252)

策略 在 时刻 tclose[t]t 收盘价,盘后才可观测)做 决策,然后 赚 到 close[t] 为止的 那一段 收益。这在结构上 等价于 在 每一步 完美 预测 当天 收盘。在 510300 上 2014-2023 跑 一下,这个 实现 打 出 大约 1.8 的 夏普——纯 bug 贡献。

最小 PIT 修复:把 signal(或 position)平移 恰好 一根 K 线,让 决策 发生 在 t - 1 收盘 之后、订单 在 t 开盘 进场:

# FIX: signal shifted one bar so it is decided at t-1 EOD
signal = (close / close.shift(5) - 1).shift(1) # 第 t 行的 signal 用了 close[t-1]
position = signal
bar_return = close.pct_change()
pnl = (position * bar_return).sum()
sharpe = pnl.mean() / pnl.std() * np.sqrt(252)

修复后的 510300 2014-2023 夏普 大约 0.4。这是 最小 PIT 修复。更隐蔽的 前视 bug——幸存者 过滤 后 的 股票池、基本面 字段 时间戳 错用 报告 期 末 而 非 公告 日期、另类 数据 没有 算上 数据 落地 延迟——需要 L2 里 的 进一步 纪律。

事件驱动 引擎 骨架

同样的 五层 架构、但 表达 为 一个 一根 K 线 一根 K 线 消费 bar 事件 的 类:

# 5-layer event-driven backtest skeleton
class BacktestEngine:
    def __init__(self, data_provider, signal_fn, portfolio_fn, fill_simulator):
        self.data, self.signal_fn, self.portfolio_fn = data_provider, signal_fn, portfolio_fn
        self.fill_simulator = fill_simulator
        self.positions, self.cash, self.pnl_history = {}, 1_000_000.0, []

    def on_bar(self, bar):
        pit_data = self.data.get_as_of(bar.timestamp)         # 数据层 派发
        target_weights = self.compute_signal(pit_data)
        orders = self.compute_orders(target_weights, self.positions)
        fills = self.simulate_fills(orders, bar)
        self.update_accounting(fills, bar)

    def compute_signal(self, pit_data):       # signal layer
        return self.signal_fn(pit_data)
    def compute_orders(self, target_weights, current_positions):  # portfolio layer
        return self.portfolio_fn(target_weights, current_positions)
    def simulate_fills(self, orders, bar):    # fill simulator
        return self.fill_simulator(orders, bar)
    def update_accounting(self, fills, bar):  # PnL + accounting
        ...

向量化 那段 bug 在这里 不可能 发生。signal_fn 收到的 pit_data 是 经过 available_at <= bar.timestamp 过滤的快照。bar t 这一刻 的 pit_data没有 close[t]——盘还没收,可见的只有 open。五层 分离 加上 时间序 事件流 让 前视 在 数据层 就 不可能 出现——程序员 再 粗心,都 制造 不出 一根 K 线 的 平移 bug。

向量化-vs-事件驱动 的 夏普 落差 作为 诊断

把同一条 5 日 动量 信号 在 沪深300 ETF 510300 上 2014-2023 跑 两个 引擎。带 close[t] 前视 bug 的 向量化 引擎 跑出 夏普 ~1.8。无 bug、下根 K 线 开盘 成交、暂不算 成本 的 事件驱动 引擎 跑出 夏普 ~0.4。1.4 的 落差 就是 前视 通胀。

实战 诊断 规则:​​从 向量化 移植 到 事件驱动 时 夏普 下降 大于 0.5,就是 向量化 实现 里 存在 前视 bug 的 诊断,不是 事件驱动 引擎 本身 的 问题​​。如果 落差 小于 0.1,向量化 实现 大概率 PIT 正确(罕见 但 可能——靠 处处 严格 .shift(1) 纪律),两个 引擎 结果 一致。

收束 本课 的 架构 铁律:​​上 实盘 的 那个 引擎 必须 是 事件驱动 的​​。向量化 引擎 只用于 原型。两个 引擎 之间 的 落差,就是 你 第一道 过拟合 / 前视 诊断 工具——开头 那位 风控 总监 在 投决会 里 默默 套用 的 就是 这条 规则。

Formula Explorer

\text{Sharpe} = \frac{\bar{r} - r_f}{\sigma_r} \cdot \sqrt{252}

夏普 公式 本身 不复杂,但 这个 数字 的 可信度 完全 取决于 产出 它 的 引擎 是否 从 结构上 排除 了 前视。完整 夏普 定义 用 超额收益 减 无风险利率;对 前视 诊断 比较 来说,两 个 引擎 跑 的 是 同一 段 收益 序列,用 原始 形式 即可。

Exercise

Exercise

你 正在 把 5 日 动量 信号 跑 在 510300 沪深300 ETF 上,窗口 2014-01-01 到 2023-12-31。做 四个 计算 并 报告 结果。

(i) 实现 本课 经典 bug 版 向量化 回测(signal = close / close.shift(5) - 1,不 加 shift),算 年化 夏普,报告 数值(应在 ~1.5-2.0 区间)。

(ii) 给 signal 加上 .shift(1) PIT 修复,重跑 向量化 回测,算 年化 夏普,报告 数值(应在 ~0.3-0.5 区间)。

(iii) 把 同一条 动量 策略 的 事件驱动 引擎 五大 层 画成 五个 Python 类方法的 顺序:data layer -> signal layer -> portfolio layer -> fill simulator -> PnL + accounting layer;为 每一个 方法 写出 一个 它 接受 的 输入 和 一个 它 产出 的 输出。

(iv) 算 (i) 与 (ii) 的 夏普 落差,套用 诊断 规则:落差 >0.5 意味着 向量化 实现 存在 前视 bug;说明 你 的 落差 是否 越过 阈值,以及 诊断 给出 什么 结论。

把 四个 答案 报告 成 一张 表。

提示
从 akshare / Wind / 通联 下载 510300 日 收盘 价。bug 版:signal = close / close.shift(5) - 1; pnl = (signal * close.pct_change())。修复版 在 signal 上 加 .shift(1)
提示
输入-输出:data 接 (symbol, as_of=t) 返 行;signal 接 data 返 权重;portfolio 接 权重 返 orders;fill 接 orders+bar 返 fills;accounting 接 fills+bar 返 PnL。

通向 L2 的 桥

事件驱动 引擎 是 信誉级 回测 的 必要 条件,但 不是 充分 条件。即便 架构 正确,五大 偏差 家族——股票池 的 幸存者 偏差、收盘价 成交 而非 下根 开盘、零 交易成本 假设、空头 端 零 融券 成本、基本面 时间戳 错位——仍 能 把 报告 的 夏普 通胀 1.2x-3x。L2 按 典型 夏普 通胀 量级 从 高 到 低 教 这 十 项 真实性 清单,并 展示 本课 诚实 基线 ~0.4 如何 在 真实性 清单 应用 之后 再 跌 到 ~0.18-0.22。前 视 信号 在 deployment 后 还 会 通过 Alpha 衰减 衰 减——L4 的 信号 衰 减 偏 差 分 解 将 量 化。本课 的 夏普比率 / 最大回撤 / 动量 信号 / Alpha 衰减 / 交易成本 等 概 念 将 在 L2-L4 中 反 复 出 现。

Components covered

  • Inline-code listing of the FIVE canonical backtest-engine layers (data layer -> signal layer -> portfolio layer -> fill simulator -> PnL + accounting layer) with the architectural rule.
  • Inline-code listing of the TWO canonical engine paradigms (vectorized backtest, event-driven backtest) with the speed comparison and the decision rule.
  • Fenced ```python code block — the buggy vectorized backtest with the close.shift(5) lookback and the # BUG: signal uses close[t] comment.
  • Fenced ```python code block — the fixed vectorized backtest with .shift(1) PIT correction and the # FIX: signal shifted one bar comment.
  • Fenced ```python code block — the BacktestEngine class skeleton tagged # 5-layer event-driven backtest skeleton.
  • Inline-code listing of the THREE evaluation modes (backtest, paper trading, shadow trading) with the no-skip rule.
  • Inline-code listing of the SIX-section minimum backtest readout report (PnL curve ... reproducibility metadata).
  • Exercise — the four sub-task computations (i)/(ii)/(iii)/(iv) on 510300 沪深300 ETF 2014-01-01 to 2023-12-31.
  • Two progressive Hints kept short.
  • FormulaExplorer — the annualized 夏普比率 formula.