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 +100ms +250ms +400ms 事件优先队列(按 t_phys 最小堆排序) 成交 @T0 快照 @+100ms 成交 @+250ms 状态 @+400ms 倍速映射:delta_wall = delta_logic / s s = 0.5x → 墙钟等待 ×2 s = 1x → 墙钟等待 ×1  s = 10x → 墙钟等待 ÷10 逻辑间隔比例保持不变 逻辑时钟推进与事件触发 t_logic 追赶至 next.t_phys → 触发事件 → 更新 t_logic → 取下一事件 高倍速优化 批量追赶 减少定时器 唤醒抖动

调度策略中有两个容易被忽视的细节。第一是同一时间戳的稳定排序:如果两笔成交时间戳相同,必须依据数据源优先级和原始序号确定先后,否则每次回放顺序可能不同,破坏可复现性。第二是断点跳转后的时钟重置:当用户从某个断点开始回放时,逻辑时钟需要以该断点的物理时间为新锚点重新起算,事件队列也要从该位置重建,而不是在旧队列上继续推进。

五、一个可操作的参数示例

假设回放起点 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。通过这种“锚点重算”方式,倍速切换不会造成时间跳变,事件序列也保持完整。

不要用墙钟时间直接给事件打时间戳或做去重判断。墙钟会受系统负载、定时器精度影响,而逻辑时钟才是回放语义的唯一权威来源。

总结来看,回放引擎的时钟抽象可以概括为一句话:物理时钟定义“发生了什么”,逻辑时钟定义“回放到哪里”,倍速因子定义“两者如何换算”。把这三者分离清楚,倍速控制、断点跳转和事件调度才能在同一套语义下稳定协作。