3. 订单簿重建:从增量消息重建完整订单簿

做量化交易的朋友都知道,交易所发过来的行情数据,很少会给你完整的订单簿快照。大多数时候,你收到的是一串增量消息——「有人撤单了」、「有人挂了一笔新单」、「有一笔成交了」。

说白了,你手里拿到的不是一张完整的照片,而是一堆拼图碎片。你的任务,就是把这些碎片拼回完整的订单簿。

我刚开始做这个的时候,觉得挺简单的——不就是增删改查嘛。结果第一次实盘测试,订单簿就崩了。嗯,这里面的坑,比想象中多得多。

3.1 增量消息的本质

先搞清楚一件事:增量消息到底是什么?

以最常见的L2行情为例,交易所通常会推送两类数据:

  • 快照消息:某个时刻的完整订单簿,一般每几秒发一次
  • 增量消息:每次订单簿变化时推送,频率极高

增量消息的格式,各家交易所大同小异。我拿一个典型的例子来说:

{
  "type": "update",
  "side": "bid",
  "price": 100.50,
  "size": 200,
  "action": "add"   // add / modify / delete
}

每条消息告诉你:有人在哪个价位,做了什么操作。你想想看,如果每秒有几千条这样的消息涌进来,你的系统能不能扛住?

3.2 事件驱动更新:核心逻辑

重建订单簿的核心,其实就是三个操作:

  1. 新增:某个价位出现新订单
  2. 修改:某个价位的订单数量变了
  3. 删除:某个价位的订单消失了

我习惯用一个有序字典来维护订单簿。Python里可以用SortedDict,C++里用std::map。为什么要有序?因为买卖双方的价格需要按顺序排列,方便计算最优报价。

来看一段核心代码:

class OrderBook:
    def __init__(self):
        self.bids = SortedDict(reverse=True)  # 买盘,价格从高到低
        self.asks = SortedDict()              # 卖盘,价格从低到高
    
    def apply_update(self, msg):
        side = self.bids if msg['side'] == 'bid' else self.asks
        price = msg['price']
        size = msg['size']
        action = msg['action']
        
        if action == 'delete':
            if price in side:
                del side[price]
        elif action == 'modify':
            side[price] = size
        elif action == 'add':
            side[price] = size
        # 注意:有些交易所的add也可能是覆盖操作

这段代码看起来简单,对吧?但我在项目中遇到过一个问题:有些交易所的增量消息不是按顺序发的。比如先发了一条delete,后发了一条add,但实际顺序应该是先add再delete。这就麻烦了。

注意:消息乱序是订单簿重建中最常见的坑。我曾经因为没处理乱序,导致订单簿里出现负数成交量,回测结果直接崩了。解决方案是给每条消息加一个序列号,或者用时间戳做排序缓冲。

3.3 并发处理:别让锁拖死你

做高频交易的人都知道,行情处理线程和策略交易线程往往是并行的。一个线程在更新订单簿,另一个线程在读取订单簿做决策。这时候,并发问题就来了。

我见过最糟糕的做法:整个订单簿加一把大锁。读和写都抢同一把锁,性能直接掉到地板。

更好的做法是什么?

  • 读写锁:多个线程可以同时读,但写的时候独占。Python里可以用threading.RLock或者asyncio.Lock
  • 无锁数据结构:比如用atomic操作更新价格和数量。C++里可以用std::atomic
  • 双缓冲:一个订单簿在后台更新,更新完直接切换指针。读线程永远读的是完整快照

我个人最推荐双缓冲方案。为什么?因为它的延迟最稳定,没有锁竞争导致的抖动。

class DoubleBufferOrderBook:
    def __init__(self):
        self.active = OrderBook()
        self.pending = OrderBook()
        self.lock = threading.Lock()
    
    def update(self, msg):
        # 在pending上更新
        self.pending.apply_update(msg)
    
    def switch(self):
        # 原子切换
        with self.lock:
            self.active, self.pending = self.pending, self.active
            self.pending.clear()  # 或者复用

3.4 性能优化:从毫秒到微秒

说到性能,我得坦白一件事。早期我做订单簿重建,用的是Python的dict,每秒处理几千条消息就卡得不行。后来我做了几件事,把吞吐量提了10倍以上。

核心优化点:

优化项 优化前 优化后 效果
数据结构 Python dict array + 索引映射 减少哈希开销
内存分配 频繁创建对象 对象池复用 减少GC压力
消息解析 JSON解析 二进制协议 解析速度提升5x
锁粒度 全局锁 分段锁/无锁 并发能力提升

你想想看,如果每秒有10万条增量消息,每条消息都要解析、更新、排序,任何一个环节慢了,都会导致行情延迟。而延迟,在量化交易里就是真金白银的损失。

我的经验:不要过早优化。先用Python或C++把逻辑跑通,然后用性能分析工具(比如perf、py-spy)找到真正的瓶颈。我曾经花了两周优化一个函数,结果发现瓶颈在别的地方——白干了。

3.5 知识体系总览

下面这张图,是我自己总结的订单簿重建核心逻辑。你看一遍,应该就能把握住整体脉络。

订单簿重建核心流程 增量消息流 消息队列缓冲 事件驱动引擎(add/modify/delete) 买盘(Bids)更新 卖盘(Asks)更新 并发控制/锁 完整订单簿

这张图里,最关键的其实是「事件驱动引擎」那一层。说白了,你写的代码就是在做三件事:增、删、改。但难就难在,这三件事要在微秒级别内完成,还要保证数据一致性。

嗯,说到这里,我想起一个细节。有些交易所的增量消息里,modify操作不是直接覆盖,而是增量修改。比如原先是100手,消息说「增加50手」,那结果应该是150手。这种操作,如果你直接赋值,订单簿就错了。

核心要点:订单簿重建的本质,就是用事件驱动的方式,把无序的增量消息还原成有序的完整快照。性能的关键在于数据结构的选择和并发控制的设计。

好了,这一章的内容就到这里。记住一句话:订单簿重建做不好,后面的策略全是空中楼阁。我见过太多人在这上面栽跟头了。


公众号:蓝海资料掘金营,微信deep3321