4、订单簿重建:从原始数据捕捉实时订单流

做量化交易的朋友都知道,订单簿就是市场的「心跳」。你看到的买一卖一、深度数据,其实都是交易所实时拼凑出来的。我刚开始做高频策略时,天真地以为直接拿API推送的OrderBook快照就够用了。结果回测时发现,策略在模拟环境里赚得盆满钵满,实盘一跑就亏——后来才明白,快照数据有延迟,而且中间丢了很多订单流细节。

说白了,订单簿重建就是「用原始逐笔数据,自己拼出交易所的实时挂单状态」。这件事做不好,你的策略就像闭着眼睛开车。

4.1 原始数据长什么样?

交易所给的原始数据,通常分两类:

  • 快照(Snapshot):每隔一段时间(比如100ms)推送一次当前全量订单簿
  • 增量(Delta/Update):每次有订单成交、撤单、新挂单时,推送一条变更记录

我习惯把增量数据想象成「补丁包」。你拿到一个初始快照,然后不断打补丁,就能维持一个实时更新的订单簿。嗯,这里要注意:不同交易所的增量格式差别很大。比如币安的逐笔数据包含「新增」「删除」「成交」三种操作,而Coinbase的格式又不一样。

一个典型的增量记录长这样:

{
  "type": "snapshot",   // 或 "delta"
  "timestamp": 1699000000123,
  "bids": [
    [100.50, 2.0],   // 价格, 数量
    [100.45, 1.5]
  ],
  "asks": [
    [100.55, 3.0],
    [100.60, 1.0]
  ]
}

增量记录则更精简:

{
  "type": "delta",
  "timestamp": 1699000000124,
  "bids": [
    [100.50, 0],     // 数量为0表示删除这个价位
    [100.48, 2.5]    // 新增或更新
  ],
  "asks": []
}
核心原则:数量为0 = 删除该价位。这是所有交易所的通用约定,但有些交易所用负数表示删除,一定要先看文档。

4.2 重建订单簿的核心逻辑

重建过程其实就三步:

  1. 初始化:拿到第一个快照,建立初始的买盘和卖盘字典
  2. 应用增量:每来一条增量,更新对应价位的数量
  3. 维护排序:买盘按价格降序,卖盘按价格升序

为什么买盘要降序?因为买单价格越高,越优先成交。卖盘则相反,价格越低越优先。这个顺序就是订单簿的「价格优先」原则。

我曾在项目中遇到过一个问题:交易所的快照和增量之间有时间差,如果增量比快照先到,订单簿就乱了。解决方案是加一个「序列号」校验,只处理比当前快照更新的增量。

4.3 Python实现:从零搭建订单簿

下面是我个人比较喜欢的一种实现方式。用字典存储价格和数量,用列表维护排序后的价格序列。

class OrderBook:
    def __init__(self):
        self.bids = {}  # 价格 -> 数量
        self.asks = {}
        self.bid_prices = []  # 降序
        self.ask_prices = []  # 升序
        self.sequence = 0

    def apply_snapshot(self, data):
        """应用快照"""
        self.bids = {float(p): float(q) for p, q in data['bids'] if float(q) > 0}
        self.asks = {float(p): float(q) for p, q in data['asks'] if float(q) > 0}
        self.bid_prices = sorted(self.bids.keys(), reverse=True)
        self.ask_prices = sorted(self.asks.keys())
        self.sequence = data.get('sequence', 0)

    def apply_delta(self, data):
        """应用增量更新"""
        if data.get('sequence', 0) <= self.sequence:
            return  # 丢弃过期数据
        self.sequence = data['sequence']

        # 处理买单
        for price, qty in data.get('bids', []):
            price = float(price)
            qty = float(qty)
            if qty == 0:
                self.bids.pop(price, None)
            else:
                self.bids[price] = qty

        # 处理卖单
        for price, qty in data.get('asks', []):
            price = float(price)
            qty = float(qty)
            if qty == 0:
                self.asks.pop(price, None)
            else:
                self.asks[price] = qty

        # 重新排序(可以优化为增量插入)
        self.bid_prices = sorted(self.bids.keys(), reverse=True)
        self.ask_prices = sorted(self.asks.keys())

    def get_top(self, n=5):
        """获取前n档买卖盘"""
        top_bids = [(p, self.bids[p]) for p in self.bid_prices[:n]]
        top_asks = [(p, self.asks[p]) for p in self.ask_prices[:n]]
        return top_bids, top_asks

    def get_spread(self):
        """获取买卖价差"""
        if not self.bid_prices or not self.ask_prices:
            return None
        return self.ask_prices[0] - self.bid_prices[0]
