ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用C++构建多类型订单簿:数据结构、撮合引擎与多品种管理实践

2026/9/4 7:19:25 拓冰建站 浏览量
用C++构建多类型订单簿:数据结构、撮合引擎与多品种管理实践 这次我们来看一个偏底层、偏数据结构的 C 项目用 C 构建一个多类型订单簿。订单簿是量化交易、行情系统、回测撮合引擎里绕不开的核心组件。网上关于“用 C 写订单簿”的代码有不少但大多数只覆盖一种玩法只有限价单只有连续竞价只有一个品种。实际工作里的需求往往更立体——既要支持限价单又要支持市价单和条件单既要跑连续竞价也要跑开盘集合竞价还要在同一个进程里管理多个品种的盘口。这个项目的核心不是做一个交易所级别的低延迟撮合系统而是把“订单簿数据结构 多订单类型 多交易模式 多品种管理”一次讲清楚。读完你能得到一个可编译、可扩展、能够继续往里面加撮合规则的 C 订单簿骨架后面接回测引擎、接模拟行情、接内部交易系统都有现成的结构可用。下面直接从规格、数据结构、代码实现、测试方法和常见坑展开。1. 核心能力速览能力项说明项目类型C 数据结构与交易撮合原型开发语言C17构建方式CMake 构建命令行程序输出成交结果主要功能限价单、市价单、连续竞价撮合、集合竞价、撤单、多品种簿管理匹配优先级价格优先、时间优先适用场景量化策略回测撮合、行情汇总、模拟盘、教学是否支持外部接口未内置网络 API适合编译链接进项目做内部组件是否支持批量任务可批量导入订单文件做撮合回放示例中给出批量生成流程是否需要 GPU不需要纯 CPU 本地编译运行硬件要求普通开发机即可运行期内存占用主要取决于订单数量合规边界仅用于开发测试不构成投资建议真实交易场景需额外风控需要先说明一点这不是一个完整可上生产的高频交易撮合引擎而是一个结构清晰、能跑通“下单 - 撮合 - 成交 - 撤单”主链路的订单簿原型。把撮合规则、盘口维护、异常处理补齐后才能向生产级演进。2. “多类型”到底多在哪里题目里的“多类型订单簿”可以从三个维度理解。2.1 多订单类型订单类型最少要覆盖三类限价单指定价格和数量只有价格可达时才成交。挂单后可长期留在盘口。市价单不指定价格直接按对手盘最优价成交通常不允许残余留在盘口。条件单/止损单先挂在系统外当市场最新价格触发止损价后再转成市价单或限价单进场。不同类型的订单在数据结构里的生命周期不同撮合时消耗盘口的逻辑也不同。所以订单类型要建模成枚举而不是用一堆布尔标记堆砌。2.2 多撮合模式订单簿模式至少分成两种连续竞价买方、卖方订单按照价格优先、时间优先实时撮合成交后残余订单留在盘口等待未来的成交。集合竞价在开盘或其他特定时段收集订单到达时间点后统一撮合目标是找出成交量最大的单一价格。连续竞价和集合竞价的状态字段、撮合函数、盘口维护方式并不相同放在同一个 OOP 类里硬套会很别扭更好的办法是抽出公共模型再分别实现。2.3 多交易品种同一个网关或回测进程往往需要同时管理多只股票、多个期货合约。这时单价价档、最小变动价位可以不同订单编号最好也带上品种前缀。最简单有效的做法是按品种 ID 建一个哈希表每个品种独立持有一个订单簿对象。理解了这三个维度之后再设计代码结构就不会写出“上帝类式”的单体订单簿。3. 环境准备与项目结构这个项目对运行环境要求很低不需要 GPU、不需要 CUDA只要有一个支持 C17 的编译器项目建议操作系统Linux / macOS / Windows 均可编译器GCC 9、Clang 10、MSVC 2019/2022构建工具CMake 3.16环境依赖无第三方库全部标准库实现源码编码UTF-8Windows 下注意 MSVC 对中文注释的编码兼容如果使用 Visual Studio 打开含有中文注释的.cpp可能出现“注释中包含非法字符”之类的编译报错。原因通常是源文件保存为 GBK而编译器按 UTF-8 解析或者反过来。推荐统一文件保存为 UTF-8 with BOMCMake 里可以加/utf-8编译选项。推荐的项目结构multi_order_book/ ├── CMakeLists.txt ├── include/ │ ├── order_types.hpp │ ├── price_level.hpp │ ├── order_book.hpp │ └── auction_book.hpp ├── src/ │ ├── order_book.cpp │ ├── auction_book.cpp │ └── main.cppCMakeLists 最小版本cmake_minimum_required(VERSION 3.16) project(multi_order_book CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(MSVC) add_compile_options(/utf-8 /W4) else() add_compile_options(-Wall -Wextra) endif() add_library(orderbook_core src/order_book.cpp src/auction_book.cpp ) target_include_directories(orderbook_core PUBLIC include) add_executable(ob_demo src/main.cpp) target_link_libraries(ob_demo PRIVATE orderbook_core)构建命令cmake -S . -B build cmake --build build -j ./build/ob_demo4. 先建模领域数据订单簿最忌讳一上来就写撮合逻辑。先把粒度最小的领域对象定义清楚后面所有功能都是对这些对象的操作。4.1 订单对象// order_types.hpp #pragma once #include cstdint namespace orderbook { using OrderId uint64_t; using Price double; using Qty double; enum class Side : uint8_t { BUY 0, SELL 1 }; enum class OrderType : uint8_t { LIMIT 0, // 限价单 MARKET 1, // 市价单 STOP 2 // 条件单触发后转市价/限价 }; struct Order { OrderId order_id{0}; Side side{Side::BUY}; OrderType type{OrderType::LIMIT}; Price price{0.0}; // 限价单价格 Price stop_price{0.0}; // 条件单触发价格 Qty quantity{0.0}; // 原始数量 Qty filled{0.0}; // 已成交数量 uint64_t seq{0}; // 时间优先级序号 Qty remaining() const { return quantity - filled; } bool is_inactive() const { return remaining() 0.0 || filled quantity; } }; struct Trade { OrderId taker_id{0}; OrderId maker_id{0}; Price price{0.0}; Qty quantity{0.0}; }; } // namespace orderbook价格和数量用double是为了原型方便生产环境如果做资金结算应该使用定点数或整数价格(价格乘以最小价档单位)来避免浮点误差。4.2 价格档位订单簿的中间层是“价格档”。同一价格的挂单放在同一个档位里按到达顺序排队。// price_level.hpp #pragma once #include list #include order_types.hpp namespace orderbook { struct PriceLevel { Price price{0.0}; Qty total_quantity{0.0}; std::listOrder orders; // 同档订单时间优先 void add_order(Order order) { orders.push_back(std::move(order)); total_quantity orders.back().remaining(); } }; struct OrderLocation { bool is_bid_side{false}; Price price{0.0}; std::listOrder::iterator iter; }; } // namespace orderbook这里选std::map管理价格档原因很简单买盘按价格降序排列最优买盘是第一个元素。卖盘按价格升序排列最优卖盘是第一个元素。需要删除某个空档位时按价格 key 可以直接删除。标准库红黑树实现能保证档位的排序维护复杂度是 O(logN)。用std::vector加线性搜索也可以但每次插入都要维护排序盘口档位一多性能就不稳定。std::map是“兼顾写代码效率和数据规模”的折中选择。专业高频撮合引擎会用手写 hash map 加双向链表的组合那是另一个量级的优化。5. 连续竞价订单簿实现连续竞价簿需要支持撮合、挂残余订单、撤单三个关键操作。撮合规则是价格优先、时间优先。5.1 订单簿接口定义// order_book.hpp #pragma once #include list #include map #include unordered_map #include vector #include order_types.hpp #include price_level.hpp namespace orderbook { class OrderBook { public: std::vectorTrade add_order(Order order); bool cancel(OrderId order_id); // 方便外部查看盘口 const auto bids() const { return bids_; } const auto asks() const { return asks_; } private: using BidMap std::mapPrice, PriceLevel, std::greaterPrice; using AskMap std::mapPrice, PriceLevel, std::lessPrice; BidMap bids_; AskMap asks_; // order_id - 订单所在位置 std::unordered_mapOrderId, OrderLocation index_; PriceLevel* best_opponent_level(Side side); bool crosses_price(Side side, Price taker_price, Price level_price); void fill_level( Order taker, PriceLevel level, std::vectorTrade trades); OrderLocation store_resting_order(Order order); }; } // namespace orderbook买盘使用std::greater排序所以第一个档位是最高买价。卖盘使用std::less排序第一个档位是最低卖价。加一个按订单 ID 的unordered_map后撤单时就能从任意位置快速定位不需要扫描整个盘口。5.2 撮合主流程实现// order_book.cpp —— 只列出核心路径 #include order_book.hpp namespace orderbook { PriceLevel* OrderBook::best_opponent_level(Side side) { if (side Side::BUY) { if (asks_.empty()) return nullptr; return (asks_.begin()-second); // 买方打最优卖盘 } if (bids_.empty()) return nullptr; return (bids_.begin()-second); // 卖方打最优买盘 } bool OrderBook::crosses_price(Side side, Price taker_price, Price level_price) { if (side Side::BUY) return taker_price level_price; return taker_price level_price; } void OrderBook::fill_level( Order taker, PriceLevel level, std::vectorTrade trades) { auto it level.orders.begin(); while (it ! level.orders.end() taker.remaining() 0.0) { Order maker *it; Qty exec_qty std::min(taker.remaining(), maker.remaining()); Price exec_price level.price; maker.filled exec_qty; taker.filled exec_qty; level.total_quantity - exec_qty; trades.push_back(Trade{ taker.order_id, maker.order_id, exec_price, exec_qty }); if (maker.is_inactive()) { index_.erase(maker.order_id); it level.orders.erase(it); } else { it; } } } OrderLocation OrderBook::store_resting_order(Order order) { const bool is_buy (order.side Side::BUY); auto table is_buy ? static_castBidMap(bids_) : static_castAskMap(asks_); // 这里用 map operator[] 会在不存在时创建档位 PriceLevel level table[order.price]; level.price order.price; level.orders.push_back(std::move(order)); auto order_iter std::prev(level.orders.end()); level.total_quantity order_iter-remaining(); return OrderLocation{is_buy, order.price, order_iter}; } std::vectorTrade OrderBook::add_order(Order order) { std::vectorTrade trades; if (order.type OrderType::MARKET || order.type OrderType::LIMIT) { while (order.remaining() 0.0) { PriceLevel* level best_opponent_level(order.side); if (level nullptr) break; if (order.type OrderType::LIMIT !crosses_price(order.side, order.price, level-price)) { break; } fill_level(order, *level, trades); if (level-orders.empty()) { // 吞掉一个空档位 if (order.side Side::BUY) { asks_.erase(level-price); } else { bids_.erase(level-price); } } } } // 限价单残余挂入盘口 if (order.type OrderType::LIMIT order.remaining() 0.0) { OrderId rest_id order.order_id; OrderLocation loc store_resting_order(std::move(order)); index_.emplace(rest_id, loc); } // 市价单残余不落盘按“丢弃超挂数量”处理 return trades; } bool OrderBook::cancel(OrderId order_id) { auto hit index_.find(order_id); if (hit index_.end()) { return false; // 不存在或已成交 } OrderLocation loc hit-second; if (loc.is_bid_side) { auto lit bids_.find(loc.price); if (lit bids_.end()) return false; PriceLevel level lit-second; level.orders.erase(loc.iter); if (level.orders.empty()) { bids_.erase(lit); } } else { auto lit asks_.find(loc.price); if (lit asks_.end()) return false; PriceLevel level lit-second; level.orders.erase(loc.iter); if (level.orders.empty()) { asks_.erase(lit); } } index_.erase(hit); return true; } } // namespace orderbook这段实现有几个教学上值得注意的点第一市价单进来的瞬间如果对手盘不足不会挂单多余数量直接作废。真实交易系统通常会有“市价转限价”“冰山单隐藏量”等策略原型阶段先明确这个行为即可。第二限价单先撮合再挂单。如果价到达不了对手档crosses_price会直接 break整单挂入盘口。如果部分成交剩余部分继续挂单。第三fill_level里同时维护了三处状态maker 的已成交量、taker 的已成交量、档位的总量。漏掉任何一个盘口数量都会对不上。6. 支持条件单和机器单的骨架条件单 STOP 不直接进入连续竞价簿。它的标准流程是先放在“待触发订单列表”里不占用买卖盘档位当最新成交价或最新价穿过触发价后再生成一张新的市价单或限价单。struct StopOrderContext { Order trigger_order; OrderType convert_to; // 触发后转成 LIMIT 还是 MARKET }; class StopOrderManager { public: void submit(Order order, OrderType convert_to) { context_.push_back(StopOrderContext{order, convert_to}); } std::vectorOrder on_trade(Price last_price) { std::vectorOrder activated; for (auto it context_.begin(); it ! context_.end();) { bool touch false; if (it-trigger_order.side Side::BUY) { touch last_price it-trigger_order.stop_price; } else { touch last_price it-trigger_order.stop_price; } if (touch) { activated.push_back(it-trigger_order); it context_.erase(it); } else { it; } } return activated; } private: std::vectorStopOrderContext context_; };这样条件单的扫描和连续竞价簿解耦。每次行情 tick 后调用on_trade拿到被触发的订单后再交给OrderBook::add_order主结构不用为了支持条件单做大改动。7. 集合竞价簿与多品种簿管理7.1 集合竞价模型集合竞价不会实时成交而是在截单时间点统一计算。它要解决的核心问题是找一个成交价使成交量最大。简单实现思路如下把所有买单按价格降序排序把所有卖单按价格升序排序。从最高买价开始向下扫描累计买方数量。累计买方数量与同价累计卖方数量取最小值得到该价位的可成交量。记录可成交量最大的价位输出成交。class AuctionBook { public: void add_order(Order order) { orders_.push_back(std::move(order)); } struct AuctionResult { bool has_match{false}; Price match_price{0.0}; Qty match_qty{0.0}; std::vectorTrade trades; }; AuctionResult run_auction(); private: std::vectorOrder orders_; };这里的实现重点是“成交量最大优先价差最小次优先”和连续竞价的逐笔撮合完全不同。把两种模式拆成两个类反而更清晰。7.2 多品种簿管理多个品种共用同一套OrderBook类型外层做一层路由即可。#include unordered_map #include cstdint class MultiInstrumentBook { public: orderbook::OrderBook book(uint32_t instrument_id) { return books_[instrument_id]; } private: std::unordered_mapuint32_t, orderbook::OrderBook books_; };用unordered_map做品种维度隔离后不同品种的买卖盘互不影响。订单 ID 可以设计成instrument_id 32 | seq天然自带品种信息。后续要输出行情快照只需要遍历品种对应的盘口。这个封装虽然只有几行但避免了很多人写“单体订单簿”时最常犯的问题一个对象里塞大量if instrument X分支。8. 主流程验证与批量回放先写一个简单的main.cpp验证最基本的行为买卖挂单互相撮合、撤单被正确删除、条件单能在一个逻辑周期里激活并成交。// main.cpp #include iostream #include order_book.hpp using namespace orderbook; void print_trades(const std::vectorTrade trades) { for (const auto t : trades) { std::cout trade price t.price qty t.quantity taker t.taker_id maker t.maker_id \n; } } int main() { OrderBook book; Order sell; sell.order_id 1; sell.side Side::SELL; sell.type OrderType::LIMIT; sell.price 100.0; sell.quantity 10.0; // 卖单先挂单 book.add_order(sell); Order buy; buy.order_id 2; buy.side Side::BUY; buy.type OrderType::LIMIT; buy.price 100.5; buy.quantity 6.0; // 买单价格可以够到对手盘应产生成交 auto trades book.add_order(buy); print_trades(trades); // 撤掉尚未成交的卖单残余 bool ok book.cancel(1); std::cout cancel result ok \n; return 0; }预期输出大致是trade price100 qty6 taker2 maker1 cancel result1第一笔买单以卖单的挂单价格 100.0 成交 6 手不是以买单价格 100.5 成交这是价格优先撮合的标准结果。残余 4 手卖单随后被撤掉撤单函数返回成功。正式的验证流程不能只看一次运行建议整理成一套可重复执行的测试用例按以下维度覆盖测试项测试输入预期结果买一卖一对敲卖价 100买价 100.5成交价 100不交叉限价单卖价 101买价 100不成交订单进入盘口市价单打对手盘盘口最优卖价 100.5市价买全部成交同价多单时间优先同价先挂单 A 后挂单 BA 先被成交部分成交后挂残余买 10对手剩 4成交 4余 6 挂盘撤单撤不存在的 ID返回 false不异常撤已成交单成交完的 ID 再撤返回 false把这些场景写成单元测试框架的用例后每次改数据结构或加订单类型都能快速知道哪些行为被破坏。9. 批量任务与文件回放订单簿最常见的批量任务就是“回放历史委托流”。可以从文本文件逐行读取下单、撤单指令并把成交记录写到输出文件。文件格式示例20240101 09:30:00.000 ADD 1 BUY LIMIT 100.0 10 20240101 09:30:00.100 ADD 2 SELL MARKET 0 5 20240101 09:30:00.200 CANCEL 1主循环可以写成#include fstream #include sstream void replay_file(const std::string path, orderbook::OrderBook book) { std::ifstream in(path); std::string line; uint64_t order_seq 1; while (std::getline(in, line)) { std::istringstream ss(line); std::string action; long long order_id; int side; int type; double price; double qty; ss action order_id; if (action ADD) { ss side type price qty; orderbook::Order order; order.order_id order_id; order.side side 0 ? orderbook::Side::BUY : orderbook::Side::SELL; order.type type 0 ? orderbook::OrderType::LIMIT : orderbook::OrderType::MARKET; order.price price; order.quantity qty; order.seq order_seq; auto trades book.add_order(order); for (const auto t : trades) { std::cout TRADE t.taker_id t.maker_id t.price t.quantity \n; } } else if (action CANCEL) { bool ok book.cancel(order_id); if (!ok) { std::cerr cancel failed: order_id \n; } } } }批量回放最大的价值不是快而是可复现。同一份委托文件同一个订单簿版本必须得到同一份成交序列。这比暴力并发压测更能发现撮合逻辑错误。10. 性能观察与优化方向订单簿的性能要从四个层面观察。10.1 操作复杂度当前原型的复杂度特征操作复杂度说明下单撮合O(成交档数 挂单查找)map 查找 O(logN)撤单O(logN)先查订单索引再删档位查看最优盘口O(1)取 map begin条件单扫描O(条件单数量)每个 tick 线性扫描需要触发索引优化最明显的性能瓶颈在两点一是double作为std::map的 key。浮点比较在本场景通常可接受但极端行情下可能出现精度不一致。生产环境建议用整数价格例如每股价格乘 10000 后存储。二是条件单线性扫描。真实系统触发单按价格分段索引最新价只需要命中所属价格段的订单。订单量到百万级以后线性扫描会拖慢整个 tick 处理。10.2 内存布局std::listOrder的每个节点独立分配缓存局部性较差。档位内订单数量较多时可以考虑换成std::vector加空闲 slot 管理或者用 intrusive list 把订单节点的 next/prev 指针内嵌在订单对象里。后者是高吞吐撮合引擎的常见做法代价是代码复杂度明显上升。10.3 观察指标本地验证时可以打印# Linux 下观察进程内存占用 ./build/ob_demo ps -o pid,rss,etime,cmd -p $!不过订单簿的内存占用高度依赖订单量和残余挂单数量不能拿“单次 Demo 跑完 10 笔订单”的数据代表高负载表现。要评估性能应该直接做压力测试生成 10 万笔随机限价单。用同样随机种子跑两遍检查成交数据是否一致。观察处理 10 万笔订单的耗时和峰值内存。测试撤单密集场景确认订单索引没有内存泄漏。压测时最重要的一条一定要先固定随机种子保证输入可复现否则无法定位性能回退是哪次改动引入的。11. 常见问题与排查方法问题现象可能原因排查方式解决方案MSVC 编译含中文注释报错源文件编码与编译器解析编码不一致查看源文件保存编码文件存 UTF-8 with BOMCMake 加/utf-8买单吃不到对手盘价格比较方向写反打印买价、卖价、crosses_price 返回值统一用crosses_price封装比较盘口总量越来越大成交后只改订单量没改档位总量checklevel.total_quantity成交、撤单、残余挂单全维护 total_quantity撤单后仍能成交订单索引没有在成交时清理检查index_大小订单全成交时从 index_ 删除重复使用订单 ID订单 ID 生成逻辑遗漏插入前检查 index_使用 id 生成器或 instrument_id 32市价单残余落盘撮合完把残余统一 store检查 add_order 分支市价单残余直接丢弃或转限价集合竞价反复扫出多个价位没有记录历史最大成交量打点每个价位的成交量用“最大成交量优先最小成交量价差次优先”程序退出时崩溃存在悬垂迭代器检查 OrderLocation 是否在列表删除后仍被保留每条路径删除订单时同步 erase index_单品种写死无法扩展把盘口直接放在全局变量看是否有多品种目录结构用 MultiInstrumentBook 管理这些坑里最常出现的是“撮合成功但盘口数量不对”。建议写代码时把订单簿的总量守恒作为一个调试断言任意操作后盘口内所有订单剩余量之和等于各档位 total_quantity 之和而各档位 total_quantity 之和等于 index_ 中未成交订单的剩余量之和。围绕这个不变量做断言可以提前发现一大半问题。12. 最佳实践与合规边界C 订单簿是可以越写越深的项目起初是数据结构题后面会变成并发题、内存管理题、系统设计题。在扩展之前先守住几条边界。第一第一次实现不要追求“支持一切”。先跑通限价单 市价单 连续竞价主链路再逐步增加条件单、集合竞价、冰山单。功能一多撮合路径的组合爆炸会迅速超出可控范围。第二给订单簿做独立单元测试。不要把验证逻辑全部放进 main最小可运行配置应该能在 CI 里被反复执行。第三订单簿理论上不能并行撮合。如果后续要加并发正确做法是“单线程处理撮合 多线程接收订单并写入队列”而不是让多个线程同时操作同一个盘口。第四涉及真实行情、真实账户或客户订单时必须严格遵守所在市场的规则和机构规范。订单簿代码本身只是数据结构和撮合规则实现但接入真实交易前必须补上权限控制、资金风控、合规审计和灾难恢复设计。回测、教学、模拟盘场景下也要明确标注“不构成投资建议”。第五避免“上帝类”式演化。很多订单簿代码最终不可维护不是因为撮合规则难写而是因为把订单类型判断、风控、行情推送、日志全部塞进一个类。按功能