2. 订单簿数据结构与消息协议

要做快照与增量消息的一致性校验,第一步不是写比对代码,而是把“订单簿长什么样”和“消息怎么描述它”这两件事彻底弄清楚。校验的本质是:用增量消息在本地重建出的订单簿,必须与交易所下发的快照逐档、逐笔完全一致。如果对数据结构或协议字段的理解有偏差,后续所有比对都会建立在错误的前提上。

限价订单簿的买卖盘结构

限价订单簿(Limit Order Book,LOB)由买盘(Bid)和卖盘(Ask)两侧组成。买盘是愿意买入的挂单集合,卖盘是愿意卖出的挂单集合。两侧各自按价格排序:买盘价格从高到低,卖盘价格从低到高,最优买价(Best Bid)和最优卖价(Best Ask)分别位于两侧的顶端,二者之差即买卖价差(Spread)。

订单簿在逻辑上是“价格档位 → 订单队列”的两级结构。同一价格上的所有订单构成一个价格档位(Price Level),档位内按时间优先原则排队,先到者排在队首,撮合时优先成交。因此一个档位至少需要记录两个量:该价位的聚合数量(所有订单剩余数量之和)和订单笔数。很多协议只下发聚合数量,不暴露单笔订单,这时本地重建也只能维护到档位粒度;若协议携带订单级明细,则需额外维护订单 ID 到档位的映射。

这里有一个容易出错的约定:价格通常以整数最小变动单位(tick)表示,数量也以整数最小交易单位表示。校验时若一方用浮点价格、另一方用整数 tick,必须统一换算,否则会出现“看起来差一点”的伪不一致。下文所有示例均约定价格为整数 tick、数量为整数单位。

快照消息与增量消息的字段定义

快照(Snapshot)是某一时刻订单簿的完整切片,通常包含:快照序号(lastUpdateId 或 sequence)、买盘档位数组、卖盘档位数组,每个档位含价格与数量。增量消息(Delta / Update)只描述自上一序号以来发生的变化,典型字段包括:起始序号、结束序号、买卖两侧的变更档位。变更档位的语义有两种常见约定,必须区分清楚:

  • 绝对量覆盖:消息给出某价位的最新总量,本地直接替换该档位数量;数量为 0 表示删除该档位。
  • 相对量增减:消息给出数量的增减值,本地需在原有数量上累加;累加后为 0 则删除档位。

把这两种语义搞混,是增量重建出错的高频原因。校验程序在读入协议时,应把“覆盖/增减”作为显式配置项,而不是靠猜测。

常见协议格式

同一份订单簿数据可以用不同协议承载,字段名和编码方式差异很大,但语义可以对齐。JSON 可读性最好,常用于 REST 快照与 WebSocket 增量;FIX 是金融行业标准报文,用标签号(如 35=W 表示快照、269=0/1 表示买/卖)表达字段,适合机构对接;SBE(Simple Binary Encoding)是定长二进制编码,字段按 schema 顺序紧凑排列,延迟最低,常见于高频场景。下表给出三种格式在“买盘一档”上的字段映射示例(示例值仅用于说明结构):

语义JSON 字段FIX 标签SBE 字段示例值
快照序号lastUpdateIdTag 52 / 自定义sequence1024
买盘价格bids[0][0]Tag 132bidPrice10050
买盘数量bids[0][1]Tag 133bidSize320
增量起止序号U / u自定义 TagfirstSeq / lastSeq1025 / 1030

无论哪种格式,校验前都应先做一次“归一化”:把消息解析成统一的内部结构(序号、方向、价格、数量、语义类型),再进入重建与比对环节。这样协议差异被隔离在解析层,比对逻辑只需面对一种数据模型。

从消息到本地订单簿的重建

下面这段伪代码展示了增量应用的核心逻辑,重点在于序号衔接判断与覆盖/增减语义的分支处理:

def apply_delta(book, msg, mode="overwrite"):
    # 序号连续性检查:上一条已应用序号 + 1 应等于 msg.first_seq
    if msg.first_seq != book.last_seq + 1:
        raise SequenceGap(book.last_seq, msg.first_seq)
    for side, levels in (("bid", msg.bids), ("ask", msg.asks)):
        for price, qty in levels:
            if mode == "overwrite":
                new_qty = qty
            else:  # 相对增减
                new_qty = book.get(side, price, 0) + qty
            if new_qty == 0:
                book.remove(side, price)
            else:
                book.set(side, price, new_qty)
    book.last_seq = msg.last_seq
    return book

重建过程中有三个观察点值得记录:序号是否连续、档位数量是否出现负值、买卖盘是否出现交叉(最优买价 ≥ 最优卖价)。前两者是数据错误信号,第三者通常意味着重建逻辑或消息顺序出了问题。把这些检查嵌入重建流程,比事后逐档比对更容易定位根因。

订单簿两级结构与增量应用示意 买盘 Bid(价高优先) 价格 10050 · 数量 320 队列:O1(120) → O2(200) 价格 10040 · 数量 180 队列:O3(180) 价格 10030 · 数量 450 队列:O4(250) → O5(200) 最优买价 = 10050 卖盘 Ask(价低优先) 价格 10070 · 数量 260 队列:O6(260) 价格 10080 · 数量 390 队列:O7(150) → O8(240) 价格 10090 · 数量 120 队列:O9(120) 最优卖价 = 10070 价差 Spread = 10070 − 10050 = 20 增量消息(覆盖语义) firstSeq=1025 lastSeq=1026 bid 10050 → 数量 200 ask 10090 → 数量 0(删除) 应用后:买盘 10050 档数量变为 200;卖盘 10090 档被移除,最优卖价上移至 10080

上图把两级结构、价差关系与一次增量应用放在同一视图中:左侧买盘、右侧卖盘,中间虚线标出价差;紫色消息框给出序号区间与两条变更,箭头指向受影响的档位。应用后买盘 10050 档由 320 变为 200,卖盘 10090 档因数量归零被删除,最优卖价从 10070 上移到 10080。这个例子说明,一次增量可能同时改变档位数量与最优价位置,校验时不能只比数量,还要比档位集合本身。

校验前的归一化清单:统一价格与数量的整数单位;明确增量语义是覆盖还是增减;确认序号字段是单序号还是起止区间;确认快照序号与增量序号的衔接规则(快照序号 N 之后应接 firstSeq = N+1 的增量)。这四项对齐后,快照与增量的比对才有意义。

把上述结构、字段与协议差异梳理清楚后,本地订单簿就成为一个可预测、可检查的数据对象:每一次增量应用都有明确的输入、可观察的中间状态和可判断的结果。下一阶段的一致性校验,正是围绕“快照重建结果”与“增量重建结果”在同一序号点上的逐档比对展开的。