← 返回文章列表
July 2, 2026
5 分钟阅读

框架税:当你的回测库比手写的 pandas 循环还慢

框架税:当你的回测库比手写的 pandas 循环还慢
#算法交易
#回测
#性能
#参数搜索
#向量化
#基准测试
Part 2 of 10 · Collection
High-Performance Backtest Engines

"没有幻觉的回测"系列文章之一。

大多数量化交易项目的底层都有一个令人心安的假设:一个成熟、star 数很高的回测库一定是的。它历经多年的贡献积累,拥有真正的事件循环、一个经纪商模型、一套佣金方案。它当然会胜过你自己随手写的 pandas 循环。于是你选用了它,接好你的策略,开始跑一次参数扫描——几千种配置,一个通宵任务。第二天早上你回来一看,它还在跑。

我们在同一个参数扫描任务上对八个回测引擎做了基准测试,结果发现了一些应该改变你选择搜索工具方式的事实。两个最受欢迎的开源事件驱动框架——backtraderbt——跑这个扫描任务比我们当作一次性基线随手写的朴素 pandas 循环还要慢。而且不是慢一点点。backtrader 花的时间大约是 pandas 基线的 2.5 倍;bt 大约是 4.7 倍。与此同时,一个向量化/编译型引擎完成同样的工作,速度大约是 bt13000 倍

这就是框架税。主流的回测器是为单次认真的运行而构建的——一个策略、一个数据集、精细的成交处理、一个行为像真正经纪商的经纪商。这正是你在做最终验证或实盘一致性检查时想要的。但对于算法研究真正花费大部分时间的事情——用略微不同的参数把同一个策略跑上万次——这恰恰是错误的工具。本文将测量这个税负、解释其机制,并给出一个判断何时"真正的"回测框架是错误选择的决策规则。

本文所有数字都来自同一套可复现的测试工具(benchmarks/bench_oss_engines.py,提交号 250dbb5),并作用于同一个、笔数锁定的工作负载。如果某个引擎我们没有亲自跑过,我们会明确说明,并把它单独列在一个诚实说明部分,而不是编造一个数字。

一次运行 vs 一万次运行

同一个策略计算沿着三条分岔的路径流动:一条红橙色的事件驱动轨道被迫在每一个K线站点停下,一条琥珀色的权重/再平衡树状算子结构,以及一道翠绿-青色的单一向量化光束一次性解出整条序列——范式决定了扫描速度的命运

参数搜索的决定性事实是:引擎要运行成千上万次,但分析只发生一次。无论你为搭建一次回测所付出的固定成本是什么——构建事件循环、实例化一个经纪商、为每根K线分配一个对象——你在每一种组合上都要重复支付这笔成本。在单次运行中不可见的成本("谁在乎多花6秒?"),到了一次扫描里就变成了全部账单("6秒 × 10000 = 16.6 小时")。

回测引擎分为三种范式,而范式决定了扫描性能的命运:

  1. 事件驱动——引擎逐根K线遍历,发出事件,调用你的 next()/onBars() 回调,把订单路由经过一个经纪商对象。这是 backtrader、backtesting.py、PyAlgoTrade、zipline 和 nautilus_trader 的架构。它模拟了实盘交易真实的运作方式,这正是它被信任具有真实感的原因——也正是它慢的原因:每根K线的 Python 开销要被支付 150000 次每个组合。
  2. 权重/再平衡——bt 属于这一类。你给它一个目标权重矩阵,它在你指定的日期上进行再平衡。没有逐K线的回调,但仍然有一个逐事件的对象图(一棵算子树、一个交易台账)在 Python 中被评估。
  3. 向量化/编译型——整个策略被表达为数组运算(vectorbt)、一个 JIT 编译的内核(numba),或原生代码(Rust、一个 MLX GPU 内核)。这里完全没有逐K线的 Python 代码。如果有循环的话,它以机器速度运行。

