4、订单簿重建:从原始行情数据(逐笔委托)重建完整订单簿的算法与实现
订单簿重建,说白了就是把交易所发过来的那些零散的「逐笔委托」数据,拼回一张完整的买卖挂单表。这件事听起来简单,做起来坑不少。我最早接触这个需求时,以为就是个排序加累加,结果回测数据对不上实盘,查了三天才发现是漏处理了撤单。
今天咱们就把这个「拼图」过程拆开揉碎,从数据结构到算法实现,再到我踩过的那些坑,一次性讲清楚。
4.1 原始行情数据长什么样?
交易所给的逐笔委托数据,一般包含这几个核心字段:
| 字段 | 含义 | 示例 |
|---|---|---|
| Time | 委托时间(毫秒级) | 09:30:01.234 |
| Side | 买卖方向 | B(买)/ S(卖) |
| Price | 委托价格 | 10.25 |
| Volume | 委托数量 | 1000 |
| Type | 委托类型 | Add(新增)/ Cancel(撤单)/ Trade(成交) |
| OrderID | 委托编号 | 12345678 |
注意,不同交易所的字段名可能不一样,但核心逻辑就这六样。我见过有人把「成交」和「撤单」混在一起处理,结果订单簿越算越厚——嗯,这是个经典错误。
4.2 订单簿的「骨架」:价格档位与队列
订单簿本质上是个双层结构:
- 第一层:按价格排序的档位(Price Level)
- 第二层:每个价格档位下的委托队列(Order Queue)
买盘从高到低排,卖盘从低到高排。中间那个空隙就是「买卖价差」。我个人习惯用红绿两色区分买卖盘,红色代表卖盘(你想,红色警示嘛),绿色代表买盘。
4.3 重建算法:三步走
我一般把重建过程分成三步,你想想看,其实跟拼乐高差不多:
- 初始化:创建一个空的买盘字典和一个空的卖盘字典
- 逐笔处理:按时间顺序读取每条委托,更新对应字典
- 快照生成:按规则排序,输出当前时刻的完整订单簿
第二步最关键。每条委托的处理逻辑是这样的:
- 新增委托(Add):在对应价格档位加上数量。如果该价格不存在,新建一个档位。
- 撤单(Cancel):在对应价格档位减去数量。如果减到0,删除该档位。
- 成交(Trade):同样减去数量。注意,成交和撤单的区别在于:成交是主动单吃掉了被动单,被动单的数量减少。
4.4 代码实现:Python 版
下面是我常用的一个简化版实现。实际生产环境里会加更多边界检查,但核心逻辑就这些:
class OrderBook:
def __init__(self):
self.bids = {} # 买盘:价格 -> 数量
self.asks = {} # 卖盘:价格 -> 数量
def process(self, msg):
"""处理一条逐笔委托消息"""
time = msg['time']
side = msg['side'] # 'B' 或 'S'
price = msg['price']
volume = msg['volume']
otype = msg['type'] # 'Add', 'Cancel', 'Trade'
# 选择对应的字典
book = self.bids if side == 'B' else self.asks
if otype == 'Add':
# 新增委托:累加数量
book[price] = book.get(price, 0) + volume
elif otype in ('Cancel', 'Trade'):
# 撤单或成交:减去数量
if price in book:
book[price] -= volume
# 如果数量归零,删除该档位
if book[price] <= 0:
del book[price]
def snapshot(self):
"""生成当前订单簿快照"""
# 买盘:价格从高到低排序
bid_levels = sorted(self.bids.items(), reverse=True)
# 卖盘:价格从低到高排序
ask_levels = sorted(self.asks.items())
return {
'bids': bid_levels[:10], # 取前10档
'asks': ask_levels[:10]
}
collections.defaultdict(int) 来初始化字典,这样 book[price] += volume 就不用先判断 key 是否存在了。代码更干净,性能也更好。
4.5 性能优化:别让速度拖后腿
逐笔委托数据量很大,一天几百万条是常事。如果你用纯 Python 字典处理,速度可能跟不上。我分享几个优化思路:
- 用数组代替字典:如果价格精度固定(比如两位小数),可以预先分配一个足够大的数组,用价格索引直接定位。时间复杂度从 O(1) 哈希变成 O(1) 数组访问,常数项小很多。
- 批量处理:把同一毫秒内的多条委托合并处理,减少字典操作次数。
- 用 C 扩展:如果还是不够快,可以考虑用 Cython 或 C++ 重写核心循环。我有个项目就是用 C++ 写的订单簿,处理速度比 Python 快了 50 倍。
为什么会差这么多?说白了,Python 每条字典操作背后都有类型检查和引用计数,这些开销在百万级数据量下会被放大。
4.6 验证:怎么知道重建对了?
重建完订单簿,你怎么确认它是对的?我一般用三个方法交叉验证:
- 快照对比法:如果交易所也提供定期快照(比如每 100ms 一次),把你的重建结果和快照对比。数量差超过 1 手就算错。
- 成交验证法:订单簿上的数量变化,应该和成交数据匹配。比如卖一档少了 1000 股,那应该有一条 1000 股的成交记录。
- 零和检查:所有新增委托的总量,减去所有撤单和成交的总量,应该等于当前订单簿的总量。如果不相等,说明有数据漏了或重复了。
4.7 知识体系总览
下面这张图概括了订单簿重建的完整流程,从原始数据到最终快照:
从这张图可以看得很清楚:原始数据进来后,经过逐笔处理逻辑更新订单簿状态,然后按需生成快照,最后用交叉验证确保正确性。左侧的优化建议是实际项目中必须考虑的,否则数据量一大就容易崩。
4.8 避坑指南
最后,我把自己踩过的坑总结一下,希望能帮你少走弯路:
- 时间同步问题:不同数据源的时间戳可能不同步。我遇到过行情数据比成交数据快了 2 毫秒的情况,导致订单簿短暂「穿越」。解决办法是用交易所的序列号(Sequence Number)排序,而不是纯依赖时间戳。
- 浮点数精度:价格用浮点数存,累加多了会有精度误差。我建议所有价格都用整数表示(比如乘以 10000 变成整型),数量也用整型。最后展示时再转回浮点。
- 内存泄漏:如果订单簿长期运行,有些「僵尸委托」可能永远不撤单也不成交,导致字典越来越大。我一般会定期清理超过一定时间(比如 30 分钟)未更新的价格档位。
- 并发处理:如果多线程同时更新订单簿,记得加锁。不过更好的做法是用单线程 + 异步 I/O,省去锁的开销。
嗯,订单簿重建这块内容就这些。核心就是「新增加、撤单减、成交也减」这九个字,但每个细节都藏着坑。你动手实现一遍,跑上几天的数据,自然就全明白了。
公众号:蓝海资料掘金营,微信deep3321