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

你看,这样一整理,数据就清晰多了。每一行代表一档订单,列名告诉你这是什么。而且我们可以很方便地做各种计算。

我的小技巧: 我习惯在DataFrame里加一个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快多了。

避坑指南: 我曾经在回测中直接用Pandas逐行追加数据,结果一个小时的Tick数据跑了整整一天。后来改成NumPy预分配数组,再批量写入,速度提升了近百倍。记住:高频场景下,能不用循环就别用循环。

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
    ]
}

处理增量更新的核心逻辑是:

  1. 如果价格已存在,更新数量
  2. 如果数量为0,删除该价格档位
  3. 如果价格不存在且数量>0,新增档位

嗯,这里要注意:增量更新必须保证顺序。如果增量包丢失了,你的本地订单簿就会和交易所不同步。所以很多交易所会每隔一段时间发送一次全量快照,用于「校准」。

3.5 知识体系总览

下面这张图总结了订单簿数据结构的核心逻辑:

订单簿数据结构知识体系 原始数据 嵌套列表 / JSON 存储方式 Pandas / NumPy 操作类型 快照 / 增量 Pandas DataFrame 列名索引,可读性强 适合分析、可视化 性能瓶颈(逐行操作慢) NumPy数组 连续内存,向量化操作 适合高频回测 可读性差,需手动管理 增量 字典结构 更新逻辑 需校准 核心原则 分析用Pandas,回测用NumPy,实时更新用增量字典 三者结合,才能高效处理订单簿数据

3.6 实战中的选择建议

说了这么多,到底该用哪种方式?我根据实际经验给个建议:

场景 推荐方案 原因
数据探索/可视化 Pandas DataFrame 列名清晰,绘图方便
高频回测 NumPy数组 性能优先,避免循环
实时行情处理 字典 + NumPy 增量更新快,快照用数组
存储到数据库 Pandas + Parquet 压缩率高,读写快

我曾经在实时行情系统中踩过一个坑:用Pandas DataFrame去处理每秒200次的增量更新,结果CPU直接飙到100%。后来改成NumPy预分配数组,CPU降到了15%。

所以我的建议是:分析阶段用Pandas,生产环境用NumPy。两者结合,既保证了开发效率,又保证了运行性能。

核心要点回顾:

  • 订单簿本质是「价格-数量」的二维映射
  • Pandas DataFrame适合分析,NumPy数组适合高性能计算
  • 增量更新必须保证顺序,定期用全量快照校准
  • 高频场景下,向量化操作比循环快百倍

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