一个"已完成"的模块,其实从来没跑通过¶
现象¶
我的量化系统里有一条"板块动力学"链路:预计算每个行业的三态状态,供选股过滤使用。代码审查看上去一切完备——模块分层清晰、依赖注入齐全、测试文件就位。它还在方法论文档里被引用为核心组件。
真相是:这条链路从未成功产出过一次状态。三个相互独立的缺陷叠加在一起:
- 调用观察接口时的关键字参数名和函数签名对不上——每次调用必然抛类型错误;
- 原型库从来没有被训练或加载过——观察接口必然抛"未拟合"运行时错误;
- 两个脚本的"项目根目录"推导少上溯了一层——直接运行连包都导入不了。
任何一处都足以让它死在第一步。而它在我的代码库里躺了很久,身份一直写着"已完成"。
直觉判断的盲区¶
这条链路能以"看起来完成"的姿态躺很久,靠的是两个几乎无人会质疑的直觉:
- 代码存在且测试就位 = 模块完成。 模块分层清晰、依赖注入齐全、测试文件在——静态视角下无可挑剔。直觉不会去问"它运行过吗",因为在直觉里,"在仓库里"和"在运行"是同一个词。
- 方法没效果 = 方法无效。 如果当时不是做 A/B 验证、而是粗看一眼板块过滤的输出,看到"过滤之后还是全市场",最可能的结论是"板块状态过滤对我的数据没用",然后换方法。那样,这个 bug 会带着"方法已被证伪"的结论继续活下去——错误结论比没有结论更难纠正。
正确理解的入口是一个朴素到近乎廉价的问题:"它产出过产物吗?"——这比任何代码审查都快,也便宜得多。至于当初三个错误为什么能同时存活、为什么"第一次跑通"从来没进过完成定义,已无从还原;能确定的是:只要完成定义里没有端到端里程碑,这类模块就会被不断复制出来。
排查与关键证据¶
发现路径本身就是第一个教训:是在给它做 A/B 收益验证的第一天发现的。 验证脚本跑第一步就崩,然后像剥洋葱一样剥了三层:先是类型错误,修掉后是"未拟合"运行时错误,再修掉是模块导入失败。三层剥完,链路第一次跑通——而此时离"写完这条链路"已经过去了很久。
几个关键证据:
- "从未运行过"最硬的证明是代码级的,不需要考古:拟合/加载调用在预计算脚本里从未出现过,这是可以直接从代码读出的事实。修复后补的冒烟验证:三个板块 × 51 个交易日全部成功,状态分布为 stable 约 28%、critical 约 24%、transition 约 48%——一个正常的、有区分度的分布,说明链路确实活了。
- 回归测试锁死调用约定:用桩对象构造"hub → 管线 → 观察接口"的全链测试,参数名再错就会红。函数签名这类静态检查覆盖不到的合约,只能靠测试钉住。
- 一个附带的坑值得写:观察器在构造时绑定聚类器和转移矩阵的引用,事后替换管线对象上的属性不生效,必须重建观察器。共享组件 + 独立观察者的组合里,引用生命周期是隐形雷区。
根因¶
表层是三个低级错误,不值得写文章。深层是两件事:
第一,"存在性"和"可运行性"之间缺了一个检验环节。 import 通过、类型通过、lint 通过——但我从来没有把"第一次端到端成功 + 产物落盘"当作这条链路的完成标准。对一个从未运行过的模块来说,所有通过的质量门都只是"它看起来对",不是"它跑得通"。
第二,更让我后背发凉的:这条链路支撑的核心主张——"板块状态过滤能改善选股"——在此之前的证据量是零。 代码存在 ≠ 链路运行 ≠ 结论成立,这三者之间隔着两次跳变,而我此前把它们默认成了同一个东西。
约束与取舍¶
修好链路只是开始,真正的取舍发生在验证设计上:
- 原型库三选一。 市场级模型注入(样本最足,但个股截面和板块序列分布错配)/ 板块×时间面板专用训练(分布对齐,成本高)/ 板块截面单窗口拟合(样本=板块数,只配做诊断)。最后面板专用训练为主、市场级注入为兼容模式。放弃了每板块独立训练——板块样本天然不足,原型空间覆盖不了状态多样性。
- 板块指数自建。 数据源的指数日线权限失效(连历史区间都返回空),放弃官方指数,改为成分股等权聚合自建。代价:与官方口径不可比、存在成分漂移——必须显式声明,不能装作没差。
- 验证过程自己闯的祸(必须写进去):验证脚本里的拟合调用默认"保存"——第一次运行就把生产环境的默认原型库覆盖了,而那个目录不在版本控制里。救回来靠的是项目自带的重训脚本强制重建(旧模型停在几个月前的数据,重建后反而更新了);之后验证脚本一律改为"不保存"。教训:研究脚本对生产状态必须有写保护,"顺手保存默认名"是研究代码最危险的默认值。
- 对验证结果的诚实降级。 修好之后门控确实活了:stable 占比从 0 变成 10%~46%(随原型库和市场环境变化),过滤真实改变了持仓。但收益方向随市场环境翻转——高轮动熊市窗口里,板块面板库下 A1 比不过滤的 A0 多亏约 11 个百分点,市场级库下反而好约 4 个百分点且回撤更浅;妖股轮动的牛市窗口里,A1 比 A0 好约 43 个百分点。两个窗口里 20 日动量因子本身都大幅跑输等权基准(A 股短期动量强反转,Top10 集中持有动量头部股是主要亏损源)。结论只能写成:"过滤器有效果,但方向依赖 regime 和原型库选择;先修因子,再评估板块增量。"单一窗口的结论不能外推。
可复用检查清单¶
- 新链路的"完成定义"必须包含:一次端到端真跑 + 产物落盘 + 看一眼产物分布——测试通过不算完成;
- 怀疑"某个模块从来没跑过"时,最小探测动作是直接跑一次并检查产物文件的修改时间,比读代码快得多;
- 函数签名/关键字参数这类静态检查覆盖不到的合约,用桩测试钉死;
- 引用任何模块的结论之前,先确认这条链路产出过真实产物;
- 研究脚本默认禁止写生产状态(保存一律显式、非默认名);
- A/B 验证的结论必须分市场环境报告,单一窗口的数字只配写"初步"。