4、订单簿重建:从增量日志重建完整订单簿、数据对齐与校验
做高频交易的人都知道,交易所发过来的行情数据,说白了就是一堆「增量日志」。
每一笔成交、每一次撤单、每一个新限价单,都被记录成一条增量消息。你拿到手的,不是完整的订单簿快照,而是这些零散的「碎片」。
那问题来了——怎么把这些碎片拼回完整的订单簿?
嗯,这就是我今天要聊的「订单簿重建」。我做了这么多年量化,踩过的坑有一半都跟数据重建有关。你想想看,如果重建出来的订单簿都是错的,那后面的策略逻辑再漂亮也是白搭。
4.1 为什么需要重建?
交易所为了节省带宽,不会每毫秒都给你发一张完整的订单簿照片。它们只发「变化」——比如:
- 「价格 100.50 的买一,数量从 200 变成 180」
- 「价格 101.00 的卖二,被吃掉了,整层消失」
- 「新的限价单进来,挂在 100.60,数量 50」
这些增量消息,一条接一条地推过来。你的任务就是:拿上一刻的完整订单簿,应用这些增量,得到下一刻的完整订单簿。
核心公式:
OrderBookt+1 = Apply(OrderBookt, Δt→t+1)
其中 Δ 就是增量日志中的每一条操作。
我在项目中遇到过最头疼的情况:某次网络抖动,漏掉了 3 条增量消息。结果后面的订单簿全部「跑偏」,策略在那段时间疯狂报错。后来我才意识到——重建之前,必须先做数据对齐与校验。
4.2 增量日志的常见格式
不同交易所的增量格式略有不同,但核心字段大同小异。我整理了一个通用结构:
| 字段 | 含义 | 示例 |
|---|---|---|
| seq_no | 消息序列号(全局递增) | 1002345 |
| side | 买卖方向 | bid / ask |
| price | 价格(精确到 tick) | 100.50 |
| size | 数量(变化后的值) | 180 |
| type | 操作类型 | new / cancel / trade / clear |
| timestamp | 交易所时间戳 | 2025-01-15 09:30:00.123456 |
我个人习惯把 seq_no 当作「唯一凭证」。只要 seq_no 是连续的,我就敢放心重建。一旦发现跳号,立刻触发快照补全流程。
4.3 重建流程:三步走
重建订单簿,我一般分三步。每一步都有对应的校验逻辑,防止数据「带病运行」。
4.3.1 第一步:获取基准快照
你需要一个「干净的起点」。交易所通常每隔一段时间(比如 1 秒或 10 毫秒)会发一次完整快照。
- 快照包含当前所有价格档位的完整买卖盘口
- 快照本身也带一个 seq_no,表示「这个快照对应到哪个时刻」
拿到快照后,我建议先做一次自检:
- 检查买卖盘口是否平衡(买一价 ≤ 卖一价?)
- 检查价格档位是否按价格排序
- 检查数量是否为正数
注意: 有些交易所的快照可能包含「脏数据」。我曾经遇到过某次快照里,买一和卖一价格重叠了 0.01 元——这明显是交易所内部状态不一致导致的。遇到这种情况,直接丢弃该快照,等下一个。
4.3.2 第二步:按序应用增量
有了基准快照,接下来就是把增量一条一条「喂」进去。
伪代码大概是这样的:
def rebuild_orderbook(snapshot, deltas):
# 1. 从快照初始化订单簿
ob = OrderBook()
ob.load_snapshot(snapshot)
# 2. 按 seq_no 排序增量
sorted_deltas = sorted(deltas, key=lambda d: d.seq_no)
# 3. 逐条应用
for delta in sorted_deltas:
# 校验:当前增量 seq_no 必须等于上一增量 seq_no + 1
if delta.seq_no != expected_seq_no:
raise DataGapError(f"丢失增量: 期望 {expected_seq_no}, 实际 {delta.seq_no}")
# 应用增量
if delta.type == 'new':
ob.add_level(delta.side, delta.price, delta.size)
elif delta.type == 'cancel':
ob.update_level(delta.side, delta.price, delta.size)
elif delta.type == 'trade':
ob.reduce_level(delta.side, delta.price, delta.size)
elif delta.type == 'clear':
ob.remove_level(delta.side, delta.price)
expected_seq_no += 1
return ob
这里有个细节:增量消息中的 size 是「变化后的值」,不是变化量。比如 cancel 操作,size=0 表示这一层被完全撤掉;size=50 表示这一层还剩 50 手。
4.3.3 第三步:重建后校验
增量全部应用完后,别急着用。先做一轮校验:
- 价格档位连续性: 买盘从高到低,卖盘从低到高,中间不能有「空洞」
- 数量合理性: 单档数量不能超过交易所规定的最大限制
- 价差检查: 买一和卖一的价差应该在合理范围内(比如 1 个 tick)
- 交叉检查: 买一价必须小于卖一价,否则就是「交叉盘」,说明数据有问题
我的小技巧: 重建完成后,我会把当前订单簿的哈希值(比如所有价格档位的拼接字符串的 MD5)存下来。下次重建时,如果哈希值对不上,说明中间有数据被篡改或丢失了。这在回测中特别有用。
4.4 数据对齐:多源数据的时间同步
实际生产中,你往往不止一个数据源。比如:
- 行情数据来自交易所的 WebSocket
- 成交数据来自交易所的 REST API
- 自成交数据来自你自己的交易系统
这些数据源的时间戳可能不一致。怎么对齐?
我个人的做法是:以交易所的 seq_no 为唯一对齐基准。时间戳只作为辅助参考,不用于对齐。
为什么?因为网络延迟会导致时间戳漂移。你本地收到消息的时间,跟交易所发出消息的时间,可能差了几十毫秒。但 seq_no 是交易所内部生成的,严格递增,不会受网络影响。
4.5 常见坑与避坑指南
做订单簿重建这些年,我踩过的坑可以写一本小册子了。挑几个典型的说说:
- 坑一:增量消息乱序到达
交易所的 WebSocket 可能因为网络重传,导致后发的消息先到。解决方案:不要立即应用,先缓存起来,按 seq_no 排序后再处理。 - 坑二:快照与增量之间的「缝隙」
快照的 seq_no 是 1000,但第一条增量的 seq_no 是 1002。中间丢了 1001?这时候不能直接跳过,必须请求补发 1001,或者重新获取快照。 - 坑三:增量消息中的 size 为 0
有些交易所用 size=0 表示「删除这一层」,有些交易所则用单独的 delete 操作。一定要仔细看文档,别搞混了。
我曾经踩过的坑: 有一次,交易所的增量日志里出现了一条「size 为负数」的消息。我当时没做校验,直接应用了,结果订单簿的某一层数量变成了 -50。后面的所有计算全部崩溃。从那以后,我在应用增量之前,一定会加一层「数据合理性校验」——价格不能为负,数量不能为负,价格不能为 0。
4.6 核心逻辑流程图
下面这张图,是我自己画的一个「订单簿重建核心流程」。每次做新项目,我都会把它贴在墙上:
4.7 性能优化建议
订单簿重建如果做得不好,会成为整个系统的瓶颈。尤其是当你有几十个交易对同时重建时,性能问题会非常突出。
我分享几个优化思路:
- 使用红黑树或跳表: 订单簿的底层数据结构,建议用红黑树(C++)或 TreeMap(Java)。插入、删除、查找都是 O(log n),比线性数组快得多。
- 预分配内存: 每个价格档位的大小是固定的,可以预先分配好内存池,避免频繁的 new/delete。
- 批量处理: 如果增量消息密集到达(比如每秒 10 万条),可以攒一批再处理,减少上下文切换。
- 增量压缩: 有些交易所会发送「冗余增量」——比如同一价格档位连续更新多次。你可以合并这些更新,只保留最后一次。
我的经验: 在 C++ 实现中,我用的是 std::map 作为价格档位的容器,配合自定义的内存池分配器。实测下来,单线程每秒可以处理 200 万条增量消息。对于大部分场景,这个性能已经足够了。
4.8 总结
订单簿重建,说白了就是「快照 + 增量」的拼图游戏。核心要点就三个:
- 基准要稳: 快照必须干净、完整
- 顺序要对: 增量必须按 seq_no 严格排序
- 校验要严: 重建前后都要做数据校验
做到这三点,你的订单簿重建就不会出大问题。剩下的,就是根据具体交易所的协议细节,做一些微调了。
公众号:蓝海资料掘金营,微信deep3321