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 重建算法:三步走

我一般把重建过程分成三步,你想想看,其实跟拼乐高差不多:

  1. 初始化:创建一个空的买盘字典和一个空的卖盘字典
  2. 逐笔处理:按时间顺序读取每条委托,更新对应字典
  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 验证:怎么知道重建对了?

重建完订单簿,你怎么确认它是对的?我一般用三个方法交叉验证:

  1. 快照对比法:如果交易所也提供定期快照(比如每 100ms 一次),把你的重建结果和快照对比。数量差超过 1 手就算错。
  2. 成交验证法:订单簿上的数量变化,应该和成交数据匹配。比如卖一档少了 1000 股,那应该有一条 1000 股的成交记录。
  3. 零和检查:所有新增委托的总量,减去所有撤单和成交的总量,应该等于当前订单簿的总量。如果不相等,说明有数据漏了或重复了。
我的经验:第一次做订单簿重建时,建议先跑一小段数据(比如 1 分钟),手动核对几个关键时间点的订单簿状态。等逻辑确认无误了,再全量跑。否则全量跑完发现错了,排查起来非常痛苦。

4.7 知识体系总览

下面这张图概括了订单簿重建的完整流程,从原始数据到最终快照:

订单簿重建流程 原始逐笔委托 Time, Side, Price, Vol, Type 逐笔处理逻辑 Add → 累加 Cancel/Trade → 减去 订单簿状态 bids: {price: vol} asks: {price: vol} 快照生成 排序 + 截取前N档 交叉验证 快照对比 / 成交验证 性能优化建议 • 用数组代替字典(价格固定时) • 批量处理同毫秒数据 • 用 C++/Cython 重写核心 • 使用 defaultdict 简化代码 • 预分配内存减少 rehash 图:订单簿重建的完整数据流与优化方向

从这张图可以看得很清楚:原始数据进来后,经过逐笔处理逻辑更新订单簿状态,然后按需生成快照,最后用交叉验证确保正确性。左侧的优化建议是实际项目中必须考虑的,否则数据量一大就容易崩。

4.8 避坑指南

最后,我把自己踩过的坑总结一下,希望能帮你少走弯路:

  • 时间同步问题:不同数据源的时间戳可能不同步。我遇到过行情数据比成交数据快了 2 毫秒的情况,导致订单簿短暂「穿越」。解决办法是用交易所的序列号(Sequence Number)排序,而不是纯依赖时间戳。
  • 浮点数精度:价格用浮点数存,累加多了会有精度误差。我建议所有价格都用整数表示(比如乘以 10000 变成整型),数量也用整型。最后展示时再转回浮点。
  • 内存泄漏:如果订单簿长期运行,有些「僵尸委托」可能永远不撤单也不成交,导致字典越来越大。我一般会定期清理超过一定时间(比如 30 分钟)未更新的价格档位。
  • 并发处理:如果多线程同时更新订单簿,记得加锁。不过更好的做法是用单线程 + 异步 I/O,省去锁的开销。

嗯,订单簿重建这块内容就这些。核心就是「新增加、撤单减、成交也减」这九个字,但每个细节都藏着坑。你动手实现一遍,跑上几天的数据,自然就全明白了。


公众号:蓝海资料掘金营,微信deep3321