从集合竞价到最大成交量:深入剖析多对多批量撮合算法

本文面向具备一定分布式系统和算法基础的中高级工程师,旨在彻底剖析金融交易系统中“多对多”批量撮合(常被称为集合竞价)的核心问题。我们将从第一性原理出发,探讨其背后的经济学模型与计算机科学实现,穿越理论、算法、代码与架构的重重迷雾,最终厘清一个看似简单却充满工程挑战的核心命题:如何在瞬时海量订单中,寻找一个能让成交量最大的“黄金价格点”。你将看到的不是概念的堆砌,而是源于真实交易系统设计中的深度思考与权衡。

现象与问题背景

在连续撮合(Continuous Matching)大行其道的今天,为何还需要批量撮合(Batch Matching)?想象一个股票交易所的开盘瞬间。在正式开盘前(例如 9:15 到 9:25),全球的投资者提交了成千上万笔买卖订单。这些订单的特点是“只看价格,不看时间先后”。如果采用连续撮合的先到先得(FCFS)原则,那么 9:15:00.001 提交的订单将比 9:15:00.002 的订单有巨大优势,这会引发无意义的军备竞赛(Race to the Bottom),并可能因为开盘瞬间的某笔小单而导致价格剧烈波动。这显然是不公平且不稳定的。

批量撮合,或称集合竞价(Call Auction),正是为解决此类问题而生。它的核心目标是在一个指定时间窗口内(例如开盘前、收盘后、或盘中临时暂停后),将所有买卖订单汇集起来,计算出一个唯一的“成交价”(Equilibrium Price),并以此价格完成尽可能多的交易。所有符合条件的订单(买单价高于等于成交价,卖单价低于等于成交价)都以这个相同的价格成交,时间优先原则在此失效,仅保留价格优先。这种机制最大限度地促进了流动性,并以一种更公平、更稳健的方式为市场“定价”。

由此,我们面临的核心技术问题可以被精确地描述为:给定一个时间窗口内积累的所有买方订单集合 B 和卖方订单集合 S,如何设计一个高效且确定性的算法,来寻找一个成交价格 P,使得在该价格 P 下,买方愿意出价 ≥ P 的总数量,与卖方愿意出价 ≤ P 的总数量之间的撮合量达到最大? 这就是“最大成交量”原则(Principle of Maximum Trading Volume),它是集合竞价算法的灵魂。

关键原理拆解:从经济学到计算机科学

要真正理解这个算法,我们必须回归本源。这不仅仅是一个编码问题,其根基深植于微观经济学,并通过计算机科学的抽象和算法得以工程化实现。

(教授声音)

从经济学角度看,集合竞价是寻找市场出清价格(Market-clearing Price)的经典过程。我们可以将所有买单看作市场的“需求曲线”,将所有卖单看作“供给曲线”。

  • 需求曲线 (Demand Curve): 我们可以构建一个函数 CumulativeBuy(P),表示在价格 P 或更高价位上,市场愿意购买的总数量。这是一个阶梯状的、非递增的函数。价格越高,愿意买入的人越少。
  • 供给曲线 (Supply Curve): 同样,我们可以构建函数 CumulativeSell(P),表示在价格 P 或更低价位上,市场愿意出售的总数量。这是一个阶梯状的、非递减的函数。价格越高,愿意卖出的人越多。

理论上,供给曲线和需求曲线的交点就是市场的均衡点,此处的交易量最大。在离散的、基于订单簿的真实世界中,这个交点可能不存在于一个精确的价格点上,或者可能存在一个价格区间。因此,我们的算法目标,就是找到那个能让 min(CumulativeBuy(P), CumulativeSell(P)) 取得最大值的价格 P。这个函数 MatchedVolume(P) = min(CumulativeBuy(P), CumulativeSell(P)) 就代表了在任意给定价格 P 下的潜在成交量。

这个过程可以被形式化为一个优化问题:

Find P* = argmax( MatchedVolume(P) ) for all P in PriceSet

其中 PriceSet 是所有订单中出现过的价格的集合。算法的本质,就是通过计算和比较在每个潜在价格点上的成交量,来找出最优的那个 P*。仅仅理解这个公式是不够的,魔鬼在于如何高效地计算这些累计值并处理边界条件。

系统架构总览

在深入代码之前,我们先鸟瞰一个支持集合竞价的交易系统架构。撮合引擎是核心,但绝非孤岛。其稳定运行依赖于周边一系列组件的协同。

