2. 订单簿数据结构:深度图、Level 1/2/3 数据、快照与增量更新

订单簿这东西,说白了就是交易所的「记账本」。

谁在什么价格挂了多少单,一清二楚。我刚开始做量化那会儿,以为订单簿就是简单的「买一卖一」——后来被市场狠狠教育了一顿,才发现这里面门道深着呢。

2.1 深度图:订单簿的「可视化灵魂」

深度图,就是把订单簿里的挂单数据画成图。横轴是价格,纵轴是累计挂单量。

买方从低到高堆起来,卖方从高到低堆下去。中间那个交叉点,就是当前的最新成交价。

我个人习惯:看深度图先看「斜率」。斜率陡峭,说明流动性差,价格稍微一动就容易滑点。斜率平缓,说明深度好,大单也能吃得下。

举个例子,BTC/USDT 的深度图如果左边(买方)突然出现一个「大坑」,那大概率是某个大户在撤单。这时候你要是还按原来的价格挂单,很容易被扫到。

订单簿深度图示意 买方深度 卖方深度 当前价 价格 → 累计挂单量

2.2 Level 1/2/3 数据:你到底需要多「深」?

交易所给的数据,分三个等级。我当年刚入行时,傻乎乎地只接 Level 1,结果策略回测跑得飞起,实盘一跑就亏钱。为什么?因为数据太「糙」了。

数据等级 包含内容 典型用途 我踩过的坑
Level 1 最优买卖价(买一/卖一) 简单行情展示 做高频策略时,Level 1 根本不够用,滑点算不准
Level 2 前 5~50 档挂单 普通量化策略 有些交易所的 Level 2 其实是「聚合数据」,小心被坑
Level 3 全量挂单 + 逐笔委托 高频做市、盘口分析 数据量巨大,带宽和存储要提前规划好

我的建议:做日内策略,Level 2 就够了。做高频做市,必须上 Level 3。别贪便宜用 Level 1 做高频,那是给自己挖坑。

2.3 快照与增量更新:两种数据「投喂」方式

交易所怎么把订单簿数据发给你?主要有两种方式。

2.3.1 快照(Snapshot)

快照就是「当前时刻订单簿的全量数据」。你想想看,每次请求都返回几千条挂单记录,数据量有多大?

我曾经在某个小交易所做项目,他们每 100ms 发一次快照。结果我的服务器带宽直接被撑爆了。嗯,后来我学乖了——快照只用来做「初始同步」和「数据校验」。

2.3.2 增量更新(Incremental Update)

增量更新只告诉你「变了什么」。比如:

  • 价格 100.5 的买单增加了 2 个 BTC
  • 价格 101.0 的卖单被撤掉了 1 个 ETH

这样数据量就小多了。但有个问题——增量更新必须保证顺序正确。如果先收到「撤单」再收到「挂单」,订单簿就乱套了。

注意:增量更新依赖一个「序列号」或「时间戳」。我遇到过某交易所的序列号会回滚,导致订单簿重建时数据错乱。解决办法:每次收到增量,都跟本地快照做一次「交叉校验」。

2.4 实战:如何用 Python 维护一个本地订单簿

说白了,就是维护两个字典:一个存买单(bids),一个存卖单(asks)。

class OrderBook:
    def __init__(self):
        self.bids = {}  # 价格 -> 数量
        self.asks = {}
        self.sequence = 0  # 序列号,用于校验
    
    def apply_snapshot(self, snapshot):
        """应用快照"""
        self.bids = {item[0]: item[1] for item in snapshot['bids']}
        self.asks = {item[0]: item[1] for item in snapshot['asks']}
        self.sequence = snapshot['sequence']
    
    def apply_update(self, update):
        """应用增量更新"""
        # 检查序列号是否连续
        if update['sequence'] != self.sequence + 1:
            # 序列号不连续,需要重新请求快照
            self.request_snapshot()
            return
        
        # 处理买单更新
        for price, size in update['bids']:
            if size == 0:
                # 数量为0表示删除该价格档位
                self.bids.pop(price, None)
            else:
                self.bids[price] = size
        
        # 处理卖单更新(逻辑同上)
        for price, size in update['asks']:
            if size == 0:
                self.asks.pop(price, None)
            else:
                self.asks[price] = size
        
        self.sequence = update['sequence']
    
    def get_top_n(self, n=5):
        """获取前N档买卖盘口"""
        sorted_bids = sorted(self.bids.items(), reverse=True)[:n]
        sorted_asks = sorted(self.asks.items())[:n]
        return sorted_bids, sorted_asks

核心要点:增量更新中,数量为 0 表示「删除该价格档位」。这个细节我当年搞反了,以为 0 就是挂 0 个单,结果订单簿越积越大,内存直接爆了。

2.5 数据对齐:快照 + 增量的「双保险」

实际项目中,我一般这样设计数据流:

  1. 首次连接:请求一次快照,建立本地订单簿
  2. 持续接收:接收增量更新,实时维护订单簿
  3. 定期校验:每 5 分钟或每 1000 条增量后,重新请求一次快照做对比
  4. 异常处理:如果发现本地订单簿跟快照不一致,立即丢弃本地数据,重新从快照开始

你想想看,为什么要有第 3 步?因为网络丢包、交易所重启、序列号错乱……这些破事我都遇到过。定期校验就是给自己留一条「后路」。

避坑指南:我曾经在某个大交易所做项目,他们的增量更新偶尔会「漏发」一条。如果你不做校验,订单簿就会慢慢「漂移」。最后对账时发现差了 0.1 个 BTC,查了三天才找到原因。

2.6 深度图的实时渲染优化

如果你要做前端展示,深度图不能每收到一条增量就重绘。那样浏览器会卡死。

我的做法是:

  • 维护一个「脏标记」,只有订单簿发生显著变化时才触发重绘
  • 使用 requestAnimationFrame 做节流,保证每秒最多重绘 30 次
  • 只渲染前 100 档数据,后面的直接截断——反正也没人看

嗯,这里要注意:深度图的 Y 轴(累计量)最好用对数坐标。因为挂单量往往集中在几个关键价位,线性坐标下其他价位根本看不见。


好了,订单簿的数据结构就聊到这儿。记住一句话:数据是量化的血液,订单簿就是心脏。把数据结构搞扎实了,后面的策略开发才能站得稳。

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