跳转至

回测里的 T+1,是碰巧成立的——一次 vectorbt 撮合语义考古

本文所有数字均来自可复现脚本的实测输出:experiments/repro_t1_fees.py → evidence/repro_t1_fees_result.json。 实验环境:WSL2 / Python 3.12 / vectorbt 1.0.0(与我项目实际安装的版本一致)。

一、现象

我的回测引擎 ASharePortfolio(继承 vectorbt 的 Portfolio)在 docstring 里声称实现了三件 A 股特有的约束:

  1. T+1:当天买入的股票当天不能卖;
  2. 涨跌停:涨停不能买、跌停不能卖;
  3. 费用:佣金 + 印花税 + 最低佣金。

而一次逐项验证的结果是:

  • T+1 从未被实现——它只是日线数据粒度带来的"碰巧成立";
  • 费用模型从未成功执行过一笔——只要走到那行代码,程序必然崩溃;
  • 涨跌停约束的实现方式是"把信号作废",而不是"顺延到下一可交易日";
  • 权益曲线的时间轴是错的——周末和休市日被等间隔序列"平移填充"。

最讽刺的是:这一切没有任何报错提示。测试通过、指标正常、图形漂亮。

二、我一开始的错误判断

验证的起点不是怀疑,是勾选:给引擎的约束声明做逐项核对,docstring 写了三条,我的预期是走个流程。第一组实验(A1)跑出零成交时,第一反应是实验脚本的信号构造有问题——换了写法重跑,仍然为零,才把“为什么是零”当成问题本身。情绪最紧绷的是费用实验:TypingError 的报错栈全部指向 vectorbt 内部的 portfolio/nb.py,长得就像库的 bug——下文根因里那句“大概率会去重装/降级 vectorbt”不是修辞,是当时真实压上来的选项,拦住它的是“先做最小复现、再动环境”的习惯。

写下这篇文章时,我复盘了三个典型的误判,读者可以对照自己:

  1. "docstring 写了 = 实现了"。from_signals 里有一行注释:"T+1 rule handled by VectorBT's order processing"。我把它当成了事实陈述,而它其实只是一个未经证实的假设——vectorbt 的订单撮合里根本没有 T+1 的概念。
  2. "没有报错 = 约束生效"。把 Python 函数直接传给 fees 参数后程序"能跑",我就默认费用被正确计算了——直到把它单独拎出来跑,才发现它连 numba 编译那一关都过不去。
  3. "接了成熟回测库 = 自带正确性"。库是对的,但"库的默认撮合语义"和"A 股交易制度"之间的映射,是我自己的责任,库不会替我做。

三、排查与关键证据

我用一份五根 bar 的合成行情(其中故意跨了一个周末)做了四组最小复现,全部结论可一键重跑。

实验 A1:同一根 bar 上同时出现买入和卖出信号会怎样?

结果:成交笔数为 0,订单列表为空。买入信号和卖出信号在同一根 bar 上互相湮灭——既没有"当天买当天卖",甚至也没有"当天买入"。

这个结果说明:在日线粒度 + "一根 bar 最多触发一次调仓"的撮合模型下,当日回转在物理上就不会发生。T+1 是这个粒度的副产品,而不是我实现的约束。换句话说:哪天有人把这套引擎接到分钟线上,"T+1"瞬间消失,而 docstring 依然写着它存在。

实验 A2:隔日卖出的基线。

第 0 根 bar 买入、第 1 根 bar 卖出:以 10.0 买入 1000 股、以 11.0 卖出 1000 股——撮合机制本身完全正常。排除"库坏了"的解释。

实验 B1/B2:费用参数传标量 vs 传回调函数(完全复刻我引擎里的写法)。

  • 传标量:正常出结果;
  • 传函数(我的引擎写法):Numba TypingError 崩溃——fees 参数的契约是"可广播的数值",不是回调。函数被当作 object 数组塞进 numba 编译管线,类型推断直接失败。
  • 用我的引擎类端到端跑一次最小回测(实验 D):同样的 TypingError,在 vectorbt 的撮合内核里爆掉。

结论:我的 backtest_single_stock 从项目写完那天起,带费用模型的完整回测一次都没有成功执行过。此前所有"回测通过"的记录,走的都是没带这个费用函数的路径。

实验 C:权益曲线时间戳。

实际交易日索引:01-02, 01-05, 01-06, 01-07, 01-08(跳过周末); 引擎重建出的索引:01-02, 01-03, 01-04, 01-05, 01-06(连续日历日)。

引擎用 pd.date_range(freq='D') 等间隔重建时间轴,周末两天被"平移填充"。回测跨越的假期越多,日期错位越严重——所有按日期对齐的分析(比如和实盘对账)都会错位。

四、根因

三层问题叠加:

  1. 库的撮合默认语义 ≠ 交易制度。vectorbt 的 from_signals 是通用引擎,它不"知道" T+1,也不应该知道。把制度约束寄托在库的默认行为上,等于把正确性寄托在巧合上。
  2. 接口契约想当然。fees 接受的是可广播数值;把回调函数塞进去,错误在 numba 编译期才爆,且报错信息完全指向库内部(portfolio/nb.py),看起来像库的 bug——如果我不是刻意做最小复现,大概率会去重装/降级 vectorbt。
  3. 没有验收环节兜底。"实现了约束"这件事,从来没有被任何测试或导出记录验证过。docstring、注释、README 三处声称,抵不过一次逐笔成交导出。

五、约束与取舍

承认错误之外,当时的设计里也有真实的取舍,写出来是完整的:

  • 涨跌停处理选择了"信号作废"而不是"顺延到下一可交易日"。放弃的方案:顺延。付出的代价:涨停买不进、跌停卖不出的那些交易日被系统性抹掉,回测收益天然偏乐观——而且这个偏差和"运气"无关,是方向性的。
  • 放弃了自研撮合器。付出的代价:必须逐条确认库的边界语义(本文就是学费)。
  • 放弃了分钟级撮合。付出的代价:开盘跳空、盘中触发止盈止损的场景无法建模,模拟盘与回测的对账注定粗糙(下一篇写这个)。
  • 修正路径(写给大家避坑):费用用标量/数组形式传入,或在信号生成层预先计算净收益;T+1 用显式的"信号偏移+持仓起始日检查"实现,不依赖库的默认行为。

六、可复用检查清单

  1. 把回测库的撮合默认值逐条列出来(成交时点、成交价、同 bar 反向信号的处理、一根 bar 允许几次调仓),一条条对照交易制度——不靠文档宣传语,靠最小复现;
  2. 传给库的每一个"非数值"参数(函数、回调、自定义类)都要用一个必然改变结果的探针值验证它真的被调用;
  3. 导出逐笔成交记录,人肉抽查违反制度的交易是否出现(当日回转、涨跌停成交、费用缺失);
  4. 权益曲线的时间轴必须来自真实交易日历,禁止等间隔重建;
  5. 回测结论必须绑定库版本号——我在文末附了本实验的完整版本信息。

复现材料

  • 脚本:experiments/repro_t1_fees.py
  • 原始结果:evidence/repro_t1_fees_result.json / .md
  • 实验版本:vectorbt 1.0.0、numpy 2.5.4、numba 0.68.1、Python 3.12