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 毫秒到达本地,你用本地时间排序,结果就是错的。
具体做法:
- 每个增量事件都带上交易所的 timestamp
- 按交易所 timestamp 排序,而不是按接收顺序
- 如果两个事件 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 图,方便你理解:
4.7 性能优化:别让重建拖慢你的策略
做市交易对延迟极其敏感。如果重建订单簿需要 1 毫秒,那你的策略就已经慢了。我分享几个优化经验:
- 用 dict 不要用 list:价格作为 key,O(1) 查找,比遍历 list 快一个数量级
- 预分配内存:如果知道订单簿大概有 100 个价位,提前初始化 dict 的容量
- 增量更新,不要全量重建:每次只修改变动的价位,而不是重新构建整个订单簿
- 用 sortedcontainers 维护排序:如果你需要按价格排序的买卖盘,用
SortedDict比每次排序快得多
我的经验:曾经有一个项目,重建订单簿用了 5 毫秒,导致策略跟不上行情。后来改成增量更新 + 预分配,降到 50 微秒。嗯,差了 100 倍。
4.8 总结
订单簿重建,说白了就是三件事:对齐时间、去重、回放。但每一件都有坑。我个人建议,在写代码之前,先想清楚你的数据源——交易所给的增量事件有没有 seq_id?时间戳是哪个时钟?重发机制是怎样的?
把这些搞清楚,重建算法就是水到渠成的事。