2. 逐笔数据流与回放时间轴建模
逐笔回放的核心难点不在“播放”,而在“时间”。行情源推送的是逐笔成交与逐笔委托两类事件流,它们各自带有自己的时间戳,到达本地的时间又各不相同;而回放器需要把这些事件重新组织成一条可被倍速、暂停、跳转操控的时间轴。如果时间轴建模不清晰,倍速就会变成“加速丢帧”,跳转就会变成“状态错乱”。本节先把数据流结构与时间轴模型讲透,为后续的倍速控制与断点跳转打基础。
一、逐笔成交与逐笔委托的结构特征
逐笔委托(Order)描述的是“谁在什么时刻下了什么单”,典型字段包括证券代码、买卖方向、委托价格、委托数量、委托序号、通道号与委托时间。逐笔成交(Trade)描述的是“哪两笔委托在什么时刻撮合了多少量”,典型字段包括成交价、成交量、买方委托序号、卖方委托序号、成交序号与成交时间。两者最关键的差异在于:委托是“增量挂单”,成交是“消耗挂单”,因此回放时委托流决定盘口挂单的生成与撤单,成交流决定盘口的消耗与最新价的形成。
从回放视角看,这两类数据还有三个结构特征必须注意。第一是序号连续性:交易所对委托和成交分别给出递增序号,回放器应校验序号是否连续,一旦出现跳号说明数据缺失,必须显式标记而不是静默跳过。第二是关联性:成交记录通过买方/卖方委托序号回指委托,回放时若只回放成交而不回放对应委托,盘口将无法正确重建。第三是时间粒度:逐笔数据的时间戳通常精确到毫秒甚至微秒,同一毫秒内可能有多条事件,因此时间轴不能简单按“毫秒桶”聚合,否则会破坏同一时刻内的先后顺序。
二、事件时间与处理时间
建模时间轴前必须先区分两种时间。事件时间(Event Time)是交易所撮合发生时打上的时间戳,它决定了事件在“市场真实历史”中的位置;处理时间(Processing Time)是回放程序读取并处理该事件时的本地时钟时间,它决定了事件在“回放过程”中被消费的节奏。回放时间轴应当以事件时间为基准轴,处理时间只用于控制推进速度。
这一区分直接决定了倍速的实现方式。若以处理时间为轴,倍速只是加快读取循环,事件之间的相对间隔会被压缩甚至打乱;若以事件时间为轴,倍速改变的是“每单位真实时间推进多少事件时间”,事件之间的相对顺序与间隔比例得以保留。正确做法是:维护一个虚拟回放时钟 playbackClock,它按 playbackClock += Δreal × speed 推进,所有事件时间戳小于等于 playbackClock 的事件被依次释放。这样无论 1 倍、5 倍还是 20 倍,事件序列的相对关系都不变。
三、回放时间轴的定义与推进机制
回放时间轴可以定义为一个三元组:起点(回放开始的事件时间)、终点(回放结束的事件时间)、当前游标(已释放到的最大事件时间)。推进机制采用“事件驱动 + 时钟驱动”的混合模式:时钟驱动负责按倍速推进 playbackClock,事件驱动负责在每次时钟推进后,从按事件时间排序的事件队列中取出所有时间戳不超过 playbackClock 的事件并释放。若某段时间内没有事件,时钟仍照常推进,表现为“空放”;若同一时刻事件密集,则一次性释放,表现为“瞬时爆发”,这正是真实行情的节奏特征。
下面这张图展示了逐笔委托流与逐笔成交流如何汇入统一事件队列,再经虚拟时钟按倍速释放到盘口重建模块的过程,同时标出了跳转时游标的重定位路径。
图中下半部分的时间轴标出了起点、当前游标、跳转目标与终点。断点跳转的本质不是“把时钟拨过去”,而是“把游标重定位到目标事件时间,并恢复该时刻的盘口状态快照”。若没有快照,跳转后必须从起点重放全部事件才能重建盘口,代价随数据量线性增长;有了周期性快照(例如每 5000 条事件或每 30 秒事件时间存一次),跳转只需从最近快照开始重放少量事件即可。
四、倍速与跳转对时间轴的影响
倍速对时间轴的影响体现在“推进速率”而非“事件内容”。设真实经过时间为 Δreal,倍速为 s,则虚拟时钟推进量为 Δreal × s。以 20 倍速为例,真实 1 秒对应事件时间 20 秒,若这 20 秒事件时间内有 3000 条逐笔事件,则这 3000 条会在约 1 秒内被释放。此时必须注意两点:一是释放循环要按批处理,避免逐条渲染造成界面卡顿;二是盘口重建要保证同一时刻内“先委托后成交”的处理顺序,否则会出现成交量大于挂单量的异常。
跳转对时间轴的影响则体现在“游标不连续”。向前跳转(跳到更晚时间)需要把游标直接移到目标时间,并丢弃中间事件;向后跳转(回到更早时间)必须依赖快照回滚,不能简单地把游标往回拨,因为盘口状态已经被后续事件修改。一个稳妥的实现约定是:跳转目标时间必须落在快照网格上或由快照加增量重放覆盖,跳转完成后校验盘口买卖档数量非负、累计成交量单调不减,任一校验失败即判定跳转异常并回退到上一个有效快照。
把上述内容落到实现上,可以用一个简化的伪代码描述推进与跳转的主循环,帮助理解时钟、队列与快照三者的协作关系:
// 事件队列已按 eventTime 升序稳定排序
while (playing) {
deltaReal = now() - lastTick; // 真实经过时间
playbackClock += deltaReal * speed; // 虚拟时钟推进
while (queue.peek().eventTime <= playbackClock) {
evt = queue.pop();
applyEvent(evt); // 委托建单 / 成交消耗
if (evt.seq % SNAPSHOT_STEP == 0) {
snapshots.save(evt.eventTime, bookState());
}
}
lastTick = now();
}
// 断点跳转
function seek(targetTime) {
snap = snapshots.floor(targetTime); // 最近的不晚于目标的快照
bookState.restore(snap.state);
queue.seekTo(snap.eventTime); // 游标重定位
playbackClock = snap.eventTime;
// 随后由主循环重放 snap 到 targetTime 之间的事件
}
这段伪代码体现了三个关键设计:时钟推进与事件释放解耦、快照按事件序号周期落盘、跳转以快照为锚点而非直接改时钟。实际工程中还需处理数据缺失(序号跳号时插入占位事件并告警)、乱序到达(以事件时间排序而非到达顺序)以及倍速切换时的时钟锚定。把这些边界条件在建模阶段就定义清楚,后续的倍速控制与断点跳转才有稳定的时间基准可依。