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 数据对齐:快照 + 增量的「双保险」
实际项目中,我一般这样设计数据流:
- 首次连接:请求一次快照,建立本地订单簿
- 持续接收:接收增量更新,实时维护订单簿
- 定期校验:每 5 分钟或每 1000 条增量后,重新请求一次快照做对比
- 异常处理:如果发现本地订单簿跟快照不一致,立即丢弃本地数据,重新从快照开始
你想想看,为什么要有第 3 步?因为网络丢包、交易所重启、序列号错乱……这些破事我都遇到过。定期校验就是给自己留一条「后路」。
避坑指南:我曾经在某个大交易所做项目,他们的增量更新偶尔会「漏发」一条。如果你不做校验,订单簿就会慢慢「漂移」。最后对账时发现差了 0.1 个 BTC,查了三天才找到原因。
2.6 深度图的实时渲染优化
如果你要做前端展示,深度图不能每收到一条增量就重绘。那样浏览器会卡死。
我的做法是:
- 维护一个「脏标记」,只有订单簿发生显著变化时才触发重绘
- 使用 requestAnimationFrame 做节流,保证每秒最多重绘 30 次
- 只渲染前 100 档数据,后面的直接截断——反正也没人看
嗯,这里要注意:深度图的 Y 轴(累计量)最好用对数坐标。因为挂单量往往集中在几个关键价位,线性坐标下其他价位根本看不见。
好了,订单簿的数据结构就聊到这儿。记住一句话:数据是量化的血液,订单簿就是心脏。把数据结构搞扎实了,后面的策略开发才能站得稳。