10、订单簿重建:从 Tick 数据重建订单簿、快照与增量更新、性能优化
10.1 为什么需要重建订单簿?
做量化交易的朋友都知道,订单簿是市场的「心跳」。但交易所给的数据,往往不是完整的订单簿。
你拿到的,可能只是一个个的 Tick——每笔成交、每次挂单、每次撤单。说白了,就是一堆零散的「事件」。而我们要做的,就是把这些碎片拼回一张完整的「全景图」。
我刚开始做高频策略时,就踩过这个坑。当时以为直接拿交易所的快照就够用了,结果回测时发现策略信号延迟了整整一个 Tick。后来才明白——快照是「照片」,而我们需要的是「电影」。
10.2 快照与增量更新
交易所通常提供两种数据:
- 快照(Snapshot):某一时刻的完整订单簿。比如每隔 100ms 发一次。
- 增量(Incremental / Delta):两次快照之间的变化。比如「新增一个买单」、「撤销一个卖单」。
重建的逻辑其实很简单:
- 拿到一个快照,作为初始状态。
- 然后不断应用增量事件,更新订单簿。
- 等下一个快照来了,用它对冲一下误差。
嗯,这里要注意——快照和增量之间,可能存在时间差。我遇到过一种情况:交易所的增量事件比快照还早到几毫秒。结果订单簿直接乱套了。
10.3 数据结构选型
重建订单簿,核心就是维护两个「价格-数量」映射:买单(Bids)和卖单(Asks)。
选什么数据结构?我个人的习惯是:
| 数据结构 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 红黑树(SortedDict) | 有序、支持范围查询 | 插入/删除 O(log n) | 低频交易、研究分析 |
| 跳表(Skip List) | 并发友好、实现简单 | 内存占用稍高 | 中频策略 |
| 数组 + 价格索引 | 极快、O(1) 访问 | 价格范围固定、浪费内存 | 高频、Tick 级重建 |
| 哈希表 + 排序列表 | 灵活、易维护 | 排序开销大 | 原型开发 |
你想想看,高频场景下,每秒钟可能有几千个 Tick。如果用红黑树,每次插入删除都要 O(log n),累积下来就是不小的开销。我建议用「数组 + 价格索引」——把价格映射到数组下标,直接 O(1) 更新。
10.4 性能优化实战
重建订单簿的性能瓶颈,通常不在计算,而在 I/O 和内存分配。
我分享几个实战经验:
- 预分配内存:别在循环里 new 对象。提前分配好数组,用下标复用。
- 批量处理:把多个 Tick 攒起来,一次更新。而不是来一个处理一个。
- 避免排序:如果增量事件本身是有序的,就别再排序了。直接追加。
- 用 struct 代替 class:在 C# 或 Go 里,struct 是值类型,没有 GC 压力。
举个例子,我曾经把一个 Python 实现的订单簿重建,从每秒处理 5000 个 Tick 优化到了 20000 个。怎么做的?就是把 dict 换成了 array,把 sorted() 换成了 bisect。
# 优化前:用 dict + sorted
bids = {}
def apply_tick(tick):
bids[tick.price] = tick.volume
sorted_prices = sorted(bids.keys(), reverse=True)
# 优化后:用数组 + 索引
MAX_PRICE = 100000
bids = [0] * (MAX_PRICE + 1)
def apply_tick(tick):
idx = int(tick.price * 100)
bids[idx] = tick.volume
# 直接遍历数组,不用排序
10.5 容错与一致性
数据总会有问题。网络延迟、丢包、乱序……这些都是家常便饭。
我建议你建立一套「校验机制」:
- 序列号检查:每个事件带一个递增 ID,如果发现跳跃,就丢弃当前状态,重新请求快照。
- 快照对冲:每收到一个快照,就与当前重建的订单簿做对比。如果差异超过阈值,就重置。
- 时间戳对齐:确保所有事件的时间戳是单调递增的。如果发现时间回退,就标记为异常。
我曾经因为没做容错,导致一个策略在实盘中连续交易了 3 秒「错误」的订单簿。嗯,那笔亏损让我记忆犹新。
10.6 知识体系总览
下面这张图,是我自己总结的订单簿重建核心逻辑。你可以把它当作一个「检查清单」:
10.7 总结
订单簿重建,说白了就是「事件驱动」的状态管理。你不需要多高深的算法,但需要对细节足够敏感。
我个人觉得,最重要的三点是:
- 选对数据结构——高频用数组,低频用红黑树。
- 做好容错——序列号、快照对冲、时间戳对齐,一个都不能少。
- 性能优化——预分配、批量处理、避免 GC。
如果你能把这三件事做好,订单簿重建就不再是瓶颈。你的策略,也能更早地看到市场的「真实面貌」。