3、订单簿快照与增量更新:快照数据的生成,增量更新的机制(如逐笔委托),以及如何合并两者

做量化交易的朋友都知道,订单簿是市场的「心跳」。但心跳怎么记录、怎么传输、怎么还原,这里面门道不少。

我刚开始接触高频数据时,也踩过不少坑。最典型的一个——以为拿到快照就万事大吉了。结果回测时发现,订单簿状态根本对不上。后来才明白,快照和增量更新,缺一不可。

3.1 快照数据是怎么生成的?

快照,说白了就是某一时刻订单簿的「全家福」。它记录了当前所有挂单的价格、数量、方向。

交易所生成快照的时机,一般有两种:

  • 定时生成:比如每100毫秒拍一张。我见过有些平台甚至每秒拍10张。
  • 事件触发:比如每发生N笔成交,或者订单簿变化超过阈值,就生成一次。

快照的数据结构,通常长这样:

{
  "timestamp": 1628000000123,
  "bids": [
    [100.50, 2000],  // 价格, 数量
    [100.49, 1500],
    ...
  ],
  "asks": [
    [100.51, 1800],
    [100.52, 2200],
    ...
  ]
}

这里有个细节——快照里的价格档位,通常只保留前N档。比如只保留买一至买十、卖一至卖十。为什么?因为全量数据太大了,传输和存储都扛不住。

核心要点:快照是「静态的切片」,它告诉你「现在长什么样」。但光有快照,你无法知道中间发生了什么。

3.2 增量更新机制:逐笔委托

增量更新,就是记录「变化」。最常见的增量形式,就是逐笔委托

每一笔委托进来,交易所会广播一条消息。消息里包含:

  • 委托ID(唯一标识)
  • 方向(买/卖)
  • 价格
  • 数量
  • 操作类型(新增、撤销、成交)

举个例子:

// 新增买单
{
  "type": "add",
  "side": "bid",
  "price": 100.51,
  "size": 500,
  "order_id": "abc123"
}

// 撤销卖单
{
  "type": "cancel",
  "side": "ask",
  "price": 100.52,
  "size": 300,
  "order_id": "def456"
}

// 部分成交
{
  "type": "trade",
  "side": "bid",
  "price": 100.50,
  "size": 100,
  "order_id": "ghi789"
}

嗯,这里要注意——增量更新的顺序,必须严格按时间排列。一旦顺序乱了,订单簿就重建不回去了。我在项目中遇到过,因为网络延迟导致增量乱序,结果回测数据全废了。后来加了序列号校验,才解决这个问题。

个人经验:建议在接收增量时,给每条消息打上本地时间戳和交易所序列号。这样即使乱序,也能通过排序恢复。

3.3 快照与增量如何合并?

这才是核心问题。你想想看,快照是静态的,增量是动态的。怎么把它们拼起来?

标准做法是三步走:

  1. 拿到一个基准快照:作为初始状态。
  2. 记录快照之后的增量:从快照时间点开始,缓存所有增量消息。
  3. 逐条应用增量:把增量按顺序「回放」到快照上。

用代码表示,大概是这样:

class OrderBook:
    def __init__(self, snapshot):
        self.bids = snapshot['bids']  # 买盘
        self.asks = snapshot['asks']  # 卖盘
        self.seq = snapshot['seq']    # 快照序列号

    def apply_update(self, update):
        # 检查序列号是否连续
        if update['seq'] != self.seq + 1:
            # 这里要处理丢包或乱序
            self.resync()
            return

        if update['type'] == 'add':
            self._add_order(update)
        elif update['type'] == 'cancel':
            self._cancel_order(update)
        elif update['type'] == 'trade':
            self._execute_trade(update)

        self.seq = update['seq']

    def _add_order(self, update):
        side = self.bids if update['side'] == 'bid' else self.asks
        side.append([update['price'], update['size']])
        # 按价格排序
        side.sort(key=lambda x: x[0], reverse=(update['side'] == 'bid'))

你可能会问:如果增量丢了怎么办?

嗯,这是个好问题。实际中,增量丢失是常态。解决方案是:

  • 定期重新获取快照:比如每5分钟拉一次新快照,然后丢弃之前的增量。
  • 序列号校验:发现跳号时,主动请求缺失的增量或重新同步。

避坑指南:我曾经因为没做序列号校验,导致订单簿价格偏离了0.5个tick。回测结果看起来很美,实盘一跑就亏钱。后来花了整整两天排查,才发现是增量丢了一条。

3.4 知识体系总览

下面这张图,是我自己总结的订单簿数据流。你看一眼,应该就能明白整个流程:

交易所 定时/事件触发 快照数据 逐笔委托流 增量更新 合并与重建 本地订单簿 交易策略 下单指令 图:订单簿快照与增量更新数据流

从图上你能看到,整个流程是闭环的。交易所产生快照和增量,我们在本地合并重建订单簿,然后喂给策略。策略做出决策后,再发单回交易所。

3.5 实际工程中的几个坑

最后,分享几个我踩过的坑,希望能帮你省点时间:

问题 表现 解决方案
增量乱序 订单簿价格异常 加序列号校验,缓存乱序消息
快照与增量时间戳不匹配 合并后数据有跳变 以快照序列号为基准,丢弃之前的增量
增量丢失 订单簿逐渐偏离真实状态 定期重新拉取快照,做全量同步
内存占用过高 缓存了太多增量 设置增量缓存上限,超限强制同步

说白了,快照和增量更新就是一对「搭档」。快照给你一个起点,增量告诉你每一步怎么走。两者配合好了,你才能还原出真实的订单簿状态。

我个人习惯是,每5分钟强制拉一次新快照。这样即使中间有丢包,最多也就5分钟的数据偏差。对于大多数策略来说,这个精度完全够用了。

一句话总结:快照是「基准」,增量是「变化」。合并时,先拿基准,再按顺序回放变化。别忘了做序列号校验和定期同步。

专注资料整理