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": []
}
4.2 重建订单簿的核心逻辑
重建过程其实就三步:
- 初始化:拿到第一个快照,建立初始的买盘和卖盘字典
- 应用增量:每来一条增量,更新对应价位的数量
- 维护排序:买盘按价格降序,卖盘按价格升序
为什么买盘要降序?因为买单价格越高,越优先成交。卖盘则相反,价格越低越优先。这个顺序就是订单簿的「价格优先」原则。
我曾在项目中遇到过一个问题:交易所的快照和增量之间有时间差,如果增量比快照先到,订单簿就乱了。解决方案是加一个「序列号」校验,只处理比当前快照更新的增量。
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画了一个简单的订单簿结构图。左边是买盘(红色),右边是卖盘(绿色)。中间那条线就是当前价格。
你看,买一和卖一之间的空隙就是价差。价差越小,市场流动性越好。我一般会在策略里监控价差变化,如果突然扩大,说明市场可能出现了异常波动。
4.6 实战:用订单簿信号做交易决策
重建好订单簿之后,我们能提取哪些信号?
| 信号名称 | 计算方法 | 交易含义 |
|---|---|---|
| 买卖盘不平衡 | 买盘总量 / 卖盘总量 | 比值 > 1.5 可能看涨,< 0.7 可能看跌 |
| 价差变化 | 卖一价 - 买一价 | 价差突然扩大,说明流动性枯竭 |
| 深度斜率 | 前5档累计量的变化率 | 斜率变陡,说明大单在堆积 |
| 订单撤销率 | 单位时间内撤销订单数 / 总订单数 | 撤销率高,可能是虚假挂单 |
我个人比较喜欢用「买卖盘不平衡」这个指标。举个例子,如果买一档突然增加了50个BTC,而卖一档纹丝不动,说明有大资金在托底。这时候做多胜率会高一些。但要注意,有些做市商会故意挂大单然后撤单,这就是所谓的「虚假深度」。所以我会结合订单撤销率一起看。
4.7 性能优化:实盘每秒处理10万条增量
如果你做高频交易,每秒可能收到几万甚至几十万条增量。Python的字典操作虽然快,但排序会成为瓶颈。我分享几个优化技巧:
- 用数组代替字典:如果价格精度固定(比如小数点后2位),可以用数组索引价格,O(1)访问
- 延迟排序:只在需要获取前几档时才排序,平时只更新字典
- 使用C扩展:比如用
numpy或numba加速排序 - 增量排序:用
bisect.insort维护有序列表,避免全量排序
我记得有一次优化订单簿性能,把排序从O(n log n)降到了O(log n),回测速度提升了40倍。嗯,细节决定成败。
好了,订单簿重建的核心内容就这些。你拿到原始数据后,先跑通上面的代码,然后用历史数据验证一下重建的订单簿是否和交易所快照一致。这一步做扎实了,后面的订单流分析才有意义。
公众号:蓝海资料掘金营,微信deep3321