3. 回放引擎的时钟抽象与倍速控制原理
逐笔回放的核心难点并不在于“把历史数据按顺序播放”,而在于时间本身需要被重新定义。真实市场中的每一笔成交、每一次盘口变化都带有物理时间戳,而回放时我们希望这些时间戳能够被压缩、拉伸甚至跳过。因此,一个成熟的回放引擎必须把“物理时钟”和“逻辑时钟”分离,并通过倍速因子把两者映射起来。本节围绕这一映射关系,讲清楚倍速的数学表达、时钟推进算法以及事件调度策略。
一、物理时钟与逻辑时钟的分离
先明确两个参照系,全文保持一致:
- 物理时钟(Physical Clock):数据记录时真实发生的时间,通常来自交易所时间戳,单位可以是毫秒或微秒,记为
t_phys。它是只读的、不可更改的。 - 逻辑时钟(Logical Clock):回放引擎内部维护的“当前回放进度”,记为
t_logic。它从回放起点开始,随引擎推进而增长,是调度器判断“下一事件是否该触发”的唯一依据。
两者之间通过一个回放起点建立锚点。设回放从物理时间 T0 开始,则逻辑时钟的零点对应 T0。这样,任意时刻的逻辑时钟值就表示“已经回放了多少市场时间”,而不是“真实过去了多少墙钟时间”。这一区分是理解倍速的前提:倍速改变的是逻辑时钟相对墙钟的推进速率,而不是数据本身的时间戳。
二、倍速因子的数学表达
设倍速因子为 s(speed factor),墙钟时间记为 t_wall。倍速的本质是逻辑时钟对墙钟的导数:
d(t_logic) / d(t_wall) = s
即:t_logic = T0 + s × (t_wall − t_wall0)
其中 t_wall0 是回放启动时的墙钟时刻。几个典型取值的含义如下:
| 倍速 s | 含义 | 1 秒墙钟对应的市场时间 |
|---|---|---|
| 0.5x | 慢放,便于观察逐笔细节 | 0.5 秒 |
| 1x | 原速回放 | 1 秒 |
| 2x | 两倍速 | 2 秒 |
| 10x | 快速浏览 | 10 秒 |
需要注意,倍速因子只影响推进速率,不改变事件的相对顺序,也不改变事件之间的逻辑间隔比例。也就是说,10x 下两笔相隔 100ms 的成交,在逻辑时钟上仍然相隔 100ms,只是在墙钟上只花了 10ms 就跨过去了。这一点保证了回放的可复现性:无论用什么倍速,只要事件调度正确,最终呈现的市场序列完全一致。
三、时钟推进算法
回放引擎通常不采用“每个墙钟 tick 都去扫描全部事件”的暴力方式,而是采用事件驱动 + 时钟追赶的混合推进。核心循环可以抽象为:
while (存在未处理事件) {
next = 事件队列中逻辑时间最小的事件
target_logic = next.t_phys // 目标逻辑时刻
// 按倍速把逻辑时钟推进到 target_logic
while (t_logic < target_logic) {
delta_logic = target_logic - t_logic
delta_wall = delta_logic / s // 关键:除以倍速
sleep(delta_wall) // 或注册定时器
t_logic = target_logic
}
触发 next 事件,更新 t_logic = next.t_phys
}
这里的关键是 delta_wall = delta_logic / s:倍速越大,等待的墙钟时间越短。实际工程中不会真的用 sleep 阻塞主线程,而是把“下一次唤醒时间”注册到定时器或事件循环中,避免高倍速下频繁唤醒带来的抖动。对于 10x 以上的倍速,单次 delta_wall 可能小于 1ms,此时应改为批量追赶:一次性取出逻辑时间落在同一窗口内的所有事件,按顺序连续触发,再统一推进时钟,从而减少定时器开销。
四、事件调度策略
逐笔回放的事件来源通常包括成交、委托簿快照、行情状态变更等,它们各自带物理时间戳。调度器需要把它们合并成一条按逻辑时间排序的队列。常用做法是最小堆(优先队列),按 t_phys 排序,相同时间戳再按数据源优先级和原始序号稳定排序,保证同一毫秒内的事件顺序可复现。
下图展示了从物理时间轴到逻辑时钟、再到倍速映射与事件触发的完整链路:
调度策略中有两个容易被忽视的细节。第一是同一时间戳的稳定排序:如果两笔成交时间戳相同,必须依据数据源优先级和原始序号确定先后,否则每次回放顺序可能不同,破坏可复现性。第二是断点跳转后的时钟重置:当用户从某个断点开始回放时,逻辑时钟需要以该断点的物理时间为新锚点重新起算,事件队列也要从该位置重建,而不是在旧队列上继续推进。
五、一个可操作的参数示例
假设回放起点 T0 = 09:30:00.000,事件队列中下一事件在 09:30:00.250,当前逻辑时钟为 09:30:00.100,倍速 s = 2x。则:
delta_logic = 250ms − 100ms = 150ms
delta_wall = 150ms / 2 = 75ms
引擎应等待约 75ms 墙钟后触发该事件。若此时用户把倍速切到 10x,则应以当前逻辑时钟 09:30:00.100 为锚点,重新计算剩余 150ms 逻辑时间对应的墙钟等待为 15ms。通过这种“锚点重算”方式,倍速切换不会造成时间跳变,事件序列也保持完整。
总结来看,回放引擎的时钟抽象可以概括为一句话:物理时钟定义“发生了什么”,逻辑时钟定义“回放到哪里”,倍速因子定义“两者如何换算”。把这三者分离清楚,倍速控制、断点跳转和事件调度才能在同一套语义下稳定协作。