土法炼钢兴趣小组的算法知识备份

【量化交易】回测引擎设计:事件驱动与向量化

文章导航

分类入口
quant
标签入口
#backtest#event-driven#vectorbt#simulation#replay

目录

回测引擎是量化团队里最容易被低估的工程系统。基金经理把它当成「跑一根曲线」的工具,研究员把它当成「调一组参数」的脚本,CTO 把它当成「实盘前过一遍」的检查关卡——三种期望叠加到同一份代码上,结果就是大多数团队的回测引擎在三件事上同时不及格:跑得不够快所以参数搜索靠拍脑袋,跑得不够准所以策略上线后业绩腰斩,跑得不够稳所以同一份代码同一份数据两次结果对不上。

这三个问题不是「再优化优化就好」的细节问题,而是架构选择的问题。向量化(vectorized)回测和事件驱动(event-driven)回测是两条根本不同的路:前者把整段历史当一张矩阵,用 numpy 广播一次算出所有信号与净值;后者把历史拆成时间戳严格单调的事件流,让策略、撮合、组合在事件循环里逐条交互。两条路在性能、保真度、调试体验、与实盘代码复用度上的权衡完全相反,没有哪条路「更好」,只有「在哪一阶段用哪一条」。

本文不写「如何用 backtrader 写第一个 SMA 策略」这种入门内容,而是把回测引擎当作一套工程系统讲清楚:从工程目标出发,先讲清向量化的能力边界,再展开事件驱动的组件分解(事件循环、订单状态机、撮合模型、组合记账),然后讨论保真度、成本嵌入、多资产多频率、并行加速、回放对账。每一节给出可以直接抄走的 Python 实现骨架,关键决策附带证据与权衡。

这套讨论的隐含读者是已经写过几个回测脚本、被实盘业绩与回测预期之间的 gap 困扰过的工程师与研究员。如果你的团队曾经发生过这些场景之一——回测里夏普 2 的策略上线后跑出 0.5、同一份代码两次回测结果对不上、参数扫描跑了一周才跑完一组、回测—实盘对账出现单日 100 bp 偏离却找不到原因——那本文给出的架构选择与对账流程是直接可用的工程蓝图。如果你的研究员还停留在「pandas 写两行代码看一眼夏普就上线」的阶段,那本文可以作为下一步规范化的参照,但需要先补齐数据管道、版本管理、point-in-time 这些前置工程。

本系列前面的篇章已经讨论过相关基础:第 05 篇《数据管道》给出了点对点数据落盘与版本管理的工程方案;第 06 篇《幸存者偏差》讨论了 universe 时间一致性问题;第 18 篇《执行成本》推导了 Almgren–Chriss 与平方根法则;接下来第 20 篇会专门讨论回测中的常见陷阱(未来函数、过拟合、p-hacking、deflated Sharpe)。本文位于这条工程链的中间位置——它假设上游的数据与因子已经做对了,重点解决「怎么把策略代码、市场数据、撮合规则、组合记账粘合成一套可信回测系统」这个问题。读完之后建议立刻继续读第 20 篇,把「回测能正确跑」与「回测结果能正确解读」两件事一起做完。

风险提示与适用范围:本文不构成任何投资建议。所有代码、数据与示例数值均用于说明算法与工程结构,未经过完整生产审计,直接套用产生的资金损失由使用者自负。本文给出的最小事件驱动回测器是教学骨架,撮合规则、成本模型、风控逻辑均经过简化,与任何交易所真实撮合规则(如上交所集合竞价、深交所 Y10 撮合、CME Globex matching、Binance Futures liquidation engine)有显著差异;商用部署请使用 QuantConnect Lean、Zipline-Reloaded、backtrader、vn.py、wonton 等经过社区或机构验证的引擎,并自行进行严格的生产审计与监管合规审查。


一、回测的工程目标

写回测引擎之前必须先回答一个被多数团队跳过的问题:这套系统到底要满足什么工程目标?把目标说清楚,后面所有的架构选择都有了评判标准;目标说不清楚,写出来的就是一个「能跑、但不知道为什么这么写」的脚本集合。回测引擎需要同时满足下面四件事,缺一不可。

再现性(reproducibility)

同一份代码、同一份数据、同样的随机种子,今天跑和明年跑必须得到逐 tick 一致的结果。听起来像废话,但工程实践中能做到的团队不多。常见的破坏再现性的因素至少有六个:

第一,时间戳精度漂移。历史数据从供应商那里下载下来时是毫秒精度,落到 pandas 里被自动转换成 datetime64[ns] 损失了时区信息,再被 resample('1min') 时使用了不同的对齐方式(label='left' vs label='right'),同一段数据在两次回测里就变成了两段不同的时间序列。

第二,浮点累加顺序。组合净值是几万笔成交逐笔累加得来的,浮点加法不满足结合律。当回测从单线程改成多线程并行时,事件处理顺序变化导致最终净值在小数点后第五位漂移;如果策略里有「净值跌破阈值则平仓」这种触发器,第五位的漂移会引发完全不同的成交序列,最终曲线发散。

第三,字典遍历顺序。Python 3.7 之前 dict 顺序不保证;3.7 之后 dict 保序,但 set 仍然不保序。任何依赖 set 遍历顺序产生订单提交顺序的代码,在不同 Python 版本之间会有微妙差异。

第四,第三方库版本漂移。pandas 1.x → 2.x 的 groupby 默认行为变了;numpy 的 random.default_rng() 从 1.17 版才稳定。一份没锁版本的回测代码,明年跑出来的曲线大概率和今年不一样。

第五,数据更新(point-in-time 违反)。Wind / Bloomberg / Refinitiv 的财报数据会被回填——一只股票 2019 年的净利润在 2024 年被审计后修正了。如果你的因子计算从 2024 年的快照里取 2019 年的财报,你今天跑出来的回测和当年实时跑会完全不同。这一项不光破坏再现性,还直接构成数据泄漏(lookahead bias)。

第六,外部时钟依赖。任何 datetime.now()time.time()uuid.uuid4() 出现在策略代码里,再现性立刻破产。回测引擎必须提供一个 SimulatedClock,所有时间相关的调用都要走它。

把这六条做对的工程方案是:数据落地为 parquet 并用内容哈希做指纹(content-addressed dataset);锁定 Python、numpy、pandas、numba 的精确版本(用 conda-lock 或 pip-tools);事件队列同一时间戳内强制按 (event_type, seq) 二级排序消除字典顺序依赖;任何随机性必须经过 numpy.random.Generator(seed) 注入;所有 point-in-time 数据按发布日(announcement date)而非记录日(report date)索引。

可比性(comparability)

第二条目标是同一系列实验之间结果可比。如果策略 A 跑出来夏普 1.5、策略 B 跑出来夏普 1.8,那 0.3 的差距必须确实来自策略本身,而不是来自「策略 A 用的是 2018-01-01 到 2023-12-31,策略 B 用的是 2019-06-01 到 2024-05-31」这种时间窗口差异,也不能来自「A 用了 5 bp 滑点,B 用了 3 bp 滑点」这种成本假设差异。

可比性要求回测引擎把所有「评测设置」(universe、时间窗、再平衡频率、成本模型、初始资金、再投资规则)从策略代码里抽出来,放到一个独立的 BacktestConfig,每次跑回测时落盘存档;任何对比报告必须把双方的 config 一同列出来。这套约束在论文复现、研究员之间互相批阅、实盘前性能基线对齐的场景里都是底线。

可调参(parametrizability)

第三条目标是支持大规模参数扫描。一个均线策略涉及短均线周期、长均线周期、止损比例、再平衡周期至少四个参数;如果每个参数取 10 个值,全网格就是 10000 组;加上 5 折时间序列交叉验证,就是 50000 次回测。如果每次回测要 30 秒,全网格扫一遍要 17 天,研究员根本没法工作。

这一条直接决定向量化在研究阶段的不可替代性:vectorbt 用一行 vbt.Portfolio.from_signals(close, entries, exits, sl_stop=sl_grid) 可以把成千上万组参数广播到同一个 numpy 张量上一次性算完,10 年日频数据 5000 标的级别的全市场扫描在普通笔记本上是分钟级。事件驱动引擎做不到这一点,必须靠横向并行(joblib、ray、multiprocessing)把 N 组参数分到 N 个进程,启动开销与 GIL 限制都使其相对慢一个量级以上。

贴近实盘(fidelity)

第四条目标是回测结果与实盘业绩之间的差距足够小。这一条与前三条经常冲突:贴近实盘意味着精细撮合、L2 重放、滑点冲击、订单延迟、撤单费、保证金调用——所有这些细节都让回测变慢、变难调试,更难做参数扫描。

