上一篇把订单簿的数据结构搭好了,这篇让它转起来。连续竞价的撮合规则用自然语言只要两句:价格高的买单优先;同价位先来的先成交。但"规则"到"引擎"之间隔着一堆规则没说的细节:成交价到底算谁的?部分成交怎么记簿?买卖同价不同量的档位怎么互相吃?这篇给出一个可以直接编译跑的最小撮合引擎,然后逐段解释。
成交价规则:先入者定价
撮合成交的价格由先进入订单簿的一方(被动方/passive)决定,主动进来吃单的一方(aggressive)接受这个价格:
- 主动买单吃卖一 → 按卖一价成交;
- 主动卖单吃买一 → 按买一价成交。
为什么这样定?因为被动方的价格是公开承诺:它挂在簿上,是所有市场参与者都能看到并据此决策的报价。主动方选择"吃"这个报价,等于接受要约。反过来如果按主动方价格成交,挂单价 10.00 的卖单可能被迫按 10.05 成交——挂单者的报价承诺就失效了,没人敢挂单,流动性直接消失。集合竞价的定价规则不同(下一篇),但"保护被动方"这个原则贯穿始终。
这个规则在数学上很简单:若主动单价格为 $p_a$、对手方最优档价为 $p_b$,则成交条件与成交价分别是
$$\text{可成交} \iff (p_a \geq p_b \ \text{买主动}) \ \lor\ (p_a \leq p_b \ \text{卖主动}), \qquad p_{\text{trade}} = p_b$$
撮合主循环
有了订单簿(bids/asks 两棵价格树,档内 FIFO 队列,order_index 支持 O(1) 撤单),撮合新委托的完整逻辑:
class MatchingEngine:
def on_new_order(self, order):
# 1. 对手方是谁?
if order.side == BUY:
book, opp_best = self.asks, self.asks.best_price() # 卖一
can_match = lambda p: p is not None and order.price >= p
else:
book, opp_best = self.bids, self.bids.best_price() # 买一
can_match = lambda p: p is not None and order.price <= p
# 2. 逐档吃单:价格优先已隐含在"从最优档开始"
while order.qty > 0 and can_match(opp_best):
level = book.best_level() # O(1):最优档
while order.qty > 0 and level.queue:
maker = level.queue.head # 时间优先:队头先成交
trade_qty = min(order.qty, maker.qty)
self.emit_trade(
price=level.price, # 被动方定价
qty=trade_qty,
taker=order, maker=maker,
)
order.qty -= trade_qty
maker.qty -= trade_qty
level.total_qty -= trade_qty
if maker.qty == 0:
book.pop_head(level) # 全部成交,摘出队列
self.order_index.remove(maker.id)
if level.total_qty == 0:
book.remove_level(level.price) # 档位清空,从价格树摘除
opp_best = book.best_price()
# 3. 剩余数量进簿排队(限价单);市价单到这里说明流动性枯竭
if order.qty > 0:
if order.type == LIMIT:
self.insert_resting(order)
else:
self.emit_incomplete(order) # 市价单未成部分的处理
逐档吃单这个循环结构本身就是"价格优先"的机械实现:从对手方最优档开始,吃完一档才看下一档。时间优先由档内队列的 FIFO 顺序保证,不需要任何比较——结构即规则。
四个教科书不写的细节
自成交(self-match)。 同一客户(或同一实控人)的买单卖单在簿上相撞。交易所规则是拒绝后进入的一方,防止对倒做量。实现上撮合循环里加一道 if taker.owner == maker.owner: break——但要注意"break"还是"跳过该档看下档":A 股规则是拒绝本次委托的剩余部分(整单拒绝更常见,因为部分成交后的残单状态复杂)。自成交检查和风控黑名单检查一样,是撮合引擎里少数几道中资逻辑的校验。
部分成交的簿更新。 撮合循环里 maker 被部分成交后,订单簿里该委托的数量字段原地减少,委托状态变"部成",不从队列里移动——时间优先的位次是入场时确定的,成交不改变排队位置。只有数量归零才摘出。这条纪律保证了"先来先服务"在整个生命周期里成立。
零档清理。 内层循环吃完一档后,档总量归零,必须把空档从价格树里摘除。漏掉这一步的 bug 表现很邪门:最优价查询返回已清空的档,撮合以为还有流动性,实际成交不出去。防御写法是树操作全部经过簿对象的封装方法,业务代码不直接摸树。
回报的顺序。 一笔主动单吃掉三个档位,产生三笔成交,回报必须按成交发生顺序发出——下游的持仓成本、实时风控都按回报顺序累计,乱序会算出错的均价。单线程撮合天然保证这一点;一旦为了吞吐把不同证券并行到多个线程(按证券代码分片),就要注意同一证券的撮合永远落同一线程,跨证券的顺序无所谓,同证券的顺序神圣不可侵犯。
为什么是这个形状
回头看这个引擎的形状——单线程、逐档循环、被动方定价——每个特征都对应一条约束:
- 单线程:撮合循环要修改订单簿、发回报、更新档总量,三者必须原子。与其靠锁,不如靠"一个证券一个线程"把原子性交给调度器;
- 逐档而非全簿扫描:可成交的对手方只可能从最优档开始连续的一段(限价单价格范围内的档),扫到第一个不满足价格条件的档就可以停;
- 被动方定价:保护公开报价的承诺,前面已述。
市场深度的数学也值得一带:限价买 $p$ 的最大可成交量,是对手方所有卖价 $\leq p$ 的档总量之和,$Q(p) = \sum_{i:\,p_i \leq p} q_i$。这条曲线(累计对手方量随价格的关系)是流动性分析的基础,也是集合竞价最大成交量定价的原型——下一篇的主演。
最小引擎到这里就完整了:几十行代码,四个细节。真正的生产引擎在这个骨架上加的东西(熔断、市价保护、多市场、债券的一价多量等),没有一件改变这个骨架的形状——好算法的标志就是主干简单到没法再简。