本文接下来的内容都是这套分类法的实证后果。我们搭建了一个工作负载,让每个引擎都做可证明相同的工作,并对其计时。

工作负载:一个策略,八十个参数,对所有人一致

一个基准测试只有在每个引擎都做同样的工作时才是诚实的。我们的测试刻意保持简单——一个工作负载,引擎之间唯一的差异就是引擎本身。

  • 数据。 单一的合成几何布朗运动收盘价序列:150,000 根K线,seed=42,逐K线波动率 sigma=0.0008x0=30000。确定性生成,所以任何人都可以逐字节复现它。构造上仅有收盘价——每一条腿的 OHLC 都设为收盘价,因为策略本身就是一个收盘价交叉策略。
  • 策略。 一个 Hull 移动平均线交叉:长度为 L 的 HMA 与一个更快的三分之一变体 HMA(HMAHMA3)。始终持仓,每次交叉切换多空方向。这是一个真实的、非平凡的指标——两个嵌套的加权移动平均加上一个平方根窗口的平滑器——而不是一个玩具级的 SMA,所以每根K线的计算量是有代表性的。
  • 扫描。 80 个跨度从 6..200 的 HMA 长度。这就是把"一万次运行"缩小到可以直接测量的规模:80 个独立的组合,每个都是一次覆盖 15 万根K线的完整回测。
  • 成本。 往返手续费 0.09%,对于按边收取佣金的引擎则按单边拆分。同K线成交,成交价为 close[i]——第 i 根K线上的信号在该K线的收盘价成交,这是我们生产引擎所采用的约定。

每个组合的计时器精确地包裹了两件事:numpy 的 HMA 预计算和引擎的运行。真正的一次性搭建成本(加载数据、构建K线对象)位于计时器之外。有一次预热运行,然后进行最优 N 次重复;而且——因为事件驱动引擎慢到足以让 80 个完整组合耗费从几分钟到超过一小时——我们对网格中的一个均匀样本计时,然后线性外推。由于各组合是独立的,线性外推在期望意义上是精确的;同样的约定也应用于 pandas 基线,因此没有任何引擎因此获得优势。

一致性:证明每个引擎都做了相同的工作

一个朴素的引擎对比很容易掉进这样一个陷阱:一个"更快"的引擎可能只是做得更少。如果 backtrader 记录了 2700 笔交易,而你的向量化引擎只记录了 40 笔,那么向量化引擎并不是更快——它是错的,这样的比较毫无意义。

因此我们用一个交易笔数一致性检查来锁定比较。在 L=104 时,numpy 参考实现恰好产生 2707 笔已平仓交易。每个引擎都必须在 ±1 的容差内复现这个数字,否则运行就会以 work-parity FAILED 断言中止。这个容差的存在仅仅是因为各引擎在记账约定上存在分歧——比如最终的持仓是否被强制平仓并计入、初始建仓是否算作一笔"交易"——而不是因为交易本身有差异:

引擎 L=104 时报告的交易笔数 约定
numpy 参考实现 2707 已平仓的往返交易
backtesting.py 2708 +1:末尾持仓被强制平仓并计入
backtrader 2707 最终未平仓的持仓不计入
bt 2708 +1:初始建仓被算作一笔交易
PyAlgoTrade 2708 +1:初始建仓被算作一次成交

每个引擎都落在 2707 ± 1 之内。无论速度差异最终是什么,它们都不是某个引擎悄悄少做工作的产物。正是这种严谨性,让我们能把一个事件驱动框架和一个 GPU 内核放进同一张表里,并且这样做是有意义的。

结果

下面是完整的表格,按从快到慢排序。combos/s 是吞吐量;最后一列是完整跑完 80 个组合的扫描所需的时间。基线行是 M0——朴素的 pandas 引擎,一个对K线做标量记账的 for 循环,一个你花一个下午写出来然后就会扔掉的东西。任何比这个基线还慢的都用粗体标出

