C++多类型订单簿的设计:从状态机到撮合引擎的实践指南
2026/9/4 21:43:33 网站建设 项目流程

做一个交易类项目或行情回放工具时,很多人会先写一个“能跑”的订单簿:订单进来,按价格聚合,然后按价格从最优到次优排队。我当时也这样做的。处理限价单足够,可一旦系统里出现市价单、止损单、冰山单,原来的按价格聚合逻辑就会变得非常别扭。问题不是C++写订单簿难,而是订单本身是多变的,而订单簿的骨架却往往是按一种最理想化的限价单模型设计的。于是,我重新用 C++ 构建了一个多类型订单簿,并在重构过程中意识到:真正的难点不是“支持多少种订单类型”,而是如何让订单生命周期、价格队列和撮合规则都围绕同一个稳定的抽象来工作。

这篇文章我不打算贴一个完整的高性能撮合引擎源码,那是几千行甚至上万行的事;我想先把构建这类系统时最容易困惑的部分拆开,讲清数据结构选型、多类型订单的建模、撮合顺序、并发演进和排查路径。如果你正在写自己的回测系统、模拟撮合引擎,或者只是想把订单簿原理搞清楚,这篇内容应该对你有用。

1. 多类型订单簿到底在解决什么问题

先回到基础问题。订单簿的职责是什么?通常说法是:管理订单、维护双向挂单深度、撮合买卖订单。但真正的订单簿要处理的不是静态快照,而是连续到达的订单流。每个订单从进入系统到最终被完全成交、撤销或失效,中间会经历多个状态。只用一个 struct 保存“价格、数量、方向、类型”,很难支撑不同订单类型的差异。

1.1 一个订单真正会经历哪些状态变化

一个普通限价买单的路径相对简单:进入订单簿,检查是否有卖单可以成交,能成交则成交,剩余数量挂入买单队列。不能成交则直接挂单,等待后续卖单到达。

但订单类型一旦多起来,状态一下子就复杂了。我先列几个常见动作:

  • 市价单没有价格,它进入系统后必须以对手盘价格尽快成交;
  • 止损单通常不主动成交,而是在市场价格触及某个触发价后,转换成市价单或限价单;
  • 冰山单只显示一部分数量,当显示量被吃完后,需要从隐藏量中释放下一批显示量;
  • IOC(立即成交否则撤销)单要求不能成交的部分立刻撤销;
  • FOK(全部成交否则撤销)单要求要么全部数量一次成交,要么整单撤销。

因此,多类型订单簿必须在订单对象里记录当前状态,而不仅是订单类型。

一个可用的状态设计至少要包含:

状态含义主要动作
新订单刚进入系统校验并准备进入撮合流程
待撮合已进入撮合逻辑按价格/顺序参与撮合
已挂单全部或部分数量未成交,进入订单队列等待对手方订单
已部分成交一部分数量已成交,剩余仍排队或等触发继续参与交易或保留
已触发止损/止盈单到达触发条件转换为市价或限价单
已完成全部数量成交从活动订单集合中移除
已撤销被用户取消或超时失效从订单队列中移除

在设计订单簿时,不要把这些状态散落在各种条件分支里,而要尽量让订单状态和行为组合起来。比如“止损单”不应只是一个 bool 字段,它意味着:订单进入系统后先不进入普通排队,而是等待市场价格达到某个触发价,然后转换为另一种行为。

1.2 多类型订单簿的“多类型”体现在哪里

很多人以为,多类型订单簿就是支持 LIMIT、MARKET、STOP 这样的枚举值,然后 switch 一下。这是一种极其容易把代码写崩的做法。

