← 返回文章列表

AI 写的量化代码会不会偷看未来?用“前缀一致性”做一次小检查

作者:杨轩

AI 写的指标代码,有没有用到当时还不知道的信息?

用8个虚构价格,对比完整数据与历史前缀的指标输出:检出三种时序问题,也用一个反例说明零差异不能证明没有偏差。附离线代码与实跑结果。

AI 很容易写出一段能运行、能画收益曲线的代码。但能运行,只说明程序接受了输入;它不一定遵守“在当时只能知道当时的信息”。

这篇做一个可以离线运行的小实验:把同一段指标代码分别放在完整数据和历史片段上运行,看过去的输出会不会改变。用 8 个虚构价格、4 个小函数,先认识一种检查方法,再看它会漏掉什么。

这是 AI 辅助完成的方法实验,使用虚构数据,没有接行情、下单或测策略收益。附件只有 Python 标准库代码;没有运行 Freqtrade 或 pandas。

1. 先问:到第 3 步时,代码知道多少?

设一段价格序列为:

步骤: 1   2   3   4   5   6   7   8
价格:10  12  11  13  12  14  13  15

每一行都代表已经完成的观测。到第 3 步时,当前价格 11 已知,但第 4 步的 13 还不知道。这里没有交易日历、复权和盘中更新。

下面的函数故意取“下一步价格”:

def next_close(prices):
    future = list(prices[1:])
    return future + [None]

如果一次喂入 8 个价格,第 3 步输出 13;如果只给前三个价格,第 3 步只能输出 None。这就是一个很容易看见的时序问题:完整历史让代码获得了当时尚未出现的信息。

这类风险也出现在全样本预计算中。Freqtrade 官方的 lookahead-analysis 文档介绍了通过多次回测的输出变化检查前瞻问题的办法。下面借用“比较输出”的思路,做更小的指标练习,不是复现它的命令或回测流程。

2. 四个实现:三个问题,一个对照

  • next_close:读取下一行,当前步骤直接用了未来价格。
  • centered_mean3:算前一行、当前行、后一行的均值,居中窗口包含下一步。
  • full_sample_demean:当前价格减完整样本均值,早期步骤也用了后面的价格。
  • trailing_mean3:算当前行和前两行的均值,对照只用已完成的观测。

居中窗口在离线描述数据时可以有用途,但不能把包含未来观测的值直接当成当时可用的信号。pandas 的 rolling 文档说明了窗口位置的 center 参数;检查 AI 代码时,要留意窗口究竟取了哪些行。

对照函数如下。前两行历史不足,返回 None:

from statistics import fmean

def trailing_mean3(prices):
    return [
        fmean(prices[i-2:i+1])
        if i >= 2 else None
        for i in range(len(prices))
    ]

注意“当前行已完成”的前提:这里可以使用当前收盘值,不意味着你能在获得它之前成交。

3. 怎么检查:完整输出与每个历史前缀比较

“前缀”就是从第 1 行开始,截到当前行的片段。第 3 步比较的是:

  • 完整 8 行输入,取输出的第 3 个值。
  • 只输入前 3 行,取输出的最后一个值。

然后对第 1 到第 8 步各做一次。检查器要求函数确定、无外部状态,返回长度与输入相同;所有片段的起点固定为第 1 行。

核心循环可以概括为:

full = indicator(prices)
for i in range(len(prices)):
    partial = indicator(prices[:i+1])
    # 比较 full[i] 与 partial[-1]

附件中的完整实现还处理三件事:双侧都为 None 时跳过,不当作通过;只有一侧为 None 时算差异;两侧为有限数值时,用相对和绝对容差 1e-12 比较。无效价格、非有限输出和长度不符会报错。

这是一份教学实现,会反复计算,适合小样本和小函数。不要直接拿它逐行重跑多年全市场数据。

4. 实际结果:差在哪里,比差几个更有用

下表来自附件代码实跑,在 Python 3.14.7 与 3.12.2 下结果一致:

实现 差异 / 比较
下一步价格 7 / 7
居中均值 6 / 6
全样本去均值 7 / 8
历史均值 0 / 6

双空跳过数按上表顺序为 1、2、0、2,不计入比较数。

差异位置:下一步价格和全样本去均值均在步骤 1–7;居中均值在步骤 2–7;历史均值没有差异。

例如第 3 步,按“完整输入 → 前三行输入”列出输出:

实现 完整 → 前缀
下一步价格 13 → None
居中均值 12 → None
全样本去均值 −1.5 → 0
历史均值 11 → 11

完整样本均值是 12.5,前三行均值是 11,所以第三行的去均值结果分别为 11−12.5 和 11−11。第 8 步时两份输入完全相同,去均值自然相同;这不抵消前七步的问题。

拿到差异后,回到具体步骤查看“它用了哪些行”,比只盯着一个通过率更有帮助。

5. 零差异,不等于没有偏差

把 8 个价格全部改成 10,故意使用全样本均值的 full_sample_demean 也会得到 0 / 8 差异:完整样本和所有前缀的均值恰好相同。附件测试包含这个反例。

因此,这个检查只能说明:在选定输入和运行条件下,截去未来行有没有改变对应输出。它不能替你确认:

  • 成交时点:用收盘值生成信号,再声称按同一收盘价无条件成交,指标仍可能通过检查。
  • 数据可用时点:后来修订的财报、成分股名单、复权数据,可能本身就含有当时拿不到的信息。
  • 模型训练:函数外提前用全样本拟合并缓存的模型,不会因为你截短函数输入就自动重新训练。
  • 数据覆盖与运行状态:常数样本、未覆盖分支、随机数、外部缓存、跨标的数据可能让结果失真。
  • 历史长度:本例所有片段从同一起点开始,不检查上线时只拿到短历史造成的初始化差异。相关问题可参看 Freqtrade recursive-analysis。

发现差异是继续排查的线索;没有差异,也要检查数据、训练和成交流程。

6. 下载、复现,以及让 AI 帮忙检查

下载完整复现包 ZIP,解压后在该文件夹运行:

python3 check_prefix.py --out run-01
python3 -m unittest discover \
  -p test_prefix.py -v

代码面向 Python 3.10+,本次实际验证为 3.12.2、3.14.7,其他版本未逐个测试。不需要安装第三方依赖或提供密钥。已有 run-01 时请改用新名字,程序会拒绝覆盖旧报告。

run-01/report.json 含逐点输出、差异位置、容差、输入和脚本 SHA-256、Python 版本。附件另有已跑结果、使用说明和文件校验清单。换数据后请保留自己的输入和新报告,不把本例结果当作你的结果。

可以把下面这段提示连同一小段代码交给 AI:

请标出这个指标在每一步读取了哪些数据。假设当前行已经完成,对比完整输入与每个从第一行开始的历史前缀,列出对应输出的差异位置。双侧都无值时单独统计;给出可手算的小样本和一个可能漏检的反例。再单独检查函数外拟合、数据可用时点和信号成交顺序。不要把零差异写成策略无偏或可盈利的证明。

最后用四个问题收尾:当前行完成了吗?指标读取哪些行?数据当时已经公开了吗?订单最早何时能成交?

继续阅读:先检查数据 → 核对费用与成交配置 → 本文时序检查;需要选框架时再看量化工具研究。

本文代码与样本:资料与下载 →

让分享,成为交流的开始。

关于文章、AI 实践或阅读,有想法欢迎来信。