引擎 combos/s 范式 完整的80组合扫描
MLX GPU 内核 779 向量化(Apple GPU) 0.10 秒
原生 Rust ~350 编译型 0.23 秒
mp + numba 246 编译型 JIT + 多进程 0.33 秒
vectorbt 56.9 向量化(numpy/numba) 1.4 秒
numba(单核) 39.7 编译型 JIT 2.0 秒
backtesting.py 1.42 事件驱动 56 秒
PyAlgoTrade 0.51 事件驱动 2.6 分钟
M0 — 朴素 pandas + 循环 0.28 标量基线 4.8 分钟
backtrader 0.11 事件驱动 12.7 分钟
bt 0.06 权重 / 再平衡 22.5 分钟

从上到下读这张表,范式会自动分层:前五名全是向量化或编译型,后五名全是事件驱动或对象图型——朴素的 pandas 循环高于两个成熟的、流行的框架。从头到尾的跨度是四个数量级。在完全相同的、2707笔交易的工作负载上,MLX 内核在十分之一秒内跑完了整个扫描;bt 需要二十二分半。这大约是 13000 倍的差距。

表格中间的丑闻

表格中段的悖论:一个朴素的灰蓝色 for 循环运行器率先冲过终点线,而两台华丽的、功能丰富的事件驱动框架机器闪耀着红橙色,仍在后面费力挣扎,被经纪商模型和分析器堆栈拖累——朴实的基线在完全相同的2707笔交易工作负载上击败了成熟的框架

抓人眼球的数字在两端,但有启发性的结果在中间:backtrader(0.11 combos/s)和 bt(0.06 combos/s)都比朴素的 pandas 基线(0.28 combos/s)慢。

这一点值得细细品味。M0 并不是一个聪明的引擎。它就是一个对 DataFrame 做索引访问、用普通标量追踪持仓和现金、把交易 append 进一个列表的 Python for 循环——我们特意加入的、未经优化的"对照组",目的就是有一个明显糟糕的东西可以被击败。pandas 逐行访问是出了名的慢,而我们索性顺着这一点走。然而生态系统中两个最被推荐的回测库却输给了它:backtrader 输了 2.5 倍,bt 输了 4.7 倍。

保持这一切诚实的细微差别在于:并非每个事件驱动引擎都比 pandas 慢。 backtesting.py(1.42 combos/s)以 5 倍的速度胜过基线,因为它是一个精简的、以 numpy 为基础的事件循环,把每根K线的对象创建降到了最低。PyAlgoTrade(0.51)也略微领先于基线。所以"事件驱动"并不必然是死刑判决——但逐K线的机制越重,情况就越糟,而 backtrader 和 bt 在这里承载的机制最重。范式设定了上限;实现决定了你落在这个上限之下的哪个位置。

关键不在于这些是糟糕的库。backtrader 的经纪商模型和 bt 的算子树设计的存在,是为了换来正确性和表达力——真实的订单处理、组合再平衡、分析器。这些特性有运行时成本,而这个成本在你只跑一次时是不可见的。而在一次扫描中,它就是全部故事。

事件驱动引擎为何要支付这笔税

框架税在每根K线上是如何被征收的:单次K线迭代被拆解成其代价高昂的组成部分——一个被物化的K线对象、一个回调栈帧、一个正在路由订单的经纪商、一个正在被追加记录的台账——每一个都是一个闪着红橙色光芒的收费亭在收取一枚硬币,沿着一条向远方延伸的、由一千两百万次相同的K线迭代组成的巨大走廊重复出现

这个机制并不神秘。一个事件驱动的回测,在每根K线上大致会做以下这些事:

  1. 推进时钟,从数据源中切出下一根K线,并将其物化为一个对象(一个 Bar、一个 Line、一个 dict)。
  2. 向用户代码触发一个回调(next()onBars()),这是一次带有自己栈帧的 Python 函数调用。
  3. 在回调内部,查询经纪商/持仓状态,同样是通过方法调用和属性查找。
  4. 如果创建了一个订单,把它路由经过经纪商:校验它、检查保证金/现金、安排成交、修改一个组合对象、追加到一个交易台账。
  5. 更新分析器、观察者,以及框架维护的任何记账信息。