工程上没有银弹,只有双引擎策略:研究阶段用向量化跑参数扫描得到候选参数集;候选集再用事件驱动引擎在更高保真度下精跑一遍,对滑点、路径依赖、实盘代码复用做最终验证。两套引擎跑同一组参数得到的净值曲线 diff 应该在合理误差带内(年化收益差 < 50 bp),否则其中一套有 bug。

把这四条目标钉在墙上,下面每一节的设计选择都有了判据。

工程目标的优先级

四条目标在不同阶段权重不同。研究阶段,可调参 > 再现性 > 可比性 > 贴近实盘——研究员要的是吞吐与迭代速度,撮合保真度可以先放一放;策略候选筛选阶段,可比性 > 再现性 > 贴近实盘 > 可调参——参数已经选定,要的是横向比较口径一致;上线前验证阶段,贴近实盘 > 再现性 > 可比性 > 可调参——这一步要直接对应到下周的实盘业绩,参数扫描已经结束。同一份回测引擎在三个阶段切换时,配置(撮合模型、滑点假设、并行度)必须显式切换并落盘留档,避免「研究员用乐观假设跑出来的曲线被当成上线决策依据」。

一个具体的反例

把上面四条说抽象了不容易记住,举个实际的反例:某团队的均线策略在历史回测里夏普 2.1、最大回撤 4%,按这个数据上线 1 亿规模资金,实盘三个月夏普 0.6、最大回撤 11%,与回测严重背离。逐条复盘下来发现了四个独立的 bug:再现性方面,回测代码用了 np.random.shuffle 生成参数初始化但没固定 seed,每次跑结果略有不同,研究员选了「最好看」的那次报告;可比性方面,与基准策略对比时用了不同的 universe(剔除了 ST 股的回测 vs 包含 ST 的基准);贴近实盘方面,撮合假设是开盘价瞬时全成交,但策略持仓集中在小盘股,开盘前 5 分钟根本买不满,实际成交均价比理论开盘价高 30 bp;可调参方面,参数在 5 折交叉验证里挑了最优组合但没做 deflated Sharpe 修正,过拟合无法察觉。任意一个 bug 单独看都「不致命」,叠加起来就是回测—实盘 1.5 倍的业绩缩水。这种事故在工程不到位的团队里每年都会发生几次。

把这一段反例与上面那四条目标对照看,可以提炼出一条经验法则:回测引擎的工程质量是策略上线规模的乘数。一份没有再现性的引擎给出的夏普 2.1 是噪声,不能作为放规模的依据;一份有对账闭环的引擎给出的夏普 1.3 反而是可信的下限,可以放心放规模。两个数字相比,前者看起来漂亮但工程意义为零,后者看起来普通但工程意义远高于前者。研究员应当主动选择「让引擎更可信」而不是「让数字更漂亮」——这两件事多数时候是冲突的。


二、向量化回测

向量化回测的核心想法就一句话:把整段历史当成一张矩阵,把策略表达成矩阵运算,让 numpy / numba / GPU 一次性算完所有时间点的所有标的。这种思路在研究阶段威力极大,在实盘阶段几乎无法直接用,原因来自其本质,而不是某个库实现的局限。

最小骨架

最朴素的向量化回测只需要四步:因子值矩阵 → 信号矩阵 → 持仓矩阵 → 净值矩阵。下面是一段不依赖任何回测库、纯 pandas 的双均线骨架,作为后面 vectorbt 实现的对照。

import numpy as np
import pandas as pd

def vector_backtest_sma(close: pd.DataFrame, fast: int, slow: int,
                        cost_bp: float = 5.0) -> pd.DataFrame:
    """
    输入: close 每列一只标的, 每行一个交易日
    输出: 每列净值曲线
    成本: 每次换仓按 cost_bp / 10000 的双向手续费扣除
    """
    fast_ma = close.rolling(fast).mean()
    slow_ma = close.rolling(slow).mean()

    raw_signal = (fast_ma > slow_ma).astype(int)
    # 严禁在生成信号的同一根 K 线立即成交; shift(1) 是底线
    position = raw_signal.shift(1).fillna(0)

    ret = close.pct_change().fillna(0)
    strat_ret = position * ret

    # 换仓产生成本
    turnover = position.diff().abs().fillna(0)
    cost = turnover * (cost_bp / 10000.0)
    strat_ret_after_cost = strat_ret - cost

    equity = (1 + strat_ret_after_cost).cumprod()
    return equity

这二十几行代码已经踩了向量化回测最常见的两个坑:第一,raw_signal.shift(1),如果忘掉这个 shift,策略就用了今天收盘价生成信号、今天收盘价立即成交,构成典型的未来函数;第二,turnover = position.diff().abs(),这个公式只对仓位 ∈ {0, 1} 的策略成立,对仓位 ∈ {−1, 0, 1} 的多空策略要写成 position.diff().abs() 没错,但对仓位 ∈ ℝ 的连续仓位策略,turnover 应该等于「绝对值变化」而不是「变化的绝对值」。两者在多空切换时差一倍。这种细微差异在 DataFrame 屏幕上看起来都对,跑出来的曲线也都「像样」,只有比对事件驱动引擎逐笔成交时才会暴露。

用 vectorbt 重写

vectorbt 是当下 Python 生态里最成熟的向量化回测库,核心实现走 numba JIT,能把上面这段 SMA 策略加上止损、止盈、参数扫描、组合分析全部以 numpy 张量形式并行跑完。下面是同一策略的 vectorbt 版本:

import numpy as np
import pandas as pd
import vectorbt as vbt

# 假设 close 是 (T, N) 的 DataFrame, T 个交易日, N 只标的
# 参数网格
fast_grid = np.arange(5, 31, 5)      # [5, 10, 15, 20, 25, 30]
slow_grid = np.arange(40, 121, 20)   # [40, 60, 80, 100, 120]

# 一次性计算所有 (fast, slow) 组合的均线
fast_ma = vbt.MA.run(close, fast_grid, short_name='fast')
slow_ma = vbt.MA.run(close, slow_grid, short_name='slow')

entries = fast_ma.ma_crossed_above(slow_ma)
exits = fast_ma.ma_crossed_below(slow_ma)

pf = vbt.Portfolio.from_signals(
    close,
    entries=entries,
    exits=exits,
    init_cash=1_000_000,
    fees=0.0005,        # 5 bp 双向
    slippage=0.0002,    # 2 bp 滑点
    freq='1D',
)

# pf 是一个多维 Portfolio, 包含所有参数组合
sharpe = pf.sharpe_ratio()           # Series, 索引为 (fast, slow, symbol)
top10 = sharpe.sort_values(ascending=False).head(10)

这段代码在 5000 标的、10 年日频、30 组参数下用 numba 后端在 16 核 CPU 上跑完大概是分钟级。同样规模的事件驱动回测,即便用 Cython 后端的 backtrader,也要小时级。

向量化的能力边界

vectorbt 的强大不能掩盖向量化思路本身的几个结构性限制。这些限制不是某个库实现的 bug,是数学上的:

第一,路径依赖很难表达。「连续亏损三天后减半仓位、第四天才能加回来」这种逻辑,本质上是状态机,不是矩阵运算。numba 写循环可以救一半,但写起来已经离向量化的初衷很远,调试也比事件驱动困难。

第二,多资产之间的交互很难表达。「当 SPY 跌破 200 日均线时减仓所有持仓」是简单的 broadcast;但「当组合保证金率低于 30% 时按市值倒序平仓直到保证金率回到 50%」这种顺序依赖的逻辑,矩阵广播表达不出来。

第三,撮合保真度受限。vectorbt 的 from_signals 默认在信号产生的下一根 K 线开盘价立即全部成交。这个假设对日频策略勉强够用,对分钟频已经偏乐观,对秒频和高频则完全失真——真实订单要排队、要承担队列后退、要面对部分成交。

第四,与实盘代码无法复用。实盘必须是事件驱动的——市场数据按时间到达、订单按时间提交、成交按时间回报。任何向量化策略要上线都得重写一遍,两套代码意味着两份 bug。多数团队最后的工程方案是研究和实盘各写一套,研究阶段每周与实盘对账一次。

pandas vs polars vs vectorbt

向量化生态里这三个工具的定位经常被混淆,澄清一下:

工程实践中三者常见组合是:polars 处理原始 tick / bar 数据 → pandas DataFrame 喂给 vectorbt → vectorbt 输出参数扫描结果 → 候选参数交给事件驱动引擎精跑。

向量化里隐藏的未来函数

向量化回测里最危险的一类 bug 是「肉眼看着正确、实际上偷看了未来」。下面列几个常见模式,每一个都在实战中真实发生过:

