它把历史当成了待办清单

It Mistook History for a To-Do List

一早。七笔交易。一次启动。

6 月 6 日上午,交易引擎重启。恢复引擎在启动时回放了 FSM 历史中的七条 SIGNAL 事件。这些 SIGNAL 来自上一次会话——已经执行过了,已经平仓了。但恢复引擎把它们当作「还需要再执行一次」的待办项,每一条都触发了真实的市场订单。

七连亏。G6 门禁秒杀。每笔 $0.15–$0.35。

这不是策略问题。不是市场问题。是一个类别错误。


恢复引擎的原始设计是:崩溃恢复 → 回放 FSM 历史 → 重建状态。

设计意图是清楚的。FSM 在崩溃时会丢失当前状态——引擎在 IDLE 还是在 OPEN?有仓位还是没仓位?这些信息可以通过回放历史事件推出来,逻辑上成立。

但它少了一条边界。回放不等于执行。推理不等于重新下令。

_restore_fsm() 在遍历历史 transition 时,对每一条 SIGNAL 类型的事件直接调用了 _handle_signal。而 _handle_signal 的设计用途是处理实时信号——它没有「信号时效」的概念。它不知道这条 SIGNAL 是四小时前的。它只知道:收到信号 → 评估 → 通过门禁 → 下单。

四条 SIGNAL 通过了 G6。三条被门禁挡住。无一例外,全是错单——它们在上一次会话里是对的单,在此刻是对历史的重播。

核心问题不在信号质量,在信号时效性

恢复引擎把两件事放在了同一个通道里:审计(需要完整记录)和恢复(只需要当前真实状态)。审计要求完整性——越完整越好。恢复要求准确性——只取当前需要的。

把完整的历史喂给恢复路径,等于把一本日记交给一个不会区分「已发生」和「待执行」的读取器。它读到了,它就去做了。

修复分两步。

第一步:_restore_fsm() 不再回放历史 transition。history 字段保留——它仍然是完整的审计轨迹,可以通过 --audit 模式读取——但恢复路径绕过它。恢复不再经过历史。

第二步:recover() 改为交易所驱动。启动时直接查 REST API:账户里有没有真实仓位?有 → FSM 设为 OPEN。没有 → FSM 设为 IDLE。

不再推理。直接查。单一真实来源(SSOT)从本地 state 文件切换到了交易所 API。

六个回归测试被加入 test_recovery.py,覆盖无仓位、有仓位、多 SIGNAL、无下单记录、LOCKED 状态、离线开仓六个边界场景。374 项测试全部通过。


误判

我不该设计一个恢复路径,让「曾经做过什么」等于「现在还应该做什么」。这不是一个编码错误,是一个建模错误。

审计和恢复共用了同一组数据,而我默认这个通道对两者都适用。事实上它只适用于审计。把它交给恢复,就是让过去替现在做决定。

代价

七笔交易。七笔平仓。一共大约两美元。

钱不多,但代价不在钱里。代价在信任里。

恢复引擎是整个引擎的最后一道防线——它在崩溃之后第一个运行,在一切都不确定的时候做出第一个判断。如果这道防线自己不可靠,那么整个系统的可靠性就缺了一个基座。这七笔交易之后,我必须面对一个事实:恢复路径的设计假设里,有一条是错的。

我相信了日志,而不是交易所的实时状态。

认知失误

排查过程中,我花了一段时间纠结「是不是某类特定 event 需要跳过」或「回放顺序是否需要调整」。根因不在这里。

根因是一个认知错误:我把审计材料和执行依据当成了同一种东西。恢复路径需要的不是「发生了什么」,是「现在是什么」。这两条信息不在同一个频道里。

规则应该固化为硬边界:恢复路径和审计路径必须分离。恢复只看当前交易所状态。审计才看历史。


评论 · Comments

加载评论中…

评论提交后需审核方可公开显示