现在把这一切乘以 15 万根K线,再乘以 80 个组合:每次扫描一千两百万次K线迭代,每一次都是一大把 Python 层面的对象分配和动态派发。Python 每次操作的开销——一次属性查找或一次小分配是几十到几百纳秒——一次是微不足道的,一千两百万次就是灾难性的。bt 的情况是同一种病症的一个变体:即便它只在交易日而不是每根K线上进行再平衡,每次再平衡都要评估一棵算子对象树,并触及一个以 pandas 为底层的组合台账,而每个组合有 2707 次这样的操作,再乘以 80。

朴素的 pandas 循环击败 backtrader 和 bt 的原因很直接:它每根K线做的事更少。 它跳过了经纪商、事件对象、分析器堆栈、订单路由状态机。它支付了 pandas 那种难看的逐行税,但这一份难看的税仍然比框架那种整洁的、功能齐全的、逐事件对象的税要便宜。当你把一个回测精简到"持仓 × 下一期收益率 − 手续费"时,框架每根K线所做的大部分事情,在一次搜索中都是你用不上的开销。

而这正是陷阱所在:这些开销正是你选择这个框架的原因。你想要真实的经纪商。你想要分析器。在最终验证时,你想要全部这些。而在一次一万个组合的搜索中,你只从另一端读出一个标量目标值,你却在为一辆豪华轿车跑圈的费用买单。

表格的另一端:向量化与编译型

表格的顶部是当你把逐K线的 Python 完全删除之后会发生的事。

  • vectorbt(56.9 combos/s) 把整个策略表达为 numpy/numba 数组运算。Python 中没有K线循环——信号、持仓、盈亏全都是数组层面的。它在 1.4 秒内跑完这次扫描,而 bt 需要 22.5 分钟:在完全相同的工作上快约 950 倍。(我们在vectorbt 综述以及更广泛的pandas 与 polars 对比中更深入地讨论了 vectorbt 的设计。)
  • numba(39.7) 就是 pandas 循环,形态不变,只是被 JIT 编译成了原生代码。和 M0 一样的算法,但 @njit 把 0.28 combos/s 变成了 39.7——一个装饰器带来约 140 倍的加速,因为原本主导标量循环的解释器开销直接蒸发了。
  • mp + numba(246) 把编译后的内核跨 CPU 核心运行。各个组合是天然可并行的——每一个都是独立的——所以多进程在 JIT 的基础上近乎线性地扩展。
  • 原生 Rust(~350) 移除了最后一点 Python 胶水代码:整个扫描都是原生代码。
  • MLX GPU(779) 把这次扫描映射到一个 Apple 芯片的 GPU 内核上。80 个组合变成 80 条并行的算术通道;扫描在你松开回车键之前就已经结束了。

有两点值得精确地指出。第一,numba 证明了范式比语言更重要。 M0 和 numba 运行的是同一个算法——差异纯粹在于一个是逐K线解释执行的 Python,另一个是编译后的代码。这就是整个框架税在一次受控 A/B 测试中的全部体现:从内层循环中去掉解释器带来约 140 倍的差距。第二,从 numba(39.7)到 mp+numba(246)再到 MLX(779)的跃升,已经完全不再关乎引擎本身了——而是关乎编排和硬件。一旦逐K线的税负被消除,速度就变成了一个关于你能在多少并行度和什么硬件上运行组合的问题。我们在回测引擎速度阶梯中走完了这整个演进过程,并在 IPC 税文章中解释了为何最后一段里程会被进程/序列化成本所主导。

我们没有测量的部分(以及为什么要告诉你)