第一种,resample 默认对齐方向df.resample('1D').last() 取的是当日最后一个值,时间戳标签默认是 label='left'(即时间戳记为当日 00:00),而值是当日尾值。如果你拿这个 DataFrame 直接 .shift(0) 当作信号,就用了「当日尾盘值标记在当日开盘时间」的数据,构成 24 小时未来函数。修复办法是 resample 完立刻 .shift(1),或者改用 label='right' 让时间戳指向区间右端。

第二种,rolling 包含当前点df.rolling(20).mean() 默认包含当前点,所以「20 日均线」实际上是「过去 19 日 + 今天」的均线。如果策略用今天的 close 算今天的均线再用今天的 close 与之比较产生信号,技术上没有未来函数,但要立刻 .shift(1) 防止下一行代码用今天的信号在今天成交。

第三种,group_by 后的全期统计df.groupby('symbol')['close'].transform(lambda x: (x - x.mean()) / x.std()) 把整段历史的均值方差算出来用做 z-score 标准化,等于用未来信息做 normalization,回测结果几乎肯定虚高。正确做法是 rolling 窗口标准化或扩展窗口标准化(expanding mean/std)。

类似的陷阱还出现在因子标准化的 winsorize / clip 操作上:df['factor'].clip(df['factor'].quantile(0.01), df['factor'].quantile(0.99)) 用了全样本 1% 与 99% 分位作截断点,回测里把样本期最后一天的 outlier 也用样本期开头的截断点处理掉,这是隐蔽的 lookahead。正确做法是按时间滚动窗口估计分位数,或在每个截面(每天)单独估计。

第四种,财报数据 announcement vs report date 错位。Wind / Bloomberg 给的财报数据默认按报告期(report date)索引,比如 2023Q1 的报表索引在 2023-03-31;但实际公告日(announcement date)可能在 2023-04-25。如果回测里 2023-04-01 就开始使用 2023Q1 财报数据,等于用了未来 25 天的信息。修复办法是按 announcement date 索引,未到公告日的财报全部填 NaN。

向量化引擎不会替你检查这四类 bug,事件驱动引擎物理上避免它们。这是事件驱动在「上线前验证」环节几乎不可替代的根本原因。


三、事件驱动回测

事件驱动回测的核心想法和向量化完全相反:模拟一个真实交易系统的运行,时间从过去某一刻向前流逝,市场数据按时间戳到达,策略按事件响应,订单经过撮合形成成交,组合按成交记账。每一次时间推进都只看「当前及之前」的信息,物理上不可能产生未来函数。

事件驱动回测的工程结构如下图所示:

事件驱动回测引擎组件与数据流

下面把每个组件的职责、接口、关键约束讲清楚,并给出可运行的最小实现。

事件类型与优先队列

事件驱动引擎的「事件」一般分四类,优先级从高到低(同一时间戳内按优先级排序):

  1. MarketEvent:市场数据事件,每根 K 线、每笔 tick、每次 L2 更新都是一个 MarketEvent。
  2. SignalEvent:策略产生的方向信号,包含「买/卖/平」与强度。
  3. OrderEvent:经过风控检查后准备发到交易所的订单。
  4. FillEvent:撮合引擎产生的成交回报。

为什么要分四类而不是三类(直接 Market → Order → Fill)?因为 Signal 与 Order 之间需要插入风控、仓位管理、订单切片等中间层,这些层次有时是同步的、有时是异步的(如 TWAP 把一个大单切成 30 个小单分时段发出)。把 Signal 和 Order 解耦让架构能扩展。

事件队列必须是按 (timestamp, priority, seq) 排序的最小堆,seq 是单调递增的全局序号,用来打破同优先级事件的歧义并保证再现性:

import heapq
from dataclasses import dataclass, field
from typing import Any
from enum import IntEnum

class EventType(IntEnum):
    MARKET = 0
    SIGNAL = 1
    ORDER = 2
    FILL = 3

@dataclass(order=True)
class Event:
    ts: int                       # 纳秒级时间戳
    priority: int = field(default=0)
    seq: int = field(default=0)
    type: EventType = field(default=EventType.MARKET, compare=False)
    payload: Any = field(default=None, compare=False)

class EventQueue:
    def __init__(self):
        self._heap: list[Event] = []
        self._seq = 0

    def push(self, ts: int, etype: EventType, payload: Any):
        self._seq += 1
        ev = Event(ts=ts, priority=int(etype), seq=self._seq, type=etype, payload=payload)
        heapq.heappush(self._heap, ev)

    def pop(self) -> Event | None:
        if not self._heap:
            return None
        return heapq.heappop(self._heap)

    def __len__(self):
        return len(self._heap)

任何组件都不能直接调用其他组件的方法(除查询接口外),所有副作用都通过 push 事件完成。这样的好处是把整个系统变成一台确定性状态机:给定相同的事件流,输出永远一致;同时所有事件可以全程落盘,事后任意回放、任意单步调试。

订单状态机

订单不是「发出去就成交了」的简单对象,它有完整的生命周期。最小可用的订单状态机至少包含下面这些状态与转移:

from enum import Enum

class OrderStatus(Enum):
    NEW = 'NEW'                   # 创建, 未发送
    PENDING = 'PENDING'           # 已发送到交易所, 未确认
    OPEN = 'OPEN'                 # 已确认, 在簿
    PARTIAL = 'PARTIAL'           # 部分成交
    FILLED = 'FILLED'             # 全部成交
    CANCELLED = 'CANCELLED'       # 已撤单
    REJECTED = 'REJECTED'         # 被拒
    EXPIRED = 'EXPIRED'           # 超时

class OrderType(Enum):
    MARKET = 'MARKET'
    LIMIT = 'LIMIT'
    IOC = 'IOC'                   # immediate-or-cancel
    FOK = 'FOK'                   # fill-or-kill

class Side(Enum):
    BUY = 'BUY'
    SELL = 'SELL'

@dataclass
class Order:
    order_id: int
    symbol: str
    side: Side
    type: OrderType
    qty: float                    # 原始下单量
    filled_qty: float = 0.0       # 已成交量
    price: float | None = None    # 限价
    status: OrderStatus = OrderStatus.NEW
    create_ts: int = 0
    update_ts: int = 0

    @property
    def remaining(self) -> float:
        return self.qty - self.filled_qty

    def on_fill(self, fill_qty: float, ts: int):
        self.filled_qty += fill_qty
        self.update_ts = ts
        if abs(self.filled_qty - self.qty) < 1e-9:
            self.status = OrderStatus.FILLED
        else:
            self.status = OrderStatus.PARTIAL

这套状态机看起来朴素,但它是回测与实盘代码复用的关键边界。实盘里的 IB、CTP、Binance、OKX、IBKR 各家 API 返回的状态码不同,但映射到上面这套统一状态机之后,策略代码完全不用关心交易所差异。

最小事件驱动回测器

把上面所有组件拼起来,下面是一个可运行的最小事件驱动回测器。它支持市价单、限价单、部分成交、双向手续费、mark-to-market 估值,足以验证一个 SMA 策略并与 vectorbt 的结果做对账。代码经过删减,仅保留主线逻辑:

# event_engine.py
import heapq
import math
from dataclasses import dataclass, field
from collections import defaultdict
from typing import Callable

# ---------- 1. 简化的 OrderBook (顶档撮合) ----------
@dataclass
class TopOfBook:
    bid: float
    ask: float
    bid_size: float
    ask_size: float
    ts: int

class OrderBook:
    """简化版本: 只跟踪买一卖一. 真实场景需 L2 全簿."""
    def __init__(self):
        self.tob: dict[str, TopOfBook] = {}

    def update(self, symbol: str, bid: float, ask: float,
               bid_size: float, ask_size: float, ts: int):
        self.tob[symbol] = TopOfBook(bid, ask, bid_size, ask_size, ts)

    def mid(self, symbol: str) -> float:
        t = self.tob[symbol]
        return (t.bid + t.ask) / 2.0


