3. 订单簿数据结构:Python中如何用Pandas/NumPy存储和操作订单簿快照
做量化交易的朋友都知道,订单簿是市场的「心跳」。每一笔挂单、每一次撤单,都像心电图上的波动。我刚开始接触高频数据时,面对每秒几十笔的订单簿快照,第一反应是——这玩意儿怎么存?
说白了,订单簿就是一个「价格-数量」的二维映射。买盘叫Bid,卖盘叫Ask。每一档都有价格和对应的挂单量。你想想看,如果用手工去维护这个结构,光是处理价格变动就能把人逼疯。
嗯,这里要注意:订单簿快照不是静态的。它每时每刻都在变。所以我们需要一种高效的数据结构来存储和操作它。
3.1 订单簿快照的原始形态
先看一个典型的订单簿长什么样。假设我们拿到交易所的Rest API返回数据:
{
"bids": [
[100.50, 2000], // 价格, 数量
[100.48, 1500],
[100.45, 3000]
],
"asks": [
[100.52, 1800],
[100.55, 2500],
[100.58, 1200]
]
}
这是最原始的形态。但说实话,这种嵌套列表在Python里操作起来很别扭。你每次取价格都要用bid[0],取数量用bid[1]。代码可读性差,而且容易出错。
我在项目中遇到过一个问题:有一次我直接用列表切片去取前五档价格,结果因为数据源返回的顺序偶尔会乱,导致取到的价格根本不是最优档。那一次回测结果直接崩了。
3.2 用Pandas DataFrame存储订单簿
我个人习惯用Pandas DataFrame来存储订单簿快照。为什么?因为DataFrame天然支持列名索引,而且切片、筛选、聚合操作都非常方便。
import pandas as pd
import numpy as np
def orderbook_to_df(bids, asks, depth=5):
"""
将订单簿原始数据转换为DataFrame
"""
# 取前depth档
bids = bids[:depth]
asks = asks[:depth]
# 构建DataFrame
df_bids = pd.DataFrame(bids, columns=['price', 'volume'])
df_asks = pd.DataFrame(asks, columns=['price', 'volume'])
# 标记买卖方向
df_bids['side'] = 'bid'
df_asks['side'] = 'ask'
# 合并
df = pd.concat([df_bids, df_asks], ignore_index=True)
# 添加一些衍生字段
df['notional'] = df['price'] * df['volume'] # 名义价值
df['log_price'] = np.log(df['price']) # 对数价格
return df
# 使用示例
bids = [[100.50, 2000], [100.48, 1500], [100.45, 3000]]
asks = [[100.52, 1800], [100.55, 2500], [100.58, 1200]]
df = orderbook_to_df(bids, asks, depth=3)
print(df)
输出结果:
price volume side notional log_price
0 100.50 2000 bid 201000.0 4.610158
1 100.48 1500 bid 150720.0 4.610077
2 100.45 3000 bid 301350.0 4.609962
3 100.52 1800 ask 180936.0 4.610178
4 100.55 2500 ask 251375.0 4.610253
5 100.58 1200 ask 120696.0 4.610328
你看,这样一整理,数据就清晰多了。每一行代表一档订单,列名告诉你这是什么。而且我们可以很方便地做各种计算。
timestamp列,记录这个快照的时间戳。这样后续做时间序列分析时,可以直接按时间分组或排序。
3.3 用NumPy数组加速操作
但是,Pandas DataFrame虽然方便,性能上有个硬伤——它太慢了。如果你做的是Tick级别的回测,每秒要处理几百个快照,DataFrame的创建和操作会成为瓶颈。
这时候就该NumPy上场了。NumPy数组是连续内存块,操作速度比DataFrame快一个数量级。
def orderbook_to_numpy(bids, asks, depth=5):
"""
将订单簿转换为NumPy数组
结构: [price, volume, side]
side: 0=bid, 1=ask
"""
bids = np.array(bids[:depth])
asks = np.array(asks[:depth])
# 添加side列
bids_side = np.zeros((bids.shape[0], 1)) # bid标记为0
asks_side = np.ones((asks.shape[0], 1)) # ask标记为1
bids = np.hstack([bids, bids_side])
asks = np.hstack([asks, asks_side])
# 合并
orderbook = np.vstack([bids, asks])
return orderbook
# 使用示例
orderbook = orderbook_to_numpy(bids, asks, depth=3)
print(orderbook)
输出:
[[100.5 2000. 0. ]
[100.48 1500. 0. ]
[100.45 3000. 0. ]
[100.52 1800. 1. ]
[100.55 2500. 1. ]
[100.58 1200. 1. ]]
用NumPy的好处是,你可以用向量化操作一次性处理整个数组。比如计算所有档位的名义价值:
notional = orderbook[:, 0] * orderbook[:, 1] # 价格 * 数量
这比用Pandas的apply快多了。
3.4 订单簿的「快照」与「增量」
这里要区分两个概念:快照(Snapshot)和增量(Incremental)。
快照是某一时刻订单簿的完整状态。增量是相对于上一个快照的变化——哪些价格被撤单了,哪些价格新增了挂单。
我个人习惯用字典来存储增量更新:
# 增量更新示例
incremental_update = {
'bids': [
[100.50, 0], # 价格100.50的挂单被撤单(数量为0)
[100.47, 800] # 新增一档100.47的挂单
],
'asks': [
[100.55, 1500] # 价格100.55的挂单数量更新为1500
]
}
处理增量更新的核心逻辑是:
- 如果价格已存在,更新数量
- 如果数量为0,删除该价格档位
- 如果价格不存在且数量>0,新增档位
嗯,这里要注意:增量更新必须保证顺序。如果增量包丢失了,你的本地订单簿就会和交易所不同步。所以很多交易所会每隔一段时间发送一次全量快照,用于「校准」。
3.5 知识体系总览
下面这张图总结了订单簿数据结构的核心逻辑:
3.6 实战中的选择建议
说了这么多,到底该用哪种方式?我根据实际经验给个建议:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 数据探索/可视化 | Pandas DataFrame | 列名清晰,绘图方便 |
| 高频回测 | NumPy数组 | 性能优先,避免循环 |
| 实时行情处理 | 字典 + NumPy | 增量更新快,快照用数组 |
| 存储到数据库 | Pandas + Parquet | 压缩率高,读写快 |
我曾经在实时行情系统中踩过一个坑:用Pandas DataFrame去处理每秒200次的增量更新,结果CPU直接飙到100%。后来改成NumPy预分配数组,CPU降到了15%。
所以我的建议是:分析阶段用Pandas,生产环境用NumPy。两者结合,既保证了开发效率,又保证了运行性能。
核心要点回顾:
- 订单簿本质是「价格-数量」的二维映射
- Pandas DataFrame适合分析,NumPy数组适合高性能计算
- 增量更新必须保证顺序,定期用全量快照校准
- 高频场景下,向量化操作比循环快百倍
公众号:蓝海资料掘金营,微信deep3321