能跑:当设计稿撞上真实数据
上一篇文章里,我给自己定了个规矩:设计可以画到 v0.6,代码按四阶段长出来。第一阶段叫「能跑」,原则三个字:砍、硬、短。砍功能,硬编码可以,代码短、依赖轻。退出条件两条:挑 3 个交易日逐笔手工核算一致;信号没有未来函数。
然后我真的开始写了。这篇文章是「能跑」阶段的实录,重点不在代码怎么写的,而在设计稿里那些当时觉得合理的假设,遇到真实数据后哪些站不住、怎么改的。
第一天:契约不是复杂度
仓库就一个包,依赖四个:polars、tushare、plotly、pyarrow。没有数据库,没有七包,没有 UI。
但有一件事没省:数据契约。bar 存什么字段、因子值长什么样、信号怎么表达、成交记录记什么——在写任何拉数代码之前就冻结成了 schema。报告头也一并定义了:数据版本、commit、策略参数、费用参数、区间,任何一份报告都能回溯到当时的代码和数据。
当时觉得这有点过度,后来每次被验证脚本打脸时都在庆幸:契约让 bug 无处藏身。
拉数:接口文档和实际是两回事
Tushare 拉数很顺利,问题全出在细节:日期是 YYYYMMDD 字符串要显式指定格式;stk_limit 接口返回的列叫 up_limit / down_limit,不是文档里写的 limit_up。不跑永远不知道。
真正有意思的是校验。设计稿写了一条「复权因子单调」——听起来很合理,复权因子怎么会降呢?真实数据说:现金分红日,因子会小幅下降。茅台某天因子从 8.0205 变成 8.02,这不是数据错误,是分红除息的正常语义。于是校验改成「后复权价连续性」:close × adj_factor 不能异常跳变。而且阈值要按板块分——创业板涨跌幅 20%、主板 10%,宁德时代某天涨 14% 是合法的,用主板阈值会误报。
这是第一次,设计稿的假设被真实数据纠正。之后还有很多次。
回测:先把语义做对
策略选了最土的:双均线金叉死叉,参数 5 和 20。因子是动量(20 日收益率)。信号用后复权价算,防止除权造成假交叉。
难在回测的 A 股语义:T 日收盘出信号、T+1 开盘成交;一字涨停买不进、一字跌停卖不出;停牌不可交易;100 股整数倍。写之前先用合成数据自测——造一只假股票,故意放一天一字涨停、一天停牌,验证买单被拒、卖单被拒、停牌不成交。这套自测后来证明极其值钱。
验证抓到三个 bug
真实数据跑通后,按退出条件做验证:自动断言 + 手工核算。一共抓到三个 bug,都是「看起来对、算起来错」的典型。
第一个,全仓买入没预留费用空间。初始资金 100 万,某票开盘 50 元,算下来正好买 2 万股、金额 100 万,加上佣金和过户费就超了——直接拒单,一股没买。验证脚本报「应成交但未成交」。修法是超支时降档 100 股重试。
第二个,浮点舍入。过户费 909500 × 0.00001 = 9.095,round(9.095, 2) 在二进制浮点下可能得到 9.09,而手工四舍五入是 9.10,一分钱之差。换 Decimal 精确计算,从此与手工核算完全一致。
第三个,权益恒等差一分钱。equity 用未舍入的现金和市值求和再取整,而表里存的是取整后的值,偶尔差 0.01。统一口径后归零。
三个 bug 都很小,但都不是想出来的——是逐笔核对逼出来的。这也是阶段一最大的收获:验证不是收尾动作,是开发方法。
茅台的教训
还有个非 bug 的插曲。初始资金设了 10 万,回测跑完,茅台 0 笔交易。不是没信号,是买不起——茅台每股 1500 多,10 万连一手(100 股)都买不起,全仓逻辑遇到「买不起一手」直接不成交。
资金约束是回测设计的一部分,不是 bug。把初始资金调到 100 万/票,样本才都能交易。这个教训后来一直提醒我:回测参数要覆盖样本的真实约束。
结果
5 票样本、879 个交易日,814 项自动断言全过:T+1 成交价等于次一交易日开盘价、股数全是 100 的倍数、费用逐笔重算一致、信号只用当日及以前的数据。另外挑了 3 笔交易手工核算——成交价、股数、佣金、印花税、过户费,一笔一笔算,和系统输出完全一致。退出条件达成,进入「跑得准」。
「能跑」证明了一件事:拉数→因子→策略→回测→报告这个循环,转得起来,且数字可信到敢继续。而最大的产出,是一套验证方法论:合成数据测语义,自动断言查口径,手工核算兜底。
寒蝉 Hancic