# ---------- 2. 撮合引擎 ----------
class MatchingEngine:
    def __init__(self, book: OrderBook, queue: 'EventQueue',
                 cost_bp: float = 2.0, spread_floor: float = 1e-4):
        self.book = book
        self.queue = queue
        self.cost_bp = cost_bp
        self.spread_floor = spread_floor
        self._open_orders: dict[int, Order] = {}

    def submit(self, order: Order, ts: int):
        order.create_ts = ts
        order.status = OrderStatus.OPEN
        self._open_orders[order.order_id] = order
        self._try_match(order, ts)

    def on_market(self, symbol: str, ts: int):
        # 市场数据更新后, 重新尝试撮合该标的所有未成交限价单
        for oid, o in list(self._open_orders.items()):
            if o.symbol == symbol and o.status in (OrderStatus.OPEN, OrderStatus.PARTIAL):
                self._try_match(o, ts)

    def _try_match(self, o: Order, ts: int):
        if o.symbol not in self.book.tob:
            return
        tob = self.book.tob[o.symbol]

        if o.type == OrderType.MARKET:
            # 市价单: 吃对手价, 受 ask_size / bid_size 限制
            if o.side == Side.BUY:
                fillable = min(o.remaining, tob.ask_size)
                fill_price = tob.ask
            else:
                fillable = min(o.remaining, tob.bid_size)
                fill_price = tob.bid
            if fillable > 0:
                self._fill(o, fillable, fill_price, ts)

        elif o.type == OrderType.LIMIT:
            # 限价单: 价格穿越对手价才成交; 简化为穿越即全成
            crossed = (o.side == Side.BUY and o.price >= tob.ask) or \
                      (o.side == Side.SELL and o.price <= tob.bid)
            if crossed:
                fill_price = tob.ask if o.side == Side.BUY else tob.bid
                fillable = min(o.remaining,
                               tob.ask_size if o.side == Side.BUY else tob.bid_size)
                if fillable > 0:
                    self._fill(o, fillable, fill_price, ts)

        elif o.type == OrderType.IOC:
            self._try_match_as_market(o, ts)
            if o.status != OrderStatus.FILLED:
                o.status = OrderStatus.CANCELLED
                self._open_orders.pop(o.order_id, None)

    def _try_match_as_market(self, o: Order, ts: int):
        tob = self.book.tob[o.symbol]
        if o.side == Side.BUY:
            fillable = min(o.remaining, tob.ask_size)
            self._fill(o, fillable, tob.ask, ts)
        else:
            fillable = min(o.remaining, tob.bid_size)
            self._fill(o, fillable, tob.bid, ts)

    def _fill(self, o: Order, qty: float, price: float, ts: int):
        if qty <= 0:
            return
        o.on_fill(qty, ts)
        commission = qty * price * (self.cost_bp / 10000.0)
        fill_payload = {
            'order_id': o.order_id,
            'symbol': o.symbol,
            'side': o.side,
            'qty': qty,
            'price': price,
            'commission': commission,
            'ts': ts,
        }
        self.queue.push(ts, EventType.FILL, fill_payload)
        if o.status == OrderStatus.FILLED:
            self._open_orders.pop(o.order_id, None)


# ---------- 3. 组合 ----------
class Portfolio:
    def __init__(self, init_cash: float = 1_000_000.0):
        self.cash = init_cash
        self.init_cash = init_cash
        self.positions: dict[str, float] = defaultdict(float)
        self.avg_price: dict[str, float] = defaultdict(float)
        self.realized_pnl = 0.0
        self.equity_curve: list[tuple[int, float]] = []

    def on_fill(self, fill: dict):
        sym = fill['symbol']
        qty = fill['qty'] if fill['side'] == Side.BUY else -fill['qty']
        price = fill['price']
        commission = fill['commission']

        prev_qty = self.positions[sym]
        new_qty = prev_qty + qty

        # 简化的平均价 / 已实现盈亏更新
        if prev_qty == 0 or (prev_qty > 0) == (qty > 0):
            # 同向加仓
            total_cost = self.avg_price[sym] * abs(prev_qty) + price * abs(qty)
            self.avg_price[sym] = total_cost / max(abs(new_qty), 1e-9)
        else:
            # 反向减仓: 触发已实现盈亏
            close_qty = min(abs(prev_qty), abs(qty))
            sign = 1 if prev_qty > 0 else -1
            self.realized_pnl += sign * (price - self.avg_price[sym]) * close_qty
            if abs(qty) > abs(prev_qty):
                # 反手, 新均价
                self.avg_price[sym] = price

        self.positions[sym] = new_qty
        self.cash -= qty * price + commission

    def mark_to_market(self, ts: int, book: OrderBook):
        unrealized = 0.0
        for sym, qty in self.positions.items():
            if qty == 0 or sym not in book.tob:
                continue
            mid = book.mid(sym)
            unrealized += qty * (mid - self.avg_price[sym])
        equity = self.cash + sum(qty * book.mid(sym) for sym, qty in self.positions.items()
                                  if qty != 0 and sym in book.tob)
        self.equity_curve.append((ts, equity))


# ---------- 4. 策略基类 ----------
class Strategy:
    def __init__(self, queue: 'EventQueue'):
        self.queue = queue
        self._next_order_id = 1

    def on_market(self, market_event: dict, book: OrderBook, portfolio: Portfolio):
        raise NotImplementedError

    def on_fill(self, fill_event: dict, portfolio: Portfolio):
        pass

    def submit_order(self, ts: int, symbol: str, side: Side, qty: float,
                     order_type: OrderType = OrderType.MARKET, price: float | None = None):
        order = Order(
            order_id=self._next_order_id,
            symbol=symbol, side=side, type=order_type, qty=qty, price=price
        )
        self._next_order_id += 1
        self.queue.push(ts, EventType.ORDER, order)
        return order


# ---------- 5. 主回放循环 ----------
class BacktestEngine:
    def __init__(self, strategy_cls: Callable[..., Strategy],
                 init_cash: float = 1_000_000.0, cost_bp: float = 2.0):
        self.queue = EventQueue()
        self.book = OrderBook()
        self.matcher = MatchingEngine(self.book, self.queue, cost_bp=cost_bp)
        self.portfolio = Portfolio(init_cash)
        self.strategy = strategy_cls(self.queue)

    def feed(self, market_events: list[dict]):
        for ev in market_events:
            self.queue.push(ev['ts'], EventType.MARKET, ev)

    def run(self):
        while len(self.queue) > 0:
            ev = self.queue.pop()
            if ev.type == EventType.MARKET:
                m = ev.payload
                self.book.update(m['symbol'], m['bid'], m['ask'],
                                 m['bid_size'], m['ask_size'], ev.ts)
                self.matcher.on_market(m['symbol'], ev.ts)
                self.strategy.on_market(m, self.book, self.portfolio)
                self.portfolio.mark_to_market(ev.ts, self.book)
            elif ev.type == EventType.ORDER:
                self.matcher.submit(ev.payload, ev.ts)
            elif ev.type == EventType.FILL:
                self.portfolio.on_fill(ev.payload)
                self.strategy.on_fill(ev.payload, self.portfolio)
        return self.portfolio.equity_curve

下面是用这个引擎跑一个均线交叉策略的完整示例:

# strategy_sma.py
from collections import deque

class SmaCross(Strategy):
    def __init__(self, queue, fast: int = 10, slow: int = 30, qty: float = 100):
        super().__init__(queue)
        self.fast = fast
        self.slow = slow
        self.qty = qty
        self.window: dict[str, deque[float]] = {}
        self.position_dir: dict[str, int] = {}

    def on_market(self, m, book, portfolio):
        sym = m['symbol']
        mid = (m['bid'] + m['ask']) / 2.0
        w = self.window.setdefault(sym, deque(maxlen=self.slow))
        w.append(mid)
        if len(w) < self.slow:
            return
        arr = list(w)
        fast_ma = sum(arr[-self.fast:]) / self.fast
        slow_ma = sum(arr) / self.slow
        cur_dir = self.position_dir.get(sym, 0)

        if fast_ma > slow_ma and cur_dir <= 0:
            if cur_dir < 0:
                self.submit_order(m['ts'], sym, Side.BUY, self.qty)
            self.submit_order(m['ts'], sym, Side.BUY, self.qty)
            self.position_dir[sym] = 1
        elif fast_ma < slow_ma and cur_dir >= 0:
            if cur_dir > 0:
                self.submit_order(m['ts'], sym, Side.SELL, self.qty)
            self.position_dir[sym] = 0

跑通后用同一份数据用 vectorbt 重写:把 mid 价构造为 close DataFrame,参数 fast=10, slow=30,cost_bp=2。两套引擎得到的最终净值理论上不会逐 tick 一致(vectorbt 默认下一根开盘成交、没有部分成交、没有买卖价差),但年化收益差应当落在 0.5% 以内。如果差距超过 1%,就要逐笔对照成交日志找原因——绝大多数时候是滑点 / 价差假设不一致。


四、撮合模型保真度

撮合保真度是事件驱动引擎里花得最贵、坑最多的一块。「同样一个限价单在簿上挂着,到底什么时候、以什么价、成交多少」这个问题在不同保真度下有完全不同的答案,下面按从粗到细排列。

瞬时成交(next-bar / fill-on-close)

