3、订单簿数据结构:Python 中如何用字典和列表模拟订单簿

做量化交易,订单簿就是你的战场地图。我刚开始接触这个领域时,第一反应是——这东西不就是个价格和数量的表格吗?后来在实盘对接中才发现,订单簿的更新速度、数据结构的选型,直接决定了你的策略能不能跑赢市场。

说白了,订单簿就是记录当前市场上所有买单和卖单的电子账本。买方挂单叫Bid,卖方挂单叫Ask。每一档价格上,都堆着一定数量的订单。我们做策略,就是盯着这个账本的变化来决策。

3.1 订单簿的核心要素

一个标准的订单簿,至少包含以下信息:

  • 价格档位:每个价格点就是一个档位
  • 挂单数量:该价格上所有订单的总量
  • 订单方向:买还是卖
  • 时间戳:数据生成的时间

嗯,这里要注意——不同交易所的订单簿深度不一样。有的只给前20档,有的给到100档。我个人习惯在本地维护一个完整的快照,然后增量更新。

3.2 用字典模拟订单簿

Python里最直观的方式就是用字典。键是价格,值是数量。为什么?因为价格是唯一的,而且查询速度极快。

# 一个简单的订单簿字典结构
order_book = {
    'bids': {  # 买单
        100.50: 200,   # 价格100.50,数量200
        100.40: 150,
        100.30: 300
    },
    'asks': {  # 卖单
        100.60: 180,
        100.70: 250,
        100.80: 100
    }
}

# 获取最优买价(最高买价)
best_bid = max(order_book['bids'].keys())
print(f"最优买价: {best_bid}, 数量: {order_book['bids'][best_bid]}")

# 获取最优卖价(最低卖价)
best_ask = min(order_book['asks'].keys())
print(f"最优卖价: {best_ask}, 数量: {order_book['asks'][best_ask]}")

我在项目中遇到过一个问题:字典虽然快,但价格是浮点数。浮点数做键,精度问题会坑死人。比如100.50和100.5000001,在字典里是两个不同的键。所以,我建议把价格乘以10000转成整数来存。

小技巧:用整数表示价格,比如100.50存为1005000(小数点后四位)。这样既避免精度问题,又方便排序。

3.3 用列表模拟订单簿

字典适合随机访问,但如果你需要按价格排序遍历,列表更合适。尤其是做深度分析时,列表的切片操作非常方便。

# 用列表存储订单簿,每个元素是 [价格, 数量]
bids = [
    [100.50, 200],
    [100.40, 150],
    [100.30, 300]
]

asks = [
    [100.60, 180],
    [100.70, 250],
    [100.80, 100]
]

# 计算买卖价差
spread = asks[0][0] - bids[0][0]
print(f"当前价差: {spread}")

# 计算前5档买单总量
total_bid_volume = sum([item[1] for item in bids[:5]])
print(f"前5档买单总量: {total_bid_volume}")

你想想看,列表的好处是天然有序。但坏处也很明显——插入和删除操作是O(n)的。如果订单簿每秒更新几百次,列表的性能就扛不住了。

3.4 字典+列表的组合方案

实战中,我更喜欢把两者结合起来。字典负责快速查找,列表负责有序遍历。这样既保证了速度,又保留了顺序。

class OrderBook:
    def __init__(self):
        self.bids_dict = {}   # 价格 -> 数量
        self.asks_dict = {}   # 价格 -> 数量
        self.bids_list = []   # 按价格降序排列
        self.asks_list = []   # 按价格升序排列
    
    def update(self, side, price, volume):
        """更新订单簿"""
        if side == 'buy':
            self.bids_dict[price] = volume
            self.bids_list = sorted(self.bids_dict.items(), reverse=True)
        else:
            self.asks_dict[price] = volume
            self.asks_list = sorted(self.asks_dict.items())
    
    def get_best_bid(self):
        """获取最优买价"""
        return self.bids_list[0] if self.bids_list else None
    
    def get_best_ask(self):
        """获取最优卖价"""
        return self.asks_list[0] if self.asks_list else None
    
    def get_depth(self, levels=10):
        """获取指定深度的订单簿"""
        return {
            'bids': self.bids_list[:levels],
            'asks': self.asks_list[:levels]
        }
核心思路:字典做索引,列表做视图。更新时只改字典,需要排序或切片时再生成列表。这种懒加载模式在数据量大时特别有用。

3.5 订单簿的增量更新

交易所推送的数据,通常是增量快照。比如「价格100.50的买单数量变为0」,意味着这个档位被吃掉了。我曾经踩过一个坑:直接覆盖字典,结果漏掉了某些档位的删除操作。

def apply_snapshot(order_book, snapshot):
    """
    应用增量快照
    snapshot格式: [(price, volume), ...]
    volume为0表示删除该档位
    """
    for price, volume in snapshot:
        if volume == 0:
            # 删除档位
            order_book.bids_dict.pop(price, None)
            order_book.asks_dict.pop(price, None)
        else:
            # 更新或新增档位
            # 这里需要判断是买单还是卖单
            # 实际项目中会从snapshot的side字段获取
            pass
    
    # 重新排序
    order_book.bids_list = sorted(order_book.bids_dict.items(), reverse=True)
    order_book.asks_list = sorted(order_book.asks_dict.items())
注意:增量更新时,一定要处理volume为0的情况。很多新手直接覆盖字典,导致死单一直挂在订单簿上,策略信号全乱套了。

3.6 订单簿的可视化结构

为了让你更直观地理解订单簿,我画了一张图。这张图展示了买单和卖单如何堆叠,以及买卖价差的位置。

订单簿结构示意图 卖单 (Asks) 价格: 100.80 数量: 100 累计: 100 价格: 100.70 数量: 250 累计: 350 价格: 100.60 数量: 180 累计: 530 买卖价差 (Spread): 0.10 买单 (Bids) 价格: 100.50 数量: 200 累计: 200 价格: 100.40 数量: 150 累计: 350 价格: 100.30 数量: 300 累计: 650 买单从高到低排列,卖单从低到高排列 最优买价 100.50,最优卖价 100.60

3.7 实战中的选择建议

说了这么多,到底该用哪种方案?我根据经验给你列个表:

场景 推荐方案 原因
高频策略(毫秒级) 纯字典 + 排序列表缓存 字典O(1)查询,列表缓存减少排序开销
中低频策略(秒级) 列表 + 二分查找 数据量不大,列表操作简单直观
回测模拟 字典 + 列表组合 需要频繁插入删除,字典更灵活
数据存储 Pandas DataFrame 方便做统计分析,但实时性差
我的经验:回测时用DataFrame很爽,但实盘千万别用。DataFrame每次插入一行都要复制整个对象,性能惨不忍睹。我曾经在回测里用DataFrame模拟订单簿,结果跑一次要半小时,换成字典+列表后,10秒搞定。

最后说一句——订单簿的数据结构没有银弹。你得根据策略的频率、数据量、交易所的API特性来选。但不管怎么选,字典做索引、列表做视图这个思路,基本能覆盖90%的场景。


专注资料整理