3. 订单簿构建:从原始行情数据重建订单簿,价格-数量映射与排序

做市商这行,说白了就是跟订单簿打交道。你每天盯着屏幕上的买一卖一,其实背后是一整套数据重建的逻辑。今天我就带你手把手,把原始行情数据变成一张可用的订单簿。

3.1 原始行情数据长什么样?

先看看交易所到底给了我们什么。以最常见的增量快照模式为例,数据大概长这样:

// 快照数据(每100ms推送一次)
{
  "type": "snapshot",
  "timestamp": 1680000000000,
  "bids": [
    [100.5, 1.0],  // [价格, 数量]
    [100.4, 2.5],
    [100.3, 0.8]
  ],
  "asks": [
    [100.6, 1.2],
    [100.7, 3.0],
    [100.8, 0.5]
  ]
}

// 增量数据(事件驱动推送)
{
  "type": "update",
  "timestamp": 1680000000100,
  "changes": [
    {"side": "bid", "price": 100.5, "size": 0.0},  // 数量为0表示删除
    {"side": "ask", "price": 100.6, "size": 2.0}   // 非0表示更新或新增
  ]
}

嗯,这里要注意:不同交易所的格式差异很大。币安的格式和Coinbase就不一样,但核心逻辑是相通的——价格和数量必须一一对应。

3.2 订单簿的核心数据结构

我个人习惯用红黑树或跳表来维护订单簿。为什么?因为我们需要频繁的插入、删除和范围查询。Python里可以用sortedcontainers库,C++里直接用std::map

核心要求:

  • 买盘(bids):按价格降序排列(价高者优先)
  • 卖盘(asks):按价格升序排列(价低者优先)
  • 每个价格点只保留一个数量值
  • 数量为0时,删除该价格档位

说白了,订单簿就是一个价格到数量的映射,再加上排序规则。你想想看,如果价格不排序,你怎么知道最优买卖价在哪?

3.3 重建订单簿的完整流程

我在项目中遇到过最头疼的问题:增量数据丢失导致订单簿错乱。所以第一步,必须做快照同步。

class OrderBook:
    def __init__(self):
        self.bids = SortedDict(lambda k: -k)  # 降序
        self.asks = SortedDict()              # 升序
        self.last_snapshot_id = None
        self.pending_updates = []
    
    def apply_snapshot(self, snapshot):
        """应用快照,清空并重建"""
        self.bids.clear()
        self.asks.clear()
        
        for price, size in snapshot['bids']:
            if size > 0:
                self.bids[price] = size
        
        for price, size in snapshot['asks']:
            if size > 0:
                self.asks[price] = size
        
        self.last_snapshot_id = snapshot['id']
        # 处理积压的增量更新
        self._flush_pending_updates()
    
    def apply_update(self, update):
        """应用增量更新"""
        for change in update['changes']:
            price = change['price']
            size = change['size']
            
            if change['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
    
    def get_top_n(self, n=5):
        """获取前N档报价"""
        top_bids = list(self.bids.items())[:n]
        top_asks = list(self.asks.items())[:n]
        return top_bids, top_asks

我曾经踩过的坑:

增量更新的顺序问题。如果快照和增量之间有时间差,必须用序列号或时间戳做排序。否则,先处理了后面的增量,再处理前面的快照,订单簿就全乱了。建议用单调递增的序列号做校验。

3.4 价格-数量映射的细节

这里有个容易忽略的点:价格精度。不同交易所的价格精度不一样,比特币可能到小数点后两位,山寨币可能到小数点后八位。我建议统一用整数表示,避免浮点数误差。

交易所 价格精度 内部表示
Binance 小数点后2位 乘以100存整数
Coinbase 小数点后2位 乘以100存整数
OKX 小数点后4位 乘以10000存整数

你想想看,如果用浮点数做key,万一出现100.5000000001和100.5,那就会变成两个不同的价格档位。这显然不对。

3.5 性能优化:增量更新 vs 全量重建

做市商系统对延迟极其敏感。我个人建议:

  • 正常情况:用增量更新,只修改变化的档位
  • 异常恢复:每5分钟或每1000笔增量后,主动请求一次快照做校验
  • 内存管理:定期清理深度过大的档位(比如价格偏离最优价超过10%的)

小技巧:

可以用双缓冲技术。一个订单簿用于查询,另一个用于更新。更新完成后原子切换指针。这样查询线程永远不会被阻塞。

3.6 知识体系总览

下面这张图,是我做订单簿系统时画的架构图。你看一眼就能明白整个流程:

订单簿构建核心流程 原始行情数据 快照 + 增量更新 数据解析与校验 序列号校验、去重 价格-数量映射 SortedDict维护 买盘 (Bids) - 降序排列 价格从高到低,价高者优先成交 卖盘 (Asks) - 升序排列 价格从低到高,价低者优先成交 订单簿输出 最优买卖价、深度分布、市场冲击计算

3.7 实战中的避坑指南

最后,分享几个我踩过的坑:

  • 数据粘包:WebSocket推送时,可能一次收到多条消息。记得用换行符或长度前缀做分割。
  • 快照与增量不匹配:快照的ID和增量的ID必须连续。我曾经因为网络重连,导致增量ID跳号,订单簿直接废了。
  • 内存泄漏:如果某个价格档位长期不成交,数量一直不变,但价格还在订单簿里。建议加个过期机制,超过一定时间没更新的档位自动清理。

一句话总结:

订单簿构建,核心就是维护两个有序的字典。一个降序存买盘,一个升序存卖盘。增量更新时注意序列号校验,快照同步时注意清空重建。做到这几点,你的订单簿基本就稳了。

专注资料整理