资讯详情

资讯详情

C++实现多类型订单簿:从订单类型到撮合引擎全解析

多类型订单簿这个主题放在 C 项目里并不只是“写一个 map 存价格和数量”那么简单。它真正要解决的问题是一套内部结构能不能同时处理限价单、市价单、止损单、冰山单还能在多个交易对上复用同一套撮合逻辑。如果你正在做量化模拟、行情仿真、交易系统教学或者想把 C 的数据结构和并发能力落在实际系统上这类订单簿是最值得动手实现的项目之一。我会从订单类型设计、基础簿结构、撮合循环、多交易对管理一直到测试链路完整拆开讲。代码以 C17 为主不需要外部依赖适合本地建一个控制台工程逐步跑通。1. 多类型订单簿的第一件事先把“多类型”定义到实现层面1.1 两种“多类型”要先分开很多人在做订单簿时一开始就把需求理解为“支持好几个品种”。其实“多类型订单簿”这个说法在项目里有两层含义单个交易对上支持多种订单类型比如限价单、市价单、止损单、冰山单。多个交易对同时存在比如股票代码 A 和债券代码 B 各自维护独立簿。这两件事不是同一个问题。前者考验的是订单状态机、冻结条件、冰山拆单这类撮合逻辑后者考验的是容器管理、并发隔离、数据源切换这类系统设计。真正合理的落地路径是先在一个交易对上把多种订单类型跑稳然后把单个簿封装成可实例化的对象再去管理多个交易对。反过来做通常会陷入两难刚写完多品种的字典管理又要回调到单一簿里继续补订单逻辑代码很难拆干净。1.2 适合先做成什么样这个项目最适合做成一个命令行可验证的撮合引擎核心。输入可以是简单文本指令输出是成交回报、行情快照和簿内状态。建议验收标准按下面三条定用同样的簿对象能同时处理限价、市价、止损、冰山单。能创建两个以上的交易对簿彼此互不影响。撮合成交后买卖方向、剩余数量、冰淇淋可见量都能对齐。做到这三条已经能覆盖很多生产系统的核心雏形。不要一开始就追求锁优化先保证语义正确。1.3 避免中间出现“上帝类”C 项目里经常出现一个现象所有功能都往一个类上堆结果一个订单簿类里同时承担行情解析、订单管理、订单簿存储、撮合、风控、日志、数据库同步。类代码迅速膨胀最后没人敢改。更好的做法是让核心簿只负责“挂单、撤单、撮合、输出成交”其余全部通过事件回调或外围模块处理。这个原则适合你写的任何 C 工程不只在订单簿里适用。2. 订单类型和订单状态先把状态机拆清楚2.1 核心枚举与订单结构订单首先要区分方向、类型和状态。方向、类型是静态属性状态是动态属性。enum class Side : int8_t { BUY 1, SELL -1 }; enum class OrderType : int8_t { LIMIT 0, // 限价单 MARKET 1, // 市价单 STOP 2, // 止损单触发后转市价单 ICEBERG 3 // 冰山单只暴露可见数量 }; enum class OrderStatus : int8_t { NEW 0, PARTIALLY_FILLED 1, FILLED 2, CANCELLED 3, PENDING_TRIGGER 4 // 停留在等待触发区 };订单结构建议统一保存所有可能出现的字段。虽然这么定义会让单个订单体积变大但买卖方向混在一个容器里会大大减少类型转换和分支判断。struct Order { uint64_t order_id 0; Side side Side::BUY; OrderType type OrderType::LIMIT; OrderStatus status OrderStatus::NEW; int64_t price 0; // 限价或触发后的价格市价单填0 int64_t trigger_price 0; // 止损单触发价 uint64_t quantity 0; // 总剩余委托量 uint64_t iceberg_visible 0; // 冰山单可见量 uint64_t filled 0; int64_t create_ns 0; // 撮合排序用 };对冰山单来说quantity 保存总剩余量iceberg_visible 表示本轮暴露到簿上的数量。市价单的 quantity 就是期望成交量。2.2 注意“止损单”在簿里到底放哪很多人会把止损单直接放进订单簿的价格队列。这是错误做法。止损单在行情价格没有越过触发价之前并不参与撮合也不能被主动吃单所以它应该留在“待触发池”里由每次行情更新后的扫描逻辑判断是否触发。只有触发之后它才转换成一个普通市价单或限价单进入簿。这也是为什么订单状态里需要单独一个 PENDING_TRIGGER。行情最新价 买入止损触发价 - 买止损单触发变市价买 行情最新价 卖出止损触发价 - 卖止损单触发变市价卖这两条是基础触发规则如果做的是真实交易场景还要考虑是否按“成交价”触发而不是按“最新行情价”触发但模拟器里通常用行情快照即可。2.3 状态流转和外部可见性建议把订单状态变化集中到一个接口里不要散落在撮合代码各处。void updateOrder(Order ord, OrderStatus next) { if (ord.status OrderStatus::FILLED || ord.status OrderStatus::CANCELLED) { throw std::runtime_error(order already terminal); } ord.status next; }这样做的好处是后续加撤单、改单、异常恢复都能在一个地方做校验不会出现“已经撤掉的单还在撮合”这类低级 bug。3. 基础簿的数据结构价格档位与 FIFO 顺序3.1 买盘和卖盘使用天然相反的排序方向买盘簿按价格从高到低排列最高买价在第一位因为买方更愿意出高价成交卖盘簿按价格从低到高排列最低卖价在第一位。C 里用 std::map 时直接给买盘传 std::greaterint64_t给卖盘传 std::lessint64_t 即可。这样每一侧队伍的最优价格都是 begin()。3.2 价格档里使用链表维护 FIFO 顺序如果同价位的订单很多必须保持先到先得。用 std::list 保存该价格上的订单列表并且只在队头或队尾追加。using Price int64_t; using Quantity uint64_t; struct OrderBookLevel { std::listuint64_t order_id_list; // 该价格上的订单队列 }; class OrderBook { private: using BidMap std::mapPrice, std::listuint64_t, std::greaterPrice; using AskMap std::mapPrice, std::listuint64_t, std::lessPrice; BidMap bids_; AskMap asks_; std::unordered_mapuint64_t, Order orders_; std::unordered_mapuint64_t, Price order_price_; // 订单当前所在价格 std::unordered_mapuint64_t, Side order_side_; };为什么不直接用 std::vector因为撤单时如果订单夹在中间vector 的删除是 O(n)而且后续指针会失效。用链表能把单笔撤单控制在 O(1)代价是链表节点缓存不友好。对于教学类的 C 订单簿语义正确优先链表是最稳妥的直观实现。3.3 每个订单的价格映射要保持干净撤单和部分成交时最容易出错的是找不到订单在哪个价格档。所以我在设计里额外维护 order_price_ 和 order_side_。表面上多存了一份冗余字段实际上是给撤单操作一条“直达路径”不需要扫描整个盘口。void removeOrder(uint64_t order_id) { auto it orders_.find(order_id); if (it orders_.end()) return; Side side order_side_[order_id]; Price price order_price_[order_id]; if (side Side::BUY) { auto queue bids_[price]; queue.remove(order_id); if (queue.empty()) { bids_.erase(price); } } else { auto queue asks_[price]; queue.remove(order_id); if (queue.empty()) { asks_.erase(price); } } orders_.erase(it); order_price_.erase(order_id); order_side_.erase(order_id); }注意 queue.remove 其实是线性遍历如果该价格队列特别长可以考虑换成自定义双向链表并在订单里保存迭代器。后面讲优化边界时再展开。4. 撮合主循环让买卖单在簿上碰撞并成交4.1 撮合的基本前提是盘口交叉买一价 卖一价时就会产生成交。对于限价单它的逻辑是如果是买单去找卖盘的最低价只要最低卖价 我的买价就成交。如果是卖单去找买盘的最高价只要最高买价 我的卖价就成交。市价单就更直接只要对方有量就一直吃到没量或自己没剩余。4.2 撮合循环的最小实现先定义成交回报结构struct Trade { uint64_t buy_order_id; uint64_t sell_order_id; Price price; Quantity quantity; int64_t ts_ns; };撮合入口用一个 tryMatch 函数完成核心是按剩余量持续吃对方盘口std::vectorTrade OrderBook::match(uint64_t taker_id) { std::vectorTrade trades; auto taker orders_[taker_id]; while (taker.quantity 0 isCrossed(taker)) { if (taker.side Side::BUY) { auto it asks_.begin(); if (it asks_.end()) break; Price exec_price it-first; uint64_t best_ask_id it-second.front(); executeCross(taker, best_ask_id, exec_price, trades); } else { auto it bids_.begin(); if (it bids_.end()) break; Price exec_price it-first; uint64_t best_bid_id it-second.front(); executeCross(taker, best_bid_id, exec_price, trades); } } if (taker.quantity 0) { updateOrder(taker, OrderStatus::FILLED); } else if (taker.filled 0) { updateOrder(taker, OrderStatus::PARTIALLY_FILLED); } return trades; }这段代码里最关键的是 executeCross。它需要一次性处理三个问题计算本次成交量。更新双方订单剩余量。如果主动单成交量大于被动单剩余量就要把被动单从簿里移除再看下一档。4.3 为什么从对侧最优档拆开处理如果你把买卖双方都放在同样排序的数据结构里撮合时只需要“从最优档开始切”。如果两个订单同一侧就永远不主动去撮合同侧。真正的坑是部分成交买一剩余量是 2 你作为卖单要卖 5 第一次成交 2买一被吃掉 第二次要去撮下一个买价这时候撮合循环必须继续直到你的订单完全成交或盘口已经没有可成交价格。很多实现会出现“成交一笔就退出”的情况这是常见的初学者问题。撮合循环一定要放在 while 里并且每轮都要重新查最优档因为上一档已经被清空了。4.4 主动单始终是“吃单方”被吃单仍然是簿上的单撮合完成后要记得区分角色被动单可能被全部成交此时调用 removeOrder 清掉。主动单如果还有剩余而且它是限价单就需要挂进簿里。市价单如果没吃够量通常视为永不进入簿需要做撤单或拒单处理。如果市价单没有完全成交还强制挂单会造成“市价单被放进价格档里而市价单根本没有价格”的现象。所以市价单不能进簿未成交余额要直接走取消路径。5. 限价挂单与撤单的状态管理5.1 新增订单的完整链路新增订单不应只做“塞进 map”这一步。顺序应该是校验订单 ID 是否重复。校验 quantity 必须大于 0。如果订单类型是 LIMIT先尝试撮合。撮合后剩余量 0 时把剩余部分加入簿。返回成交列表和订单最终状态。核心函数可以按下面这样拆std::vectorTrade OrderBook::addOrder(const Order ord) { if (orders_.find(ord.order_id) ! orders_.end()) { throw std::runtime_error(duplicate orderId); } if (ord.quantity 0) { throw std::runtime_error(zero quantity); } Order copy ord; copy.status OrderStatus::NEW; orders_[copy.order_id] copy; order_side_[copy.order_id] copy.side; if (copy.type OrderType::MARKET || (copy.type OrderType::LIMIT canMatchWithCounterSide(copy))) { auto trades match(copy.order_id); return trades; } // 剩余委托量挂入盘口 addToBook(copy); return {}; }限价单需要先判断是否与对侧盘口交叉。如果不交叉直接进簿。如果交叉别直接进簿先走撮合。否则可能出现“买单挂在卖一之上”这种脏盘口。交叉判断很简单bool OrderBook::canMatchWithCounterSide(const Order ord) const { if (ord.side Side::BUY !asks_.empty()) { return asks_.begin()-first ord.price; } if (ord.side Side::SELL !bids_.empty()) { return bids_.begin()-first ord.price; } return false; }5.2 撤单路径要锁状态撤单只允许撤 NEW 或 PARTIALLY_FILLED 的订单。如果订单已经 FILLED 或 CANCELLED就要抛出错误或静默跳过。bool OrderBook::cancelOrder(uint64_t order_id) { auto it orders_.find(order_id); if (it orders_.end()) return false; if (it-second.status OrderStatus::FILLED || it-second.status OrderStatus::CANCELLED) { return false; } if (it-second.status ! OrderStatus::PENDING_TRIGGER) { removeFromBook(order_id); } updateOrder(it-second, OrderStatus::CANCELLED); return true; }很多人撤单时报错最后发现不是逻辑问题而是订单根本不在簿里止损单还停留在待触发池。所以撤单前要先判断状态不能无脑去盘口队列里找。5.3 剩余数量变化时的簿侧一致性部分成交后簿侧订单 ID 没变但数量变了。此时不需要重插订单只要修改订单结构里的 quantity 和 filled然后继续参与 FIFO 排队。如果你重新把订单从簿里拔出来再插入队尾就变相把先到订单挪到队尾破坏了时间优先。很多早期实现都会栽在这里。所以撮合代码里被动单的更新应该是原地修改而不是删除重插passive.filled match_qty; passive.quantity - match_qty;当 quantity 归零时再把订单从盘口链表中移除。6. 怎么加入止损单和冰山单又不把代码写散6.1 止损单要单独管理触发条件止损单不能在 addOrder 里直接进簿。我的做法是addOrder 时发现 type STOP就只登记到 orders_ 和 pending_triggers_ 容器里。每次收到行情更新后遍历 pending_triggers_。检查触发条件满足后把原订单转成 MARKET 单再调用 match。std::vectorTrade OrderBook::onPriceUpdate(int64_t last_price) { std::vectorTrade trades; std::vectoruint64_t triggered_ids; for (auto [id, ord] : orders_) { if (ord.type ! OrderType::STOP) continue; if (ord.status ! OrderStatus::PENDING_TRIGGER) continue; bool hit false; if (ord.side Side::BUY last_price ord.trigger_price) { hit true; } else if (ord.side Side::SELL last_price ord.trigger_price) { hit true; } if (hit) { triggered_ids.push_back(id); } } for (uint64_t id : triggered_ids) { orders_[id].status OrderStatus::NEW; orders_[id].type OrderType::MARKET; auto part match(id); trades.insert(trades.end(), part.begin(), part.end()); // 如果市价未成交部分按拒单或保留当天可用两种方式处理 } return trades; }遍历 pending_triggers_ 时不要一边遍历一边改订单类型否则容易导致迭代器失效。先收集触发 ID再逐个处理是更稳妥的方式。6.2 冰山单要拆成“簿上可见量”和“冰山下隐藏量”冰山单本质是一个大订单但盘口只暴露一部分。假设总委托 10000每次暴露 2000。当可见 2000 全部成交后再从剩余 8000 里拿 2000 补到盘口。设计上可以用一个隐藏总数和当前可见量两个字段来表达struct IcebergState { uint64_t remaining_total; // 含可见和隐藏 uint64_t current_visible; // 当前已经放上簿的量 };订单进入簿时只挂 current_visible。撮合时吃掉的量如果小于 current_visible被动单只是减少可见剩余。如果 current_visible 变成 0说明这轮暴露的部分被吃完这时要从 remaining_total 里再次抽出新的可见量重新进簿。void OrderBook::onLevelCleared(const Order passive) { if (passive.type ! OrderType::ICEBERG) return; if (passive.quantity 0) return; // 全部完成 uint64_t next_visible std::min(passive.quantity, passive.iceberg_visible); // 重新把这个订单的可见部分放到最优价格 }这里有个细节冰山单如果已经全部成交就没有必要再补。补的时候价格仍要保持原来委托价格不能因为某个盘口走空就随手改价。6.3 使用事件回调将撮合与账户链路解耦一旦订单类型变多卖出成交时要做的事也变多比如更新仓位、记录资金流水、推送行情。如果这些都在撮合循环里做代码会非常难调试。更合理的做法是把“撮合引擎”和“外部事件”分开class TradeEvent { public: virtual ~TradeEvent() default; virtual void onTrade(const Trade trade) 0; }; class OrderBook { private: std::vectorTradeEvent* listeners_; public: void subscribe(TradeEvent* listener) { listeners_.push_back(listener); } void emit(const Trade trade) { for (auto* l : listeners_) { if (l) l-onTrade(trade); } } };撮合循环里只负责产出成交外部模块收到回调后再做风控、仓位和日志。这样 OrderBook 本身保持在一个比较单薄的“撮合核心”不会变成上帝类。7. 多品种多簿从单簿抽象到容器管理7.1 统一簿对象不单独为品种写类一个标的和另一个标的的订单簿在“撮合规则”层面上基本一致。差异主要出现在价格精度、数量步长、涨跌停这些品种属性上。所以正确做法是同一个 OrderBook 类实例化多个对象而不是为每个标的复制一份类。如果确实存在规则差异可以通过策略模式或配置字段去区分不要在类里写大量 if (symbol BTC)。7.2 保存多本的容器与查询最简单的方式是加一层 BookManagerclass BookManager { private: std::unordered_mapstd::string, std::unique_ptrOrderBook books_; public: OrderBook* getBook(const std::string symbol) { auto it books_.find(symbol); if (it books_.end()) { // 生产系统中可能需要配置参数这里先做动态创建 auto book std::make_uniqueOrderBook(); book-setSymbol(symbol); OrderBook* raw book.get(); books_[symbol] std::move(book); return raw; } return it-second.get(); } };用 unordered_map 管理多簿查找复杂度平均 O(1)。如果品种数量很少也可以直接用 vector。7.3 多簿并发要区分“数据结构并发”和“引擎并发”多品种同步运行并不一定要为每个品种启动一个线程。很多模拟器是单线程串行处理多品种消息的。单线程的好处是不用加锁行为可预测。订单簿本身是状态敏感结构不加锁反而能避免大量潜伏的竞争 bug。如果你做的是高吞吐多线程真实匹配那么更常见的是每个品种或一组品种维护独立队列。队列之间用无锁队列或每队列一把锁。不让同一个订单簿同时被两个线程直接调 addOrder。在学习阶段至少要先把单线程多簿跑通不要一上来就多线程。否则遇到盘口不一致时很难判断是撮合错误还是并发竞争。7.4 跨簿撮合时避免“自我成交”仍需单独过滤同一个人自己的买、卖单不一定挂在同一品种簿上。如果管理多个簿还要防止用户自己和自己成交的问题。通常会针对相同 uid 的买卖方向订单做预先检查。在 C 里做这个判断要带上 uid 信息单纯只有 order_id 是查不出客户维度的。这就是为什么订单结构实际项目里还会带 uid 或 account_id。struct Order { std::string user_id; ... };撮合前如果发现一个买单和一个卖单属于同一用户可以选择拒绝新单或跳过该笔成交。这是撮合引擎常见的安全过滤点也是模拟交易接口里合规设计的一部分。8. 参数边界、排查顺序与可扩展方向8.1 时间和 FIFO 的处理边界价格优先、时间优先是最基础的撮合规则。如果两个订单价格相同谁先进入簿谁先成交。所以订单结构里需要一个可靠的“时序”来源。我建议用单调递增的 sequence而不是只依赖系统时间。系统时间可能回拨可能导致后加订单排到先加订单前面。uint64_t next_sequence_ 0; uint64_t nextSeq() { return next_sequence_; }这个 seq 在订单进入簿时赋值用来在同价队列末尾追加。如果队列用 std::list本身追加顺序就能代表时间不排序也不是大问题。8.2 调试失败时按最小层排查我碰到的订单簿 debug 场景基本跑不出下面几类行情更新后盘口价格档数量不对。大量撤单之后内存中仍有残留订单。同价格订单成交顺序乱。冰山单暴露量没补齐盘口总量偏少。排查时先不要看撮合先查“簿状态是否干净”。可以用一个总校验函数把所有挂单量求和检查是否等于订单簿内部记录的总量int64_t OrderBook::totalVisibleQty(Side side) const { int64_t total 0; const auto levels side Side::BUY ? bids_ : asks_; for (const auto [price, queue] : levels) { for (auto id : queue) { total orders_.at(id).quantity; } } return total; }如果余额总和和外部订单累计对不上问题基本出在 removeOrder 或部分成交更新上。再往下就是单步打印每个订单的填单前后状态。这个顺序比直接跟踪整个 match 容易得多。8.3 低配置环境下的运行建议这类项目不要求高配硬件。即使是很普通的机器也能跑通 1 个品种、几千订单的模拟。如果跑大量订单时需要更长观察重点不是 CPU 太慢而是避免把行情日志全部打到控制台生产环境中经常会忽略日志频率对程序的影响。开发环境里可以跑多品种各 1000 笔订单的批量测试重点观察执行耗时。一旦持续上升优先检查是不是某侧盘口高度极大导致 remove 变成线性扫描。如需要更优性能可以从这几条路径改进使用稳定 vector 和空闲队列复用内存。记录订单在价格队列里的迭代器代替订单 ID 扫描。对单品种高热路径使用无锁队列把成交回报发到独立线程。这些属于中期优化不要在初期一口气全做否则正确性验证会非常费时。8.4 工程化以后要补的外部能力订单簿撮合核心稳定后至少要再补三个外围模块成交回报日志按 order_id 或者 trade_id 落盘方便复盘。回滚能力支持把某一笔撤单还原。行情推送接口把盘口快照转成外部 JSON 或二进制协议。这三个模块做好订单簿才真正能变成一个独立运行的匹配模拟服务。C 里很多项目最终会卡在“细节不可验证”这个问题上代码能编译但无法证明撮合结果正确。所以建一个多类型订单簿最好从第一步就带上测试用例。每次加入新订单类型后先回归旧能再跑新逻辑。止损触发、冰山补量、市价未成交这些都是最容易出偏差的地方值得逐个用最小样例盯住。这类订单簿项目最值得投入的地方不是炫技式的高并发设计而是把状态流转、边角条件和撮合语义做清楚。语义清楚了后面换数据结构、加多线程、接外部行情都只是工程改进不伤根基。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →