订单簿数据结构:价格队列与时间优先

做量化交易这些年,我见过不少新手一上来就盯着最新成交价看。其实真正有价值的信息,藏在订单簿里。说白了,订单簿就是交易所的「挂单台账」——谁想买、谁想卖、什么价格、挂了多少,全记在上面。

我个人习惯把订单簿想象成一个双层的书架。左边放买单,右边放卖单。每一层就是一个价格,价格越好的越靠外。嗯,这里要注意:买单是价格越高越靠前,卖单是价格越低越靠前。

价格队列的核心逻辑

每个价格点上都排着一队订单。这队人怎么排?看时间。先来的站前面,后来的排后面。这就是所谓的「价格优先、时间优先」原则。

举个例子:

  • 张三挂100元买10手,时间是09:30:01
  • 李四挂100元买10手,时间是09:30:02
  • 王五挂101元买10手,时间是09:30:03

谁先成交?王五。因为101元比100元高,价格优先。如果王五也挂100元,那张三先成交,因为时间优先。

核心要点:价格是第一优先级,时间是第二优先级。价格相同看时间,价格不同看价格。

我在项目中遇到过一个问题:某个策略在开盘瞬间频繁撤单重挂,导致订单总是排到队尾。后来才发现,交易所的时间戳精度是微秒级的,你以为同时提交的订单,其实有先后顺序。

Level 2 数据解析

Level 2 数据,也叫深度数据。它比 Level 1 多了什么?Level 1 只告诉你最优买卖价和成交量,Level 2 把整个订单簿的每一层都给你看。

一个典型的 Level 2 快照长这样:

{
  "asks": [
    [100.50, 200],
    [100.55, 150],
    [100.60, 300]
  ],
  "bids": [
    [100.45, 180],
    [100.40, 250],
    [100.35, 100]
  ]
}

每个数组里,第一个数是价格,第二个数是该价格上的总挂单量。注意,这个总挂单量是合并后的——所有挂在这个价格上的订单,不管是谁挂的,量都加在一起。

小技巧:我习惯把卖单数据叫「卖方压力」,买单数据叫「买方支撑」。如果卖方压力突然增大,价格大概率要跌。反之亦然。

快照与增量更新

这里有个关键问题:订单簿变化极快,每秒可能有上千次变动。如果每次变动都传整个订单簿,网络带宽扛不住。所以交易所用了「快照+增量」的模式。

快照就是某一时刻的完整订单簿。增量就是后续的变化记录。你拿到一个快照后,后续只接收增量数据,自己维护订单簿的更新。

增量数据长这样:

{
  "type": "update",
  "changes": [
    {"side": "ask", "price": 100.50, "size": 0},
    {"side": "bid", "price": 100.45, "size": 300}
  ]
}

size=0 表示撤单,size>0 表示新增或修改。我刚开始做的时候踩过一个坑:增量数据可能乱序到达。比如先收到一个撤单消息,后收到这个订单的挂单消息,那订单簿就乱了。

避坑指南:我曾经因为没处理增量乱序,导致策略在回测和实盘表现完全不一样。解决方案是给每条增量加一个序列号,本地维护一个缓冲区,按序列号顺序处理。

订单簿的本地维护

在代码里,我通常用红黑树或跳表来维护订单簿。为什么?因为需要快速找到最优价格,同时支持按价格范围查询。

一个简单的 Python 实现思路:

class OrderBook:
    def __init__(self):
        self.bids = {}  # 价格 -> 总量
        self.asks = {}  # 价格 -> 总量
        self.bid_prices = []  # 排序后的价格列表
        self.ask_prices = []
    
    def apply_snapshot(self, snapshot):
        self.bids = {p: s for p, s in snapshot['bids']}
        self.asks = {p: s for p, s in snapshot['asks']}
        self.bid_prices = sorted(self.bids.keys(), reverse=True)
        self.ask_prices = sorted(self.asks.keys())
    
    def apply_update(self, update):
        side = update['side']
        price = update['price']
        size = update['size']
        
        if side == 'bid':
            if size == 0:
                self.bids.pop(price, None)
            else:
                self.bids[price] = size
        else:
            if size == 0:
                self.asks.pop(price, None)
            else:
                self.asks[price] = size

你想想看,这个结构虽然简单,但实际生产环境里要考虑的东西多得多。比如内存占用、GC 停顿、多线程安全……嗯,这些后面会细讲。

数据结构的可视化

为了让你更直观地理解订单簿的结构,我画了一张图:

订单簿数据结构示意图 卖单队列(Asks) 价格: 100.60 总量: 300 订单1(09:30:01) → 订单2(09:30:05) → 订单3(09:30:10) 价格: 100.55 总量: 150 订单4(09:29:55) → 订单5(09:30:02) 价格: 100.50 总量: 200 订单6(09:29:50) → 订单7(09:30:00) → 订单8(09:30:03) ← 价格向下 | 价格向上 → 买单队列(Bids) 价格: 100.45 总量: 180 订单9(09:30:01) → 订单10(09:30:04) 价格: 100.40 总量: 250 订单11(09:29:58) → 订单12(09:30:02) → 订单13(09:30:06) 价格: 100.35 总量: 100 订单14(09:29:45) → 订单15(09:30:05) 每个价格层内部按时间排序,先到先成交

这张图里,卖单从高到低排列,买单从低到高排列。中间那条虚线就是当前最优买卖价之间的差距,也就是「价差」。价差越小,市场流动性越好。

实际应用中的注意事项

做 Level 2 数据解析时,有几个坑我帮你提前踩了:

  1. 快照和增量的对齐:订阅数据流后,先等一个完整快照,再开始处理增量。如果增量序号比快照序号还小,直接丢弃。
  2. 价格精度:不同交易所的价格精度不一样。有的支持小数点后8位,有的只支持2位。处理时统一用整数表示,避免浮点误差。
  3. 内存管理:订单簿数据量很大,尤其是高频场景。我建议用对象池复用数据结构,减少 GC 压力。

个人经验:我习惯在本地维护两份订单簿。一份用于策略决策,另一份用于校验。每隔一段时间对比两份数据是否一致,发现不一致就重新拉取快照。

说白了,订单簿数据结构是量化交易的基石。你把它搞明白了,后面那些复杂的策略逻辑才能站得住脚。不然的话,数据都是错的,策略再漂亮也是白搭。


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