LumiBot / 未来函数与时间安全
LumiBot 的未来函数修复:一个 offset=-1 的 SMA,为什么能从 25 变成 1510
回测里最容易骗过自己的不是策略,而是指标。LumiBot 官方在自己的工程笔记里公开了一个真实的回归案例:旧实现把指标放在整个回测区间上算一次,再按策略时间取索引;结果是——当「次日收盘价」从 30 变成 3000 时,一个 offset=-1 的 SMA 读数从 25 跳成 1510。也就是说,第二天还没发生的数据,改变了今天的信号。
这一页把官方的修复逻辑逐条摊开:现实现如何先按策略时间截取前缀再计算、独立时间窗的三条硬规则(闭区间、必须带显式时区偏移、拒绝越过策略时间的 end)、以及官方自己列出的仍未证明清单。最后给一份可操作的自查表。
需要说清边界:以下全部依据官方仓库的英文工程笔记与 llms.txt 的代码生成硬规则,本站没有在本机复现这些测试;LumiBot 官方文档本身也明确写了「不要从前缀回归外推其它保证」。请把这一页当成提问清单,而不是「这个框架没有未来函数」的保证书。
修复前后对照
计算范围与缓存键的变化
docs/indicator-temporal-safety.md,2026-09-22 采集)。示意图只重述该文档陈述的事实;该文档同时列出仍未证明的部分,示意图不替官方扩大结论。未来数据是怎么溜进回测的:七种常见入口
「未来函数」听起来抽象,落地就是几件很具体的事:计算范围没截断、缓存键不对、时间来源用错、数据口径用了修订后的快照。下面这张表把入口、机制和典型症状对齐起来。
| 泄漏入口 | 具体机制 | 典型症状 | 注意点 |
|---|---|---|---|
| 整段区间的指标缓存 | 指标在整个回测数据上算一次,再按策略时间取索引。官方的例子就是一个 offset=-1 的 SMA,在「次日收盘从 30 变 3000」时从 25 变成 1510 | 回测曲线平滑得反常;越往后越好;对参数极不敏感 | 这是官方公开的回归缺陷本身。现实现已改为先按策略时间截取前缀再计算 |
| 自定义函数自己聚合未来行 | 自定义指标函数若自己去取数,可以跨行聚合、把后续 bar 也算进去 | 只有自定义指标的那一段异常好 | LumiBot 官方明确:自定义函数的时间安全由使用者自负 |
| 改动共享 provider 帧 | 自定义函数就地修改了共享的数据帧,污染同一策略里其它指标的输入 | 自己没有泄漏,但同策略其它指标开始漂移 | 官方把「自定义输入隔离」单列进了测试覆盖项 |
| 策略里直接取当前时间 | 用 datetime.now() / datetime.today() 取「今天」,而不是回测推进到的策略时间 | 同样的代码在不同日期跑出不同结果;跨月边界跳变 | 官方 llms.txt 的代码生成硬规则:必须用 self.get_datetime() |
| 指标窗口 end 越过策略时间 | 请求 start/end 窗口时把 end 写到策略时间之后,等于把未来 bar 要了回来 | 临近结束的几个 bar 收益率异常突出 | 现实现会拒绝任何越过策略时间的 end;每个请求在取数前先校验窗口 |
| 借用其他月/年的 warmup bar | 为了让指标「热起来」,从别的月份或年份借前置 bar 填进当前窗口 | 月初/年初的信号特别准 | 官方写明:计算只用该窗口,不跨月跨年借 warmup bar |
| 用修订后的快照当历史 | 宏观数据用今天的公开 CSV 快照跑历史模拟;这些端点里的值是被修订过的 | 宏观择时的回测好得不真实 | 官方内置 FRED 工具因此不使用公开 CSV 回退,改走 ALFRED 的时点版本,详见第 5 个板块 |
修复后变成了什么:按策略时间截前缀,再按观测值缓存
官方给出的关键改动集中在 Indicators._dispatch 一处:从「算完再查表」改成「先截断再算」。这个改动会牺牲一次算完的性能优化,官方是知情的、也是刻意这么做的。
| 环节 | 修复前 | 修复后 | 注意点 |
|---|---|---|---|
| 计算范围 | 整个回测 store | 按策略时间截取前缀后计算 | 这是修复的核心;范围一变,「未来改变现在」在结构上就不可能发生 |
| 参数校验 | (官方未在笔记中描述校验) | _dispatch 会先校验因果参数 | 非因果的参数组合在服务端就被拒,而不是悄悄算出一个好看的数 |
| 缓存键 | 按整段序列算一次 | 按观测值 digest 缓存 | 键跟着实际观测到的值走,修正过的历史值不会命中旧缓存 |
| 性能取舍 | 全未来序列只算一次(快,但不安全) | 该优化被刻意移除 | 官方说明:要换成增量实现,必须带等效的时间与修正测试才允许替换 |
| 自定义函数 | 与内置指标同等对待 | 自己取数的函数自负时间安全 | 自定义路径不会自动获得前缀截断的保护 |
| 批量请求 | 列表式接口 | 接受 requests_json(唯一 id + 每请求参数与时框);旧列表接口保留 | 非法信封在计算前就失败;id、指标名、时框都是有界字符串,数组/对象/超长值在取数前被拒,而不是被字符串化 |
| 时框元数据 | (官方未在笔记中描述) | 存储的时框必须与请求一致 | 官方原文:缺失的分钟序列不能拿日线缓存顶替并标成分钟输出;该请求要么走数据源取到对应时框,要么如实不可用 |
独立时间窗的三条硬规则,与三条容易被忽略的补充
如果不用框架内置的指标接口,而是自己按窗口取一段历史来算,那么窗口本身的定义就成了安全边界。官方为「独立 start/end 窗口」定了六条可核对的约束。
| 规则 | 具体约束 | 为什么要这样 | 注意点 |
|---|---|---|---|
| 闭区间 | start 与 end 都是闭区间(含端点) | 避免「最后一天算不算」在不同实现间产生一字之差的结果 | 自己写边界处理时别再用半开区间口径;两种情况在月末会对不上 |
| 必须显式时区偏移 | 窗口要求显式 timezone offset | 同一份日线在不同交易所时区里属于不同的自然日 | 不要把「本地时间字符串」当窗口端点传进去 |
| 拒绝越过策略时间的 end | 任何 end 超过策略时间都会被拒绝 | 这是把「要未来数据」这件事直接变成错误,而不是静默裁剪 | 这类请求会失败而不是返回近似值——看到失败先检查窗口,不要急着改数据源 |
| 取数前校验 | 每个请求先验证窗口,再做数据工作 | 非法请求不触发任何取数,避免用脏数据算出一个看着正常的数 | 批量请求里单个请求的失败会显式保留,不会被静默吞掉 |
| 只用该窗口 | 计算只用该窗口,不借用其他月/年的 warmup bar | warmup bar 是最隐蔽的跨期泄漏来源 | 代价是窗口前几根 bar 的指标可能为空,这是正确行为而不是 bug |
| 等价时区不改时钟 | 等价的时区偏移指向同一窗口,且不改动策略时钟 | 否则一次查询就会把策略的「当前时间」带偏,后续所有判断跟着错 | 同一窗口用 +08:00 与 +0800 写法都算同一窗口 |
| 空窗/不足返回 null | 窗口为空或数据不足时保持 null | 用 0 或前值填充会把「没有数据」伪装成「数据是 0」 | 策略里必须显式处理 null,不要直接参与算术 |
官方自己列的「仍未证明」清单,比任何第三方结论都值得读
这段内容在中文资料里几乎没人引:官方在说明修复之后,紧接着列了一串还不能从这次回归里推出的结论。它的价值在于把「机制已改好」和「所有场景都验证过了」明确区分开。
| 仍未证明的类别 | 为什么还不能算证明 | 你可以怎么自测 | 注意点 |
|---|---|---|---|
| 适配器相关的已完成 bar | 不同数据适配器对「日线日期标签、交易时段、时区」的处理不同,前缀回归只覆盖了指标层,没有覆盖每家数据源的 bar 完成语义 | 用你实际使用的数据源,取同一标的同一区间,用两个相邻策略时间点各算一次,比较前缀是否稳定 | 不要用「官方已修复」覆盖数据源层面的差异 |
| 多指标的独立复算值 | 文档的回归针对特定访问路径,未声明所有指标的批量组合都被独立复算验证 | 把批量请求的结果与逐个请求的结果对拍 | 批量接口的参数与时框都有边界校验,非法信封会直接失败 |
| 真实模型推理 | 官方原文写明这是确定性测试,不是真实模型评测,也不构成 provider/session 完成语义的证明 | 如果要评估 AI agent 决策,必须自己记录 model_call_id 与决策产物 | AI 决策的可复现性属于另一套机制,见记忆与回放 |
| 长历史性能 | 改动移除了「一次算完」的优化,长历史区间上的表现没有被声明为已验证 | 在你自己的目标区间上先跑一遍,记录耗时与内存 | 性能与正确性在这里是有取舍的,不要默认两者都拿到了 |
| 测试本身未在本机复现 | 仓库里存在 tests/test_indicator_temporal_safety.py,覆盖负偏移缺陷保持、自定义输入隔离、未来不变性、修正、时间回退与批量请求契约;但本站没有在本机运行它 | 自己 clone 仓库后运行该测试文件,并把它接进你的 CI | 本站对这条只做文献陈述,不做「已验证」表述 |
| 修正与时间回退的边界 | 测试覆盖项里包含「修正」与「时间回退」,但文档没有声明所有访问路径与所有数据源下都成立;换数据源等于换一套完成语义 | 在你自己的数据源上做一次「改一根历史 bar 的值,看后续读数是否只有该时点之后才变」的对拍 | 这类回归必须固定数据源版本,否则无法区分是代码变了还是数据变了 |
| provider / session 完成语义 | 官方原文写明:这是确定性测试,不是真实模型评测,也不是 provider/session 完成语义的证明 | 接实时行情时,自己记录「bar 何时被声明为完成」,并与指标读数时间对齐 | 把「指标没有前视」与「行情判定已完成」当成两个独立命题分别验证 |
为什么宏观数据不能用今天的公开 CSV 跑历史模拟
前面六种泄漏入口都在「代码」层,第七种在「数据」层,而且更隐蔽:你拿到的历史宏观数据,可能是被修订过的版本。LumiBot 的产品决策在这里很值得抄——它的内置 FRED 工具宁愿要求 API Key,也不用公开 CSV。
| 数据口径 | 它是什么 | 优点 | 风险 | 注意点 |
|---|---|---|---|---|
| 公开 CSV 快照 | 官方或第三方站点上「今天的」历史数据文件 | 免费、无需 Key、随手可用 | 宏观序列会被反复修订:你今天下载的「2020 年 GDP」,可能不是 2020 年当时就能看到的值 | 拿它做历史模拟,等于让策略看过了后来的修正 |
| ALFRED point-in-time | FRED 的档案服务,用 realtime_start/realtime_end 指定「在某个时点能看到的版本」 | 能还原当时的信息状态,符合回测的因果要求 | 需要 FRED_API_KEY;查询写法比读 CSV 复杂 | 这正是 LumiBot 内置 FRED 工具采用的路径 |
| 内置 FRED 工具 | list_fred_series / get_fred_series / get_fred_latest / get_fred_snapshot | 把「时点版本」固化进工具接口,agent 拿到的就是当时口径 | 没有 Key 就用不了这几个工具 | 官方明确不使用公开 CSV 回退,理由写在文档里:那些端点含修订值、不是历史模拟的安全默认值 |
| 宏观数据的「最新值」 | get_fred_latest 这类取最新观测的用法 | 做当下判断很方便 | 放回历史回测里就是典型的前视 | 区分「现在用」与「回测用」是两件事 |
| 行情数据的等价问题 | 复权、分红、拆股、退市处理 | 各家数据源都能给 | Yahoo 有复权与分红、Alpaca/Polygon/Tradier 有复权但不含分红(官方对照表) | 换数据源等于换口径,收益率会变;见数据源与路由 |
| 你自己下载的本地文件 | 用 Pandas/CSV 走自有数据路径 | 完全可控、离线可跑 | 来源与时点是否一致,只有你自己知道 | 建议在文件名或元数据里写死「下载日期 + 数据版本」,否则半年后无法复现 |
| 缺口与停牌的处理 | 停牌、退市、节假日导致的缺失 bar 怎么补 | 不同处理方式都合法 | 用前向填充把缺口抹平会制造「价格不动」的假 bar,进而改变波动率与均线形态 | 合格的答案要能指出缺口检测规则,以及被填充的 bar 在指标里如何被识别 |
拿到一份回测结果,先问这八个问题
下面这份清单把上面五个板块的结论收拢成可执行提问。它的用法不是「逐条打勾」,而是逐条要证据——问不出答案的那一条,通常就是结果最可疑的地方。
| 要问的问题 | 为什么重要 | 合格的答案长什么样 |
|---|---|---|
| 策略里的「当前时间」是怎么取的? | 用 datetime.now() 取真实当前时间,在回测里就是前视;跨日期跑还会得到不同结果 | 能指出代码里用的是 self.get_datetime();官方 llms.txt 把这条列为硬规则 |
| 指标是在什么范围上算的? | 整段区间算一次再取索引,就是 25 变 1510 那个缺陷的结构 | 能说明是按策略时间截取前缀后计算,并说明缓存键跟着观测值走 |
| 有没有自定义指标函数?它自己取数吗? | 自定义函数的时间安全自负,不会自动获得前缀截断保护 | 能给出该函数的取数范围,以及它是否改动过共享的数据帧 |
| 时间窗的 end 有没有越过策略时间? | 越过策略时间的 end 会被拒绝;若没被拒绝,说明你绕过了校验路径 | 能给出窗口定义方式,以及是否出现「空窗返回 null」的处理分支 |
| 指标窗口前几根 bar 是空的,还是被填了值? | 用前值填充或跨期借 warmup bar 都是伪装成正常数据的泄漏 | 承认前几根为空,并说明策略里如何跳过这一段 |
| 宏观数据是修订后的快照还是时点版本? | 修订值会让策略「提前知道」后来的修正 | 能指出用的是 ALFRED 的 realtime_start/realtime_end 口径,或明确承认用的是快照并接受该限制 |
| 换数据源后结果还成立吗? | 分红/复权口径差异会直接改变收益率 | 能说出各数据源在除权/分红上的差别,并做过至少一次跨源对拍 |
| 这段表现是样本外还是样本内? | 参数在样本内调好再看样本内结果,等于自己给自己发奖状 | 能给出样本外区间、随机种子、以及至少一次参数扰动后的结果 |
| 手续费与滑点在回测里怎么算的? | 成本假设直接决定高频策略的盈亏符号;扣与不扣可以得出相反结论 | 能指出设置在哪里、数值来源(固定值还是按交易所档位)、以及是否随时间变化 |
| 数据缺口是怎么处理的? | 用前向填充抹平停牌缺口会制造「价格不动」的假 bar,改变波动率与均线形态 | 能说出缺口检测规则,以及被填充的 bar 在指标里如何被识别或跳过 |
关于未来函数与回测可信度的高频问题
以下回答基于 2026-09-22 对官方仓库 docs/ 工程笔记、llms.txt 与 PyPI 元数据的核对;涉及具体实现行为的,以官方仓库实现为准。本站未在本机运行官方测试,也不对任何具体策略回测下「有没有未来函数」的结论。
LumiBot 到底修了什么?一句能听懂的话
把指标的计算范围从「整个回测区间」改成「到策略当前时间为止的前缀」,并让缓存键跟着实际观测到的值走。改动之后,未来某天的数据在结构上不可能再影响今天的指标读数;官方同时主动删掉了那个快但不安全的「一次算完」优化。代价是性能,收益是因果正确性。具体实现以官方仓库为准。
那个「25 变成 1510」是怎么发生的?
旧实现对整个回测 store 只算一次指标,策略时间只用来取索引。于是一个 offset=-1 的 SMA 在计算时已经用到了后面的 bar;当「次日收盘从 30 变成 3000」时,同一根 bar 上的读数就从 25 变成 1510。读数的变化完全由未来数据驱动——这就是未来函数最典型的形态。案例数字来自官方工程笔记原文。
修复之后,我的回测就一定能信任了吗?
不能这样推。官方在同一份文档里列了四类仍未证明的部分:适配器相关的已完成 bar(日线日期标签、交易时段、时区)、多指标的独立复算值、真实模型推理、长历史性能,并写明「不要从前缀回归外推这些保证」。所以正确读法是:指标层这一类的缺陷已被针对性修复,数据源层与模型层的口径仍要你自己核对。
我用的是自定义指标,会被保护到吗?
不会自动生效。官方明确写了:自己去取数的自定义函数,时间安全由使用者自负。也就是说前缀截断、观测值缓存这些保护是给框架内置指标路径的;你的函数如果绕过它去直接读数据,就可能重新引入跨行聚合或改动共享帧的问题。建议:自定义指标只接受框架已经截好的输入,不要在函数内部自己发起取数。
为什么宏观数据不能用公开 CSV?我下载的也是官方数据啊
因为宏观序列会被反复修订。你今天下载的「某年 GDP」,很可能不是当年当时能看到的版本;拿它跑历史模拟,等于让策略看过后来的修正。LumiBot 的选择是:内置 FRED 工具要求 FRED_API_KEY,走 ALFRED 的 realtime_start/realtime_end 取时点版本,并明确不使用公开 CSV 回退。这个取舍值得抄;如果你只能用 CSV,至少把下载日期与版本写进文件名。
时间窗为什么必须带显式时区偏移?我本地时间不行吗?
不行,因为同一根日线在不同交易所时区里属于不同的自然日,而窗口是闭区间。官方要求窗口带显式 timezone offset,并且说明等价的时区偏移会指向同一窗口、且不会改动策略时钟——后者很关键,否则一次查询就会把策略的「现在」带偏,后面所有时间判断跟着错。空窗或数据不足时返回 null,策略必须显式处理。
我怎么知道自己的回测有没有这类问题?
从症状反推比从代码反推更快:①曲线平滑得反常、越往后越好;②只有某一段(尤其是月初/年初、或你自定义指标的那一段)特别好;③同样的代码换台机器或换个日期跑,结果就变了。这三类现象出现时,先按本页第 7 个板块的八个问题逐条要证据,再谈策略是否有效。本站不给「合格收益」这类判断,只提供提问框架。