土法炼钢兴趣小组的算法知识备份

【金融科技工程】撮合引擎实现:撮合算法、价格优先时间优先、状态机、低延迟工程

文章导航

分类入口
architecturefintech
标签入口
#matching-engine#order-book#price-time-priority#pro-rata#disruptor#rust#go#clob

目录

引子

撮合引擎(Matching Engine)是交易所的心脏:从证券、期货、期权到加密货币 CEX,每一笔成交价格的形成都发生在一段不超过几十微秒的代码里。它是一个看似简单、实则对工程师心智负担极重的子系统——数据结构只有一个”订单簿(Order Book)“,算法只有一个”价格时间优先(Price-Time Priority)“,但围绕它的所有周边:确定性回放、主备一致性、订单类型组合爆炸、自成交预防、集合竞价、分片与 Gateway 协同,构成了现代交易所工程师最难啃的一块骨头。

上一篇《交易所核心系统架构》里我们把撮合引擎当作一个黑盒放在图中央。本文打开这个黑盒:订单是怎么进入的?订单簿是怎么存的?匹配是怎么发生的?为什么几乎所有主流交易所都选择”单线程、无锁、按 symbol 分片”这条路?为什么说”撮合必须是确定性的”,这一句话同时决定了数据结构选型、随机数使用、时间戳来源、甚至日志格式?

本文面向两类读者:一类是想自己写一个玩具撮合引擎、理解 Nasdaq ITCH/OUCH 协议背后机制的工程师;一类是正在交易所、券商、或加密所工作,需要对撮合引擎的性能瓶颈、升级路径、灾备切换做技术判断的架构师。我们会从数据结构讲到 LMAX Disruptor,再到 Rust/Go 的可运行示例,最后给出一份”撮合引擎代码审查清单”。


一、订单簿:撮合引擎唯一的核心数据结构

1.1 订单簿的逻辑模型

中央限价订单簿(Central Limit Order Book,CLOB)是所有主流交易所撮合引擎的底层模型。它由两侧组成:

每一侧在同一价格上会有多笔订单,按到达时间先后排队,形成价格层(Price Level)内的 FIFO 队列。最优买价(Best Bid)和最优卖价(Best Ask)之间的差额叫买卖价差(Spread),它是流动性的最直接度量。

                        ASK (卖方, 升序)
                        ┌─────────────────────────┐
                 100.05 │ O7(200) O8(150)         │  350
                 100.04 │ O5(100)                 │  100
                 100.03 │ O3(500) O4(300) O6(200) │ 1000    ← Best Ask
                        ├─────────────────────────┤
    ─── SPREAD 0.01 ────┤
                        ├─────────────────────────┤
                 100.02 │ O1(300) O2(400)         │  700    ← Best Bid
                 100.01 │ O9(600)                 │  600
                 100.00 │ O10(1000)               │ 1000
                        └─────────────────────────┘
                        BID (买方, 降序)

当新订单到达并且可成交时(买单价格 ≥ 最优卖价,或卖单价格 ≤ 最优买价),撮合发生;否则订单被加入到相应价格层的队尾。

1.2 数据结构选型

撮合引擎对订单簿的核心操作只有四个:

操作 频率 目标复杂度
查询最优价(Best Bid / Best Ask) 极高 O(1)
插入新订单 O(log N) 或 O(1)
撤单(按 order_id) O(1)
弹出最优价层的队头订单 O(1)

满足这组需求的典型实现有三种:

平衡树(红黑树 / AVL)+ 哈希索引

最常见的做法。用 std::map<Price, PriceLevel>(C++)或 BTreeMap<Price, PriceLevel>(Rust)按价格排序存储价格层,每个价格层内部是一个双向链表实现的 FIFO 队列。再维护一个 HashMap<OrderId, ListNode*> 用于 O(1) 定位到节点实现快速撤单。

跳表(Skip List)

Redis ZSET 的做法,与平衡树复杂度相同,但实现更简单、插入删除的常数因子略低,易于做 lock-free 变种。一些 Go 实现(如 goex/go-exchange 的早期版本)使用跳表。

定点价格梯(Fixed-Tick Price Ladder)

当价格精度是固定档位(tick size,如股票 0.01 元、期货 0.5 点)且波动范围有界时,可以用一个数组直接存储所有价格层,下标 = (price - min_price) / tick_size

CME Globex、Nasdaq INET、以及多数追求极致低延迟的撮合引擎都采用数组价格梯。Jane Street 有一篇著名博客描述了它如何用数组(而非红黑树)把撮合延迟压到 3 微秒以内。对于加密货币撮合(价格波动大、tick size 可能变化),仍以红黑树 / 跳表为主。

1.3 价格层内部结构

价格层内部始终是 FIFO 队列(对价格时间优先规则而言)。但它不仅要支持队尾入队、队头出队,还要支持中间删除(撤单)。因此典型实现是双向链表 + HashMap 索引

PriceLevel {
    price:   i64
    qty:     u64   // 该价格层总量(O(1) 查询用)
    head:    OrderNode*
    tail:    OrderNode*
}