一个基准测试的可信度,在于它拒绝伪造什么。我们在一致性约束下端到端地跑了八个引擎。有几个知名框架我们没有给出一个数字,我们宁愿诚实地把它们列出来,也不愿臆造一个我们没有测量过的数字:

  • zipline / zipline-reloaded ——事件驱动,Quantopian 血统。搭建成本很重(一整套交易日历和数据包),这使得逐组合的苹果对苹果计时变得棘手。从架构上看,它和 backtrader 一起属于事件驱动阵营;我们预期它会靠近表格的那一端,但我们还没有证实这一点。
  • nautilus_trader ——事件驱动,核心用 Rust/Cython 编写,明确为实盘一致性而设计。它的核心是编译型的,所以它是最有可能支付完整 Python 税的事件驱动引擎——这是一个真正有意思的、我们尚未进行的测量。
  • QuantConnect Lean ——基于 C#,完全是另一种运行时环境;在一个 Python 测试工具里没有直接可比性。
  • Jesse ——事件驱动,专注于加密货币;我们在另一篇笔记中回顾过它的设计,但没有在这里对它做基准测试。
  • QSTrader ——事件驱动,面向组合;同样的范式注意事项。
  • fastquant ——我们尝试过;但在我们的环境中安装/API 是坏的,所以没有数字。我们不打算去猜一个。

关于我们确实报告的数字,有两个诚实的说明。vectorbt、numba、mp+numba、原生 Rust 和 MLX 的数字来自我们自己在同一个工作负载上的引擎阶梯测试,而不是产生那四行事件驱动数据的开源测试工具——它们是同一个工作负载,但用了不同的测量装置,而且原生 Rust 的数字是一个近似值 ~350,而不是一个精确测量。另外绝对的 combos/s 数值是与硬件相关的;能够跨硬件传递的是排序和比例,而这些比例已经足够大(从头到尾13000倍,pandas 对框架的反转是2.5-4.7倍),任何合理的硬件差异都不会把它们翻转过来。

为事件驱动引擎辩护

事件驱动引擎在其恰当角色中的样子:对一个幸存策略进行的单次、一丝不苟的高保真验证运行,被呈现为一个精确的翠绿-青色仪器,模拟一次真实的成交,尊重保证金和订单生命周期——同样这套在搜索阶段纯属浪费的逐K线机制,在这里被呈现为有价值的工艺——部署前的一次细致彩排

把这篇文章理解为"事件驱动回测器很糟糕"是很容易的,但那是错误的结论,而且并不公平。

事件驱动引擎是为了另一份不同的工作而构建的,而且它们很擅长这份工作。逐K线的经纪商、订单生命周期、成交逻辑、分析器——这些机制存在的目的,是让一次回测尽可能地贴近实盘交易。当你的目标是对一个即将部署的策略做一次单独的、值得信赖的彩排时,你想要引擎为每一次成交操心、模拟部分成交、尊重保证金、拒绝让你以你本不可能拿到的价格交易。这种保真度就是产品本身。它的运行时成本是真实感的代价,而对于一次运行来说,这个代价可以忽略不计。

问题不在于引擎本身,而在于把它用在了错误的阶段。研究有两个截然不同、要求相反的阶段:

  • 搜索需要吞吐量。你在探索一片地形,其中大部分都是垃圾,你需要评估成千上万个点,才能找到少数几个值得再看一眼的点。每个点的保真度几乎无关紧要——你是在排序,而不是在部署。在这里,框架税纯粹是浪费。
  • 验证需要保真度。你手上有少数几个候选策略,你需要尽可能精确地知道,它们能否在真实的执行、手续费、滑点,以及那些会虚增账面收益的前视陷阱面前存活下来。在这里,事件驱动引擎赚回了它的成本。

框架税所惩罚的错误,是把你的搜索跑在了你的验证引擎上——为了探索一片你最终会扔掉99%的地形,而支付豪华轿车的价钱。

决策规则

这个实用的结论可以压缩成一句话:

在向量化/编译型引擎上做搜索。在幸存者身上,用事件驱动引擎做验证。

