【量化系统从0到1】回测引擎:性能不是难点,语义才是

45 阅读 2607 字 · 约 9 分钟
分类:科技 专栏:量化系统从0到1

回测是量化系统的裁判。策略好不好,不看信号生成得多漂亮,看回测数字。裁判出错,后面的一切都是沙上建塔。回测引擎有两种出错方式:语义错——数字不对,但错得隐蔽;性能慢——跑不完,但错得显眼。我做完这个系统最大的体会是:语义错的代价远大于性能慢。所以这篇讲回测引擎,主线不是"怎么跑得快",是"怎么把 A 股语义做对"。

为什么自研,不用现成回测库

设计稿第一次讨论回测引擎时,第一个问题是:用现成库还是自研?

现成库很多:backtrader、zipline、vectorbt。但它们有个共同前提——都是美股语义。美股 T+0、无涨跌停、可以卖空、没有 100 股整数倍,费用结构也完全不同。A 股语义:T+1、一字涨跌停买不进卖不出、停牌不可交易、100 股整数倍、佣金印花税过户费三件套。用现成库,要么接受它的语义改自己的需求,要么花大力气改库,两条路都不轻松。

而我的需求本身很窄:日线、盘后、单策略逐票回测。撮合逻辑说到底是一个逐 bar 循环:拿到 T 日信号,在 T+1 开盘撮合,处理费用和涨跌停。这个核心用 Python 写了 142 行。现成库省下的时间,不够抵消它和 A 股语义的差异。

自研要分清哪些必须做对、哪些可以以后再说:A 股语义是功能需求,必须做对;性能是优化需求,按需再做。后面所有决策都按这个区分来。

A 股语义清单:哪些必须做对

设计稿把 A 股语义列成了清单,落地时一条条对着实现:

  • T 日收盘信号 → T+1 开盘成交。信号用后复权价计算,防止除权造成假交叉;成交用 T+1 开盘价。这是无未来函数的根基。
  • 一字涨停买不进、一字跌停卖不出。用真实价和涨跌停价比较:开盘价 ≥ 涨停价,买单拒;开盘价 ≤ 跌停价,卖单拒。
  • 停牌不可交易。停牌日的信号作废,不顺延。
  • 100 股整数倍。买入数量向下取整到 100 的倍数。
  • 费用三件套:佣金(万 2.5,最低 5 元)+ 印花税(卖出 0.05%)+ 过户费(0.001% 双向),滑点可选,默认 0。

这张清单看着不起眼,但每一条都对应真实场景。2023-2026 的真实行情里,两条拒单规则各触发过一次:某票信号日次日一字涨停,买单被拒;另一票信号后恰逢吸收合并停牌,交易挂起。合成数据自测过的逻辑,在真实数据里各自验证了一遍。

价格口径:回测里最隐蔽的错

回测里最容易犯、最隐蔽的错,是价格口径不自洽。

第一版回测用真实价撮合、持仓股数固定。这个口径在除权日会出事:股票 10 送 10,价格当天腰斩,但回测里的持仓股数没变——权益凭空缩水一半。反过来,配股之类的场景就出现"假"暴涨。样本从 5 只扩到 50 只后,报告里冒出两只刺眼的票:+313%、+425%。追下去不是行情暴涨,是除权日的假跳变。

修法是整个账户换成后复权口径:成交价用后复权开盘价、市值用后复权收盘价、权益 = 现金 + 持仓 × 后复权价。后复权价在除权日是连续的,权益不再假跳变。代价也有:费用按后复权金额计算,比例费率不变,但最低佣金 5 元的阈值在放大后的价格尺度下轻微失真——对大盘股几乎不触发,可接受,记进决策。

这个 bug 的教训:回测的价格口径必须自洽。真实价撮合看起来贴近实际,但除权日会出错;后复权口径牺牲一点直觉,换来权益连续。

一分钱也要对:费用计算用 Decimal

回测里第二个隐蔽的错,是浮点舍入。

过户费 909500 × 0.00001 = 9.095,round(9.095, 2) 在二进制浮点下可能得到 9.09,而手工四舍五入是 9.10——一分钱之差。单看无关痛痒,但回测的退出条件之一,就是逐笔费用与手工核算完全一致。一分钱对不上,就是核算不过。

修法很朴素:费用计算全走 Decimal,分项各自保留两位(ROUND_HALF_UP),合计 = 分项之和。从此与手工核算完全一致。

全仓买不入的细节与买不起的茅台

还有一个细节:全仓买入要预留费用空间。初始实现按 现金 / 价格 直接算股数,某票开盘 50 元、买 20000 股正好 100 万,加上佣金和过户费就超支了——直接拒单,一股没买。验证脚本报"应成交但未成交"。修法是超支时降档 100 股重试。

还有一个插曲:初始资金设了 10 万,回测跑完,茅台 0 笔交易。不是没信号,是买不起——茅台每股 1500 多,10 万连一手(100 股)都买不起。资金约束是回测设计的一部分,不是 bug。把初始资金调到 100 万/票,样本才都能交易。

验证:三层检查

回测语义对不对,靠验证。三层:

  1. 合成数据自测。造一只假股票,故意放一天一字涨停、一天停牌,验证买单被拒、卖单被拒、停牌不成交。写正式回测之前先过这一关。
  2. 自动断言。814 项:T+1 成交价等于次一交易日开盘价、股数全是 100 的倍数、费用逐笔重算一致、信号只用当日及以前的数据。
  3. 手工逐笔核算。挑 3 笔交易——成交价、股数、佣金、印花税、过户费,一笔一笔用真实行情手算,和系统输出完全一致。

三个 bug——全仓费用空间、浮点舍入、权益恒等差一分钱——都不是事先想到的,是逐笔核对时发现的。从那以后,验证就作为流程的一部分固定下来,不是最后补的检查。

Rust 为什么没有写

设计稿里,回测引擎最重的规划是 Rust 撮合核心(PyO3),理由是现成库非 A 股特化、Python 太慢、"不接受一次策略跑数小时"。这个理由在写代码之前听着很充分。

Python 版撮合写完后,我按路线图的原则先做了 profiling,再决定要不要上 Rust:

  • 当时的样本:50 只股票、43K 行 bar、2478 笔成交
  • 逐票回测加指标计算实测 0.30 秒,摊到单票 0.006 秒
  • 读数据、因子、信号、IC、报告加起来,纯计算不到 1 秒
  • 按行数线性外推(估算,非实测):全市场 5000 只股票,回测约 30 秒

"Python 太慢"是当时的预判。实测结果是全市场回测也就 30 秒量级,离"一次跑数小时"很远。设计稿里写过一条假设——"如果 Python 回测足够快,整个 Rust 取消"。它落地了:Rust 没有写。

结论只记在脑子里不够。我把触发条件写进了决策文档:单票回测超过 0.1 秒,或全市场回测超过 1 分钟,再考虑 Rust。将来规模真的上来,直接对照阈值做决定,不用重新争论。

小结

回测引擎的可信度来自三件事:A 股语义清单做对、价格口径自洽、验证方法兜底。性能不是难点,语义才是——回测的数字错得再快也没有意义,跑得再慢也总比数字错强。

Rust 没有写,和存储选型里没引入 DuckDB、PostgreSQL 是一个道理:不是技术不好,是当前的规模没有触发它。设计稿里的方案是候选,最后用不用,由真实数据和实测决定。