2. 订单簿构建:Level2快照与增量更新、订单簿重建算法

好,咱们进入第二章。这一章要聊的是订单簿的构建,说白了就是怎么把交易所发过来的原始数据,变成我们量化策略能用的那个「实时价格地图」。我个人觉得,这是所有微观结构分析的基石。你想想看,如果地基没打牢,后面那些流动性信号、订单流分析,全都会跑偏。

2.1 Level2快照:一张静态的全景图

先说说快照。快照是什么?就是交易所每隔一段时间(比如100毫秒或500毫秒)给你拍一张「当前订单簿的全家福」。这张照片里包含了所有价位的挂单量和挂单笔数。

我刚开始做高频策略的时候,以为快照就是全部了。后来发现,快照之间丢失的信息量,有时候比快照本身还大。但不管怎样,快照是重建订单簿的起点。

一个典型的Level2快照数据结构长这样:

{
  "timestamp": 1630000000000,
  "asks": [
    [100.50, 2000, 15],  // 价格, 总量, 委托笔数
    [100.51, 1500, 10],
    ...
  ],
  "bids": [
    [100.48, 3000, 20],
    [100.47, 2500, 12],
    ...
  ]
}

这里要注意,不同交易所的字段名可能不一样。有的叫asks,有的叫sell,有的把笔数放在另一个字段里。嗯,这里要提醒一句:永远不要假设数据格式是固定的。我在项目中遇到过,某次交易所升级后,快照里突然多了一个seq_num字段,差点把解析逻辑搞崩。

避坑指南:快照数据通常只在开盘后或重启连接时推送一次。如果你断线重连了,必须先请求一次快照,再开始处理增量。否则你的订单簿会「越跑越偏」。

2.2 增量更新:捕捉每一笔变化

快照是静态的,但市场是动态的。每一笔成交、每一个撤单、每一个新挂单,都会改变订单簿。增量更新就是把这些「变化」一条一条告诉你。

增量更新的类型通常有这几种:

  • 新增订单:某个价位上多了一笔挂单
  • 撤销订单:某个价位上少了一笔挂单
  • 成交:订单被吃掉,数量减少或完全消失
  • 修改订单:价格不变但数量变了(有些交易所会拆成撤单+新单)

每条增量消息一般包含:

字段 说明
side 买盘还是卖盘('b' 或 'a')
price 价格
size 变化后的数量(注意:不是变化量!)
order_id 订单唯一标识
type 操作类型(新增/删除/修改)

这里有个关键点:size字段是变化后的绝对值,不是差值。比如原来有100手,成交了20手,增量消息里size就是80。这个设计其实是为了方便你直接覆盖,不用做加减法。但如果你没搞清楚,直接用累加,那订单簿就乱套了。

个人经验:我习惯在收到增量消息后,先检查一下order_id是否已经在本地订单簿里。如果不在,那就是新增;如果在,那就是修改或删除。这个判断逻辑虽然简单,但能避免很多边界情况。

2.3 订单簿重建算法:从零到一

好了,现在我们有快照和增量了。怎么把它们拼起来?这就是订单簿重建算法要干的事。

核心流程其实不复杂,我画了一张图帮你理解:

订单簿重建流程 步骤1:获取快照 步骤2:初始化订单簿 步骤3:接收增量 持续循环 步骤4:应用增量到订单簿 步骤5:输出最新订单簿

流程看着简单,但实现起来有几个坑。我直接上代码,咱们边看边聊:

class OrderBook:
    def __init__(self):
        self.bids = {}  # price -> {order_id: size}
        self.asks = {}  # price -> {order_id: size}
        self.bid_levels = []  # 排序后的买盘价位
        self.ask_levels = []  # 排序后的卖盘价位
        
    def apply_snapshot(self, snapshot):
        """应用快照,重建订单簿"""
        self.bids.clear()
        self.asks.clear()
        
        for price, size, count in snapshot['bids']:
            # 每个价位用一个虚拟order_id存储总量
            self.bids[price] = {'snapshot_total': size}
            
        for price, size, count in snapshot['asks']:
            self.asks[price] = {'snapshot_total': size}
            
        self._sort_levels()
        
    def apply_increment(self, inc):
        """应用增量更新"""
        side = self.bids if inc['side'] == 'b' else self.asks
        price = inc['price']
        order_id = inc['order_id']
        size = inc['size']
        
        if price not in side:
            side[price] = {}
            
        if size == 0:
            # 订单被撤销或完全成交
            if order_id in side[price]:
                del side[price][order_id]
            if not side[price]:
                del side[price]
        else:
            # 新增或修改订单
            side[price][order_id] = size
            
        self._sort_levels()
        
    def _sort_levels(self):
        """排序价位,买盘降序,卖盘升序"""
        self.bid_levels = sorted(self.bids.keys(), reverse=True)
        self.ask_levels = sorted(self.asks.keys())
        
    def get_top(self, n=10):
        """获取前n档买卖盘口"""
        top_bids = [(p, sum(self.bids[p].values())) for p in self.bid_levels[:n]]
        top_asks = [(p, sum(self.asks[p].values())) for p in self.ask_levels[:n]]
        return top_bids, top_asks