OrderNode {
    order_id, account_id, qty, ts_ns
    prev, next
}

Index: HashMap<OrderId, OrderNode*>

这样插入、撤单、队头出队都是 O(1)。唯一的 O(log N) 操作是新建 / 删除价格层时对树的操作。

1.4 冰山订单与隐藏单的存储

冰山订单(Iceberg) 在订单簿中只暴露一部分显示量(Visible Qty / Peak),剩余为隐藏量(Hidden Qty / Reserve)。实现上,每次显示量被吃掉,引擎自动从隐藏量”补货”,但补货后的新切片会在队尾重新排队(因为时间优先要求:后来的量不能优先于同价位的其他订单)。

Iceberg Order: display=100, reserve=900
 ┌──────────────────────────────────┐
 │ PriceLevel 100.03 queue:         │
 │  A(300) B(100-visible) C(200)    │ ← B 是冰山,显示 100
 └──────────────────────────────────┘
                ↓ B 被吃掉 100
 ┌──────────────────────────────────┐
 │  A(300) C(200) B'(100-visible)   │ ← 补货后 B' 重新排队尾
 │  (B reserve: 800)                │
 └──────────────────────────────────┘

隐藏单(Hidden Order / Iceberg with display=0) 则完全不出现在公开行情中,但在成交时仍然按价格 / 时间优先参与撮合,且通常让利于显示单(见 1.5)。

1.5 L1 / L2 / L3 行情视图

订单簿对外输出的行情有三种深度:

L3 信息是撮合引擎内部状态的最完整对外投影,但只给做市商、专业交易者;普通用户看到的是 L2 或 L1。详见下一篇《行情分发》。


二、匹配算法:从价格时间优先到 Pro-Rata

2.1 价格时间优先(Price-Time Priority)

绝大多数现货、股票、加密交易所采用价格时间优先

  1. 先比价格(更优价优先)
  2. 同价格比时间(先到优先)

伪代码:

function match(incoming_order):
    while incoming_order.qty > 0:
        best_level = opposite_book.top()
        if best_level is None: break
        if not crosses(incoming_order.price, best_level.price): break

        maker_order = best_level.head
        trade_qty = min(incoming_order.qty, maker_order.qty)
        emit_trade(incoming_order, maker_order, best_level.price, trade_qty)

        maker_order.qty -= trade_qty
        incoming_order.qty -= trade_qty
        if maker_order.qty == 0:
            best_level.pop_head()
            if best_level.empty(): opposite_book.remove(best_level)

    if incoming_order.qty > 0 and incoming_order.type == LIMIT:
        same_book.insert(incoming_order)

成交价取 maker(被动方) 的挂单价格,而非 taker(主动方)的价格——这是”价格改进(Price Improvement)“的来源,也是和”询价 / RFQ”模式的本质区别。

2.2 Pro-Rata(按比例分配)

在某些高度集中、买卖价差经常为一个 tick 的市场(如 CME 某些短期利率期货、欧洲 Bund),价格时间优先会导致”谁快谁通吃”、做市商过度拥堵。Pro-Rata 规则把到来的订单量按比例分配给同一价格层的所有挂单:

PriceLevel 100.03 (total 1000):
  A(600), B(300), C(100)

Incoming SELL 500 @ 100.03:
  A 分得 500 * 600/1000 = 300
  B 分得 500 * 300/1000 = 150
  C 分得 500 * 100/1000 = 50

Pro-Rata 鼓励挂大单,但引入了另一个问题:大单可能被故意挂出去再撤单(Spoofing 嫌疑),因此监管对 Pro-Rata 市场的 Layering/Spoofing 监控更严。

2.3 混合规则:Pro-Rata with Top Order / Allocation

为了缓解”大单垄断”,很多期货市场采用混合规则

CME Globex 针对不同合约有多种匹配算法:FIFO、Allocation、Pro-Rata、Threshold Pro-Rata、Configurable TPRO、LMM(Lead Market Maker)Allocation。每个合约在上市前就固定,不允许随意切换。

2.4 集合竞价(Call Auction)

连续竞价(Continuous Trading)之外,交易所在开盘、收盘(以及某些情况的临时停复牌)会进行集合竞价:接受订单但不立即撮合,在某个时间点以一个统一价格一次性成交最大量。

集合竞价定价算法通常遵循最大成交量 → 最小未匹配量 → 参考价附近三优先原则:

  1. 能使当次成交量最大的价格
  2. 如多个价格都能达到最大成交量,选择买卖剩余未成交量最小的
  3. 如仍有多个,选择距离前一成交价(或参考价)最近的
  4. 仍并列,取中间值或按交易所规则定
flowchart TD
    A[订单持续进入集合竞价簿] --> B[T-1s 停止接单]
    B --> C[计算每个候选价的可成交量]
    C --> D{是否存在唯一最大量价格?}
    D -- 是 --> E[该价即开盘价]
    D -- 否 --> F[选剩余量最小的]
    F --> G{仍有并列?}
    G -- 是 --> H[取最接近参考价的]
    G -- 否 --> E
    E --> I[以开盘价统一成交 按价格时间优先分配]
    I --> J[进入连续竞价]