具体来说:

  1. 为扫描搭建或借用一个快速内核。 如果想要开箱即用就用 vectorbt;如果你的策略不能被干净地向量化,就用一个 numba 编译的循环(仅仅是 @njit 装饰器,在这里就带来了约 140 倍的提升)。让整个参数空间都跑过它。
  2. 永远不要在 backtrader、bt、zipline 或任何重量级事件驱动框架上跑一次大规模扫描。 如果这类引擎上的一次扫描成了你的瓶颈,解决方法不是换一台更强的机器——而是换一个引擎。就连朴素的 pandas 循环都能击败其中两个。
  3. 把入围的候选名单提升到事件驱动引擎上做保真度检查。 拿出那少数几个幸存者,在真实的引擎上重新跑一遍,在那里,经纪商模型和成交逻辑能够暴露出那些被快速内核抽象掉的问题。
  4. 强制两者之间的一致性。 快速引擎和保真度引擎必须在一个固定配置上,对交易笔数和盈亏达成一致(我们在 L=104 时的 ±1笔交易检查),否则搜索和验证就是在测量两个不同的策略,整条流水线就是一个谎言。

这正是每当搜索和验证的成本结构相反时都会出现的同一种双速架构,也是为什么我们自己的技术栈,始终为参数搜索保留一条快速的向量化/编译型路径,而把重量级的机制留给最终的目标函数评估平台期检查

要点总结

  1. 流行 ≠ 适合扫描时的快。 在同一个、笔数锁定的工作负载上(15万根K线,80个HMA交叉组合,2707笔交易),backtrader(0.11 combos/s)和 bt(0.06)都比朴素的 pandas 循环(0.28)更慢。一个成熟的、star数很高的框架并不自动就是快的选择。
  2. 框架税是按K线计算的,而一次扫描会把它成倍放大。 每次扫描一千两百万次K线迭代,每一次都携带一个事件对象、一个回调、一次经纪商往返。一次运行中不可见的成本,是一万次运行的全部账单。
  3. 范式决定了上限。 向量化/编译型引擎(vectorbt 56.9,numba 39.7,mp+numba 246,原生 Rust ~350,MLX 779)比事件驱动引擎快两到四个数量级——从头到尾最多约13000倍。同一个算法,仅仅经过 JIT 编译,速度就快了140倍。
  4. 并非所有事件驱动引擎都是一样的。 backtesting.py(1.42)和 PyAlgoTrade(0.51)仍然击败了朴素基线;税负的高低与逐K线机制的重量成正比。实现方式决定了你落在上限之下的哪个位置。
  5. 两个引擎,两个阶段。 在一个向量化/编译型内核上做搜索;在真实的事件驱动引擎上验证幸存者。在两者之间强制交易笔数/盈亏的一致性,让它们测量的是同一个策略。
  6. 对你测量了什么保持诚实。 我们在一致性约束下对八个引擎做了基准测试,并列出了我们没有测量的那些(zipline、nautilus_trader、Lean、Jesse、QSTrader,以及那个装不上的 fastquant),而不是为它们编造数字。

这个令人不安的总结是:如果一次参数扫描是你的瓶颈,问题很可能既不在你的机器上,也不在你的策略上。而是你在一个为单次认真运行而构建的引擎上做搜索——而你原本用那个你羞于保留的 pandas 循环,反而会更快。

完整的测试工具、一致性断言,以及每个引擎的原始 JSON 结果,都保存在 benchmarks/bench_oss_engines.pybenchmarks/results_oss/ 中,提交号 250dbb5。关于编译型/GPU那一端的阶梯,参见回测引擎速度阶梯IPC 税分析

免责声明:本文提供的信息仅用于教育和参考目的,不构成财务、投资或交易建议。加密货币交易涉及重大损失风险。

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

紧跟市场步伐

订阅我们的时事通讯,获取独家 AI 交易见解、市场分析和平台更新。

我们尊重您的隐私。您可以随时退订。