4. 限价订单簿核心操作实现

限价订单簿(Limit Order Book,LOB)的重建,本质上是在内存中维护两个有序队列:买方(Bid)按价格从高到低排列,卖方(Ask)按价格从低到高排列;同一价格档位内部,则按订单进入撮合系统的时间先后排列。逐笔事件流中的每一笔新增、撤单、成交,最终都要落到“插入一条订单记录”或“删除一条订单记录”这两个原子操作上。本节围绕这两个原子操作展开,重点讲清楚数据结构选型、插入定位、删除定位,以及价格优先与时间优先在更新逻辑中如何被严格保持。

一、订单簿的数据结构选型

订单簿需要同时支持三类高频操作:按价格快速找到最优档位、在指定价格档位内按时间顺序追加订单、按订单编号快速定位并删除。单一数据结构很难同时满足,工程上通常采用“价格档位索引 + 档位内订单队列 + 订单编号反查表”的组合结构。

价格档位索引可以用平衡二叉搜索树(如 C++ 的 std::map、Java 的 TreeMap)或跳表实现,键为价格,值为该价格档位内的订单队列。买方用价格降序比较器,卖方用价格升序比较器,这样 begin() 永远指向最优档位。档位内订单队列用双向链表(如 std::list)或侵入式链表,保证 O(1) 尾部追加和 O(1) 任意节点删除。订单编号反查表用哈希表,键为订单 ID,值为指向链表节点的迭代器或指针,使撤单和成交删除不必遍历档位。

下面给出一个简化的 C++ 结构定义,用于说明各部件之间的引用关系:

struct Order {
    uint64_t id;
    double   price;
    uint64_t qty;
    Side     side;      // Bid / Ask
    uint64_t seq;       // 进入撮合系统的时间序号
};

using OrderList = std::list<Order>;

struct BookSide {
    // 买方:价格降序;卖方:价格升序
    std::map<double, OrderList, std::greater<double>> levels; // Bid 示例
    std::unordered_map<uint64_t, std::pair<double, OrderList::iterator>> index;
};

这里 seq 字段是时间优先的物理载体。即使链表本身已按插入顺序排列,保留显式序号仍便于跨档位比较、日志回放校验和异常排查。

二、插入操作:先定位档位,再追加队尾

插入一笔限价订单时,处理顺序应为:先校验订单合法性(价格大于零、数量大于零、方向合法),再检查订单 ID 是否已存在(防止重复事件导致重复插入),然后按方向选择买方或卖方结构,用价格在 levels 中查找档位。若档位不存在则创建空链表,最后将订单追加到该档位链表的尾部,并把迭代器写入 index。

追加到队尾这一动作,正是时间优先的实现:同一价格上先到的订单排在前面,后到的排在后面,撮合时从队首开始消耗。需要特别注意的是,不能按 seq 排序后插入,因为逐笔事件流本身已保证到达顺序,排序插入既增加复杂度,又可能在乱序回放场景下掩盖数据问题。正确做法是信任事件流顺序,用尾部追加保持 O(1)。

插入后应立即更新档位的聚合量(该价格上的总挂单量),供行情快照和最优价计算使用。若插入的档位恰好成为新的最优价,还需同步更新最优买价或最优卖价缓存。

三、删除操作:按订单编号 O(1) 定位

删除有两种触发来源:撤单事件和成交事件。两者在订单簿层面的动作完全一致——把指定订单从它所在的档位链表中摘除。实现时先用订单 ID 在 index 中查到 (price, iterator),再通过 levels[price].erase(iterator) 完成 O(1) 删除,最后从 index 中移除该 ID 并扣减档位聚合量。

删除后必须处理两个边界情况。第一,若该档位链表已空,应从 levels 中删除整个价格档位,否则最优价计算会读到空档位。第二,若被删订单原本是最优价档位上的队首订单,删除后最优价可能不变(同档还有订单)也可能变化(档位被清空),需要重新取 levels.begin() 的价格作为最优价。部分成交不属于删除:订单数量减少但仍在队列中,此时只更新 qty 和档位聚合量,订单在队列中的位置保持不变,时间优先属性不受影响。

常见错误:用“价格 + 数量”匹配来删除订单。当同一价格存在多笔数量相同的订单时,这种匹配会删错对象,破坏时间优先顺序。必须使用订单 ID 作为唯一删除依据。

四、价格优先与时间优先在更新中的一致性

价格优先由 levels 的比较器保证:买方降序、卖方升序,最优档位始终位于 begin()。时间优先由档位内链表的尾部追加和“部分成交不移动位置”保证。两者在插入、删除、部分成交三类更新中都必须同时成立,任何一次更新破坏了其中一条,后续撮合结果就会与真实市场偏离。

下图以一笔具体示例展示订单簿在一次“新增—部分成交—撤单”事件序列中的结构变化,买方档位按价格降序、同价按时间先后排列:

买方订单簿更新过程(价格降序 · 同价时间优先) ① 初始状态 价格 100.20 #A1 qty 300 seq 1 价格 100.10 #A2 qty 500 seq 2 最优买价 = 100.20 新增 ② 插入 #A3(100.20, qty 200) 价格 100.20 #A1 qty 300 seq 1 #A3 qty 200 seq 3 ← 队尾 价格 100.10 #A2 qty 500 seq 2 成交 ③ #A1 部分成交 100 价格 100.20 #A1 qty 200 seq 1 ← 队首 #A3 qty 200 seq 3 价格 100.10 #A2 qty 500 seq 2 更新规则要点 · 插入:按价格定位档位,追加到链表尾部,时间序号递增,不重排已有订单。 · 部分成交:只扣减 qty,订单留在原位置,队首仍是 #A1,时间优先不被破坏。 · 档位聚合量随插入、成交、撤单同步增减;档位清空时从价格索引中移除。 ④ 撤单 #A3 后 价格 100.20: #A1 qty 200 价格 100.10: #A2 qty 500 删除路径:index[#A3] → (100.20, 迭代器) → levels[100.20].erase(迭代器) → 扣减聚合量 档位未空,最优买价仍为 100.20

从图中可以看到,部分成交只改变数量、不改变位置,这是很多实现容易出错的地方:如果为了“更新数量”而先删后插,订单会被追加到队尾,时间优先顺序就被悄悄破坏了。正确做法是原地修改 qty,并同步扣减档位聚合量。

五、更新逻辑的校验与可观察结果

实现完成后,应通过一组可复现的示例事件序列验证更新逻辑。例如按顺序输入:新增 #A1(买 100.20,300)、新增 #A2(买 100.10,500)、新增 #A3(买 100.20,200)、成交 #A1 数量 100、撤单 #A3。预期结果是:100.20 档位仅剩 #A1 且数量为 200,100.10 档位保留 #A2,最优买价为 100.20,买方档位顺序为 100.20 在前、100.10 在后,同档内 #A1 位于队首。

校验时建议同时检查四项内容:订单 ID 反查表与链表节点是否一一对应、各档位聚合量是否等于链表内订单数量之和、最优价缓存是否等于 levels.begin() 的价格、买方与卖方档位是否严格满足价格优先比较器。这四项检查覆盖了插入、删除、部分成交三类更新路径,可作为订单簿重建模块的基础自检手段。只有这些不变量在每一笔事件处理后都成立,后续基于订单簿计算的盘口深度、价差和失衡指标才具备可信度。