上交所、深交所开盘集合竞价 9:15–9:25(其中 9:20–9:25 不允许撤单),收盘集合竞价 14:57–15:00。港交所、欧交所、Nasdaq 都有自己的集合竞价窗口。

2.5 自成交预防(Self-Trade Prevention, STP)

同一交易账户(或关联账户组)的买卖单在订单簿中相遇时,按法规和交易所规则不应成交(会被视为 wash trade,操纵市场嫌疑)。STP 策略常见有:

策略 处理方式
Cancel Taker (CT) 撤销主动方新单
Cancel Maker (CM) 撤销被动方旧单
Cancel Both (CB) 双方全撤
Decrement Both (DB) 按较小量同时减少;若一方归零则撤,另一方保留剩余

实现上,在匹配循环检测到 incoming.account_group == maker.account_group 时分叉走 STP 分支。注意:STP 会影响价格发现(被迫跳过一些挂单),所以通常只针对同一账号、或同一账号组(如 master-subaccount 关系),不跨账户。

Binance、Bybit 公开 API 中 STP 模式是下单参数之一:selfTradePreventionMode=EXPIRE_TAKER|EXPIRE_MAKER|EXPIRE_BOTH|NONE。Coinbase 默认 Decrement and Cancel。


三、订单类型的实现

订单类型是撮合引擎复杂度的主要来源。下表概括最常见的订单类型及其在引擎内部的实现要点:

类型 含义 实现要点
Limit (GTC) 限价单,Good-Till-Cancel 撮合剩余部分挂入订单簿
Market 市价单 按对手方价格逐档吃,直到填满或对手方空;剩余直接拒绝(A 股)或按最差价保护拒绝(美股 NBBO 保护)
IOC Immediate-or-Cancel 可成交部分立即成交,剩余撤销
FOK Fill-or-Kill 如不能全部立即成交则全部撤销
GTD Good-Till-Date 带过期时间的 GTC;引擎或定时任务在到期时自动撤销
Post-Only / Maker-Only 只允许做 maker 如果会立即成交则撤销(不当 taker)
Stop / Stop-Limit 触发式 价格触发条件满足后转为 Market / Limit 单进入撮合
Iceberg 冰山 显示量 + 隐藏量,显示量吃完从隐藏量补货并重排
Hidden / Dark 隐藏单 不出现在公开行情;通常成交优先级低于显示单
Peg 钉盘单 价格跟随最优买 / 卖价自动调整
MOC / LOC Market/Limit-on-Close 仅参与收盘集合竞价

3.1 Stop 单的引擎实现

Stop 单不进入订单簿,而是进入一个独立的”待触发单”结构,按触发价索引(再按 symbol 分片)。撮合引擎每次成交后,拿最新成交价去该结构里找”触发价被穿过”的 Stop 单,把它们转为 Limit/Market 注入主撮合循环。

关键约定:Stop 单的触发与进入撮合之间必须是同一个事件循环内的连续步骤,否则会被抢跑。这也是为什么几乎所有撮合引擎都坚持单线程(见第六章)。

3.2 FOK 的二阶段扫描

FOK 要求”要么全部立即成交,要么全部撤销”。实现上先预扫描订单簿对手侧,计算若以该价格吃进所有可成交单,累计量是否 ≥ 订单量。若是,正式进入匹配循环;若否,直接拒绝。预扫描不得修改簿状态。

3.3 GTD 的过期清理

GTD / GTC with expiry 需要一个过期清理线程时间轮,到期后把订单从簿中撤出。清理本身必须通过撮合主循环的消息队列(而非直接修改簿),以保持事件顺序的确定性。


四、确定性:撮合引擎的第一性原则

4.1 为什么必须确定性

“确定性(Deterministic)”指:给定相同的初始状态和相同的输入序列,撮合引擎必须产生逐字节相同的输出(成交记录、订单簿快照、行情事件)。这是以下能力的前提:

  1. 主备热备与故障切换:备机重放主机的输入流即可得到相同状态
  2. 审计与回放:监管、风控、客户纠纷时需要回放撮合过程
  3. 跨机房灾备:异地备机可以从 WAL 回放建立一致副本
  4. 压力测试与回归:可以录制生产流量,在测试环境完全重放

任何违反确定性的代码(如 time.Now()rand.Intn()、goroutine 调度、HashMap 遍历顺序)都必须被输入化——即把不确定来源变成输入事件的一部分。

4.2 时间戳的处理

撮合引擎使用的时间戳必须来自输入事件(Gateway 打上的 ingress 时间),而不是引擎内部 time.Now()。这样,备机重放时间戳来自同一条输入流。

# 错误做法
func onOrder(order *Order) {
    order.AcceptedAt = time.Now()   // 非确定性
    book.Add(order)
}

