回测里的 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 股特有的约束:
- T+1:当天买入的股票当天不能卖;
- 涨跌停:涨停不能买、跌停不能卖;
- 费用:佣金 + 印花税 + 最低佣金。
而一次逐项验证的结果是:
- T+1 从未被实现——它只是日线数据粒度带来的"碰巧成立";
- 费用模型从未成功执行过一笔——只要走到那行代码,程序必然崩溃;
- 涨跌停约束的实现方式是"把信号作废",而不是"顺延到下一可交易日";
- 权益曲线的时间轴是错的——周末和休市日被等间隔序列"平移填充"。
最讽刺的是:这一切没有任何报错提示。测试通过、指标正常、图形漂亮。
二、我一开始的错误判断¶
验证的起点不是怀疑,是勾选:给引擎的约束声明做逐项核对,docstring 写了三条,我的预期是走个流程。第一组实验(A1)跑出零成交时,第一反应是实验脚本的信号构造有问题——换了写法重跑,仍然为零,才把“为什么是零”当成问题本身。情绪最紧绷的是费用实验:TypingError 的报错栈全部指向 vectorbt 内部的 portfolio/nb.py,长得就像库的 bug——下文根因里那句“大概率会去重装/降级 vectorbt”不是修辞,是当时真实压上来的选项,拦住它的是“先做最小复现、再动环境”的习惯。
写下这篇文章时,我复盘了三个典型的误判,读者可以对照自己:
- "docstring 写了 = 实现了"。
from_signals里有一行注释:"T+1 rule handled by VectorBT's order processing"。我把它当成了事实陈述,而它其实只是一个未经证实的假设——vectorbt 的订单撮合里根本没有 T+1 的概念。 - "没有报错 = 约束生效"。把 Python 函数直接传给
fees参数后程序"能跑",我就默认费用被正确计算了——直到把它单独拎出来跑,才发现它连 numba 编译那一关都过不去。 - "接了成熟回测库 = 自带正确性"。库是对的,但"库的默认撮合语义"和"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') 等间隔重建时间轴,周末两天被"平移填充"。回测跨越的假期越多,日期错位越严重——所有按日期对齐的分析(比如和实盘对账)都会错位。
四、根因¶
三层问题叠加:
- 库的撮合默认语义 ≠ 交易制度。vectorbt 的
from_signals是通用引擎,它不"知道" T+1,也不应该知道。把制度约束寄托在库的默认行为上,等于把正确性寄托在巧合上。 - 接口契约想当然。
fees接受的是可广播数值;把回调函数塞进去,错误在 numba 编译期才爆,且报错信息完全指向库内部(portfolio/nb.py),看起来像库的 bug——如果我不是刻意做最小复现,大概率会去重装/降级 vectorbt。 - 没有验收环节兜底。"实现了约束"这件事,从来没有被任何测试或导出记录验证过。docstring、注释、README 三处声称,抵不过一次逐笔成交导出。
五、约束与取舍¶
承认错误之外,当时的设计里也有真实的取舍,写出来是完整的:
- 涨跌停处理选择了"信号作废"而不是"顺延到下一可交易日"。放弃的方案:顺延。付出的代价:涨停买不进、跌停卖不出的那些交易日被系统性抹掉,回测收益天然偏乐观——而且这个偏差和"运气"无关,是方向性的。
- 放弃了自研撮合器。付出的代价:必须逐条确认库的边界语义(本文就是学费)。
- 放弃了分钟级撮合。付出的代价:开盘跳空、盘中触发止盈止损的场景无法建模,模拟盘与回测的对账注定粗糙(下一篇写这个)。
- 修正路径(写给大家避坑):费用用标量/数组形式传入,或在信号生成层预先计算净收益;T+1 用显式的"信号偏移+持仓起始日检查"实现,不依赖库的默认行为。
六、可复用检查清单¶
- 把回测库的撮合默认值逐条列出来(成交时点、成交价、同 bar 反向信号的处理、一根 bar 允许几次调仓),一条条对照交易制度——不靠文档宣传语,靠最小复现;
- 传给库的每一个"非数值"参数(函数、回调、自定义类)都要用一个必然改变结果的探针值验证它真的被调用;
- 导出逐笔成交记录,人肉抽查违反制度的交易是否出现(当日回转、涨跌停成交、费用缺失);
- 权益曲线的时间轴必须来自真实交易日历,禁止等间隔重建;
- 回测结论必须绑定库版本号——我在文末附了本实验的完整版本信息。
复现材料¶
- 脚本:
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