一个典型的简化架构如下(以文字描述):

  • 1. 接入层 (Gateway Cluster): 负责处理来自客户端(券商、机构等)的 TCP/WebSocket 连接。它进行协议解析、用户认证、基础的订单校验(如检查价格、数量是否合法)。通过水平扩展,它可以处理海量的并发连接。
  • 2. 排序与持久化层 (Sequencer): 这是系统的咽喉。所有合法的订单请求在进入撮合引擎前,必须经过一个全局定序器。在分布式系统中,通常使用 Kafka 或类似的分布式消息队列。订单被写入一个特定交易对(如 BTC/USDT)的 Topic 的单个 Partition 中。这确保了所有订单的全序关系 (Total Order),为撮合的确定性和系统崩溃后的恢复提供了基础。没有这一层,公平性和一致性无从谈起。
  • 3. 核心撮合引擎 (Matching Engine): 这是一个独立的、通常是单线程的进程。它顺序消费来自 Kafka 的订单消息。在集合竞价阶段,它只是将订单加载到内存中的订单簿数据结构里,并不立即撮合。当竞价时间窗口结束(例如,收到一个“开始撮合”的特殊指令),它会暂停接收新订单,对内存中的所有订单执行批量撮合算法,生成成交报告。

  • 4. 数据广播层 (Market Data Publisher): 撮合引擎产生的结果,包括最终成交价、成交量、以及逐笔成交记录,都会被发布到另一个消息队列(如 Kafka 或专门的低延迟消息中间件)。下游的行情系统、风控系统、监控系统等会订阅这些数据。
  • 5. 清结算与持久化层 (Clearing & Persistence): 成交记录不仅需要广播,还必须可靠地存入数据库(如 MySQL、PostgreSQL)用于后续的清算、结算和审计。这一步通常是异步执行的,以避免拖慢撮合主流程。

这个架构的关键设计决策是将撮合引擎本身保持单线程(针对单个交易对)。这极大地简化了并发控制,避免了复杂的锁机制,从而保证了撮合逻辑的确定性(相同的输入序列永远产生相同的结果),这在金融场景中是至关重要的。系统的吞吐能力通过接入层和排序层的水平扩展来保障,而撮合引擎的性能则依赖于高效的算法和内存操作。

核心模块设计与实现:最大成交量算法

(极客声音)

好了,理论讲完了,我们来点硬核的。怎么用代码把这个最大成交量算法搞定?别想着什么花里胡哨的数据结构,比如用两个堆(一个最大堆买,一个最小堆卖)来搞,那是连续撮合的玩法。对于批量撮合,更简单、更粗暴、更高效的方法是直接操作聚合后的价格档位。

首先,价格别用 float 或 double!这是血的教训。浮点数精度问题在金融计算里是灾难。所有价格都应该转换为定点整数处理。比如,如果价格精度是小数点后 4 位,那就把所有价格乘以 10000 转换成 `int64` 或 `long` 来操作。

我们的核心数据结构非常朴素:

  • `buyLevels`: 一个 `map[int64]int64`,Key 是价格,Value 是该价格上所有买单的数量总和。
  • `sellLevels`: 同上,用于卖单。
  • `priceSet`: 一个 `map[int64]struct{}`,用来收集所有出现过的唯一价格,方便后续排序。

算法执行分为清晰的几个步骤:

第一步:数据聚合 (Aggregation)

遍历集合竞价窗口内的所有订单,填充上面说的三个数据结构。这个过程是 O(N),N 是订单数量。非常直接。


// 假设 Order 结构体定义如下
type Order struct {
    Price    int64 // 定点整数价格
    Quantity int64
    Side     Side // BUY or SELL
}

// 聚合订单到价格档位
func aggregateOrders(orders []Order) (
    buyLevels, sellLevels map[int64]int64,
    prices []int64,
) {
    buyLevels = make(map[int64]int64)
    sellLevels = make(map[int64]int64)
    priceSet := make(map[int64]struct{})

    for _, o := range orders {
        priceSet[o.Price] = struct{}{}
        if o.Side == BUY {
            buyLevels[o.Price] += o.Quantity
        } else {
            sellLevels[o.Price] += o.Quantity
        }
    }

    // 将唯一的价位放入切片并排序
    prices = make([]int64, 0, len(priceSet))
    for p := range priceSet {
        prices = append(prices, p)
    }
    // 价格从低到高排序
    sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] })
    
    return buyLevels, sellLevels, prices
}