# 正确做法
type Input struct {
    SeqNo      uint64
    IngressTs  int64  // Gateway 打上,随 WAL 持久化
    Payload    Order
}
func onInput(in *Input) {
    in.Payload.AcceptedAt = in.IngressTs
    book.Add(&in.Payload)
}

4.3 序列号与输入流

所有进入撮合的请求(下单、撤单、改单、定时触发、行情快照请求……)被 Gateway 层统一编号成一个单调递增的输入序列,以该序列为 WAL 写入顺序、也是撮合处理顺序。输出事件(Trade、Ack、Reject、BookUpdate)同样带连续 seq,供下游消费与回放对齐。

Input WAL:
  seq=1001 ts=...  NEW_ORDER  order_id=A qty=100 px=10.03
  seq=1002 ts=...  NEW_ORDER  order_id=B qty=50  px=10.03
  seq=1003 ts=...  CANCEL     order_id=A
  seq=1004 ts=...  NEW_ORDER  order_id=C qty=200 px=10.04  ← 触发撮合

Output WAL:
  seq=2001 ref=1004 TRADE     taker=C maker=B qty=50 px=10.03
  seq=2002 ref=1004 ACK       order_id=C remaining=150
  seq=2003 ref=1004 BOOK      level=10.04 +150

4.4 随机数与并发

如果业务确实需要随机(如 Pro-Rata 中有零头时的分配),从输入事件里带入种子,或用 hash(seq_no) 作为确定性随机源。严禁使用 /dev/urandom 或系统 RNG。

撮合引擎核心一律单线程(见第六章),这是解决并发不确定性最简单粗暴的办法。


五、内存结构 + WAL + 快照 + 主备

5.1 状态机模型

撮合引擎本质上是一个纯函数:

next_state, output_events = match(prev_state, input_event)

State = 订单簿 + 待触发单 + 账户持仓(部分引擎把持仓剥离出去),Input 来自上游 Gateway 的有序流,Output 是成交 + 行情事件 + ACK。这是典型的状态机复制(State Machine Replication)模型——与 Raft、Multi-Paxos 管理的状态机完全同构。

5.2 WAL(预写日志)

每条输入在进入撮合前先落盘 WAL。WAL 的几个关键设计:

5.3 快照

只有 WAL 无法应对长时间运行(重放整天 WAL 太慢)。引擎定期把整个订单簿序列化成快照(Snapshot),落盘时记录”对应到哪个 WAL seq”。恢复时:加载最近快照 + 重放该 seq 之后的 WAL。

快照的关键工程点:

5.4 主备同步

主备架构有两种主流模式:

Active-Passive with Log Shipping

主机把 WAL 条目流式发送给备机,备机应用相同事件序列,得到相同状态。切换时备机接管 Gateway 流量。这是 Nasdaq、Deutsche Börse、CME 等传统交易所使用的模式。

flowchart LR
    G[Gateway] -->|seq序列化| P[Primary ME]
    P -->|WAL| D1[(Local Disk)]
    P -->|Replicate| B[Backup ME]
    B -->|Apply same seq| D2[(Local Disk)]
    P --> M[Market Data]
    B -.standby.-> M

Raft / Paxos 复制

写入 WAL 本身就走共识协议,至少 2/3 副本落盘才返回。一致性更强(无需额外切换协议),但延迟高一档(多一次 RTT)。dYdX v4 链上 CLOB 撮合走 Cosmos SDK + CometBFT(Tendermint),走的就是这条路:撮合逻辑本身跑在多个全节点上,通过 BFT 共识达成一致。

5.5 切换与一致性

主备切换场景下要避免的最大灾难是脑裂下的双撮合。常见手段:


六、性能工程:单线程、无锁、缓存友好

6.1 为什么是单线程

撮合引擎的计算非常轻——大部分订单的处理只是几次指针操作、一次树查找、一次链表操作——CPU 几十到几百纳秒就能完成。真正的瓶颈不是 CPU,而是:

所以业界共识是:单 symbol 的撮合必须单线程。多 symbol 通过分片(Sharding)并行:不同 symbol 分到不同撮合线程 / 实例上。BTC/USDT、ETH/USDT 各自跑一个 worker,没有任何跨 symbol 状态。

6.2 LMAX Disruptor 模型

LMAX(伦敦一家多资产交易所)2010 年开源了 Disruptor 模式,把并发队列换成了环形缓冲区 + CAS 序号,在一台机器上做到每秒 600 万 TPS,单次订单处理延迟稳定在微秒级。核心思想:

Disruptor 的流水线版本最终形态:

flowchart LR
    GW[Gateway<br/>多生产者] --> RB[Ring Buffer<br/>单一事件序列]
    RB --> J[Journal 线程<br/>WAL]
    RB --> R[Replication 线程<br/>发备机]
    RB --> ME[Matching 线程<br/>核心撮合]
    J -.fence.-> ME
    R -.fence.-> ME
    ME --> OUT[Output 线程<br/>广播]

撮合线程等待 Journal、Replication 都越过了自己要处理的 seq 才处理(通过 fence / barrier 等待),确保”撮合输出的都是已持久化、已复制的订单”。