性能提示:每次增量后都重新排序,在数据量大的时候会很慢。我建议用bisect模块做增量插入,或者用sortedcontainers库。回测时可以用简单排序,实盘必须优化。

4.4 避坑指南:我踩过的三个坑

坑一:浮点数精度

我曾经因为浮点数比较,导致订单簿里同一个价格出现了两个条目。比如100.50和100.5000000001,在字典里是两个不同的key。解决方案是用Decimal或者直接乘以10000转整数。

坑二:增量乱序

WebSocket推送的增量可能乱序。我吃过一次亏:先收到一个删除操作,后收到新增操作,结果订单簿里凭空多了一个价位。后来加了序列号校验,只处理严格递增的增量。

坑三:快照与增量重叠

有些交易所的快照和增量是独立推送的。如果你在快照到达前已经处理了一些增量,快照会覆盖掉这些更新。我的做法是:收到快照后,清空所有增量缓存,重新开始。

4.5 可视化:看一眼订单簿长啥样

下面我用SVG画了一个简单的订单簿结构图。左边是买盘(红色),右边是卖盘(绿色)。中间那条线就是当前价格。

订单簿结构示意图 买盘 (Bids) 卖盘 (Asks) 买一: 100.50 2.0 BTC 买二: 100.45 1.5 BTC 买三: 100.40 3.0 BTC 买四: 100.35 0.8 BTC 买五: 100.30 1.2 BTC 卖一: 100.55 1.8 BTC 卖二: 100.60 2.2 BTC 卖三: 100.65 1.0 BTC 卖四: 100.70 3.5 BTC 卖五: 100.75 0.5 BTC 价差: 100.55 - 100.50 = 0.05 买盘按价格降序排列,卖盘按价格升序排列

你看,买一和卖一之间的空隙就是价差。价差越小,市场流动性越好。我一般会在策略里监控价差变化,如果突然扩大,说明市场可能出现了异常波动。

4.6 实战:用订单簿信号做交易决策

重建好订单簿之后,我们能提取哪些信号?

信号名称 计算方法 交易含义
买卖盘不平衡 买盘总量 / 卖盘总量 比值 > 1.5 可能看涨,< 0.7 可能看跌
价差变化 卖一价 - 买一价 价差突然扩大,说明流动性枯竭
深度斜率 前5档累计量的变化率 斜率变陡,说明大单在堆积
订单撤销率 单位时间内撤销订单数 / 总订单数 撤销率高,可能是虚假挂单

我个人比较喜欢用「买卖盘不平衡」这个指标。举个例子,如果买一档突然增加了50个BTC,而卖一档纹丝不动,说明有大资金在托底。这时候做多胜率会高一些。但要注意,有些做市商会故意挂大单然后撤单,这就是所谓的「虚假深度」。所以我会结合订单撤销率一起看。

重要提醒:订单簿信号只是概率,不是确定性。我见过太多人看到买盘厚就无脑做多,结果被庄家反手砸穿。一定要结合成交量、K线形态一起分析。

4.7 性能优化:实盘每秒处理10万条增量

如果你做高频交易,每秒可能收到几万甚至几十万条增量。Python的字典操作虽然快,但排序会成为瓶颈。我分享几个优化技巧:

  • 用数组代替字典:如果价格精度固定(比如小数点后2位),可以用数组索引价格,O(1)访问
  • 延迟排序:只在需要获取前几档时才排序,平时只更新字典
  • 使用C扩展:比如用numpynumba加速排序
  • 增量排序:用bisect.insort维护有序列表,避免全量排序

我记得有一次优化订单簿性能,把排序从O(n log n)降到了O(log n),回测速度提升了40倍。嗯,细节决定成败。

好了,订单簿重建的核心内容就这些。你拿到原始数据后,先跑通上面的代码,然后用历史数据验证一下重建的订单簿是否和交易所快照一致。这一步做扎实了,后面的订单流分析才有意义。


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