3、订单簿数据结构:价格-数量映射与排序规则

做量化交易这些年,我接触过不少订单簿的实现。说实话,数据结构选对了,后面的事情就顺了。选错了,你会发现性能瓶颈一个接一个冒出来。

订单簿的核心是什么?说白了就两样东西:价格数量。但怎么组织这两者的关系,决定了你的系统能跑多快、能处理多大规模的数据。

3.1 价格-数量映射:核心数据结构

我个人习惯把订单簿看作一个双层映射。第一层是价格到订单列表的映射,第二层是订单ID到订单详情的映射。这样设计的好处是——查询和修改都能做到O(1)或O(log n)。

先看一个最基础的结构:

// 价格到订单列表的映射
// 买盘用最大堆,卖盘用最小堆
struct OrderBook {
    // 买盘:价格降序排列
    map<double, vector<Order>, greater<double>> bids;
    // 卖盘:价格升序排列
    map<double, vector<Order>, less<double>> asks;
    // 订单ID到订单的快速索引
    unordered_map<uint64_t, Order> orderMap;
};

嗯,这里要注意:价格精度问题。我在项目中遇到过,直接用double做key,结果因为浮点精度导致两个明明相同的价格被当成不同key。后来我统一改用整数表示价格,比如把1.2345存成12345,乘以一个精度因子。

核心原则:价格作为key时,必须保证唯一性和可比较性。浮点数不适合直接做map的key。

3.2 排序规则:买盘降序,卖盘升序

为什么买盘要降序?你想想看,买单当然是价格越高的越优先成交。卖盘反过来,价格越低的越先被吃掉。这就是价格优先原则

我见过一些新手把买卖盘都按升序排,结果每次撮合都要遍历整个列表找最优价。其实用有序容器就能自动维护顺序:

// Python示例:使用SortedDict
from sortedcontainers import SortedDict

class OrderBook:
    def __init__(self):
        # 买盘:降序排列
        self.bids = SortedDict(lambda x: -x)
        # 卖盘:升序排列
        self.asks = SortedDict()
    
    def get_best_bid(self):
        # 直接取第一个就是最高买价
        return self.bids.peekitem(0) if self.bids else None
    
    def get_best_ask(self):
        # 直接取第一个就是最低卖价
        return self.asks.peekitem(0) if self.asks else None

这里有个小技巧:用负号实现降序。SortedDict默认升序,把key取负就变成降序了。我早期用C++的std::map时,得自己写比较器,Python这个写法清爽多了。

3.3 价格档位聚合:Level 2数据的精髓

真正的订单簿不会把每个订单单独存,而是按价格档位聚合。比如在1.2345这个价位上,可能有10个买单,总数量5000股。我们只关心总数量和最优价,不关心每个订单的细节。

聚合后的结构长这样:

// 价格档位结构
struct PriceLevel {
    double price;      // 价格
    uint64_t volume;   // 总数量
    uint32_t orderCount; // 订单数量
};

// 订单簿快照
struct OrderBookSnapshot {
    vector<PriceLevel> bids;  // 买盘档位,从高到低
    vector<PriceLevel> asks;  // 卖盘档位,从低到高
    uint64_t timestamp;       // 快照时间戳
};

为什么要聚合?性能。一个活跃的股票可能有几万个订单,但价格档位通常只有几十到几百个。传输和计算量都小了两个数量级。

避坑指南:我曾经在聚合时忽略了冰山订单(Iceberg Order)。这种订单只显示部分数量,实际还有隐藏量。如果按显示量聚合,撮合逻辑会出错。后来我加了一个字段标记是否有隐藏量。

3.4 排序规则的实际应用:撮合流程

有了排序规则,撮合就变得很直观了。我画个图帮你理解:

订单簿撮合流程 买盘 (Bids) 降序排列 价格: 1.2350 数量: 5,000 价格: 1.2345 数量: 3,200 价格: 1.2340 数量: 8,000 价格: 1.2335 数量: 1,500 卖盘 (Asks) 升序排列 价格: 1.2355 数量: 2,000 价格: 1.2360 数量: 4,500 价格: 1.2365 数量: 3,000 价格: 1.2370 数量: 6,000 价差: 0.0005 最优买价 1.2350 vs 最优卖价 1.2355

撮合逻辑其实就三步:

  1. 取最优价:买盘取第一个(最高价),卖盘取第一个(最低价)
  2. 比较价格:如果买价 ≥ 卖价,就能成交
  3. 消耗数量:从双方档位中扣除成交数量,直到一方耗尽

注意:撮合时一定要考虑时间优先原则。同价位下,先到的订单先成交。所以每个价格档位内部,订单是按到达时间排序的队列。

3.5 数据结构选型对比

不同的场景适合不同的数据结构。我整理了一个对比表:

数据结构 插入 删除 查询最优价 适用场景
跳表 (Skip List) O(log n) O(log n) O(1) 高频交易,需要快速插入删除
红黑树 (std::map) O(log n) O(log n) O(1) 通用场景,实现简单
数组 + 二分查找 O(n) O(n) O(log n) 低延迟场景,数据量小
哈希表 + 链表 O(1) O(1) O(n) 需要按ID快速查找订单

我个人偏好跳表。为什么?因为它在插入和删除时不需要像红黑树那样做复杂的旋转操作,而且范围查询(比如查某个价格区间内的所有订单)特别快。我在做期权做市商系统时,就用跳表实现了订单簿,延迟控制在微秒级。

3.6 实战中的坑与经验

最后分享几个我踩过的坑:

  • 价格精度问题:前面提过,用整数代替浮点数。我习惯用price * 10000转成整数,保留4位小数。
  • 并发访问:订单簿会被多个线程同时读写。我用读写锁(Read-Write Lock)保护,读操作不阻塞,写操作互斥。
  • 内存管理:订单簿里的订单对象不要频繁new/delete。我用对象池(Object Pool)复用,减少GC压力。
  • 快照与增量:全量快照太大,我一般每100ms生成一次快照,中间用增量更新(新增、修改、删除)。

避坑指南:我曾经在回测时发现订单簿重建速度很慢。排查后发现是每次重建都重新排序。后来我改用增量重建——只处理变化的档位,不动的档位直接复用。速度提升了10倍。

订单簿的数据结构,说白了就是用空间换时间。多建几个索引,多存几份冗余数据,换来的是毫秒级的撮合速度。这个取舍,值得。


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