qlib 里找不到「前视偏差检测」命令,因为它把这件事挪到了数据层
- 能说清「事后检测」和「数据层防错」是同一个问题的两条路,以及各自成立的前提条件
- 能看懂时点数据层的核心动作——把按报告期排的数折叠成按观察日排的数——以及为什么指定了期号也换不来一个尚未公布的值
- 能说出这套防错收的三笔费用,以及它明确管不到的范围
你已经知道有的交易框架专门造了工具,用来查用户写的策略有没有偷看未来。于是你打开另一个做投研的开源项目,习惯性地去找它的对应命令——检测、分析、诊断,翻了一圈,没有。
你的第一反应大概是:这个项目不重视这件事。
再翻一会儿你会发现刚好相反。它对这件事的重视程度,高到不肯用「检测」这种方式解决。它把整件事往前挪了一层,挪到了数据层:它没有造一台检测偷看的机器,它让「偷看」这个动作没有对应的操作。
这个对照,比两边各自的实现都值钱。它是本卷里我最想让你带走的一处东西。
先说人话:出厂抽检,和插不进去的插头
想象一个组装车间,工人要把一根线插到板子上。这根线插反了,板子当场不坏,但用上三个月会出问题。
第一种办法:装一套检测工位。每块板子组装完过一遍机器,测电压、比对参考样本,插反的挑出来返工。这套办法有效,但你得付钱——机器要买、要校准,产线要慢下来,还会有误报,每次误报都得停下来人工复检。更麻烦的是它只能抽检你实际测到的那些板子,测漏的就是测漏了。
第二种办法:把插头做成不对称的。缺一个角,或者一边宽一边窄,插反了物理上就是插不进去。这套办法一分钱的检测成本都没有,也永远不会漏检——因为「插反」这个状态压根不存在。
第二种办法当然更好。但它有个前提,而且这个前提很硬:插头的形状得归你管。 如果那根线是客户自己带来的、标准接口、你无权改,那你只能装检测工位。
这两种思路在工程里都有名字,但名字不重要。重要的是它们之间的选择从来不是「谁更聪明」,而是**「你有没有资格改那个插头」**。
投资层:这两种保证,含金量不一样
把这套区分挪到投资研究上,你马上能用。
一份历史检验结果摆到你面前,声称没有用到当时拿不到的信息。这句话背后可能是两种完全不同的东西。
一种是「我查过了」。 它的可信度取决于查得全不全。查了几笔交易?覆盖了哪几种情形?没触发过的分支等于没查。这类保证有一个致命的表达问题:「查过了没有问题」和「没查出来问题」,在报告上长得一模一样,而后者可能只是因为压根没查到那儿。关于这条路怎么走、以及它自己承认的盲区,一个框架专门造工具来防的坑里讲得很细,这篇不重复。
另一种是「我问不出来」。 它的可信度不取决于覆盖率,因为它根本不是抽检。它取决于另外一件事:这套「问不出来」的机制,管的范围有多大,以及它自己吃进去的输入干不干净。
你该问的问题因此完全不同。面对第一种,你问「查了多少、漏了哪些」;面对第二种,你问「哪些数据走了这条路、哪些没走」和「公布日这一列本身是从哪来的」。
后面这半篇,讲的就是第二种是怎么造出来的,以及造它花了多少钱。
系统层:它到底是怎么做到「问不出来」的
这里需要先把前视偏差这类问题在基本面数据上的具体形态说清楚一句话:财报有两个时间,它描述的那段时间(报告期)和它被公开的那天(公布日),两者差着一两个月,而且同一期数据还会被反复修订。这个概念本身、以及时点数据文件长什么样,站内时点数据那篇已经讲透了,请先读那篇。
这篇只问一个工程问题:知道了这些,一个数据层要设计成什么样,才能让用户想偷看都偷不着?
答案的核心只有一件事,而且不是「加了个检查」。
它换了一套坐标
原始的财务数据,天然长在一套坐标里:横轴是报告期。你手上那张表,每一行标着某年某季,这就是它的世界。
但回测需要的是另一套坐标:横轴是观察日。你要问的是「二〇一二年四月十号那天,我知道的是多少」,而不是「二〇一一年四季度那一期是多少」。
这两套坐标之间不是简单换个标签就能对上的。同一个报告期,在不同的观察日上,答案可以不一样——没公布之前它不存在,公布之后它是一版,修订之后它是另一版。同一个格子,随着你站的位置移动,里面的数会变。
绝大多数偷看未来的事故,本质上就是把这两套坐标当成一套用了:拿报告期当日期贴回去,一贴就早了一两个月,而且贴的还是修订完的最终版。这个动作在表格软件里只需要一次拖拽,在代码里只需要一次合并,没有任何东西会提醒你。
这个数据层的全部设计,就是逼你显式地做一次坐标变换,并且只提供一条合法的变换通道。
折叠:把一整套坐标压成一个点
它提供的那条通道,源码作者在文件开头的说明里写得很清楚:整件事分两个阶段,先在报告期坐标里算完,再折叠成观察日坐标里的一个点,最后把所有点串起来。
翻译成人话,对日历上的每一天,它做这么三步(示意,不是源码):
原始数据的坐标: (报告期) → 值 例:2011 年四季度那一期,值是多少
回测需要的坐标: (观察日) → 值 例:2012-04-10 这天,我知道的值是多少
对日历上的每一天 t:
1) 站在 t 这天,重建一张「按报告期排的表」
—— 公布日晚于 t 的记录,全部当作不存在
2) 在这张只含当时可见信息的表上,把你的表达式整个算完
3) 只取结果的最后一个数,作为 t 这天的值
把每天得到的那一个数串起来 → 一列按观察日排的普通特征
第二步是这套设计最讲究的地方。它不是先算好再截断,也不是截断了只取原始值——它是在一张「当时的表」上,把你整个式子从头算一遍。所以你写「最近四期的合计」也好、「本期比上期」也好、「两个指标相除再取变化率」也好,每一天算出来的都是那天那个人能算出来的数,包括那天那个人算错的部分。
第三步的「只取最后一个数」看着不起眼,其实是整个封装的落点。折叠完成之后,这一列在外面看就是一列普通特征了,跟收盘价没有区别。你可以在它外面再套滚动平均、再做标准化、再拿去当模型输入,这些后续操作全都自动是诚实的——因为每一天的输入已经在源头上诚实了。
这一点非常关键,值得单独说一句:在数据层解决,正确性会自己往下传;靠事后检测解决,正确性不会传。 检测是一次性的,你换一套规则就得重新查一遍,查一遍的钱还得重新付。而坐标变换只做一次,之后所有人、所有策略、所有模型都白拿这个保证。这是「防错」相对「检测」最大的一笔红利。
你指名道姓要那个数,它也不给
前面那些还只能算「设计合理」。真正让我觉得这套东西认真的,是另一个算子。
它允许你明确指定一个报告期——你不再说「最近一期」,你直接说「我就要某年某季那一期的数」。听上去这就绕开了整套机制:期号是我写死的,你还怎么拦我?
上游测试文件里就查了这么一列:指定一个后来才公布的季度,然后从那之前一年多开始往后看。测试里写死的期望值是这样的——在这一期真正公布之前的每一个交易日,那一列返回的是空值;直到公布日过去,它才开始出数,而且出的是当时那一版,不是最终版。
你指名道姓要它,它给你的回答是「没有这个东西」。
这一处最能说明这套设计的性格。它不是在你的输入上做过滤,它是让「未公布的数」这个东西在那个时刻的世界里根本不存在。你问一个不存在的东西,得到的当然是空。
同一份测试里还有个更细的对照:同一天,指定期号的那一列是空,而「最近一期」的那一列有值——因为「最近一期」指的是当时已经公布的更早那一期。两列并排放着,一空一实,把「当时」这两个字画得很清楚。
拒绝发生在三个不同的层级
顺着看下去会发现,「拒绝」这件事被拆到了三个地方,各管一段。这个分层本身就是可学的工程习惯。
在类型这一层,字段名的后缀决定它按季度还是按年度解析,不带合法后缀的字段直接被拒——因为这套机制只对会被修订的周期性数据成立,别的数据不许混进来蹭。
在接口这一层,你没法绕过折叠算子直接去取原始字段。真去取了,它不但报错,还在错误信息里把正确写法给你写出来。这个细节我特别喜欢:报错信息本身在教学。一个愿意在异常里写教程的项目,说明维护者预判到了大量用户会在这里走错,而且认为纠正比拦截更重要。
在查询这一层,任何试图往后取期数的表达都会抛错,理由写在错误信息里:这套数据库不支持引用未来的报告期。同一件事在下游的取数函数里还有一道断言把着,等于同一个禁令被写了两遍——不是冗余,是不同调用路径都得堵上。
三层加起来,效果是:你不是「被查出来了」,你是根本没能把这句话说出口。
一个诚实带来的副作用:特征向量会漏洞
这一节讲的是代价,而且是很多人没预料到的那种。
财务指标不是一起公布的。同一家公司的两个指标,有可能一个先出、一个后出;不同公司之间更是错开的。当你把两个时点指标放在一起做运算的时候,就会撞上一种情况:这一期,A 有了,B 还没有。
源码里对这件事有一句很坦白的注释,大意是:由于这套数据是按报告期对齐的,当只有部分财务指标公布时,跨指标的计算可能产生出乎意料的值(比如空值)。
它没有替你补。没有向前填充,没有拿上一期顶上,没有用同行业均值抹平。它把「这一天,这个组合算不出来」原样交给你。
对做模型的人来说这很不舒服。你的特征与标签矩阵会在某些日子出现空洞,而且空洞出现的位置有规律——正好在部分披露的那几天,也正好是信息在变化的那几天。你当然可以补,但补的那一刻你就得自己想清楚:你补进去的东西,当时的人真的知道吗?
这个副作用其实是个礼物。它把一个平时被自动填充悄悄抹掉的问题,摆到了你必须做决定的位置上。多数数据处理流程的「贴心」,恰恰就是在这种地方把「我不知道」偷偷改写成「我知道」。
这套防错,收了三笔钱
插头做成不对称的,也不是免费的。这套设计的账单很具体。
第一笔:算力,而且是明摆着的
看看那个折叠动作的循环:对日历上的每一天,都重新走一遍完整的取数和计算流程。 不是算一次然后往后填,是每一天独立地重建一次「当时的视图」。
代价有多大,源码作者自己写在了注释里。他在最耗时的那一段前面留了两行(qlib 采用 MIT 许可,原文如下):
# NOTE: The most significant performance loss is here.
# Does the acceleration that makes the program complicated really matters?
大意是:性能损失最严重的地方就在这儿;那个会让程序变复杂的加速,真的重要吗?
紧接着的注释里他交代了处理结果:他主动废弃了之前那版更快的实现,理由是那版会让接口参数变复杂、逻辑不好懂,而且拼在一起看性能也未必最优;取而代之的是保持逻辑简单,另外给索引文件加缓存。文件里还留着成对的、被注释掉的加速代码,像一段没删干净的犹豫痕迹。项目文档在「已知限制」里也直接承认这套计算不是最优实现,还有很大的提速空间。
一个把正确性做进数据层的项目,选择了慢而简单,并且把这个选择写在注释里给你看。 你可以不同意这个取舍,但你没法说它藏着掖着。
第二笔:自由度
这是更贵的一笔,而且不体现在任何性能指标上。
要让折叠成立,用户就不能直接摸到原始数组。你得用它的那套表达方式来说话——你想算什么,得能用它认识的算子拼出来。拼不出来的东西,你就没法在这套保证之下算。
对照一下另一条路:造检测工具的那个框架,把一整张历史数据表交到你手上,你爱用什么方式处理都行,写任何计算都可以。自由度拉满,代价就是它没法在事前拦你,只能事后来查。
防错的代价永远是自由度,这条规律没有例外。 插头做成不对称的那一刻,你就同时失去了「反着插也能用」的可能性——哪怕某个场合你真的需要反着插。
这也顺带回答了开头那个问题:为什么两个项目选了不同的路。不是谁想得更深,而是谁攥着数据的门把手。数据从它自己的引擎里出来的项目,有资格改插头;把数据整张递给用户的项目,只能装检测工位。你以后评估任何一套研究工具,都可以先看这一条:它有没有资格防错?如果没有,那它的所有保证就都只能是抽检级别的。
第三笔:你得先有一列真的公布日
这一笔最容易被忽略,也最致命。
整座堡垒建在一个假设上:每条记录上那个公布日,是真的。 这一列不是算出来的,是从数据源那里抄来的。抄错了、抄漏了、或者数据源自己是事后回填的,堡垒就是个摆设——因为过滤器本身用的就是被污染的时间。
而且这类污染没有任何自动手段能发现。你在下游看到的一切都会正常运转,查询会规规矩矩地按公布日过滤,只不过它过滤用的那把尺子刻度是错的。上游项目提供了抓取和转换的脚本来生成这些文件,但数据本身来自外部源——质量责任在源头,不在这套机制。
所以「用了时点数据库」这句话,跟「我的历史检验干净」之间,还差一整个数据尽调。想系统看看历史检验里还有哪些别的坑,回测偏差总览那篇做了分类。
它明确管不到的地方
把边界说清楚,这篇才算负责。
只管会被修订的周期性基本面数据。 项目文档在已知限制里写明了这一点:这套设计面向季度或年度的财报类数据。价格、成交量这类不修订的数据不走这条路,它们有自己的时间对齐问题,那属于另一篇的范围——想看这个项目的数据层整体是怎么组织的,读它的数据层为什么长这样;想先知道这个项目整体在干什么,读qlib 是什么。
不管名单。 你用今天的成分股名单回到几年前,那份名单早就把中途掉队的公司剔掉了。这个漏洞跟修订毫无关系,时点机制一个字都碰不到它。
不管你在它外面干了什么。 折叠之后那列特征是诚实的,但你把它导出来、跟别处来的数据拼在一起、再自己对齐一次日期——这一步归你负责,没有任何东西会拦着你。防错装置只在它的地界里有效,出了地界,你又回到了赤手空拳。
不管方法层面的问题。 参数在历史上被磨得过于合身、样本外检验做得不老实、你反复试了几十种设定才挑出最好看的那个——这些跟偷看未来是完全不同的病因,时点数据一个都治不了。它甚至会让这些问题更隐蔽,因为你现在有了一句很有底气的话:「我的数据是时点的。」
给不写代码的你
你不一定会自己搭数据库,但上面这套区分可以直接拿去用。
第一,先问「这个保证是抽检来的,还是构造上就不可能」。 这两句话的分量差着一个数量级。听到「我们检查过了」,接着问覆盖率;听到「我们的数据层不允许」,接着问范围和数据源。
第二,问「哪些数据走了这条路」。 一套研究里往往只有一部分数据享受了这个保证。财报走了,价格没走;主表走了,从外面拼进来的那几列没走。保证是分块的,不是整套的。
第三,问「公布日这一列从哪来」。 这是最能分辨真做过和听说过的问题。真的搭过这类数据的人,会立刻跟你聊数据源、聊哪些字段的公布日不可靠、聊他怎么处理修订;没搭过的人会告诉你「我们用的是专业数据库」。
还有一条是关于你自己的。你现在回想两三年前对某家公司的看法,脑子里浮现的所有数字都是修订后的版本,都带着后来发生的事。人脑没有公布日这一列,它会用最新的版本静悄悄地覆盖旧版本,还不留痕迹。你给自己搭的那个「数据层」,就是决策日志——把你当天知道的和当天怎么想的冻在那儿。这件事一分钱不花,而且是本篇里唯一你今天就能开始做的。
别把「数据层做了防护」当成「这套研究可信」。 防错解决的是一类实现错误,它不产生任何关于方法好坏的信息。一套用了时点数据、历史成绩依然平庸的方法,和一套没用时点数据、历史成绩很漂亮的方法,前者诚实,但两者都还没回答「这个想法本身对不对」。工具能替你堵住犯错的路,不能替你走对路。
小结
- 同一个问题有两条路:造工具事后检测,和在数据层让它不可能发生。选哪条不取决于谁更聪明,取决于你有没有资格改那个插头——数据从自己引擎里出的项目才有资格防错。
- 数据层防错的核心不是「加了检查」,而是换坐标:原始数据长在报告期坐标里,回测需要观察日坐标,绝大多数偷看事故就是把这两套坐标当成了一套。
- 折叠动作是每天重来一遍:站在那一天重建当时可见的表,把整个式子在这张表上算完,只取最后一个数。因此后续所有加工自动诚实——防错可以往下传,检测不能。
- 它认真到你指定报告期也没用:那一期公布之前,返回的是空,不是值。
- 拒绝分三层:类型层拒非周期数据,接口层拒绕过折叠(并在报错里教你正确写法),查询层拒引用未来期。
- 账单有三笔:每天重算的算力(作者自己在注释里承认这是最大性能损失,并主动放弃了更快但更复杂的实现)、必须用它的表达方式说话的自由度、以及整座堡垒依赖公布日那一列是真的。
- 它不管名单、不管价格类数据、不管你把数据导出去之后的操作、更不管方法层面的过拟合。
一句话记住这篇:与其造一台机器去查有没有人偷看,不如把要偷看的那个东西,在那个时间点上根本不摆出来。
本文所引源码、文档与测试期望值均来自前述快照版本,是阅读代码得出的机制说明,不是运行结果;实现细节会随版本变化,具体行为请以你手上那个版本的文档为准。本站内容为投资教育,不推荐任何标的、不承诺任何收益,也不构成投资建议;涉及自动化交易的部分风险自负,边界见免责声明。
本文为投资教育整理,关键数据与结论请结合下列权威来源验证。
- qlib 源码快照 79633dd(2026-07-23):qlib/data/pit.py 中的 P 与 PRef 算子、qlib/data/data.py 中的 LocalPITProvider.period_feature、qlib/utils/__init__.py 中的 read_period_data 与 get_period_list、docs/advanced/PIT.rst、tests/test_pit.py。仅阅读源码、文档与测试中写死的期望值作概念注解,未实际运行 ↗
- freqtrade 源码快照 b3404c9(2026-08-18):freqtrade/optimize/analysis/ 下的前视与递归检测、docs/lookahead-analysis.md。本篇仅作对照引用,同样未实际运行 ↗