最简陋的撮合假设:信号在 bar t 产生,订单在 bar t+1 的开盘价(或收盘价)瞬间全部成交。优点是逻辑简单、跟向量化等价、跑得飞快;缺点是完全忽略买卖价差、没有滑点、没有部分成交。这种保真度只适合日频以上的中长周期策略做粗筛——一旦换到分钟频或秒频,结果会显著偏乐观。vectorbt 默认是这种模式。

价差 + 固定滑点(bid-ask aware)

第二档:知道买卖价差,买入吃 ask、卖出吃 bid,再在此基础上额外扣一段固定滑点(通常按基点或波动率倍数)。比瞬时成交真实一档,可以反映「快进快出策略最大的对手是 spread」这一事实。本文上面那个最小事件驱动引擎就停留在这一档。这种保真度对分钟频策略通常够用,对秒频和高频不够。

需要补充说明的是:spread 在不同时段差异巨大——A 股开盘前 5 分钟与收盘前 30 分钟流动性最差,spread 经常是日内中段的 3-5 倍;美股盘前盘后的 spread 比盘中宽 5-10 倍;加密货币周末与工作日 spread 差异 2 倍以上。如果回测里只用了一个固定 spread 假设,会系统性高估那些恰好集中在流动性差时段交易的策略业绩。改进方法是按时段分层估计 spread 分布,回测时按订单提交时间从分布里取分位数。

队列模型(queue-aware limit fills)

第三档:限价单不是一挂上去就成交,而是要在价位上排队,等前面的量先被吃完才能轮到自己。队列位置(queue position)决定了在 spread 不变的情况下能否吃到 maker rebate、能否避免逆向选择。

队列模型的最简实现是 FIFO:当一个 LIMIT BUY 挂在 best bid,初始队列位置为「该价位当前总挂单量」;之后该价位有成交(被 SELL market 吃单)时,队列位置按成交量减少;该价位有撤单时,需要按某个假设分配(保守做法是不减自己只减别人);如果该价位被吃穿,自己挂单视为成交。

更精细的模型还要考虑:本方有新挂单进来时不动自己;价格穿越后未成交部分是否撤回;不同交易所的优先级规则差异(pro-rata vs price-time)。这些细节决定了高频做市策略的回测结果是否可信。CFM 的 Almgren-Chriss、Gould 等论文里给出的 queue position model 是工业界事实标准,本文不展开。

队列模型还需要解决一个隐蔽的问题:自己的订单会改变市场。当回测里自己挂的限价单代表了该价位 30% 的总挂单量,对手方在真实历史上的成交决策可能就会因为这部分流动性而改变——他们看到更厚的盘口,可能延迟撤单或加仓。回测里没法真正模拟这种二阶反应,工程上的折中是当自己挂单占该价位 10% 以上时给出告警,提醒研究员该策略已经接近其容量上限,回测结果的边际可信度下降。

L2 重放(full book replay)

第四档:拿历史 L2/L3 全簿快照与逐笔委托/成交流(order-by-order tape)逐条重放,自己的订单作为「外部插入」混入真实事件流,让模拟撮合按真实优先级与队列动态运行。这是高频做市、L2 alpha 策略上线前唯一可信的回测方法。

L2 重放的工程门槛高:单只标的一天的 L2 数据 1-10 GB,全市场全年 PB 级;CME MDP 3.0、Nasdaq ITCH、SSE Level-2 each 有不同的协议;时间戳精度从微秒到纳秒不等;自己的订单插入会改变市场(在低流动性场景里这一点不能忽略)。商用 L2 回测系统(Itiviti / Beeks / 自研)基本都是 C++ 写的,Python 在这个保真度上不够用。

L2 重放还有一个被低估的复杂度:数据完整性。历史 L2 数据流里 missing message、out-of-order delivery、cancel-replace 链的断裂、跨交易所路由的二次成交,这些异常在原始流里都是常态而不是例外。任何 L2 回测系统都必须先建一层数据清洗,把 sequence number 不连续的段标记出来、把对应不上 ack 的 cancel 当做未发送处理、对断点附近的 ±1 秒数据加风险标记。这层数据清洗本身的代码量就比简单的 next-bar 撮合大一个数量级,是判断一个团队是否真正具备高频回测能力的硬指标。

工程取舍

保真度不是越高越好——研究阶段每天只想让一万组参数跑完,谁也不会上 L2 重放。下面是工程上常用的对应关系:

策略类型 推荐保真度
日频选股 / 多因子 瞬时成交 + 5-10 bp 滑点
分钟频统计套利 bid-ask + 2-5 bp 额外滑点
秒频做市 / 短周期 queue model + 真实 spread
tick 级高频 L2 全簿重放

队列模型的简化实现

下面给出一段 FIFO 队列模型的最小实现,放在撮合引擎里替换原来的「穿越即成交」逻辑:

@dataclass
class QueuedLimitOrder:
    order: Order
    queue_ahead: float        # 自己之前还有多少量

class QueueAwareMatching:
    def __init__(self, book: 'OrderBook', queue: 'EventQueue'):
        self.book = book
        self.queue = queue
        self._queued: dict[int, QueuedLimitOrder] = {}

    def submit_limit(self, order: Order, ts: int):
        tob = self.book.tob[order.symbol]
        # 假设挂在 best 价位时, queue_ahead = 该价位当前总挂单量
        if order.side == Side.BUY and order.price == tob.bid:
            ahead = tob.bid_size
        elif order.side == Side.SELL and order.price == tob.ask:
            ahead = tob.ask_size
        else:
            ahead = 0.0       # 挂在更深价位另算
        self._queued[order.order_id] = QueuedLimitOrder(order, ahead)

    def on_trade(self, symbol: str, trade_side: Side, trade_qty: float, ts: int):
        # 该价位有人成交了, 自己排队前进
        for oid, q in list(self._queued.items()):
            if q.order.symbol != symbol:
                continue
            opp = (q.order.side == Side.BUY and trade_side == Side.SELL) or \
                  (q.order.side == Side.SELL and trade_side == Side.BUY)
            if not opp:
                continue
            if q.queue_ahead >= trade_qty:
                q.queue_ahead -= trade_qty
            else:
                # 自己也被吃到
                self_fill = min(q.order.remaining, trade_qty - q.queue_ahead)
                q.queue_ahead = 0.0
                self._emit_fill(q.order, self_fill, q.order.price, ts)
                if q.order.status == OrderStatus.FILLED:
                    self._queued.pop(oid)

这段代码把队列位置的本质动态描述清楚了:自己挂上去时排在最后,市场成交一笔就往前挪一个身位,挪到 0 还有量来吃就轮到自己。真实场景里还要处理撤单、价位变化、盘口加深等情况,但骨架就是这样。


五、滑点与成本嵌入

第 18 篇《执行成本与冲击模型》详细推导了 Almgren–Chriss 模型与平方根法则,这里只讲怎么把那些公式嵌入到回测引擎里、以及在嵌入过程中容易踩的坑。

成本模型的三层结构

回测里的「成本」不是一个数字,而是分层叠加的:

第一层:显式成本(commission / fees)。券商佣金、交易所手续费、印花税(A 股卖方千一)、监管费(SEC fee)、清算费。这一层是确定性的,按公式扣即可。

第二层:价差(spread)。买入吃 ask、卖出吃 bid,半价差就是单边即时成本。这一层在事件驱动引擎里通过撮合自然嵌入,不需要额外扣;在向量化引擎里要手工加一段 slippage = spread_bp / 2

第三层:冲击成本(market impact)。订单太大相对于流动性时,自己的成交会推动价格。Almgren–Chriss 把冲击拆成临时(temporary)与永久(permanent)两块,工程实现常用平方根近似 impact_bp = c * sqrt(qty / ADV) * sigma。这一层在事件驱动引擎里要在撮合后单独叠加一段「成交价被推动了多少」;向量化引擎里通常嵌到 turnover 上扣百分比。

保守原则

回测里关于成本的工程纪律只有一条:保守。具体做法:

第一,滑点取观测分布的 75 分位而非中位。实盘里逆向选择会让你恰好在最不利的时刻成交,回测用中位会系统性高估业绩。

第二,冲击系数取上界。Almgren–Chriss 的 c 在不同标的、不同时段差异大,没有充分估计就取偏大值。具体到 A 股,沪深 300 成分股 c 大约 0.3-0.5、中证 500 成分股 0.5-0.8、中证 1000 与小盘 0.8-1.5;如果不能保证策略只交易大盘股,就按 0.8 起步。

第三,不要让回测里出现「占满了卖一档但吃到了卖一价」的成交。真实订单要么排队等成交(要承担 adverse selection),要么 take liquidity(要付 spread + 冲击)。瞬时无成本吃满一档的回测,结果几乎肯定虚高。