第二步:构建累计曲线 (Cumulative Curves)

这是算法的核心。我们需要计算出在每个价位上的累计买量和累计卖量。正确高效地做这件事是关键。

  • 累计买量:价格从高到低遍历。在 P 价位上的累计买量 = P 价位本身的买量 + 所有高于 P 的价位的累计买量。
  • 累计卖量:价格从低到高遍历。在 P 价位上的累计卖量 = P 价位本身的卖量 + 所有低于 P 的价位的累计卖量。

这个过程的时间复杂度是 O(P),P 是唯一价格档位的数量。由于 P 通常远小于 N,所以效率很高。


// ... 接上文 ...
// prices 已经是排序好的从低到高的价格列表
func buildCumulativeCurves(prices []int64, buyLevels, sellLevels map[int64]int64) (
    cumulativeBuy, cumulativeSell map[int64]int64,
) {
    cumulativeBuy = make(map[int64]int64)
    cumulativeSell = make(map[int64]int64)
    
    // 计算累计卖量(从低价到高价)
    var currentSellQty int64
    for _, p := range prices {
        currentSellQty += sellLevels[p] // 加上当前价位的卖量
        cumulativeSell[p] = currentSellQty
    }
    
    // 计算累计买量(从高价到低价)
    var currentBuyQty int64
    for i := len(prices) - 1; i >= 0; i-- {
        p := prices[i]
        currentBuyQty += buyLevels[p] // 加上当前价位的买量
        cumulativeBuy[p] = currentBuyQty
    }
    
    return cumulativeBuy, cumulativeSell
}

第三步:寻找最大成交量及价格 (Find Equilibrium)

现在我们有了每个价位上的“供给”和“需求”,可以开始寻找那个黄金价格点了。遍历所有唯一的价格点,计算每个点的潜在成交量 `min(cumulativeBuy[p], cumulativeSell[p])`,并记录下产生最大成交量的那个价格。


// ... 接上文 ...
func findEquilibriumPrice(prices []int64, cumulativeBuy, cumulativeSell map[int64]int64) (
    equilibriumPrice, maxVolume int64,
) {
    maxVolume = 0
    equilibriumPrice = 0 // 或者某个默认值

    for _, p := range prices {
        // 在p价位,可成交的买单是所有出价>=p的,即 cumulativeBuy[p]
        // 在p价位,可成交的卖单是所有出价<=p的,即 cumulativeSell[p]
        buyQty := cumulativeBuy[p]
        sellQty := cumulativeSell[p]
        
        volume := min(buyQty, sellQty)
        
        if volume > maxVolume {
            maxVolume = volume
            equilibriumPrice = p
        } else if volume == maxVolume {
            // 这里进入 tie-breaking 规则
        }
    }
    return equilibriumPrice, maxVolume
}

func min(a, b int64) int64 {
    if a < b {
        return a
    }
    return b
}

第四步:平局处理 (Tie-Breaking)

这是最容易被忽略但又极端重要的工程细节。如果多个价格都能产生同样的最大成交量,选哪个?这没有标准答案,完全取决于交易所的业务规则。常见的规则有:

  • 规则一:最小剩余量原则。选择那个使得未成交量 `abs(cumulativeBuy[p] - cumulativeSell[p])` 最小的价格。这个规则旨在让市场更平衡。
  • 规则二:参考价优先原则。选择最接近上一个交易周期收盘价(或其他参考价)的价格。这有助于价格的连续性。
  • 规则三:高价(或低价)优先原则。在A股市场,如果多个价格满足前述条件,上海交易所取使得剩余量最小的价格,深圳交易所则取最接近前收盘价的价格。如果还有平局,则会选择中间价。规则非常细致。

在代码实现中,当 `volume == maxVolume` 时,就需要嵌入这些复杂的业务判断逻辑。健壮的系统必须清晰地定义并实现这些规则,否则撮合结果将是不确定的。

性能优化与高可用设计

对于一个追求极致性能的交易系统,上述 O(N + P log P) 的算法(排序占大头)在大多数情况下已经足够好。但我们总能做得更好。

