4、订单簿重建算法:从增量快照重建全量订单簿、时间戳对齐、去重逻辑

做市交易里,订单簿就是我们的战场地图。但交易所给的数据,往往不是一张完整的图——它只告诉你「刚才谁动了」。说白了,我们拿到的是增量快照,不是全量快照。

我刚开始做高频做市那会儿,就吃过这个亏。当时直接拿增量数据拼订单簿,结果价格算出来全是错的。后来才明白,重建订单簿这件事,比想象中要复杂得多。

4.1 为什么需要重建?

交易所为了节省带宽,通常不会每次都推送完整的订单簿。它们会这样做:

  • 全量快照:每隔几秒或几十毫秒,发一次完整的买卖盘口
  • 增量更新:在两次全量之间,只推送「新增」「删除」「修改」的操作

你想想看,如果每秒有上万笔订单变动,每次都发全量,带宽早就炸了。所以我们必须自己动手,把增量拼回去。

核心原则:以全量快照为基准,用增量事件逐条回放,重建出任意时刻的完整订单簿。

4.2 增量事件的三要素

每个增量事件,本质上就是一条指令。我习惯把它拆成三个字段:

字段 含义 示例
side 买卖方向 bid / ask
price 价格 100.50
size 数量(0表示删除) 0.0 或 1.5

嗯,这里要注意:size = 0 代表删除该价位。这是最常见的坑——很多人以为 size 为 0 就是没变化,其实它是「清空」的意思。

4.3 重建算法:逐行回放

算法本身不复杂,但细节决定成败。我直接上代码,这是我在生产环境里用过的版本:

def rebuild_orderbook(snapshot, deltas):
    """
    snapshot: dict, 格式 {'bids': {price: size}, 'asks': {price: size}}
    deltas: list of dict, 每个元素是增量事件
    """
    book = {
        'bids': snapshot['bids'].copy(),
        'asks': snapshot['asks'].copy()
    }
    
    for delta in deltas:
        side = delta['side']       # 'bid' 或 'ask'
        price = delta['price']
        size = delta['size']
        
        if size == 0:
            # 删除该价位
            book[side + 's'].pop(price, None)
        else:
            # 新增或更新
            book[side + 's'][price] = size
    
    return book

这段代码看起来简单,对吧?但我在项目中遇到过一个问题:增量事件可能乱序到达。比如网络延迟导致先到的反而是后面的数据。这时候直接回放,订单簿就乱了。

避坑指南:我曾经因为没处理乱序,导致策略在盘中被拉爆。后来加了一个「序列号校验」——每个增量事件都带一个递增的 seq_id,如果发现 seq_id 跳跃或回退,就丢弃当前增量,等待下一次全量快照。

4.4 时间戳对齐:谁的时间?

做市交易里,时间就是钱。但时间戳这个东西,其实有多个来源:

  • 交易所时间:服务器撮合时打的时间戳
  • 本地接收时间:你的程序收到数据包的时刻
  • 网卡硬件时间:支持 PTP 的网卡记录的精确时间

我个人习惯用交易所时间作为主时间轴。为什么?因为本地时间受网络抖动影响太大。你想想看,如果两个增量事件在交易所只差 1 微秒,但网络延迟让它们差了 10 毫秒到达本地,你用本地时间排序,结果就是错的。

具体做法:

  1. 每个增量事件都带上交易所的 timestamp
  2. 按交易所 timestamp 排序,而不是按接收顺序
  3. 如果两个事件 timestamp 相同,按 seq_id 排序

小技巧:我一般会在本地维护一个「时间偏移量」——定期计算本地时间与交易所时间的差值。如果偏移量突然变大,说明网络有问题,这时候我会暂停策略,等稳定了再恢复。

4.5 去重逻辑:别让同一笔订单出现两次

去重,说白了就是「同一个订单,别重复处理」。交易所偶尔会重发增量事件,尤其是在网络波动时。如果你不处理重复,订单簿里的数量就会翻倍。

我的去重方案是这样的:

class OrderBookReconstructor:
    def __init__(self):
        self.seen_ids = set()      # 已处理的订单ID
        self.book = {'bids': {}, 'asks': {}}
    
    def apply_delta(self, delta):
        order_id = delta['order_id']
        if order_id in self.seen_ids:
            return  # 已处理过,跳过
        
        self.seen_ids.add(order_id)
        # 正常更新订单簿
        # ...

这里有个性能问题:seen_ids 会越来越大。我建议定期清理——比如每收到一次全量快照,就把之前的 seen_ids 清空,因为全量快照之后,所有订单都是新的。

去重三原则

  • 用订单 ID 去重,不要用价格+数量组合
  • 全量快照后清空去重缓存
  • 如果订单 ID 重复但数据不同,以最新到达的为准

4.6 完整流程:一张图说清楚

我把整个重建流程画成了 SVG 图,方便你理解:

订单簿重建流程 全量快照 增量事件流 时间戳对齐 & 排序 去重(seen_ids) 逐行回放重建 完整订单簿 乱序检测 发现乱序 → 丢弃增量 等待下次全量快照

4.7 性能优化:别让重建拖慢你的策略

做市交易对延迟极其敏感。如果重建订单簿需要 1 毫秒,那你的策略就已经慢了。我分享几个优化经验:

  • 用 dict 不要用 list:价格作为 key,O(1) 查找,比遍历 list 快一个数量级
  • 预分配内存:如果知道订单簿大概有 100 个价位,提前初始化 dict 的容量
  • 增量更新,不要全量重建:每次只修改变动的价位,而不是重新构建整个订单簿
  • 用 sortedcontainers 维护排序:如果你需要按价格排序的买卖盘,用 SortedDict 比每次排序快得多

我的经验:曾经有一个项目,重建订单簿用了 5 毫秒,导致策略跟不上行情。后来改成增量更新 + 预分配,降到 50 微秒。嗯,差了 100 倍。

4.8 总结

订单簿重建,说白了就是三件事:对齐时间、去重、回放。但每一件都有坑。我个人建议,在写代码之前,先想清楚你的数据源——交易所给的增量事件有没有 seq_id?时间戳是哪个时钟?重发机制是怎样的?

把这些搞清楚,重建算法就是水到渠成的事。