第四,借贷费、资金费率、隔夜利息要计入。卖空持仓要付 borrow fee(A 股融券 8-10% 年化、美股小盘 30%+),永续合约要付 funding rate(Binance Perpetual 每 8 小时一次,年化可达 ±50%),杠杆持仓要付保证金利息。这些费用在长期回测里能吃掉相当一部分收益。

把这四条纪律写进 CostModel 的代码里,并在回测报告里单独列出每一层的累计扣除值,让任何看报告的人能看到成本来自哪里。

一个三层成本模型的实现

class CostModel:
    def __init__(self,
                 commission_bp: float = 1.5,    # 佣金, 含规费
                 stamp_duty_bp: float = 10.0,   # A 股卖方印花税
                 spread_bp_p75: dict[str, float] = None,
                 impact_coef: float = 0.5,      # 平方根法则系数
                 sigma_daily: dict[str, float] = None,
                 adv: dict[str, float] = None):
        self.commission_bp = commission_bp
        self.stamp_duty_bp = stamp_duty_bp
        self.spread_bp_p75 = spread_bp_p75 or {}
        self.impact_coef = impact_coef
        self.sigma_daily = sigma_daily or {}
        self.adv = adv or {}

    def commission(self, qty: float, price: float, side: Side, market: str) -> float:
        notional = qty * price
        c = notional * (self.commission_bp / 10000.0)
        if market == 'A_SHARE' and side == Side.SELL:
            c += notional * (self.stamp_duty_bp / 10000.0)
        return c

    def spread_cost(self, qty: float, price: float, symbol: str) -> float:
        bp = self.spread_bp_p75.get(symbol, 5.0)
        return qty * price * (bp / 2.0 / 10000.0)

    def impact_cost(self, qty: float, price: float, symbol: str) -> float:
        if symbol not in self.adv or symbol not in self.sigma_daily:
            return 0.0
        ratio = qty / self.adv[symbol]
        impact_bp = self.impact_coef * math.sqrt(max(ratio, 0)) \
                    * self.sigma_daily[symbol] * 10000.0
        return qty * price * (impact_bp / 10000.0)

    def total(self, qty: float, price: float, side: Side,
              symbol: str, market: str) -> dict[str, float]:
        c = self.commission(qty, price, side, market)
        s = self.spread_cost(qty, price, symbol)
        i = self.impact_cost(qty, price, symbol)
        return {'commission': c, 'spread': s, 'impact': i, 'total': c + s + i}

注意这个模型把三层成本分别返回,回测报告里各项独立累加,研究员可以看到「总扣除 12 万里有 4 万是佣金、3 万是 spread、5 万是冲击」,这种信息密度比一个总数有价值得多。


六、多资产、多频率、多账户支持

研究阶段的回测往往是单标的、单频率、单账户;走到生产阶段三个维度都要扩展。

多资产的对齐问题

多资产回测最容易出错的地方是时间对齐。中美港三地股票、加密货币、商品期货的开盘时间、交易日历、节假日、夏令时全都不一样。把它们硬塞到同一个 pd.DataFrame 里再 ffill() 看起来很方便,但会引入两类 bug:

第一类,用 ffill 制造未来函数:A 股周末不开盘,美股周末开盘(美股盘后 + 加密 24/7),如果 A 股的周一行情被 ffill 到周日,再被加密策略读取,就用了周一的信息生成周日的信号。

第二类,跨时区时间戳错位:A 股 09:30 北京时间和美股 09:30 纽约时间不是同一时刻。所有时间戳必须统一到 UTC + 纳秒,再在策略里按交易所交易时段过滤。

工程上正确做法是事件驱动天然的:每个标的有自己的事件流,按 UTC 时间戳进入同一个全局优先队列,策略只在每个市场的交易时段内响应。pandas 的多列对齐做不到这一点,所以多资产、多市场场景下事件驱动几乎是唯一干净的选择。

多频率混合

「日频股票 + 分钟频期权对冲」是一个常见的多频率组合。两种频率的事件混在同一队列里,按时间戳全局排序就好,关键是策略的 on_market 必须显式分发

def on_market(self, m, book, portfolio):
    if m['symbol'].startswith('OPT_'):
        self.on_option_tick(m, book, portfolio)
    else:
        self.on_stock_bar(m, book, portfolio)

不要试图把两种频率折叠到同一个 K 线,混合频率信息会让因子定义混乱不堪。

多账户

多账户的工程动机有三:策略隔离(不同策略独立资金曲线,方便归因)、风控隔离(一个账户爆仓不连累另一个)、产品隔离(专户产品 vs 公募产品独立报告)。多账户在事件驱动里只需要把 Portfolio 复制 N 份,每个 OrderEvent 标记 account_id,撮合后把 FillEvent 路由到对应账户即可:

class MultiAccountEngine(BacktestEngine):
    def __init__(self, accounts: list[str], ...):
        super().__init__(...)
        self.portfolios = {acc: Portfolio() for acc in accounts}
        # 中央 RiskGate 统一切风控

中央风控(RiskGate)对所有账户做合并仓位检查,避免「策略 A 在账户 1 做多 X、策略 B 在账户 2 做空 X」这种内部对冲消耗手续费的浪费——真正生产里的解法是中央 netting + 内部清算,这套结构在回测里也要预留好接口。

多账户场景下的归因

多账户结构同时是业绩归因的天然载体。当一个产品里跑了 5 个策略,组合净值可能很漂亮,但其中两个策略可能贡献了 -2% 的年化、被另外三个 +5% 的策略掩盖。如果不按账户分开记账,这两个亏损策略可能会一直跑下去。最小可用的归因方法:每个 account_id 对应一个 Portfolio,每天结束时记录每个账户的净值与持仓;月末把每个账户的 Sharpehit_rateavg_holding_periodmax_drawdown 列成对比表,自动标记「过去 3 个月夏普低于 0.3」的账户进入观察名单。这套机制在多策略 fund-of-strategies 结构里是标配。


七、并行与加速

回测的并行需求来自三处:参数扫描、分布式回测、实时增量。每一处用的工具不同。

numba:内层热点加速

最常用的加速方式是 numba @njit 给热点循环加 JIT。事件驱动引擎里热点不在事件循环本身(Python 调度开销在百万级事件下大约几秒),而在策略的特征计算和撮合的内层逻辑。

import numba as nb

@nb.njit(cache=True)
def rolling_zscore(arr: np.ndarray, window: int) -> np.ndarray:
    out = np.full_like(arr, np.nan)
    for i in range(window, len(arr)):
        s = arr[i-window:i]
        out[i] = (arr[i] - s.mean()) / (s.std() + 1e-9)
    return out

numba 的限制:纯 numpy 数据;不能调用 pandas / Python 对象;不能跨线程持久化状态。把策略的特征计算抽离到 njit 函数、把组合记账保留为 Python,是常见的折中。

joblib / multiprocessing:参数扫描横向并行

事件驱动引擎跑参数扫描的标准方案是 joblib Parallel:每个参数组合分到一个进程,进程间不共享状态,最后汇总结果。

from joblib import Parallel, delayed

def run_one(fast, slow):
    eng = BacktestEngine(lambda q: SmaCross(q, fast=fast, slow=slow))
    eng.feed(market_events)
    eq = eng.run()
    return (fast, slow, eq[-1][1])

results = Parallel(n_jobs=-1, backend='loky')(
    delayed(run_one)(f, s)
    for f in fast_grid for s in slow_grid
)

注意点:每个进程要重新加载市场数据,I/O 不可忽略,建议用 mmap 或 Arrow IPC 共享内存;Windows 下 fork 不可用,启动开销更大;CPU 密集场景 n_jobs 设为物理核数(不是逻辑核数)。

Ray:分布式参数扫描

joblib 在单机上够用;千万组参数级别的扫描需要跨机器分布式,Ray 是当前最成熟的 Python 分布式框架。Ray 的 @ray.remote 与 joblib 用法接近,但能跨节点调度、支持容错、内置 Object Store 共享数据。这个层面已经超出本文范围,做大规模因子搜索的团队需要单独搭建 Ray 集群。

GPU 的边界

GPU 加速回测在三种场景下有意义:

第一,深度学习生成信号——这是 GPU 的本职工作,PyTorch / TensorFlow 直接跑。

第二,大规模 Monte Carlo——价格路径模拟、bootstrap、cross-validation 在 GPU 上可以快几十倍。CuPy 是 numpy 的 CUDA 替身,迁移成本最低。

第三,全市场向量化扫描——vectorbt 有实验性的 GPU 后端,但成熟度不高;RAPIDS cuDF 可以替代 pandas 部分操作。

