我们在一笔交易进行到一半时重启了一个模拟策略,得到的订单序列和重启前不同。市场数据相同,策略版本相同,账户余额也相同。信号计算结果一致,但策略对持仓和未完成订单的记忆却不一致。
我们花了一天时间重放,追查这处差异。得到的经验很简单:对软件来说,重启也是一次市场事件。如果你不测试策略如何重建状态,干净的回测就可能掩盖模拟交易系统忘记自己持有什么的问题。
09:10 — 我们选了一个普通持仓
测试策略交易一种流动性较好的永续合约:短期移动平均线向上穿过长期移动平均线时做多,反向交叉时平仓。开始重放时,我们已经有一个小仓位,并挂着一笔只减仓限价单来减仓。这样一来,恢复过程需要重建两项信息:我们持有什么,以及已经要求交易场所执行什么操作。
检查点时,账户持有 0.04 张合约,另有一笔 0.01 张的挂单。策略进程在内存中缓存了这两个值,但启动流程只获取了持仓,把缓存中的订单当成已经消失。
09:25 — 第一笔重复订单出现了
重启后,策略发现已有多仓,运行信号逻辑,又提交了一笔 0.01 张的减仓单。模拟交易场所现在有两笔有效订单。单看每一笔都没有问题,但如果两笔都成交,卖出的数量就会是预期的两倍。
起初我们以为是信号循环出了问题。问题不在循环,而在快照不完整。策略问了“我持有什么仓位?”,却从没问“哪些订单仍在执行中?”
| 重启后的状态 | 进程的判断 | 账户实际持有 |
|---|---|---|
| 持仓 | 多仓 0.04 | 多仓 0.04 |
| 未完成的减仓单 | 无 | 两笔,每笔 0.01 |
| 一笔订单成交后的目标敞口 | 多仓 0.03 | 可能变成多仓 0.02 |
10:00 — 我们修复了恢复流程,随后发现了时序边界问题
我们调整了启动流程:先根据账户持仓和未完成订单重建状态,再允许策略做出新决策。重复订单因此消失了。接着,我们让断连场景变得不那么简单:策略离线期间有一笔订单成交,成交通知则在重连后才到达。
账户快照已经反映了这笔成交。之后到达的延迟通知又让本地持仓减少了一次。有几秒钟,策略以为自己持有 0.02 张合约,而账户实际持有 0.03 张。它接下来的再平衡因此基于一个虚假的仓位缺口。
我们增加了对账规则:以账户快照作为起点,用事件标识忽略已反映在快照中的成交,并在初始同步完成前禁止提交订单。通知可能延迟到达,也可能重复到达。恢复流程必须能够应对这两种情况。
13:40 — 重放发现了一处不易察觉的差异
我们对原始运行和重启后的运行重放了同一段价格路径。如果只比较最终 P&L,就会漏掉问题:市场反转后,两种运行最终持仓相同。比较订单事件才暴露了差异。
我们记录了每次决策读取的状态:持仓、未完成订单、最近处理的成交标识、信号值和策略版本。这样,第一处分歧事件就有了明确解释:一次运行看到了正在执行的订单,另一次看到的却是空列表。之后,其中一次运行还把同一笔成交处理了两遍。
最终余额相同,并不能证明行为一致。要比较决策和订单的完整序列,尤其要关注恢复边界附近的情况。
16:20 — 下次我们会换一种做法
我们花了太多时间重放价格数据,却没先检查账户状态的变化。下次,我们会先注入故障场景,并尽量让市场路径保持平稳。这样更容易看清软件错误,也不会被剧烈行情干扰诊断。
- 在有持仓且订单部分成交时重启。
- 提交订单后断连,并在成交通知到达前重连。
- 将同一成交事件发送两次,确认状态只改变一次。
- 在持仓和未完成订单都完成对账前,阻止提交新订单。
- 比较连续运行与重启运行的决策日志和订单日志。
我们还学会了将恢复快照与策略版本、事件日志一起保存。这样几分钟内就能复现故障,不必依赖某个人记得确切的重连顺序。
如果一个模拟策略只有在进程一直运行时才表现正常,那它就还没有通过完整演练。试着在持仓期间重启它,让成交延迟到达,再检查它之后发出的每一笔订单。目的不是证明它永远不会出错,而是在模拟账户意外地再次给你上一课之前,让恢复过程清晰可见。
← 全部文章