算法与内存优化:

  • 用数组代替 Map: 如果价格范围是可预期的、且不太离散(例如,某股票价格在 $10.00 到 $20.00 之间,精度 0.01),我们可以创建一个大小为 (2000-1000)/1 = 1000 的数组。将价格 `(price*100 - 1000)` 作为数组下标。这样,聚合和累计曲线计算的步骤可以从哈希表的 O(P) 变成数组遍历的 O(PriceRange),避免了哈希冲突和指针跳转,对 CPU Cache 极为友好。这是典型的以空间换时间。
  • 无 GC 优化: 在 C++ 或 Rust 这类语言中,可以手动管理内存。在 Go 或 Java 中,要警惕 GC 对撮合过程的干扰。关键数据结构(如订单对象)可以使用对象池(`sync.Pool`)来复用内存,减少 GC 压力。撮合期间尽量避免任何可能导致内存分配的操作。

高可用设计:

撮合引擎作为单点,其高可用性是系统的生命线。业界标准的做法是 Active-Passive 主备模式

  • 状态复制:主引擎(Active)顺序消费 Kafka 的订单,执行撮合。备引擎(Passive)以相同的 Group ID(或从相同的 offset 开始)消费同一个 Kafka Topic。它在内存中执行与主引擎完全相同的操作(加载订单、构建订单簿),但不产生任何外部输出(不发送成交回报)。
  • 心跳与脑裂:主备之间通过 ZooKeeper 或 etcd 维持心跳和租约(Lease)。主节点定期续租,如果主节点宕机,租约会过期,备节点会检测到并尝试获取租约,升级为新的主节点。这个机制必须能防止“脑裂”(两个节点都以为自己是主节点)。
  • 故障恢复:由于所有状态(订单簿)都可以从上游的 Kafka topic 中重建,所以恢复过程非常清晰。新的主节点只需从上次处理的 Kafka offset 开始消费,就能在内存中重建出与宕机前完全一致的状态,然后继续处理新的订单。这种基于事件溯源(Event Sourcing)的模式,使得撮合引擎本身可以做到“无状态”,极大地增强了系统的健壮性。

架构演进与落地路径

一个复杂的系统不是一蹴而就的。根据业务发展阶段,我们可以规划出一条清晰的演进路径。

第一阶段:单体批处理 (Monolith Batch Job)

适用于业务初期,或非核心的、低频次的竞价场景。可以是一个定时任务(Cron Job),在指定时间从数据库(如 MySQL)中捞取一个时间段内的所有订单,在内存中完成计算,然后将成交结果写回数据库。优点是实现简单、快速,缺点是延迟高、吞吐量有限、与实时系统耦合紧密。

第二阶段:流式接入与内存撮合 (Streaming Ingestion)

引入 Kafka 作为订单的缓冲和定序器。撮合引擎作为一个独立的服务运行,实时消费订单并构建内存订单簿。这实现了接入层和撮合逻辑的解耦,显著提高了订单处理的吞吐能力和系统的弹性。撮合操作由定时器或外部信号触发。这个架构已经能满足绝大多数中等规模交易系统的需求。

第三阶段:主备高可用 (High Availability)

在第二阶段的基础上,实现上文提到的 Active-Passive 撮合引擎集群。引入 ZooKeeper/etcd 进行服务发现和领导者选举,实现秒级的故障自动切换。这是所有严肃金融系统上线的最低标准。系统的可用性从依赖单机可靠性,转变为依赖整个分布式系统的容错能力。

第四阶段:多交易对/分片架构 (Sharded Architecture)

当系统需要支持成千上万个交易对,且总订单量巨大时,单个撮合引擎实例(即使是主备)会成为瓶颈。此时需要引入分片(Sharding)。可以按交易对(Symbol)的哈希值将不同的交易对路由到不同的 Kafka Topic,每个 Topic 对应一组独立的撮合引擎集群。这样,整个撮合层就可以水平扩展。挑战在于接入网关的路由逻辑和运维管理的复杂性会相应增加。

通过这样的演进,系统可以平滑地从一个简单的脚本,成长为一个能够支撑海量交易、具备电信级可用性的工业级撮合平台。每一步演进都是对业务规模、成本和技术复杂度的深思熟虑的权衡。

延伸阅读与相关资源

  • 想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
    交易系统整体解决方案
  • 如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
    产品与服务
    中关于交易系统搭建与定制开发的介绍。
  • 需要针对现有架构做评估、重构或从零规划,可以通过
    联系我们
    和架构顾问沟通细节,获取定制化的技术方案建议。
滚动至顶部