GPU 不适合的场景同样明确:事件驱动循环(控制流多、数据量小、PCIe 传输是瓶颈)、复杂状态机、撮合引擎。如果策略本身写起来就是「每根 bar 做一次决策」的串行逻辑,GPU 帮不上忙。

加速的优先级与陷阱

加速工作的优先级应该按实测瓶颈而不是直觉决定。常见的反模式是研究员一上来就给整个事件循环加 @njit,最后发现热点其实在数据加载阶段(一次回测 80% 时间在 pd.read_parquet)。正确的工作流是先用 cProfilepy-spy 跑一次性能剖析,把前 10 个热点函数列出来,再针对性优化。常见的实测瓶颈分布(仅做参考量级):数据 I/O 30-50%、特征计算 20-40%、策略逻辑 10-20%、撮合 + 组合 5-15%、事件队列调度 1-5%。任何「事件循环本身慢」的判断都要拿剖析数据说话。

另一个陷阱是过早并行。并行带来的最大风险是再现性破坏——多线程下浮点累加顺序、字典遍历顺序、内存分配顺序都可能导致同一份数据两次跑结果不同。生产环境的回测引擎通常先确定性单线程跑通,确认结果稳定后再用 joblib 横向并行做参数扫描;事件循环本身永远单线程,不要试图用线程池并行处理事件,那是把工程灾难往家里搬。


八、回测结果对账与回放

回测结果对账是回测引擎工程化程度的最终检验。它的核心问题是:回测预测的业绩和实盘实际跑出来的业绩为什么不一样?这个 gap 不可能为零,但必须可解释、可归因、可复现。

五个常见的差异源

第一,滑点假设偏低。回测里假设 5 bp 单边滑点,实盘 vwap 拆单后实际 8 bp。把实盘每笔成交的 implementation shortfall 算出来,与回测假设对比,差值就是滑点估计误差。

第二,撮合假设过乐观。回测里限价单挂上去就成交了,实盘里 30% 的限价单到收盘还没成交被撤掉。把回测每笔订单的「下单价 vs 成交价 vs 当时簿状态」与实盘同样字段对比,能定位到撮合保真度的问题。

第三,未来函数渗漏。最难发现的一类。征兆是回测过于「平滑」、夏普高得离谱、回撤小得不合理。诊断方法:把所有特征延迟一根 bar 重跑回测,如果结果腰斩,原代码大概率有未来函数。

第四,Survivorship bias / 数据池漂移。回测用了「今天还活着的标的」的历史数据,实盘只能交易当时活着的标的。第 06 篇《幸存者偏差》详细讨论。

第五,实盘冲击 / 容量限制。回测里假设可以无限量成交,实盘单只小盘股一天就那么多 ADV,策略规模放大后边际成本飞涨。回测必须模拟「同时刻同一标的的最大可执行量」做容量分析。

第六,外部市场环境变化。回测期是低波动牛市,实盘期遇到加息、地缘冲突、行业政策——这一类因素严格说不是 bug,但必须从总体业绩差异里独立扣除,否则会把环境差异错误归因到模型缺陷。常用做法是把实盘期的因子收益(market、size、value、momentum)单独算出来,把组合在这些因子上的暴露乘上去,得到「环境贡献」,再把剩余部分作为「策略 alpha + 实现误差」。

双向对账流程

成熟团队的回测—实盘对账是一个每周例行流程,至少包含:

第一步,实盘事件流落盘。所有市场数据、订单、成交、撤单全部按时间戳与 seq 落盘到与回测引擎相同 schema 的事件文件。

第二步,回测重放实盘事件流。把实盘那一周的市场数据喂给回测引擎,跑同一份策略代码,输出回测的「应该成交什么」。

第三步,逐笔 diff。把回测产生的订单 / 成交与实盘订单 / 成交逐笔对齐,差异分类:

第四步,误差带监控。把每天的回测—实盘 PnL 差值画出来,建立误差带(mean ± 2σ),任何一次出带都是事故级别,必须分析报告。误差带的 σ 不能是固定值,要按滚动窗口(典型 60 个交易日)估计;如果差值出现持续偏移(连续一周 zscore > 1),即便没有单日出带也要触发审查,因为这种偏移通常意味着系统性变化(数据源切换、券商佣金调整、撮合规则更新),而不是随机噪声。

# 简化版: 计算每日回测 vs 实盘 PnL 差值
def daily_diff(bt_eq: pd.Series, live_eq: pd.Series) -> pd.DataFrame:
    bt_ret = bt_eq.pct_change()
    live_ret = live_eq.pct_change()
    diff = live_ret - bt_ret
    z = (diff - diff.rolling(60).mean()) / (diff.rolling(60).std() + 1e-9)
    out = pd.DataFrame({
        'bt_ret_bp': bt_ret * 1e4,
        'live_ret_bp': live_ret * 1e4,
        'diff_bp': diff * 1e4,
        'zscore': z,
    })
    return out

# zscore > 2 触发告警

确定性回放

为了让对账可以复现,回测引擎必须支持「确定性回放」:给定相同的 seed、相同的事件文件、相同的代码版本,输出每一笔订单、每一次成交、每一次组合状态都逐字节一致。本文上面那套优先队列 + seq 的设计就是为了这个目标。

确定性回放的工程代价是要把所有副作用(包括日志、监控、可视化)从主循环里抽出来,单独走一个 sink,防止异步 I/O 影响事件顺序。这一点在工程上很容易被忽视,结果是「同一份代码同一份数据两次跑出来不一样」,对账无法进行。

确定性回放的另一个用途是事故复盘。实盘里某一天某个策略突然多挂了一个空单导致大额亏损,这一类事件靠肉眼盯日志几乎查不出来;但只要事件流完整落盘了,就可以把那一天的 MarketEvent + 内部的 SignalEvent + OrderEvent 喂给回测引擎重跑一遍,单步追踪策略在哪个 tick 上做出了错误决策。这种事故复盘能力是工程化回测引擎最大的隐藏收益——它把「策略上线后出事只能写检讨」变成「能复现的 bug 就一定能修」。

向量化 vs 事件驱动两条路在所有这些维度上的取舍,下图给出对照矩阵:

向量化 vs 事件驱动对比矩阵

九、写在最后

回测引擎不是一个「能跑就行」的工具,是一个工程系统。它要同时满足再现性、可比性、可调参、贴近实盘四条目标,每一条都对架构提出了具体约束。把这套约束讲清楚之后,向量化与事件驱动的取舍就不再是技术品味问题,而是阶段问题:研究阶段需要参数扫描的吞吐,向量化(vectorbt + numba)是不二选择;实盘前验证需要撮合保真与代码复用,事件驱动是底线。两套引擎各跑一次、彼此对账,能比单跑任何一套都早发现 bug。

把这一对取舍再具体一点:研究员的日常工作流里,向量化引擎应该是默认入口——加载一份 universe 数据,跑一万组参数扫描,挑前 50 组进入候选池;候选池里的策略再用事件驱动引擎在更高保真度下跑一遍,对滑点、路径依赖、容量限制做最终验证;通过验证的策略以小规模实盘 paper trade 跑一个月,期间每天与回测引擎对账,对账误差稳定在带内才考虑放规模。这是一条研究 → 验证 → 上线的工业流水线,不是一个研究员一个人跟一段代码搏斗就能完成的工作流。

实战中真正决定胜负的几条:

第一,确定性优于性能。回测跑得慢可以加机器,回测结果不稳定就没法做研究。优先把确定性、版本管理、数据指纹做扎实,再谈性能优化。

第二,保守优于乐观。所有不确定的成本假设都取偏大值——5 bp 不够就取 8 bp,瞬时成交不真就改成 next-bar,吃满一档不真就强制部分成交。回测虚高的代价是上线后业绩腰斩,回测保守的代价是错过一些边缘策略;前者的代价高得多。把这条原则贯彻到具体数字上:滑点取实测分布的 75 分位以上、冲击系数取 95% 置信上界、借贷费按实际可借券池中最贵的那部分估计。这些数字看起来「太保守」,但活下来的策略才有意义,被高估的策略上线后没有第二次机会。

第三,事件驱动是实盘的底线。研究用什么都行,实盘前必须用事件驱动跑过一遍。两套引擎对账过的策略才能上线。这条规则没有例外——即便是一个看起来非常简单的「月度调仓再平衡」策略,事件驱动跑出来都可能与向量化结果差几十个 bp,差的部分往往就是上线后的实际业绩 gap,研究阶段提前发现、提前修复,比上线之后再追溯成因便宜得多。

第四,对账是工程,不是事后总结。回测—实盘对账要做成每周流程、要有误差带告警、要能定位到具体差异源。没有对账闭环的实盘业绩波动只能归因于「市场变了」,最后变成自欺欺人。