6.3 缓存友好布局

6.4 分片策略

symbol(合约) 分片是唯一广泛采用的策略。优点是跨分片零通信(订单、撤单、成交都在单一 symbol 内)。缺点是热门 symbol(如 BTC/USDT)单分片压力大,这时要靠垂直扩展(更快的 CPU、RDMA、内核旁路网络)。

按用户分片 不可行,因为一笔订单涉及两个用户(taker/maker),会触发跨分片事务,撮合立即退化成分布式事务。

按价格区间分片 在学术上被讨论过,但工程上没有严肃使用:价格会漂移,区间划分难以稳定。

6.5 低延迟网络栈

撮合引擎之外的延迟优化(与本文主题相关但不展开):

Nasdaq INET、Deutsche Börse T7、CME Globex 的”机柜到撮合”往返延迟在个位数微秒


七、代码示例:Rust 简化撮合引擎

下面给出一个可运行的 Rust 简化撮合引擎:价格时间优先、支持 Limit/Market/Cancel、单线程。真实引擎要再加 WAL、快照、STP、订单类型、统计、行情分发,但核心循环就是这些。

7.1 数据结构

use std::collections::{BTreeMap, HashMap, VecDeque};

pub type OrderId = u64;
pub type Price = i64;  // 定点价格, 乘以 tick=1e-4 得到实际价格
pub type Qty = u64;
pub type Ts = i64;

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Side { Buy, Sell }

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum OrdType { Limit, Market }

#[derive(Clone, Debug)]
pub struct Order {
    pub id: OrderId,
    pub side: Side,
    pub ord_type: OrdType,
    pub price: Price,   // Market 单忽略
    pub qty: Qty,
    pub account: u64,
    pub ts: Ts,
}

#[derive(Clone, Debug)]
pub struct Trade {
    pub taker: OrderId,
    pub maker: OrderId,
    pub price: Price,
    pub qty: Qty,
    pub ts: Ts,
}

7.2 订单簿

pub struct Book {
    // Buy 用 Reverse(Price) 让最大价在前; 这里用 BTreeMap 正序存, 取 iter().next_back() 得最大
    bids: BTreeMap<Price, VecDeque<OrderId>>,
    asks: BTreeMap<Price, VecDeque<OrderId>>,
    orders: HashMap<OrderId, Order>,  // 全量索引, 便于撤单
}

impl Book {
    pub fn new() -> Self {
        Self { bids: BTreeMap::new(), asks: BTreeMap::new(), orders: HashMap::new() }
    }

    pub fn best_bid(&self) -> Option<Price> { self.bids.keys().next_back().copied() }
    pub fn best_ask(&self) -> Option<Price> { self.asks.keys().next().copied() }

    fn insert(&mut self, o: Order) {
        let side_map = match o.side { Side::Buy => &mut self.bids, Side::Sell => &mut self.asks };
        side_map.entry(o.price).or_default().push_back(o.id);
        self.orders.insert(o.id, o);
    }

    pub fn cancel(&mut self, id: OrderId) -> Option<Order> {
        let o = self.orders.remove(&id)?;
        let side_map = match o.side { Side::Buy => &mut self.bids, Side::Sell => &mut self.asks };
        if let Some(q) = side_map.get_mut(&o.price) {
            if let Some(pos) = q.iter().position(|&x| x == id) {
                q.remove(pos);
            }
            if q.is_empty() { side_map.remove(&o.price); }
        }
        Some(o)
    }
}

生产级实现中,价格层应用双向链表而不是 VecDeque,以支持 O(1) 中间删除;VecDeque remove 是 O(N)。这里为了代码简短用 VecDeque。

7.3 匹配循环

impl Book {
    pub fn place(&mut self, mut order: Order) -> Vec<Trade> {
        let mut trades = Vec::new();
        loop {
            if order.qty == 0 { break; }

            // 取对手方最优价
            let (opp_px, opp_q_front_id) = match order.side {
                Side::Buy => {
                    let Some((&px, q)) = self.asks.iter_mut().next() else { break; };
                    (px, *q.front().unwrap())
                }
                Side::Sell => {
                    let Some((&px, q)) = self.bids.iter_mut().next_back() else { break; };
                    (px, *q.front().unwrap())
                }
            };

            // 是否可成交
            let crosses = match (order.ord_type, order.side) {
                (OrdType::Market, _) => true,
                (OrdType::Limit, Side::Buy)  => order.price >= opp_px,
                (OrdType::Limit, Side::Sell) => order.price <= opp_px,
            };
            if !crosses { break; }

            // 自成交预防 (简化: Cancel Taker)
            let maker = self.orders.get(&opp_q_front_id).unwrap().clone();
            if maker.account == order.account {
                order.qty = 0;  // taker 全撤
                break;
            }

            let fill = order.qty.min(maker.qty);
            trades.push(Trade {
                taker: order.id,
                maker: maker.id,
                price: opp_px,
                qty: fill,
                ts: order.ts,
            });

            order.qty -= fill;

            // 更新 maker
            let maker_remaining = maker.qty - fill;
            if maker_remaining == 0 {
                self.cancel(maker.id);
            } else {
                self.orders.get_mut(&maker.id).unwrap().qty = maker_remaining;
            }
        }

        // 剩余挂簿 (Limit 且有剩余)
        if order.qty > 0 && matches!(order.ord_type, OrdType::Limit) {
            self.insert(order);
        }
        trades
    }
}