多类型真正的差异点有三个:

  1. 订单进入方式不同:限价单可以立刻进入价格队列;止损单需要挂到一个独立的“触发集合”里;市价单则直接发起吃单。
  2. 订单成交规则不同:限价单只与指定价位或更优价格成交;市价单按对手盘价格逐档成交;冰山单前段按普通限价单处理,但显示量消耗后还有隐藏量补充;FOK 单的成交量必须全有或全无。
  3. 订单生命周期不同:普通限价单直到被成交或撤销;止损单从“等待触发”变为“已触发”后才是真正的可成交订单;GTC、GTD、IOC、FOK 这些时间策略决定了订单在未完全成交时的处置方式。

所以“多类型”不是简单多几个枚举,而是“订单在生命周期各阶段呈现出不同的数据访问方式和交易行为”。

1.3 主判断:核心不是类型枚举,而是变形与生命周期

这是我想强调的一个主线判断:用 C++ 构建多类型订单簿时,最容易出现的问题不是性能,而是把“订单类型”做成一组散落的 if/else,最后导致每个函数都要考虑“如果它是止损单怎么办”“如果它是冰山单怎么办”。

方向应该是把“类型”和“行为”解耦。订单进入系统后,它先是一个带状态的交易意图,系统根据类型和当前状态决定它下一步的行为。这样新增一个订单类型时,不需要改动撮合主流程,只需要添加新的状态转换规则和交易行为策略。

换句话说,订单簿的核心框架应该像一台状态机 + 事件驱动的交易引擎,而不是一个纯数据表。后面我会深入这一步。

2. 先搭骨架:订单、价格层级和队列关系

在讨论复杂的多类型之前,先把最基础的骨架搭好。无论最终支持多少订单类型,都需要底层的数据组织方式:订单对象、价格序列、委托队列。

2.1 订单对象要存哪些字段,哪些字段会被撮合流程修改

订单对象不要只存交易所需字段,还要考虑生命周期管理和日志追踪。一个面向多类型订单扩展的订单对象,至少要包含:

enum class OrderType : uint8_t { Limit, Market, Stop, StopLimit, Iceberg, IOC, FOK }; enum class OrderSide : uint8_t { Buy, Sell }; enum class OrderStatus : uint8_t { New, PendingTrigger, Working, PartiallyFilled, Filled, Cancelled, Rejected }; struct Order { int64_t order_id; int64_t user_id; OrderSide side; OrderType type; OrderStatus status; int64_t price; // 限价价格;市价单这里可能为0或空 int64_t quantity; // 原始数量 int64_t filled_quantity = 0; int64_t remaining_quantity() const { return quantity - filled_quantity; } // 用于止损/限价止损 int64_t trigger_price = 0; // 冰山单用 int64_t display_quantity = 0; int64_t hidden_quantity = 0; // 时间策略 int64_t create_time_ns; int64_t expire_time_ns = 0; // 可供 GTx 使用 bool is_immediate_or_cancel = false; bool is_fill_or_kill = false; // 运行时公共字段 int64_t seq = 0; int64_t last_update_time_ns = 0; };

这不是唯一写法,但它有助于说明:多类型订单并不是靠子类化每个订单类型来处理的,而是用一组通用字段描述其属性。止损单额外有trigger_price,冰山单需要hidden_quantity。C++ 里可以用继承或组合,但我更推荐在早期先用一个扁平结构 + 行为策略,因为订单对象的生命周期管理可以从同一个存储池中分配,代码也更直观。

修改最频繁的字段是:statusfilled_quantityhidden_quantity。撮合时更新这些字段,必须保证只有一个线程在写入,或者有明确的多线程访问约束。

2.2 价格序列容器选型:从 std::map 到更精细的层级管理

订单簿最核心的组织是按价格维护两个方向的订单队列。买方向需要快速找到最高买价(买一),卖方向需要快速找到最低卖价(卖一)。常见选型:

