3、订单簿重建与快照:从逐笔委托重建Order Book;Bid/Ask队列维护;Top of Book 与 Market Depth

好,咱们进入正题。这一章要聊的,是订单流分析里最基础、也最核心的一步——订单簿重建

说白了,就是给你一堆逐笔委托数据,你怎么把它还原成交易所当时看到的那个“挂单墙”。我刚开始做这个的时候,觉得不就是个队列嘛,往里塞数据就行了。结果第一次回测就发现,我的订单簿跟交易所的快照对不上,差了十几个tick。嗯,后来才明白,这里面的门道比想象中多。

3.1 为什么需要重建订单簿?

交易所给我们推送的行情,通常有两种:

  • 逐笔委托(Order Book Snapshots / Incremental Updates):每来一笔订单,告诉你“有人在某个价格挂了100手买单”或者“有人撤单了”。
  • 快照(Snapshots):每隔一段时间(比如100ms),给你一张完整的挂单表。

如果你只依赖快照,那中间发生的所有变化你都看不到。而高频策略,恰恰就是吃这中间的“残羹剩饭”。所以,我们必须自己动手,用逐笔数据把订单簿实时重建出来。

核心目标: 用逐笔委托流,维护一个本地的、实时的 Bid/Ask 队列,并能在任意时刻输出 Top of Book(最优买卖价)和 Market Depth(深度分布)。

3.2 订单簿的数据结构

我个人习惯用 价格优先队列 来维护。每个价格水平上,挂着一堆订单。具体来说:

  • Bid Side(买方):价格从高到低排序。最高价是 Best Bid。
  • Ask Side(卖方):价格从低到高排序。最低价是 Best Ask。

每个价格水平上,我们需要记录:

  • 总挂单量(Total Volume)
  • 订单数量(Order Count)
  • 有时候还需要记录每个订单的ID,方便撤单时精准删除

我见过有人用 Python 的 dictsortedcontainers 来做,性能也还行。但如果你用 C++ 或者 Rust,那直接上 std::map 或者 BTreeMap 就完事了。

3.3 逐笔委托的三种操作

逐笔委托数据,本质上就三种操作:

  1. 新增订单(Add / New):在某个价格上,增加一笔订单。
  2. 撤单(Cancel / Delete):删除一笔已有的订单。
  3. 成交(Execute / Trade):订单部分或全部成交,减少对应数量。

你想想看,交易所的订单簿就是被这三件事不断改变的。我们本地重建,就是模拟这个过程。

避坑指南: 我曾经遇到过一个坑——有些交易所的逐笔数据里,撤单和成交是分开推送的,但有些交易所会把“成交导致的减少”也标记为撤单。一定要先搞清楚数据源的字段定义,否则你的订单簿会越重建越乱。

3.4 代码示例:一个简单的订单簿维护逻辑

下面我用 Python 写一个极简的订单簿类。别指望它能直接上生产环境,但核心逻辑都在里面了。

class OrderBook:
    def __init__(self):
        self.bids = {}  # price -> total_volume
        self.asks = {}  # price -> total_volume
        self.best_bid = 0
        self.best_ask = float('inf')

    def apply_add(self, side, price, volume):
        if side == 'B':
            self.bids[price] = self.bids.get(price, 0) + volume
            if price > self.best_bid:
                self.best_bid = price
        else:
            self.asks[price] = self.asks.get(price, 0) + volume
            if price < self.best_ask:
                self.best_ask = price

    def apply_cancel(self, side, price, volume):
        if side == 'B':
            self.bids[price] -= volume
            if self.bids[price] <= 0:
                del self.bids[price]
                # 如果删掉的是 best bid,需要重新计算
                if price == self.best_bid:
                    self.best_bid = max(self.bids.keys()) if self.bids else 0
        else:
            self.asks[price] -= volume
            if self.asks[price] <= 0:
                del self.asks[price]
                if price == self.best_ask:
                    self.best_ask = min(self.asks.keys()) if self.asks else float('inf')

    def apply_trade(self, side, price, volume):
        # 成交本质上就是减少挂单量
        self.apply_cancel(side, price, volume)

    def get_top_of_book(self):
        return self.best_bid, self.best_ask

    def get_depth(self, levels=5):
        bid_levels = sorted(self.bids.items(), reverse=True)[:levels]
        ask_levels = sorted(self.asks.items())[:levels]
        return bid_levels, ask_levels

这段代码里,我故意没处理多笔订单ID的细节。实际项目中,每个价格水平上可能挂着几十笔不同ID的订单,撤单时你得精确到ID。否则,如果两笔订单在同一价格,你撤了一笔,总成交量减错了,后面全乱套。

3.5 Top of Book 与 Market Depth

订单簿重建好了,我们能拿它干什么?

  • Top of Book:就是最优买卖价(Best Bid / Best Ask)。这是最直观的行情指标。价差(Spread)就是 Best Ask - Best Bid。我习惯用这个来判断当前市场的流动性紧张程度。
  • Market Depth:指的是每个价格水平上的挂单量。比如“买一”挂了100手,“买二”挂了200手……这能告诉你,如果我想吃掉某个价位的所有订单,需要多少资金。

举个例子,假设当前 Top of Book 是:

Side Price Volume
Ask 100.05 500
Bid 100.00 300

价差是 0.05。如果你是个做市商,看到价差这么小,可能就不太愿意进场了,因为利润太薄。但如果你是个动量策略,看到买一有300手撑着,可能会觉得下方支撑很强。

注意: 有些交易所的快照数据里,Top of Book 是直接给你的。但如果你用逐笔数据重建,一定要确保你的本地订单簿和交易所的快照定期做一次“对账”。我一般每收到一次快照,就把本地订单簿重置成快照的内容,然后再继续用逐笔数据增量更新。这样能防止累积误差。

3.6 知识体系结构图

下面这张图,是我自己总结的订单簿重建的核心流程。你可以把它当作一个思维导图来看。

订单簿重建 逐笔委托数据流 定期快照(可选) 新增 / 撤单 / 成交 Bid 队列(价格降序) Ask 队列(价格升序) Top of Book / Depth 策略决策 / 信号生成

这张图从左到右,从上到下,就是整个流程。输入是逐笔数据,中间经过三种操作维护两个队列,最后输出 Top of Book 和 Depth,供策略使用。

3.7 一些实战中的小建议

  • 性能问题:如果你处理的是全市场数据,每秒可能有几万笔委托。Python 的 dict 操作虽然快,但如果你在每个 tick 都做排序,那肯定扛不住。我建议用 heapq 或者 sortedcontainers 来维护价格队列。
  • 数据对齐:逐笔数据的时间戳和快照的时间戳可能不同步。我习惯以快照时间为基准,把快照之前的逐笔数据全部回放一遍,然后再用快照做一次校验。
  • 内存管理:如果你要回放一整天的数据,订单簿里的价格水平可能会非常多。记得定期清理那些挂单量为0的价格点,否则内存会涨得很快。

嗯,这一章的内容差不多就这些。订单簿重建听起来简单,但真正做起来,细节非常多。尤其是当你面对真实的高频数据时,各种边界情况会让你头疼。不过别担心,多踩几次坑,你就知道该怎么处理了。


专注资料整理