核心要点:每个价位用字典存储所有订单ID,而不是只存总量。这样做的好处是,当某个订单被撤销时,你可以精确地移除它,而不是重新计算总量。代价是内存占用会大一些,但换来的是准确性。

2.4 增量序列号与数据一致性

增量更新是按顺序来的。每个增量消息都有一个序列号(seq_num),用来保证顺序。如果你收到的增量序列号不连续,说明中间丢包了。

我曾经在实盘环境中遇到过这种情况:网络抖动导致丢了3条增量,结果订单簿的总量对不上了。从那以后,我养成了一个习惯——每次应用增量前,先检查序列号是否连续

检查逻辑很简单:

def check_seq_num(self, new_seq):
    if self.last_seq is not None:
        if new_seq != self.last_seq + 1:
            # 序列号不连续,需要重新请求快照
            self.request_snapshot()
            return False
    self.last_seq = new_seq
    return True

如果发现丢包,别犹豫,直接请求新的快照,然后重新开始。虽然会丢失几毫秒的数据,但总比用一个「坏掉的订单簿」强。

2.5 性能优化:别让订单簿拖慢你的策略

订单簿更新频率很高,尤其是在Tick级数据下。每秒几百次甚至上千次更新都很正常。如果代码写得不够高效,CPU会先扛不住。

我分享几个优化技巧:

  • 用数组代替字典:如果价格档位是固定的(比如0.01一跳),可以用数组索引代替字典查找。速度能快一个数量级。
  • 延迟排序:不是每次更新都需要重新排序。你可以攒够10次更新再排一次,或者只在查询盘口时排序。
  • 避免内存分配:在循环里频繁创建新对象会触发GC。尽量复用已有的数据结构。
避坑指南:我曾经用Python的sortedcontainers库来维护有序列表,结果发现它的插入性能在数据量大时会退化。后来换成了自己维护的跳表结构,性能才稳定下来。如果你用Python做高频,建议自己实现底层数据结构。

2.6 实战:从零搭建一个订单簿引擎

最后,咱们把上面所有知识点串起来,写一个完整的订单簿引擎。这个引擎能处理快照和增量,并实时输出盘口数据。

class OrderBookEngine:
    def __init__(self):
        self.book = OrderBook()
        self.last_seq = None
        self.snapshot_received = False
        
    def on_snapshot(self, snapshot):
        self.book.apply_snapshot(snapshot)
        self.snapshot_received = True
        self.last_seq = snapshot['seq_num']
        
    def on_increment(self, inc):
        if not self.snapshot_received:
            # 还没收到快照,忽略增量
            return
            
        if not self.check_seq_num(inc['seq_num']):
            return
            
        self.book.apply_increment(inc)
        
    def get_market_depth(self, levels=10):
        bids, asks = self.book.get_top(levels)
        spread = asks[0][0] - bids[0][0] if bids and asks else None
        return {
            'bids': bids,
            'asks': asks,
            'spread': spread,
            'mid_price': (bids[0][0] + asks[0][0]) / 2 if bids and asks else None
        }

这个引擎虽然简单,但已经能应对大部分场景了。实际生产中,你还需要加上线程安全、日志记录、异常处理等。不过核心逻辑就是这些。

嗯,订单簿构建这块,说白了就是「快照打底,增量修补」。只要保证快照的完整性和增量的顺序性,重建出来的订单簿就不会有问题。下一章咱们聊聊怎么从订单簿里提取流动性信号,那才是真正有意思的部分。


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