某周二,上海某 量化 私募 的策略评审会上。一位 研究员 把 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)
策略 在 时刻 t 用 close[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;说明 你 的 落差 是否 越过 阈值,以及 诊断 给出 什么 结论。
把 四个 答案 报告 成 一张 表。
提示
signal = close / close.shift(5) - 1; pnl = (signal * close.pct_change())。修复版 在 signal 上 加 .shift(1)。提示
(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 barcomment. - Fenced ```python code block — the
BacktestEngineclass 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.