7.4 单元测试

#[cfg(test)]
mod tests {
    use super::*;

    fn ord(id: u64, side: Side, t: OrdType, px: Price, qty: Qty, acc: u64) -> Order {
        Order { id, side, ord_type: t, price: px, qty, account: acc, ts: id as i64 }
    }

    #[test]
    fn empty_book_limit_rests() {
        let mut b = Book::new();
        let trades = b.place(ord(1, Side::Buy, OrdType::Limit, 10_000, 100, 1));
        assert!(trades.is_empty());
        assert_eq!(b.best_bid(), Some(10_000));
    }

    #[test]
    fn partial_fill() {
        let mut b = Book::new();
        b.place(ord(1, Side::Sell, OrdType::Limit, 10_000, 100, 1));
        let trades = b.place(ord(2, Side::Buy, OrdType::Limit, 10_000, 60, 2));
        assert_eq!(trades.len(), 1);
        assert_eq!(trades[0].qty, 60);
        assert_eq!(b.best_ask(), Some(10_000));  // maker 剩 40 仍挂着
    }

    #[test]
    fn full_fill() {
        let mut b = Book::new();
        b.place(ord(1, Side::Sell, OrdType::Limit, 10_000, 100, 1));
        let trades = b.place(ord(2, Side::Buy, OrdType::Limit, 10_000, 100, 2));
        assert_eq!(trades.len(), 1);
        assert_eq!(trades[0].qty, 100);
        assert_eq!(b.best_ask(), None);
    }

    #[test]
    fn sweep_multiple_levels() {
        let mut b = Book::new();
        b.place(ord(1, Side::Sell, OrdType::Limit, 10_000, 30, 1));
        b.place(ord(2, Side::Sell, OrdType::Limit, 10_001, 40, 1));
        b.place(ord(3, Side::Sell, OrdType::Limit, 10_002, 50, 1));
        let trades = b.place(ord(4, Side::Buy, OrdType::Limit, 10_002, 100, 2));
        assert_eq!(trades.len(), 3);
        assert_eq!(trades.iter().map(|t| t.qty).sum::<u64>(), 100);  // 30 + 40 + 30
        assert_eq!(b.best_ask(), Some(10_002));  // O3 剩 20 仍挂
    }

    #[test]
    fn self_trade_prevents() {
        let mut b = Book::new();
        b.place(ord(1, Side::Sell, OrdType::Limit, 10_000, 100, 7));
        let trades = b.place(ord(2, Side::Buy, OrdType::Limit, 10_000, 50, 7));
        assert!(trades.is_empty());  // 同账户, taker 被撤
        assert_eq!(b.best_ask(), Some(10_000));  // maker 保留
    }

    #[test]
    fn market_no_counterparty() {
        let mut b = Book::new();
        let trades = b.place(ord(1, Side::Buy, OrdType::Market, 0, 100, 1));
        assert!(trades.is_empty());
        // 市价单不入簿
        assert_eq!(b.best_bid(), None);
    }

    #[test]
    fn cancel() {
        let mut b = Book::new();
        b.place(ord(1, Side::Buy, OrdType::Limit, 10_000, 100, 1));
        let canceled = b.cancel(1);
        assert!(canceled.is_some());
        assert_eq!(b.best_bid(), None);
    }
}

7.5 Go 版骨架

Go 的 BTree 需要引入第三方库(如 google/btree),示例骨架:

type Book struct {
    bids   *btree.BTreeG[*Level]  // 按 -price 排序
    asks   *btree.BTreeG[*Level]  // 按 price 排序
    orders map[uint64]*Order
}

type Level struct {
    price int64
    queue *list.List   // container/list 双向链表, 每个元素是 *Order
    total uint64
}

func (b *Book) Place(o *Order) []Trade {
    var trades []Trade
    for o.Qty > 0 {
        best := b.bestOpposite(o.Side)
        if best == nil || !crosses(o, best) { break }
        e := best.queue.Front()
        m := e.Value.(*Order)
        if m.Account == o.Account {
            o.Qty = 0  // 简化的 STP
            break
        }
        fill := min(o.Qty, m.Qty)
        trades = append(trades, Trade{Taker: o.ID, Maker: m.ID, Price: best.price, Qty: fill})
        o.Qty -= fill
        m.Qty -= fill
        if m.Qty == 0 {
            best.queue.Remove(e)
            delete(b.orders, m.ID)
            if best.queue.Len() == 0 { b.removeLevel(best) }
        }
    }
    if o.Qty > 0 && o.Type == Limit { b.insert(o) }
    return trades
}

八、订单状态机与撮合流程