第五,不要在回测引擎上重复造轮子。QuantConnect Lean、Zipline-Reloaded、backtrader、vn.py、wonton、nautilus-trader 在不同保真度上各有所长,先用现成的跑通流程、对账闭环、上线一两个策略,再考虑自研。多数团队过早自研最后写出一个比开源版还差的引擎,浪费工程师生命。

第六,回测引擎本身要有测试。这是被忽视最严重的工程纪律。回测引擎是一段会被全公司所有研究员每天调用的代码,它出 bug 的代价是所有依赖它做决策的策略全部业绩失真。最低限度的测试覆盖应当包括:撮合引擎的状态机转移测试(每种 OrderStatus 转移要有用例)、组合记账的反向减仓 / 反手 / 多空切换的盈亏计算测试、事件队列同优先级排序测试、确定性回放的字节级 diff 测试、与 vectorbt 对账的端到端测试。一个连自己单测都不通的回测引擎,跑出来的曲线没有人应该相信。

第七,关注业绩之外的指标。回测报告里夏普、最大回撤、年化收益是基本盘,但只看这三项远远不够。换手率(turnover)决定策略容量与成本敏感度,胜率与盈亏比决定策略心理可承受性,平均持仓周期决定策略对短期市场结构变化的脆弱程度,最长亏损天数决定 LP 与基金经理在回撤期能不能撑住不止损。一个夏普 1.8 但平均持仓 3 天、换手 80 倍的策略,与一个夏普 1.5 但平均持仓 30 天、换手 5 倍的策略,从工程稳定性与生命周期角度看是两件不同的事,回测报告必须把这些维度全部列出来供决策者权衡。

第八,回测报告要可消费。一份只有 PNG 图片和 Excel 表格的回测报告,每次开会都要研究员打开 Jupyter 重跑才能解释清楚——这是工程化程度不够的征兆。成熟团队的回测报告是一份 HTML 文件,包含交互式净值曲线(Plotly / Bokeh)、按账户与按因子的归因表、滑点与冲击成本的分解、与基准的相对强弱、压力测试情景下的损失曲线,所有图表的原始数据都嵌在文件里可下载。报告的消费者不只是写代码的研究员,还包括基金经理、风控、合规、LP,每一类读者都能在报告里找到自己关心的部分。这一条说起来简单做起来繁琐,但是回测引擎从「工具」走向「平台」的关键一步。

最后再次重申合规边界:本文所有代码与示例均为教学骨架,未经过完整生产审计,撮合规则、成本模型、风控逻辑均经过简化,与任何交易所真实撮合规则有显著差异。商用部署请使用经过社区或机构验证的引擎,并自行进行严格的生产审计与监管合规审查。任何把示例代码部署到生产环境的尝试都需要经过完整审计与法律审核,作者不对此承担任何责任。


参考文献

  1. Chan, E. P. (2013). Algorithmic Trading: Winning Strategies and Their Rationale. Wiley.
  2. Chan, E. P. (2017). Machine Trading: Deploying Computer Algorithms to Conquer the Markets. Wiley.
  3. Almgren, R., & Chriss, N. (2001). “Optimal Execution of Portfolio Transactions.” Journal of Risk, 3(2), 5-39.
  4. Almgren, R., Thum, C., Hauptmann, E., & Li, H. (2005). “Direct Estimation of Equity Market Impact.” Risk, 18(7), 58-62.
  5. Bouchaud, J. P., Bonart, J., Donier, J., & Gould, M. (2018). Trades, Quotes and Prices: Financial Markets Under the Microscope. Cambridge University Press.
  6. Cont, R., Stoikov, S., & Talreja, R. (2010). “A Stochastic Model for Order Book Dynamics.” Operations Research, 58(3), 549-563.
  7. Cartea, Á., Jaimungal, S., & Penalva, J. (2015). Algorithmic and High-Frequency Trading. Cambridge University Press.
  8. Harris, L. (2003). Trading and Exchanges: Market Microstructure for Practitioners. Oxford University Press.
  9. Hasbrouck, J. (2007). Empirical Market Microstructure. Oxford University Press.
  10. de Prado, M. L. (2018). Advances in Financial Machine Learning. Wiley.(第 11-13 章关于回测的方法论)
  11. Bailey, D. H., Borwein, J., López de Prado, M., & Zhu, Q. J. (2014). “Pseudo-Mathematics and Financial Charlatanism: The Effects of Backtest Overfitting on Out-of-Sample Performance.” Notices of the AMS, 61(5), 458-471.
  12. Bailey, D. H., & López de Prado, M. (2014). “The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting, and Non-Normality.” Journal of Portfolio Management, 40(5), 94-107.
  13. Harvey, C. R., & Liu, Y. (2015). “Backtesting.” Journal of Portfolio Management, 42(1), 13-28.
  14. QuantConnect (2024). Lean Algorithmic Trading Engine: Architecture Documentation. https://www.quantconnect.com/docs/v2/lean-engine
  15. Zipline-Reloaded (2024). Zipline-Reloaded: A Pythonic Algorithmic Trading Library. https://github.com/stefan-jansen/zipline-reloaded
  16. Backtrader (2023). Backtrader Documentation. https://www.backtrader.com/
  17. vectorbt PRO (2024). Documentation: From Signals, From Orders, From Holding. https://vectorbt.pro/
  18. Nautilus Trader (2024). Nautilus Trader Documentation: An Open-Source High-Performance Algorithmic Trading Platform. https://nautilustrader.io/
  19. CME Group (2023). Globex Matching Algorithms. CME Group Education.
  20. Nasdaq (2023). Nasdaq TotalView-ITCH 5.0 Specification. Nasdaq Inc.
  21. SSE (2023). 《上海证券交易所交易规则》第三章「集合竞价与连续竞价」. 上海证券交易所.
  22. SZSE (2023). 《深圳证券交易所交易规则》. 深圳证券交易所.
  23. Bouchaud, J. P., Mézard, M., & Potters, M. (2002). “Statistical Properties of Stock Order Books: Empirical Results and Models.” Quantitative Finance, 2(4), 251-256.
  24. Gould, M. D., et al. (2013). “Limit Order Books.” Quantitative Finance, 13(11), 1709-1742.
  25. Lam, S. K., Pitrou, A., & Seibert, S. (2015). “Numba: A LLVM-based Python JIT Compiler.” Proceedings of the Second Workshop on the LLVM Compiler Infrastructure in HPC.
  26. Moritz, P., et al. (2018). “Ray: A Distributed Framework for Emerging AI Applications.” 13th USENIX Symposium on Operating Systems Design and Implementation (OSDI ’18).
  27. Vink, R. (2024). Polars: Fast Multi-threaded DataFrame Library in Rust. https://pola.rs/
  28. McKinney, W. (2010). “Data Structures for Statistical Computing in Python.” Proceedings of the 9th Python in Science Conference, 51-56.
  29. Perold, A. F. (1988). “The Implementation Shortfall: Paper Versus Reality.” Journal of Portfolio Management, 14(3), 4-9.
  30. Kissell, R. (2013). The Science of Algorithmic Trading and Portfolio Management. Academic Press.

系列导航

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-05-01 · quant

【量化交易】量化交易全景:从信号到订单的工程链路

量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。

2026-05-01 · quant

【量化交易】事件驱动策略:财报、并购、指数调整

把事件驱动(event-driven)从一个含糊的「炒题材」标签,还原为带有明确触发条件、可交易窗口与统计可检验性的策略族。本文把财报后漂移(PEAD)、并购套利(merger arbitrage)、指数调整(index rebalance)、回购、解禁、宏观日历等事件,按信息传播链统一拆成「事件触发—信息扩散—价格反应—套利窗口—收敛」五个阶段,给出 SUE 计算、事件研究 AAR/CAR 的 Python 实现,以及用 vectorbt 模拟 PEAD 多空组合的端到端流水线。

2026-05-01 · quant

【量化交易】回测陷阱:前视偏差、过拟合、数据窥视

回测引擎只能保证「语法对」,但真正杀死策略的是「逻辑错、数据脏、推断不严」三件事。本文系统拆解前视偏差(lookahead bias)、过拟合(overfitting)、数据窥视(data snooping)三大陷阱,介绍 Bonferroni、BH-FDR、Family-Wise Error 的多重检验修正,给出 Deflated Sharpe 与概率 Sharpe(PSR)的可运行 Python 实现,配一份 30 条上线前自检清单。

2026-05-01 · quant

量化交易

从因子研究到生产执行的量化交易全栈工程。覆盖市场微结构、数据管线、因子构造、组合优化、回测方法论、执行算法、做市策略、高频架构到生产运维。面向策略研究员与工程师。


By .