用C++构建多类型订单簿:数据结构、撮合引擎与多品种管理实践
2026/9/12 5:23:40 网站建设 项目流程

这次我们来看一个偏底层、偏数据结构的 C++ 项目:用 C++ 构建一个多类型订单簿。

订单簿是量化交易、行情系统、回测撮合引擎里绕不开的核心组件。网上关于“用 C++ 写订单簿”的代码有不少,但大多数只覆盖一种玩法:只有限价单,只有连续竞价,只有一个品种。实际工作里的需求往往更立体——既要支持限价单,又要支持市价单和条件单;既要跑连续竞价,也要跑开盘集合竞价;还要在同一个进程里管理多个品种的盘口。

这个项目的核心不是做一个交易所级别的低延迟撮合系统,而是把“订单簿数据结构 + 多订单类型 + 多交易模式 + 多品种管理”一次讲清楚。读完你能得到一个可编译、可扩展、能够继续往里面加撮合规则的 C++ 订单簿骨架,后面接回测引擎、接模拟行情、接内部交易系统都有现成的结构可用。

下面直接从规格、数据结构、代码实现、测试方法和常见坑展开。

1. 核心能力速览

能力项说明
项目类型C++ 数据结构与交易撮合原型
开发语言C++17
构建方式CMake 构建,命令行程序输出成交结果
主要功能限价单、市价单、连续竞价撮合、集合竞价、撤单、多品种簿管理
匹配优先级价格优先、时间优先
适用场景量化策略回测撮合、行情汇总、模拟盘、教学
是否支持外部接口未内置网络 API,适合编译链接进项目做内部组件
是否支持批量任务可批量导入订单文件做撮合回放,示例中给出批量生成流程
是否需要 GPU不需要,纯 CPU 本地编译运行
硬件要求普通开发机即可,运行期内存占用主要取决于订单数量
合规边界仅用于开发测试,不构成投资建议,真实交易场景需额外风控

需要先说明一点:这不是一个完整可上生产的高频交易撮合引擎,而是一个结构清晰、能跑通“下单 -> 撮合 -> 成交 -> 撤单”主链路的订单簿原型。把撮合规则、盘口维护、异常处理补齐后,才能向生产级演进。

2. “多类型”到底多在哪里

题目里的“多类型订单簿”,可以从三个维度理解。

2.1 多订单类型

订单类型最少要覆盖三类:

  • 限价单:指定价格和数量,只有价格可达时才成交。挂单后可长期留在盘口。
  • 市价单:不指定价格,直接按对手盘最优价成交,通常不允许残余留在盘口。
  • 条件单/止损单:先挂在系统外,当市场最新价格触发止损价后,再转成市价单或限价单进场。

不同类型的订单,在数据结构里的生命周期不同,撮合时消耗盘口的逻辑也不同。所以订单类型要建模成枚举,而不是用一堆布尔标记堆砌。

2.2 多撮合模式

订单簿模式至少分成两种:

  • 连续竞价:买方、卖方订单按照价格优先、时间优先实时撮合,成交后残余订单留在盘口等待未来的成交。
  • 集合竞价:在开盘或其他特定时段收集订单,到达时间点后统一撮合,目标是找出成交量最大的单一价格。

连续竞价和集合竞价的状态字段、撮合函数、盘口维护方式并不相同,放在同一个 OOP 类里硬套会很别扭,更好的办法是抽出公共模型,再分别实现。

2.3 多交易品种

同一个网关或回测进程往往需要同时管理多只股票、多个期货合约。这时单价价档、最小变动价位可以不同,订单编号最好也带上品种前缀。最简单有效的做法是按品种 ID 建一个哈希表,每个品种独立持有一个订单簿对象。

理解了这三个维度之后,再设计代码结构就不会写出“上帝类式”的单体订单簿。

3. 环境准备与项目结构

这个项目对运行环境要求很低,不需要 GPU、不需要 CUDA,只要有一个支持 C++17 的编译器:

项目建议
操作系统Linux / macOS / Windows 均可
编译器GCC 9+、Clang 10+、MSVC 2019/2022
构建工具CMake 3.16+
环境依赖无第三方库,全部标准库实现
源码编码UTF-8;Windows 下注意 MSVC 对中文注释的编码兼容

如果使用 Visual Studio 打开含有中文注释的.cpp,可能出现“注释中包含非法字符”之类的编译报错。原因通常是源文件保存为 GBK,而编译器按 UTF-8 解析,或者反过来。推荐统一:文件保存为 UTF-8 with BOM,CMake 里可以加/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.cpp

CMakeLists 最小版本:

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_demo

4. 先建模领域数据

订单簿最忌讳一上来就写撮合逻辑。先把粒度最小的领域对象定义清楚,后面所有功能都是对这些对象的操作。

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::list<Order> 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::list<Order>::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::vector<Trade> 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::map<Price, PriceLevel, std::greater<Price>>; using AskMap = std::map<Price, PriceLevel, std::less<Price>>; BidMap bids_; AskMap asks_; // order_id -> 订单所在位置 std::unordered_map<OrderId, 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::vector<Trade>& 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::vector<Trade>& 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_cast<BidMap&>(bids_) : static_cast<AskMap&>(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::vector<Trade> OrderBook::add_order(Order order) { std::vector<Trade> 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::vector<Order> on_trade(Price last_price) { std::vector<Order> 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::vector<StopOrderContext> context_; };

这样条件单的扫描和连续竞价簿解耦。每次行情 tick 后调用on_trade,拿到被触发的订单后,再交给OrderBook::add_order,主结构不用为了支持条件单做大改动。

7. 集合竞价簿与多品种簿管理

7.1 集合竞价模型

集合竞价不会实时成交,而是在截单时间点统一计算。它要解决的核心问题是:找一个成交价,使成交量最大。

简单实现思路如下:

  1. 把所有买单按价格降序排序,把所有卖单按价格升序排序。
  2. 从最高买价开始向下扫描,累计买方数量。
  3. 累计买方数量与同价累计卖方数量取最小值,得到该价位的可成交量。
  4. 记录可成交量最大的价位,输出成交。
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::vector<Trade> trades; }; AuctionResult run_auction(); private: std::vector<Order> 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_map<uint32_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::vector<Trade>& 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 price=100 qty=6 taker=2 maker=1 cancel result=1

第一笔买单以卖单的挂单价格 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::list<Order>的每个节点独立分配,缓存局部性较差。档位内订单数量较多时,可以考虑换成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 BOM,CMake 加/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 里被反复执行。

第三,订单簿理论上不能并行撮合。如果后续要加并发,正确做法是“单线程处理撮合 + 多线程接收订单并写入队列”,而不是让多个线程同时操作同一个盘口。

第四,涉及真实行情、真实账户或客户订单时,必须严格遵守所在市场的规则和机构规范。订单簿代码本身只是数据结构和撮合规则实现,但接入真实交易前必须补上权限控制、资金风控、合规审计和灾难恢复设计。回测、教学、模拟盘场景下,也要明确标注“不构成投资建议”。

第五,避免“上帝类”式演化。很多订单簿代码最终不可维护,不是因为撮合规则难写,而是因为把订单类型判断、风控、行情推送、日志全部塞进一个类。按功能

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

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

立即咨询