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 知识体系总览
下面这张图,是我做订单簿系统时画的架构图。你看一眼就能明白整个流程:
3.7 实战中的避坑指南
最后,分享几个我踩过的坑:
- 数据粘包:WebSocket推送时,可能一次收到多条消息。记得用换行符或长度前缀做分割。
- 快照与增量不匹配:快照的ID和增量的ID必须连续。我曾经因为网络重连,导致增量ID跳号,订单簿直接废了。
- 内存泄漏:如果某个价格档位长期不成交,数量一直不变,但价格还在订单簿里。建议加个过期机制,超过一定时间没更新的档位自动清理。
一句话总结:
订单簿构建,核心就是维护两个有序的字典。一个降序存买盘,一个升序存卖盘。增量更新时注意序列号校验,快照同步时注意清空重建。做到这几点,你的订单簿基本就稳了。