3. 委托队列数据结构设计
做量化交易的朋友都知道,委托队列是市场微观结构的核心数据。说白了,它就是交易所撮合引擎的「快照」——当前所有挂着的买单和卖单,按价格排好队,等着成交。
我个人习惯用 Python 的 Pandas DataFrame 来存储这些数据。为什么?因为 DataFrame 的向量化操作快,而且天然支持时间戳索引,做回测时特别顺手。今天我就带你手撸一个 OrderBook 类,把委托队列的数据结构讲透。
3.1 委托队列的核心字段
一个标准的委托队列,至少需要记录这些信息:
| 字段名 | 类型 | 说明 |
|---|---|---|
| price | float64 | 委托价格 |
| volume | int64 | 委托数量(股/手) |
| side | str | 'buy' 或 'sell' |
| order_id | int64 | 委托单编号(可选) |
| timestamp | datetime64 | 委托时间 |
嗯,这里要注意:价格精度是个坑。A股最小变动单位是0.01元,但期货可能是0.2、0.5。我建议统一用 float64,计算时再根据品种做精度控制。
3.2 用 DataFrame 组织数据
我一般这样初始化 OrderBook:
import pandas as pd
import numpy as np
from datetime import datetime
class OrderBook:
def __init__(self, symbol: str):
self.symbol = symbol
self.bids = pd.DataFrame(columns=['price', 'volume', 'order_id', 'timestamp'])
self.asks = pd.DataFrame(columns=['price', 'volume', 'order_id', 'timestamp'])
self.bids = self.bids.astype({'price': 'float64', 'volume': 'int64', 'order_id': 'int64'})
self.asks = self.asks.astype({'price': 'float64', 'volume': 'int64', 'order_id': 'int64'})
为什么把买单和卖单分开?因为它们的排序逻辑不同。买单按价格从高到低排,卖单从低到高排。混在一起反而麻烦。
核心原则:DataFrame 的 index 用 order_id,不要用 price。因为价格会重复,而 order_id 唯一。查询时用 price 做条件过滤,效率更高。
3.3 核心操作:增删改查
一个实用的 OrderBook 类,至少需要这四个方法。我在项目中踩过不少坑,下面直接给你看最终版本:
def add_order(self, price: float, volume: int, side: str, order_id: int, timestamp: datetime):
"""新增委托单"""
new_order = pd.DataFrame([[price, volume, order_id, timestamp]],
columns=['price', 'volume', 'order_id', 'timestamp'])
if side == 'buy':
self.bids = pd.concat([self.bids, new_order], ignore_index=True)
self.bids.sort_values('price', ascending=False, inplace=True)
else:
self.asks = pd.concat([self.asks, new_order], ignore_index=True)
self.asks.sort_values('price', ascending=True, inplace=True)
def cancel_order(self, order_id: int, side: str):
"""撤销委托单"""
if side == 'buy':
self.bids = self.bids[self.bids['order_id'] != order_id]
else:
self.asks = self.asks[self.asks['order_id'] != order_id]
def get_top_n(self, n: int = 5, side: str = 'buy'):
"""获取前N档行情"""
if side == 'buy':
return self.bids.head(n)
else:
return self.asks.head(n)
def get_spread(self):
"""获取买卖价差"""
if self.bids.empty or self.asks.empty:
return None
best_bid = self.bids.iloc[0]['price']
best_ask = self.asks.iloc[0]['price']
return best_ask - best_bid
小技巧:每次 add_order 后重新排序,看起来有点笨。但实际测试下来,对于几千笔委托的规模,这个操作耗时不到1毫秒。你想想看,为了代码可读性,这点性能损失完全可以接受。
3.4 进阶:快照与增量更新
真实场景中,交易所会推送两种数据:
- 全量快照:每隔几秒推送一次完整的委托队列
- 增量更新:每笔成交或撤单时推送变化
我建议这样设计:
def apply_snapshot(self, snapshot_df: pd.DataFrame):
"""应用全量快照"""
self.bids = snapshot_df[snapshot_df['side'] == 'buy'].copy()
self.asks = snapshot_df[snapshot_df['side'] == 'sell'].copy()
# 重新排序
self.bids.sort_values('price', ascending=False, inplace=True)
self.asks.sort_values('price', ascending=True, inplace=True)
def apply_increment(self, increment_df: pd.DataFrame):
"""应用增量更新"""
for _, row in increment_df.iterrows():
if row['type'] == 'new':
self.add_order(row['price'], row['volume'], row['side'],
row['order_id'], row['timestamp'])
elif row['type'] == 'cancel':
self.cancel_order(row['order_id'], row['side'])
elif row['type'] == 'trade':
# 成交:减少对应委托的数量
self._reduce_volume(row['order_id'], row['side'], row['volume'])
我曾经踩过的坑:增量更新时,如果先收到撤单再收到新单,但 order_id 相同,会导致数据错乱。后来我加了一个版本号字段,每次更新时检查版本号是否递增,才彻底解决这个问题。
3.5 性能优化:别让 DataFrame 拖后腿
当委托数量超过10万笔时,Pandas 的 concat 操作会变慢。我试过几种方案:
- 预分配内存:初始化时指定一个较大的 size,用 loc 赋值而不是 concat
- 改用 dict of arrays:底层用 Python 字典存 numpy 数组,上层封装成 DataFrame
- 使用 polars:如果数据量真的很大,可以考虑 polars 替代 pandas
我个人推荐方案1,代码改动最小:
def __init__(self, symbol: str, max_orders: int = 100000):
self.symbol = symbol
self.max_orders = max_orders
self.bids = pd.DataFrame({
'price': np.zeros(max_orders, dtype=np.float64),
'volume': np.zeros(max_orders, dtype=np.int64),
'order_id': np.zeros(max_orders, dtype=np.int64),
'timestamp': np.empty(max_orders, dtype='datetime64[ns]')
})
self.bids_count = 0 # 实际使用的行数
3.6 知识体系总览
下面这张图,帮你把本章的核心逻辑串起来:
说白了,OrderBook 的设计就三件事:存什么、怎么存、怎么更新。存什么我们已经定了字段,怎么存用 DataFrame,怎么更新就是快照和增量两种模式。你想想看,是不是这个理?
最后说一句:别把 OrderBook 设计得太复杂。我见过有人给每个委托单加了十几个字段,结果回测时内存爆了。记住,够用就好。等你的策略跑通了,再慢慢加功能也不迟。
公众号:蓝海资料掘金营,微信deep3321