8.1 订单状态机

stateDiagram-v2
    [*] --> Received: Gateway 接收
    Received --> Validated: 风控 / 资金冻结通过
    Received --> Rejected: 校验 / 资金不足
    Validated --> Accepted: 进入撮合队列
    Accepted --> PartiallyFilled: 部分成交
    Accepted --> Filled: 全部成交
    Accepted --> Canceled: 用户撤单
    Accepted --> Expired: GTD 到期
    PartiallyFilled --> Filled: 继续成交填满
    PartiallyFilled --> Canceled: 用户撤单剩余
    PartiallyFilled --> Expired: GTD 到期
    Filled --> [*]
    Canceled --> [*]
    Rejected --> [*]
    Expired --> [*]

8.2 撮合事件流

sequenceDiagram
    participant C as Client
    participant G as Gateway
    participant W as WAL
    participant ME as Matching
    participant MD as Market Data
    participant R as Risk/Clearing

    C->>G: NewOrder(BTC/USDT, BUY, 10, 65000)
    G->>G: 签名校验 / 限流
    G->>R: 冻结 10*65000 USDT
    R-->>G: OK
    G->>W: Append seq=N (NEW_ORDER)
    W-->>G: persisted
    G->>ME: dispatch by symbol
    ME->>ME: match loop (单线程)
    ME-->>G: Ack + Trades + BookDelta
    ME->>MD: 行情事件
    ME->>R: 成交 → 扣资金 / 加持仓
    G-->>C: Exec Report (8=FIX...)
    MD-->>C: L1/L2/L3 更新

九、开源实现参考

学习撮合引擎最好的方式是读开源代码:


十、工程坑点与选型建议

10.1 真实事件里的撮合教训

10.2 坑点清单

  1. 浮点价格是大坑:永远用定点整数(price_int = round(price * 10^decimals))。用 double 的系统都会在某一天遇到 0.1 + 0.2 != 0.3 的成交价问题。
  2. 时间戳单位不统一:ns、µs、ms 混用是常见事故源。全链路规定 ns / int64 最省心。
  3. 撤单和成交的竞态:用户发起撤单、恰好同一事件循环中该单被吃掉——必须按输入顺序处理:如果撤单 seq 在成交输入 seq 之前,撤单成功;否则撤单返回 TOO_LATE。
  4. 市价单深度击穿:无上限的 Market 单在薄簿下会扫光对手方,造成价格跳跃。必须加保护(NBBO、价格偏离阈值、熔断)。
  5. Iceberg 刷新与时间优先冲突:冰山补货必须排到队尾,否则演变成”无限优先级”的作弊手段。
  6. Stop 单风暴:价格触发一个 Stop 转成 Market,Market 又把价格打低,再触发更多 Stop——典型的 cascade。Nasdaq / CME 的”Stop Limit with Protection”、熔断机制就是针对这种情况。
  7. 确定性被打破的隐蔽来源:HashMap 迭代顺序、Go map range、rand、多线程日志、异步 GC——逐一审计。
  8. WAL 文件损坏:断电、文件系统问题、位翻转。每条记录加 CRC32,启动时校验;引入doublewritechecksum per segment
  9. 性能退化:某些价格层变空但结构体未清理、冰山订单堆积、过期 GTD 未清——定期”GC”或惰性清理。
  10. 监控盲区:撮合延迟的尾延迟(P99.9、P99.99)比均值重要得多。交易所 SLA 通常是”99.999% 订单 < X µs”,用直方图(HDR Histogram)记录。

10.3 选型建议

场景 建议
创业加密所、日均 < 10 万单 Go / Rust 单进程 + Postgres WAL 模拟,优先把 STP、订单类型、对账做对,别过度优化
专业加密所、日均百万单 Rust / C++,自建 WAL + 快照,按 symbol 分片,Disruptor 风格线程模型,P99 < 1ms 可达
传统证券 / 期货交易所 闭源商业引擎(Nasdaq INET、Cinnober TRADExpress、FIS MillenniumIT)+ 深度定制;自研团队 50+ 人,3 年以上周期
链上 CLOB(DeFi) 直接基于 Cosmos SDK / dYdX v4 分叉,撮合通过 BFT 共识;延迟档次牺牲,换来可验证性与无 KYC
教学 / 研究 exchange-core、liquibook 源码 + 本文 Rust 示例

10.4 落地清单


十一、SVG:订单簿分层可视化

BTC/USDT 订单簿快照 (L2)

Price Bid Qty Ask Qty Depth Visualization

65,010 3.50

65,008 2.10

65,006 1.80

65,004 0.90

65,002 0.40 Best Ask

Spread = 2 USDT

65,000 0.60 Best Bid

64,998 1.50

64,996 2.80

64,994 3.20

64,992 4.10

每档内部 = 多个订单 FIFO 队列 (L3 中单独展开为独立事件)

十二、若干进阶话题

12.1 基准测试的正确姿势

评估撮合引擎性能时,常见的错误做法:

一个较严肃的测法:

