技术博客

撮合算法:价格优先、时间优先——一个最小撮合引擎的完整代码

第 29 / 141 篇 · 2026-05-29 · secfin · 撮合引擎算法连续竞价

上一篇把订单簿的数据结构搭好了,这篇让它转起来。连续竞价的撮合规则用自然语言只要两句:价格高的买单优先;同价位先来的先成交。但"规则"到"引擎"之间隔着一堆规则没说的细节:成交价到底算谁的?部分成交怎么记簿?买卖同价不同量的档位怎么互相吃?这篇给出一个可以直接编译跑的最小撮合引擎,然后逐段解释。

成交价规则:先入者定价

撮合成交的价格由先进入订单簿的一方(被动方/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 顺序保证,不需要任何比较——结构即规则。

主动买单 10.06 × 900 逐档吃卖单的成交过程卖一 10.05 × 400全部成交,摘档卖二 10.06 × 700吃掉 500,剩 200卖三 10.08 × 300价格不满足,停主动买单10.06 × 900(限价)成交 1:10.05 × 400(被动方定价)成交 2:10.06 × 500主动单量耗尽,簿内剩 10.06×200成为新的卖一两笔成交价不同(10.05 与 10.06)是正常的——每档按自己的挂单价成交,主动方的 10.06 只是上限

四个教科书不写的细节

自成交(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$。这条曲线(累计对手方量随价格的关系)是流动性分析的基础,也是集合竞价最大成交量定价的原型——下一篇的主演。

最小引擎到这里就完整了:几十行代码,四个细节。真正的生产引擎在这个骨架上加的东西(熔断、市价保护、多市场、债券的一价多量等),没有一件改变这个骨架的形状——好算法的标志就是主干简单到没法再简。