4、订单簿重建:L1/L2/L3订单簿数据结构、增量更新算法、快照与回放机制

订单簿重建,说白了就是给市场画一张「实时动态的挂单地图」。我刚开始做高频回测时,觉得这事很简单——不就是买一卖一嘛。结果第一次用真实逐笔数据回测,直接崩了。嗯,后来我才明白,L1、L2、L3 这三层数据结构,每一层都有它的脾气。

4.1 L1 / L2 / L3 订单簿数据结构

先说说这三层到底长什么样。我个人习惯用一张表来对比,这样最直观:

层级 数据内容 典型字段 适用场景
L1 最优买卖价 & 量 bid_price, bid_size, ask_price, ask_size 简单策略、盘口监控
L2 多档位买卖盘口 bids[][price,size], asks[][price,size] 常见回测、流动性分析
L3 逐笔委托 + 逐笔成交 order_id, price, size, side, type, timestamp 高频策略、订单簿微观结构

你想想看,L1 就像你只盯着大屏幕上的「买一卖一」,L2 则是能看到前 10 档甚至 50 档的挂单墙。而 L3 呢?那是直接把交易所的「原始流水」给你了——每一笔挂单、撤单、成交,全都有。

核心要点:L3 数据是重建完整订单簿的唯一可靠来源。L1/L2 数据只能做近似重建,因为中间可能丢失了「幽灵订单」(挂上又立刻撤掉的单子)。

4.2 增量更新算法

为什么要用增量更新?因为全量快照太慢了。我见过有人每 100ms 拉一次全量订单簿,结果回测速度直接慢了 10 倍。其实,交易所的逐笔数据流本身就是增量更新的——来一条消息,改一个状态。

增量更新的核心逻辑,说白了就是三个字:增、删、改。

  • 增(Add):新挂单进入订单簿,按价格排序插入对应档位。
  • 删(Delete):撤单或成交后,从订单簿中移除该委托。
  • 改(Modify):部分成交或改价,更新委托的数量或价格。

我建议用「价格-时间优先」原则来维护订单簿。具体来说,买盘按价格从高到低排序,卖盘按价格从低到高排序。同一价格下,按到达时间排序。

下面是我常用的 L3 增量更新核心代码(Python 伪代码):

class OrderBook:
    def __init__(self):
        self.bids = {}  # price -> list of orders
        self.asks = {}
        self.order_map = {}  # order_id -> order

    def apply(self, msg):
        if msg.type == 'NEW':
            self._add_order(msg)
        elif msg.type == 'CANCEL':
            self._remove_order(msg.order_id)
        elif msg.type == 'TRADE':
            self._match_order(msg)
        elif msg.type == 'MODIFY':
            self._modify_order(msg)

    def _add_order(self, msg):
        # 插入到对应价格队列
        side = self.bids if msg.side == 'BUY' else self.asks
        if msg.price not in side:
            side[msg.price] = []
        side[msg.price].append(msg)
        self.order_map[msg.order_id] = msg

避坑指南:我曾经在增量更新时忘记处理「部分成交」的情况。结果订单簿里的数量越积越多,回测结果完全失真。记住:成交后一定要更新对应委托的剩余数量,如果剩余为 0,立即删除。

4.3 快照与回放机制

快照是什么?就是某一时刻订单簿的「全量截图」。回放呢?就是把一段时间的增量消息按顺序「重演」一遍,重建出完整的订单簿状态。

为什么需要快照?因为增量更新有个致命问题——如果中间漏了一条消息,后面的所有状态就全错了。我早期做回测时,数据源偶尔会丢包,结果回测出来的收益曲线跟过山车一样,完全没法用。

解决方案很简单:定期打快照,然后从快照开始增量回放。

具体做法是这样的:

  1. 每 1 分钟(或每 1000 条消息)保存一次全量订单簿快照。
  2. 回放时,先加载最近的一个快照。
  3. 然后从快照时间点之后的第一条增量消息开始,逐条应用。

下面是我常用的快照回放流程示意图:

S1 快照1 T=0 +1 +2 +3 S2 快照2 T=60s +4 +5 回放路径:从最近快照开始,逐条应用增量 快照 增量消息

你看这张图,S1 和 S2 是两个快照点。如果我们要回放到 +5 时刻,直接从 S2 快照开始,然后依次应用 +4、+5 两条增量消息即可。这样既快又准。

注意:快照不能打得太频繁,否则存储和 I/O 开销会很大。我一般建议 30-60 秒打一次快照,或者每 500-1000 条消息打一次。具体要看你的数据量和回测精度要求。

4.4 实战中的几个坑

最后分享几个我踩过的坑,希望能帮你省点时间:

  • 时间戳对齐:交易所的逐笔数据时间戳和快照时间戳可能不同步。我建议统一用交易所的「事件时间」,不要用本地接收时间。
  • 价格精度:有些交易所的价格是浮点数,但订单簿里最好用整数(比如乘以 10000 转成 int),避免浮点误差。
  • 内存管理:L3 订单簿如果全量保存在内存里,一天的数据可能就上 GB。我习惯用「滑动窗口」——只保留最近 1 小时的订单簿状态,更早的归档到磁盘。
  • 并发安全:如果你的回测框架是多线程的,订单簿更新一定要加锁。否则你会看到「幽灵订单」——明明撤单了,订单簿里还挂着。

嗯,订单簿重建这块,说白了就是「数据 + 算法 + 工程」三者的结合。数据结构选对了,增量更新写稳了,快照回放搞定了,你的回测框架就成功了一半。剩下的,就是策略本身的事了。

专注资料整理