cpupower frequency-set -g performance
taskset -c 3 ./me_bench  # pin 到隔离核
# isolcpus=3,5,7 在内核启动参数里
# irqbalance 停掉, 把所有 IRQ 绑到非撮合核

12.2 撮合引擎与风控的协同

撮合侧不应该做余额 / 仓位校验——那是风控(Pre-Trade Risk Check)的职责。但撮合侧必须做的检查:

12.3 撮合引擎与配对撤单(Mass Cancel)

一个账户在极端行情下想”全撤”,如果撮合侧是单线程,一次 MassCancel 触发上千个 CANCEL 事件会堵住主循环几毫秒。实现技巧:

12.4 Maker-Taker 费率与撮合

虽然费率计算通常在撮合之后(Clearing 侧)进行,但 maker/taker 标签是撮合时才能确定的:吃单方是 taker,挂单方是 maker。这个标签必须在 Trade 事件里携带,供清算侧计算不同费率。

某些交易所(如 Binance VIP 做市商项目)还有”负费率”——做市商挂单成交收到 rebate。此时引擎需要把”做市商标识”随 Trade 事件一并带出。

12.5 限频与反熔断

做市商经常”秒内报撤几千次”,这会压垮撮合。应对:

A 股有涨跌停(±10%、ST ±5%、注册板 ±20%)+ 当日 10% 异动停牌机制;美股用 LULD(Limit Up-Limit Down);加密所用价格带 / 自动去杠杆(ADL)。

12.6 撮合引擎升级:原地升级 vs 蓝绿切换

撮合引擎线上升级是高风险操作。两种主要策略:

Hot-Standby 升级: 1. 备机升级到新版本 2. 用主机 WAL 回放建立状态 3. 业务低峰窗口做主备切换 4. 观察新主机 30 分钟后升级旧主机为新备机

交易时段外停机升级:A 股等有明确盘后窗口的市场可以直接停机升级。

灰度 by symbol:先把小众 symbol 分片切到新版本,观察稳定后再切热门 symbol。但跨版本的行情格式兼容必须提前处理。

12.7 可观测性

撮合引擎的核心指标:

日志必须结构化(JSON / Protobuf),带 trace_id(客户端请求贯穿)、seq(撮合内部排序)。延迟数据走 HDR Histogram 周期性采样导出到 Prometheus / VictoriaMetrics。


十三、交叉引用与小结

撮合引擎只解决一件事:在给定规则下,从输入订单序列产生确定的成交序列。它小而深:数据结构不超过三百行,但把 WAL、快照、主备、确定性、订单类型、集合竞价、STP 全部加齐后是一个三万行起步的系统。


参考资料

  1. LMAX Disruptor, “Disruptor: High Performance Alternative to Bounded Queues for Exchanging Data Between Concurrent Threads”, https://lmax-exchange.github.io/disruptor/disruptor.html
  2. CME Group, “Matching Algorithms”, https://www.cmegroup.com/confluence/display/EPICSANDBOX/Matching+Algorithms
  3. Nasdaq, “ITCH 5.0 Specification”, https://www.nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/NQTVITCHSpecification.pdf
  4. SEC / CFTC, “Findings Regarding the Market Events of May 6, 2010” (Flash Crash Report), 2010
  5. 上海证券交易所,“交易规则”,http://www.sse.com.cn/
  6. dYdX, “v4 Chain Technical Architecture”, https://docs.dydx.exchange/
  7. Peter Lawrey, “Low Latency in Java”, blog series, https://vanilla-java.github.io/
  8. Martin Thompson, “Mechanical Sympathy”, blog, https://mechanical-sympathy.blogspot.com/
  9. Maksim Zheravin, “exchange-core” source & design notes, https://github.com/exchange-core/exchange-core
  10. OCI, “liquibook”, https://github.com/enmotech/liquibook

上一篇《交易所核心系统架构:撮合、行情、做市、风控、清算》

下一篇《行情分发:MBP/MBO、快照+增量、组播/TCP、FIX/ITCH》

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / fintech

【金融科技工程】交易所核心系统架构:撮合、行情、做市、风控、清算

从订单网关到撮合引擎、从 L1/L2/L3 行情到清算与结算,系统梳理证券、期货、加密货币交易所的五大核心子系统;给出低延迟工程技术栈(Disruptor、Kernel Bypass、FPGA)、订单生命周期状态机、主流交易所(NYSE Pillar、Nasdaq INET、上交所新一代、CME Globex、Binance、dYdX v4)对比、以及 Flash Crash 与 Knight Capital 的工程教训。

2026-04-22 · architecture / fintech

【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图

金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。

2026-04-22 · architecture / fintech

【金融科技工程】复式记账工程化:科目、分录、余额、对账

把 500 年历史的复式记账翻译成工程师可以落地的数据模型、SQL 表结构与余额计算策略,覆盖充值、下单、退款、分润、红包、多币种与冲销的真实场景,并对比 TigerBeetle、beancount、Ledger CLI、Square LedgerDB、Stripe Ledger 等开源与工业实现。


By .