  • std::map<int64_t, OrderQueue, std::greater<>>维护买方;
  • std::map<int64_t, OrderQueue, std::less<>>维护卖方;
  • 同时还可以缓存卖一和买一迭代器,减少每次begin()的调用开销。

std::map是平衡树,查找、插入、删除都是 O(log n)。对于大部分模拟盘、研究和中小型交易系统,这个复杂度完全够用。真正需要自己写跳表或红黑树的地方,通常是每秒要处理几万到几十万笔订单,并且对延迟要求极高时。

价格队列使用 FIFO 结构,因为同价位订单需要按时间优先成交。队列里的元素可以是订单指针,也可以是订单 ID。存储指针便于快速修改订单状态和引用消息系统。需要小心的点是:队列中可能包含已被部分成交但尚未完全移除的订单,移除订单时要准确在对应队列中找到并 erase。

2.3 最小可运行框架:添加限价单与按价格聚合

先从一个只支持普通限价单的版本入手。这里我给出一个方向性示例,不是完整生产撮合代码:

class OrderBook { public: void add_order(Order* order); private: struct OrderQueue { std::deque<Order*> orders; int64_t total_quantity = 0; }; std::map<int64_t, OrderQueue, std::greater<int64_t>> bids_; // 买价从高到低 std::map<int64_t, OrderQueue, std::less<int64_t>> asks_; // 卖价从低到高 };

买入限价单的挂单过程可以简化为:

void OrderBook::add_limit_buy(Order* order) { int64_t price = order->price; if (asks_.empty() || price < asks_.begin()->first) { // 没有可成交对手价,直接挂入买队列 auto& queue = bids_[price]; queue.orders.push_back(order); queue.total_quantity += order->remaining_quantity(); order->status = OrderStatus::Working; return; } // 否则进入撮合逻辑,后面补充 }

这个步骤看起来简单,但从这里开始,你会意识到单子类型增加后,不能继续用add_limit_buyadd_market_buyadd_stop_order这样的独立函数各自处理,因为后面的市价单、止损单、冰山单都要复用同一个“检查对手盘、轮询队列、成交剩余数量”的撮合循环。

2.4 先跑通,再谈多线程和性能

这里最容易犯的错是:一上来就设计 lock-free、内存池、CPU 缓存优化。如果订单簿的业务规则还没有完全跑通,优化只是给错误代码穿上性能外衣。

我建议的开发顺序是:

  1. 用单线程实现所有订单类型的撮合逻辑;
  2. 加入完整的状态流转和日志;
  3. 用大量的历史行情回放验证撮合结果与预期一致;
  4. 分析热点,再决定是否引入并发和更复杂的数据结构。

这一步的核心目的不是得到最快代码,而是获得一个“逻辑正确”的参考实现。后续做性能优化和并发改造时,可以用它作为基准进行回归测试。

3. 多类型订单的扩展方式:策略、工厂与状态机

订单簿一旦接多类型订单,代码结构很容易从“多个 if”滑向“无法维护”。关键是你用什么样的代码组织方式应对“多类型”这个复杂度。

3.1 为什么简单的继承可能成为负担

一种自然想法是像这样定义一个基类 Order,然后派生 LimitOrder、MarketOrder、StopOrder、IcebergOrder。但这样往往带来几个问题:

  • OrderBook 容器里只能放基类指针,实际访问价格、触发价、隐藏量时要向下转型;
  • 每种派生订单在撮合流程中如果行为差异大,很容易出现大量 dynamic_cast 或类型判断;
  • 订单从“未触发”到“已触发”,对象类型难道要变化吗?止损单触发后变成市价单,你是在原对象上改类型,还是创建一个新对象替换掉原来的?

用继承表达“订单类型”看起来面向对象,实际上是把交易系统的多变行为硬塞进了类继承体系里。对于一个交易引擎,我更愿意把订单当成一组数据,把行为拆到策略组件中。

3.2 用订单类型枚举 + 行为策略拆分,而不是把每种类型写死在一个类里

一个很实用的做法是:定义一个订单创建接口,根据类型和参数填充Order,然后在核心撮合阶段使用一组职责清晰的策略函数:

  • 预处理策略:决定订单是否进入“立即撮合”通道;
  • 订单队列放置策略:决定这只订单在“触发前”放在哪个集合;
  • 价格获取策略:市价单取对手盘最优价,限价单取指定价格;
  • 部分成交后的剩余单处理策略:IOC 撤单、FOK 撤全部、GTC 继续排队、冰山单释放下一层显示量;
  • 触发策略:止损单只有在行情价格穿过触发价时才生效。

这些策略可以用OrderActionProcessor形式组织:

class OrderProcessor { public: void process(OrderBookContext& ctx, Order* order); };

内部根据order->typeorder->status选择对应的执行步骤。不让每个订单类型写一个process_foo_order,而是把整个撮合过程抽象成几个阶段,每个阶段内部的差异用小型函数处理。

这样做的好处是:测试时可以针对某个状态和策略单独验证;新增一种订单类型时,你只需要扩展一两个阶段,而不是把add_ordermatch_ordercancel_order全改一遍。

3.3 市价单、止损单、冰山单的插入/触发/撤单差异

下面我具体拆拆分三种典型差异,便于你理解状态机之后落在代码里是什么样子。

市价单

市价单进入系统后,没有价格约束,直接扫描对手盘深度,从对侧asks_bids_的最优价格开始,逐层吃掉可用数量,直到全部成交或对手盘耗尽可能还有剩余。如果剩余数量没有特殊时间策略,按 IOC 处理会更好,否则一个没有对手盘的市价单通常会被拒绝或当作“部分成交+剩余取消”,因为市价单不应该挂单等待。

止损单

当价格触发时,止损单会变成市价单或限价单。因此在订单簿中,止损单通常不放在普通价格队列里,而放在待触发集合里。你需要在行情 tick 更新时检查:当前市价是否达到了订单的触发条件。常见规则是:

  • 卖出止损单在价格下跌到小于等于触发价时触发;
  • 买入止损单在价格上涨到大于等于触发价时触发。

实际操作中,交易所对“达到触发价”的定义存在差异,需要先用边界值测试。触发后,订单的行为模式从“被动等待”切换到“立即触发”,这一步可以在订单状态中记录为PendingTrigger -> Triggered,然后再进入市价或限价逻辑。

冰山单

冰山单的复杂性在显示量和隐藏量的管理。订单进入买队列时,只向对手盘暴露一部分显示数量。当显示数量被全部吃掉时,系统需要从隐藏量里补充下一个显示量,而隐藏量不参与主动市场深度显示,但总数量要确保不会超过原始数量。

用一个简化的伪代码描述冰山单剩余处理:

void releaseNextDisplayIfNeeded(Order* iceberg) { if (!iceberg->hidden_quantity) return; int64_t next_display = std::min(iceberg->hidden_quantity, iceberg->display_quantity); iceberg->hidden_quantity -= next_display; // 把新的一段显示量加入原价格队列尾部,或者继续在当前队列中等待 }

这里要注意队尾更新和剩余数量的计算。如果冰山单当前显示量部分成交后,需要继续留在价格队列中,那么它不能从原队列中被移除后并重新插入到队头,否则会破坏时间优先。

3.4 让订单簿真正可扩展的关键接口抽象

综合前面想法,一个可供扩展的订单簿核心接口不应只暴露add_order。可以拆成几个层次:

class OrderBookEngine { public: // 输入命令 void InsertOrder(Order* order); void CancelOrder(int64_t order_id); void CancelReplace(int64_t old_order_id, Order* new_order); void OnMarketTick(int64_t price, int64_t bid, int64_t ask); // 查询 int64_t BestBid() const; int64_t BestAsk() const; size_t BidDepth() const; size_t AskDepth() const; };

OnMarketTick是触发止损单和特殊条件单的入口,它告诉订单簿“最新行情价格或成交价已改变”。你不希望在每次刷单时都遍历所有订单,因此需要维护一个按触发价排序的止损单集合。

常见的止损集合选择也可以是一个按价格和触发顺序排序的多重映射:

std::multimap<int64_t, Order*> stop_buy_orders_; // key 为触发价 std::multimap<int64_t, Order*> stop_sell_orders_;

价格 tick 到来时,可以按价格区间取出应该触发的订单,逐个处理。

把这个阶段集中在接口层面后,后续添加 AT(附加条件)、追踪止损等,都更容易接入。

4. 撮合流程:多类型叠加时如何维持公平性

多类型订单簿的真正复杂度出现在撮合顺序上。不同类型的订单在同一时刻都希望对同一个对手盘深度生效,如果规则不明确,结果很可能与真实市场不一致。

4.1 价格优先、时间优先的顺序如何定义

常规限价单队列的顺序是:价格优先,同一价格下按进入队列先后。真正需要讨论的是:市价单和止损单触发后的订单,到底插入到哪个时间点?

  • 一个直接入场且能成交的买单,通常按对手方队列的价格顺序逐档成交;
  • 在某一档价格,如果存在多个可成交的买单(如新进的限价单、止损后转成的市价单、冰山单显示量的旧委托),谁先谁后?

通常交易所采用价格优先、时间优先,但“时间”默认是同类型队列中的 order id 或 entry time。市价单通常不进入订单队列,而是直接扫对手盘,所以它相对于同价格限价单的先后,取决于系统在撮合瞬间如何选择可用量。多数情况下,可以用一个全局递增序号给所有进入撮合的订单打时间戳,用来解决同一队列内部顺序以及触发单和被动单之间的公平性。

更稳妥的做法是:先用一个单调递增的seq值记录每个订单进入系统的时间;无论订单是初始进入,还是止损触发再次进入,都分配新的 seq?这里需要注意,如果止损单触发后变成市价单,再按触发顺序进入吃单流程,通常需要重新记录触发时间而不是原始下单时间。另一种做法是保留原始 seq 但不一定公平,因为不同订单的触发时间点不同。

这个问题没有唯一答案,关键是你在文档和测试中要明确自己采用哪种规则,并持续保证一致性。常见模拟引擎的简化做法是:

  • 新进入的限价单和转成的市价单,都使用一个全局order_seq_++
  • 同一价格档,订单队列按 seq 升序排列;
  • 一个订单只保留最早进入该队列的 seq,如果它被移动或重新插入,需要重新分配 seq,或者再保留一个queue_insert_seq_

4.2 从限价单买入到吃单,怎么触发止损、怎么消耗冰山

一个多类型场景的完整撮合步骤,可以用下面这个顺序理清:

  1. 新买单到达,首先判断是否为当前限价买单,读取它的请求价格。
  2. 如果请求价格低于当前卖一且没有交叉,则该单进入买队列,交易结束。
  3. 如果有交叉,则进入撮合循环。在循环中,获取当前卖一队列的第一笔委托:
    • 如果是普通卖单,则按可成交数量匹配;
    • 如果是冰山卖单,能成交的数量必须是当前显示量,不能直接使用隐藏量;
    • 如果匹配的订单是 FOK 的对手方,需要判断剩余数量能否满足 FOK 的全部数量,若不能满足则整笔无法成交。
  4. 撮合成功后,更新双方订单的filled_quantitystatus。若对手方剩余数量为 0,则从队列中移除;若远大于当前成交,对手方原地更新或移除。
  5. 如果买单剩余数量为 0,结束;如果剩余数量 > 0 且订单是 IOC,则整单撤销剩余;如果是 GTC,则挂入买队列。
  6. 如果买单是止损单触发后来到的,先检查它的触发状态,再走同一套撮合逻辑。

整个过程听上去不复杂,但一旦有多类型,每一步都可能被插入条件分支。因此需要把“获取可成交对手方数量”这类动作封装成接口,而不是在每个 elif 里重复实现。

4.3 撮合中常见的隐蔽坑:数量回退、价格交叉、触发顺序

我列出几个容易忽略的坑,这些是我实际看过和调过的问题。

价格交叉问题:编写撮合循环时,必须严格确保买单价格 >= 卖一价格才可以成交,卖单价格 <= 买一价格才可以成交。否则可能出现价格倒挂后,一个限价单被成交在比自己限价更差的价格上。限价单的成交价必须处于订单价格与对手方价格之间,而且永远不会超出自身限价。

数量回退没有做:某个 FOK 卖单想要一次性卖 100 手,而当前买盘深度足够,但其中有几笔是状态已经部分成交但尚未移除的残留单,或者有订单刚好要被取消。如果撮合逻辑没有先计算可用的对手单数量总和,而是一边匹配一边更新,可能最终总量不足,导致 FOK 被错误满足。正确的做法是对于 FOK 订单,先快速计算对面可成交数量是否足够,如果不满足,直接拒绝,且不能让其他订单产生部分成交。

冰山单显示量被“一次性穿透”:如果一个市价买单的剩余量很大,而对面第一档有一个冰山卖单,显示量只有 10 手,但隐藏量为 1000 手。在真实市场规则中,市价单只能吃掉冰山单的显示量 10 手;如果 10 手还不够,会继续吃同一价位的普通卖单或下一档卖单,而不是直接吃到冰山单的隐藏量。隐藏量会在显示量被吃掉后,像新的限价单一样重新暴露到该价格队列尾部。因此撮合循环要区分“对手方当前可用于立即成交的露出数量”和“该订单的总剩余数量”。

止损单触发顺序:多个止损单在同一价格 tick 满足条件时,应该按什么顺序触发?如果价格是从上往下穿,系统通常需要先处理最接近刚刚触发线的那批单,这个顺序会影响它们后续吃单的先后。建议用按触发价格和时间共同排序的待触发集合,并添加 tick 级回放测试。

4.4 日志与消息输出,让撮合过程可审计

撮合引擎中的日志不是可选项。我见过很多项目在刚开始没有日志,一旦撮合结果不对,只能到处打印临时变量,效率极低。

可以为每一个事件定义结构化日志,至少包括:

  • 收到订单类型和时间;
  • 是否进入撮合循环;
  • 逐档撮合的价格数量与对手订单 ID;
  • 订单状态变化;
  • 撤单或拒绝原因。

一个简单的输出结构:

struct TradeReport { int64_t trade_id; int64_t taker_order_id; int64_t maker_order_id; int64_t price; int64_t quantity; int64_t trade_time_ns; };

把所有成交和状态变化都记录成消息,后续做行情回测和问题定位会轻松很多。甚至在验证订单簿正确性时,可以用一个“参考订单簿”对比每笔撮合结果,这比人眼盯日志更可靠。

5. 单线程到并发:无锁优化前的思考路径

当订单逻辑正确后,自然会想到性能。但多类型订单簿引入并发,比普通限价订单簿更麻烦。因为撮合过程不是一个纯增删操作,而是带有状态依赖和多个集合的一致性修改。

5.1 不要一上来就无锁

无锁队列、无锁哈希表在交易系统里被广泛讨论,但如果你的多类型订单簿仍然大量使用std::map和 FIFO deque,想改成无锁结构会非常痛苦。更常见的成熟架构是:

  • 多个行情源或订单源通过命令队列发送请求;
  • 只有一个撮合线程负责处理所有订单和行情 tick;
  • 其他线程负责读取行情快照和成交结果;
  • 快照读线程如果需要访问订单簿,采用读写锁或双缓冲快照。

这个设计下,核心订单簿只在撮合线程中被修改,读线程看到的是定期发布的不可变快照。这样你完全不需要对订单簿集合做无锁并发修改,也大大降低了错误概率。

5.2 分线程模型、跨线程命令、异步日志

多类型订单簿的并发瓶颈通常不在订单簿本身,而在 IO、日志、行情数据接入。合理的线程划分可以这样:

  1. 行情线程:负责从 TCP/共享内存/API 读取行情数据,把价格 tick 和交易所事件投递到订单簿线程的命令队列;
  2. 订单簿线程:只负责处理订单和 tick,更新订单簿状态;
  3. 风险/管理系统线程:负责读取快照,做下单决策;
  4. 日志线程:异步写入统计和交易记录。

命令队列可以使用一个有界 MPMC 队列或单生产者单消费者 SPSC 队列。多数模拟系统用互斥锁保护的std::deque就够了,真正的核心不是队列性能,而是每个处理步骤不要持有锁时发起网络调用。

价格和数量的判断要放在撮合线程内,不能假设外部行情已对齐到同一个时间点。

5.3 内存池和对象复用:减少分配抖动

如果每秒处理几十万笔订单,频繁 new/delete 会变成一个大开销,尤其是订单对象和队列节点。常见的做法:

  • 使用std::vector<Order>作为订单存储池,订单 id 就是索引;
  • 使用 free list 管理已撤销订单的空闲槽位;
  • 对不同价格的订单队列节点,可以自行实现一个轻量内存池,避免每次插入队列都分配内存。

但请注意,内存池应该在逻辑和性能测试跑通后再引入。过早优化会掩盖很多语义问题。当你已经明确要压榨性能时,可以用pmr或自研内存池替换默认分配器;但要保留相同的接口和可测试性。

这里展示一个简单的订单池对象复用思路:

class OrderPool { public: Order* allocate(int64_t order_id) { if (free_ids_.empty()) { orders_.emplace_back(); orders_.back().order_id = order_id; return &orders_.back(); } int64_t idx = free_ids_.back(); free_ids_.pop_back(); Order* o = &orders_[idx]; o->reset(); o->order_id = order_id; return o; } void deallocate(Order* order) { order->status = OrderStatus::Cancelled; free_ids_.push_back(order->order_id); } private: std::vector<Order> orders_; std::vector<int64_t> free_ids_; };

简单写法的局限是订单 ID 和向量下标不一定一致,生产环境通常需要更复杂的索引映射。但方向是对的:减少动态内存分配,让订单生命周期尽量可控。

5.4 性能验证:从哪里开始量化

不要靠感觉优化。先建立压测脚本和目标:

  • 单线程每秒钟能处理多少条订单插入?
  • 在 10000 档深度、每档 50 笔订单的情况下,单笔吃单延迟是多少?
  • 多类型订单触发和撤单在总订单中的占比是多少?
  • 内存占用和碎片率又有多少?

从我的经验看,很多系统的延迟瓶颈其实出现在锁竞争和缓存未命中,而不是算法复杂度。你可以先用 profiler 找到热点函数,再用性能基线做对比。如果没有基线,任何“优化后更快”的说法都没有意义。

6. 落地建议与排查链路

前面讲了设计思路和技术点,最后给出一套能落地的开发路径、排查方式和边界判断。

6.1 一个可以开始的最小开发路径

如果你的目标是理解或验证订单簿,不要直接从一个完整交易所的历史数据开始,也不要从一个全是延迟指标的高性能引擎开始。我更建议这样推进:

  1. 写一个只支持限价单并且能正常撮合的订单簿,用一条条手工测试确认买卖队列和成交顺序;
  2. 给订单对象加状态字段,加入撤单和部分成交逻辑;
  3. 加入市价单;
  4. 加入阈值触发逻辑,支持止损单;
  5. 加入数量管理和隐藏量,支持冰山单;
  6. 加入 IOC/FOK 等时间策略;
  7. 用随机订单流回放测试,对比某种基准规则下的事件数量级;
  8. 完成日志和状态机后,再评估是否需要优化。

每一步都保持系统可运行,并且输出清晰的事件日志。路径图可以简化为:

限价单订单簿 → 状态与撤单 → 市价单 → 止损/条件单 → 冰山单 → 时间策略(IOC/FOK) → 回放与验证

6.2 排查问题时的正确顺序

当订单簿表现不对,别急着改代码。我习惯按这个顺序排查:

  1. 先看现象:是价格错误、数量错误、顺序错误,还是程序崩溃?
  2. 再看输入:订单时间戳、类型、价格、数量、触发价是否正确;行情 tick 到达顺序是否与测试用例一致;
  3. 再看状态:订单当前状态是否符合预期,是否因为一次错误触发导致状态机发散;
  4. 再看撮合顺序:搜索成交日志,看逐档撮合时,是否使用了正确的最佳买卖价和对手订单;
  5. 再看数据结构:在std::map中增删价格层级时,迭代器是否失效,价格层级数量是否被正确更新。
  6. 最后看并发问题:是否有非撮合线程修改了订单对象或队列数据。

如果使用的是异步命令队列,还要检查该命令事件是否被重复投递或乱序。回放日志法是最有效的复现手段:把输入事件和输出事件全部记录下来,一个小样本能复现,再加日志定位。

6.3 什么情况不该自己造订单簿

需要说明适用边界。如果只是做策略研究或教学,自己造订单簿是很好的学习路径。但如果目标是实盘或需要极低延迟,而你又没有足够的时间夯实调研、撮合规则和异常恢复,那么使用成熟的交易所撮合引擎、已有的开源仿真系统或券商提供的 API,往往是更稳妥的选择。

自己造轮子适合的场景:

  • 你需要深入理解订单簿机制;
  • 你的策略需要对不同订单类型的成交规则做定制;
  • 你在做学术论文或回测框架,不想依赖外部订单簿接口;
  • 你想训练 C++ 系统设计能力。

不适合自己造的场景:

  • 直接实盘且没有内部专门团队维护风险控制;
  • 目标仅仅是把策略发到回测里,没有特别复杂的交易所微观结构;
  • 缺少有效撮合结果验证数据;
  • 你需要面对极端参数和异常行情,但没有足够的容灾设计。

很多问题不是出在订单簿本身,而是你能拿到的逐笔数据、成交规则、撤单规则不够完整。在撮合规则不确定时,自己写的再快,结果也可能带偏。

6.4 长期演进:从撮合引擎到回测与仿真

多类型订单簿构建完成之后,长期价值在于它可以作为回测系统的“真实市场模拟器”。这也是我最终选择自己实现它的原因之一:外部平台的撮合规则类似一个黑盒,而你的策略如果想在“部分成交后继续等待”“冰山单吃单深度”“止损触发瞬间的流动性冲击”等维度上有更加真实的表现,就需要一个能由自己控制的撮合内核。

回测系统需要输出每个订单进入系统后的状态变化和成交回报,而我们上述设计的订单对象和管理日志已经覆盖了这一点。后续增加大量随机订单、不同订单类型的混合比例、模拟市场噪声,都只需要接入事件流。

多类型订单簿不是一次性项目,它更像一个持续演进的系统组件。你的重点是让状态、规则、数据和策略保持清晰边界,这样每次增加一种新类型,或调整某条撮合规则时,不会把整个系统推倒重来。

从最初的“按价格聚合成字典”,到后来的状态机、生命周期、策略化扩展,这个重构过程给我最大的启发是:在 C++ 中实现这类系统,真正值钱的不是内存池和无锁队列,而是对交易模型本身的抽象能力。把订单类型看作状态与行为的组合,把所有规则收敛到有限的几个流程节点上,订单簿的扩展和稳定性都会好很多。要做的代码不一定多,但每一步都得想清楚“这个字段在哪里被修改、这个状态由谁触发、这个剩余量应该交给哪个队列”。先把这些问题回答清楚,再谈高性能也不迟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询