4. 订单簿快照重建

逐笔数据本身是“事件流”,而绝大多数因子计算、回测撮合和微观结构分析都需要“状态量”——某一时刻买一卖一的价格与数量、前五档深度、盘口不平衡度等。把事件流还原为状态序列的过程,就是订单簿重建。本节围绕三件事展开:如何用逐笔委托与逐笔成交维护限价订单簿(LOB)、快照频率如何选择、以及如何对重建结果做状态校验。

一、事件语义:先分清“谁改变了订单簿”

重建的第一步不是写代码,而是明确每类逐笔事件对订单簿的作用。以常见的逐笔委托(order)与逐笔成交(trade)双通道数据为例,需要区分以下语义:

  • 限价委托挂单:在指定价位新增一个订单,增加该价位队列尾部,订单簿深度增加。
  • 市价委托/即时成交剩余撤销:不进入订单簿,直接与对手方最优价撮合,只通过成交事件体现。
  • 撤单:从订单簿中移除指定订单,减少对应价位数量;若该价位清零则删除该档。
  • 成交:同时减少买、卖两侧对应订单的剩余量,成交量从盘口扣除。

关键陷阱在于:若成交事件已经隐含了“订单被消耗”,而委托通道又重复推送了撤单,就会造成重复扣减。工程上通常采用“以订单号为唯一键”的订单表来消歧:每个订单记录 order_id、side、price、qty、remaining、status,任何事件先查订单表再决定是否改动盘口。若数据源只提供成交而不提供委托明细,则只能用“成交价+成交量”做近似扣减,此时必须接受盘口数量存在误差,并在校验环节标注。

二、LOB 的数据结构与更新逻辑

一个可用的 LOB 至少需要两侧的价格档位映射。买侧用大顶堆或降序有序映射维护“价格→该价位订单队列”,卖侧用升序结构。每个价位节点保存该价位的总剩余量与该价位订单的 FIFO 队列,用于后续计算队列位置、撤单率和挂单存活时间等因子。

# 伪代码:单事件更新
def on_order(ev):
    if ev.type == "ADD":
        book[ev.side][ev.price].append(ev)      # 入队尾
        book[ev.side][ev.price].total += ev.qty
    elif ev.type == "CANCEL":
        o = orders[ev.order_id]
        book[o.side][o.price].remove(o)
        book[o.side][o.price].total -= o.remaining
        if book[o.side][o.price].total == 0:
            del book[o.side][o.price]

def on_trade(ev):
    for side in ("buy", "sell"):
        o = orders[ev.resting_order_id_on(side)]
        o.remaining -= ev.qty
        book[side][o.price].total -= ev.qty
        if o.remaining == 0:
            book[side][o.price].remove(o)

注意成交事件通常只标明“主动方”,被动方(resting order)需要靠订单表反查。若同一价位有多个订单,必须按 FIFO 顺序扣减,否则队列位置类因子会失真。更新完成后,盘口的最优价即买侧最大价、卖侧最小价,任一时刻应满足 best_bid < best_ask,这是最基本的合法性约束。

逐笔事件 → 订单表 → 限价订单簿 → 快照与校验 逐笔事件流 ADD 限价挂单 CANCEL 撤单 TRADE 成交 MARKET 市价单 按时间戳排序 同毫秒按序号 订单表(唯一键) order_id side / price qty / remaining status 成交反查被动方 撤单定位价位 防止重复扣减 限价订单簿 卖侧(升序) ask3 10.06 300 ask2 10.05 500 ask1 10.04 200 spread = 0.02 bid1 10.02 400 bid2 10.01 600 bid3 10.00 350 每档含 FIFO 队列 快照与校验 定时快照 事件快照 bid < ask 数量非负 与官方快照比对 偏差超阈值告警

三、快照频率的选择

快照频率不是越密越好。它取决于下游因子的时间尺度与数据成本:

  • 高频微观因子(盘口不平衡、队列位置、订单流不平衡 OFI):需要事件级或至少 100ms~500ms 快照,否则会丢失盘口变化细节。
  • 中频因子(价差、深度斜率、成交量加权价差):1~3 秒快照通常足够,能显著降低存储与计算量。
  • 低频因子(日内波动、流动性水平):分钟级快照即可,但要注意快照时点应避开整分钟边界,减少与其它参与者同步采样带来的偏差。

实践中推荐“双轨制”:以事件驱动方式维护 LOB 状态,在需要的时间点(定时或事件触发)落盘快照。定时快照便于对齐多标的;事件快照(如每次盘口变化后)便于研究冲击响应。若存储受限,可只保留前 N 档(如 5 档或 10 档),但需注意深度类因子在档位截断处的偏差。

四、状态维护与校验

重建过程中最容易出问题的是“状态漂移”:某一笔撤单未匹配到订单、成交重复扣减、或跨日未重置。建议在每个快照点执行以下校验,并记录异常计数:

校验项判断条件异常含义
盘口交叉best_bid ≥ best_ask更新顺序错误或漏处理撤单
数量非负任一档 total < 0重复扣减或订单表缺失
订单表一致性订单表剩余量之和 ≠ 盘口总量事件丢失或乱序
与官方快照比对前 5 档价格/数量偏差超阈值语义理解错误或数据源差异

校验发现异常时,不要简单跳过。可先检查事件时间戳是否单调、同毫秒事件是否按交易所序号排序;若数据源本身存在丢包,则需在快照中标记 quality_flag,下游因子计算时对低质量区间降权或剔除。对于跨日重建,务必在开盘前清空订单表并重新初始化,避免昨日未成交订单污染今日盘口。

工程建议:把 LOB 重建写成“事件处理器 + 快照器 + 校验器”三段式,事件处理器只负责状态变更,快照器只负责序列化,校验器只读不写。这样任何一段出问题都能独立定位,也便于用官方快照做回归测试。

最后,重建结果应能回答一个基本问题:给定任意时刻,我能准确说出买一卖一的价格、数量以及前 N 档的分布。只有这个状态序列可信,后续的订单流不平衡、盘口压力、队列位置等因子才有意义。建议先用一小段真实逐笔数据(例如某只股票连续竞价 30 分钟)跑通全流程,与交易所官方快照逐点比对,确认偏差在可接受范围内,再扩展到全市场与全交易日。