从集合竞价到连续撮合:深入剖析交易所核心交易模式

本文面向有经验的工程师与架构师,旨在深入剖析现代金融交易系统中最核心的两种订单撮合模式:集合竞价(Call Auction)与连续竞价(Continuous Auction)。我们将从市场微观结构与算法原理出发,穿透到系统实现、性能权衡,最终探讨一个高性能撮合引擎的架构演进路径。这不仅仅是概念的辨析,更是对高频、低延迟、高并发系统设计哲学的一次深度审视,尤其适用于股票、期货、数字货币交易所等核心系统的设计者。

现象与问题背景

任何一个成熟的金融交易所,其交易日都不是铁板一块的“连续交易”。以典型的A股市场为例,一个交易日被精确地划分为几个阶段:

  • 9:15 – 9:25 开盘集合竞价: 在正式开盘前,有一个10分钟的窗口期。投资者可以下单和撤单,但系统并不立即撮合。所有订单汇集在一起,旨在产生一个公平的“开盘价”。
  • 9:30 – 11:30, 13:00 – 14:57 连续竞价: 这是我们最熟悉的盘中交易时段。订单遵循“价格优先、时间优先”的原则,一旦有匹配的对手方订单,立即成交。价格在毫秒间不断波动。
  • 14:57 – 15:00 收盘集合竞价: 与开盘类似,为确定一个公允的“收盘价”,避免尾盘价格被少数大单操纵。

这种制度设计并非偶然,它直接回答了两个根本性问题:

  1. 如何处理信息真空期的海量订单? 经过一夜或一个周末的休市,大量影响价格的宏观新闻、公司公告、市场情绪得以积累。若在9:30开市瞬间直接采用连续竞价,第一笔成交价极可能因偶然的订单顺序而产生剧烈、无序的跳动,这不符合市场效率。集合竞价通过汇聚一段时间内的所有意图,寻找一个能最大化成交量的“共识价格”,有效地实现了价格发现(Price Discovery)功能,平滑了开盘波动。
  2. 如何在流动性充裕时提供即时性? 在盘中交易时段,市场信息流动快,参与者众多。此时,交易者最关心的是订单能否被立即执行。连续竞价模式提供了这种即时性(Immediacy),保证了市场的流动性。任何一个新订单的到来都可能改变最优报价(Best Bid/Offer)并立即触发成交。

因此,双向拍卖(集合竞价)与连续竞价并非互相取代,而是在不同市场阶段、解决不同核心矛盾的两种关键机制。理解其底层原理与实现差异,是构建任何高性能交易系统的基石。

关键原理拆解:从市场微观结构到算法

作为一名架构师,我们必须回归计算机科学的基础原理。撮合交易的本质,是一个基于特定规则的匹配算法。这两种模式的差异,根源在于算法的目标函数和执行时机不同。

(教授声音)

首先,我们必须定义交易的核心数据结构:限价订单簿(Limit Order Book, LOB)。LOB是一个动态的、按价格排序的买卖订单队列。所有未成交的限价单(Limit Order)都栖身于此。它的状态遵循一个黄金法则:价格优先、时间优先(Price-Time Priority)

  • 价格优先: 买单(Bid)按出价从高到低排序,出价最高的排在最前面;卖单(Ask)按出价从低到高排序,出价最低的排在最前面。
  • 时间优先: 在同一价格水平上,先提交的订单排在前面。

基于这个数据结构,我们来剖析两种算法的数学本质。

集合竞价(Call Auction)算法

集合竞价的目标是在一个指定时间点,找出一个唯一的清算价格(Clearing Price),使得在该价格下,可成交的买单和卖单总量最大。这是一个典型的离散优化问题。

其算法步骤如下:

  1. 数据收集: 在指定时间窗口内(如9:15-9:25),收集所有买卖方向的限价订单,放入一个临时的订单池中,不对它们进行任何撮合。
  2. 生成供需曲线:
    • 对所有买单,按价格从高到低排序。计算每个价格水平上的累计买方需求量。即,在价格P,愿意购买的总量是所有出价≥P的买单数量之和。
    • 对所有卖单,按价格从低到高排序。计算每个价格水平上的累计卖方供给量。即,在价格P,愿意出售的总量是所有出价≤P的卖单数量之和。
  3. 寻找均衡点: 遍历所有出现过的价格点P,计算在每个P上的可匹配成交量,即 min(累计买方需求量@P, 累计卖方供给量@P)
  4. 确定清算价格: 能够使可匹配成交量达到最大的那个价格P,就是清算价格。

这里存在一个工程上的关键细节:如果多个价格点都能产生相同的最大成交量怎么办?这时就需要引入 tie-breaking 规则。不同交易所的规则略有差异,但通常遵循以下层层递进的原则:

  1. 最小未成交量原则: 选择那个使得买卖双方未成交总量最小的价格。
  2. 参考价接近原则: 如果最小未成交量也相同,则选择最接近上一个交易日收盘价(或其他参考价)的价格。
  3. 交易所预设高低原则: 如果上述条件都相同,则按交易所规定,取较高的或较低的价格。

集合竞价的本质,是在一个静态的订单集合上,求解一个全局最优解。它牺牲了即时性,换取了价格的公允性和稳定性。

连续竞价(Continuous Auction)算法

连续竞价则完全不同。它的目标是即时性。算法是事件驱动的,每当一个新订单进入系统,就立即尝试与订单簿中已存在的对手方订单进行匹配。

其算法步骤如下(以一个新买单为例):

  1. 订单到达: 一个新的买单(比如,买入100股,限价$10.05)进入撮合引擎。
  2. 扫描对手方订单簿: 引擎立即查看卖方订单簿(Ask Book)的顶部,即价格最低的卖单。假设当前最优卖单是卖100股,价格$10.02。
  3. 价格匹配判断: 新买单的出价($10.05) ≥ 最优卖单的出价($10.02),满足成交条件。
  4. 执行撮合:
    • 成交价格遵循订单簿价格优先原则,即以老订单(在簿内的订单)价格为准。所以成交价是$10.02。
    • 成交数量是双方数量的最小值。假设买单和卖单都是100股,则成交100股。
    • 双方订单的数量被更新。在这个例子中,两个订单都完全成交,从订单簿中移除。
  5. 持续撮合或入簿: 如果新买单的数量大于最优卖单(例如,新买单要买200股),则在完全吃掉第一个卖单后,继续与下一个最优卖单(比如价格是$10.03)进行比较和撮合,直到新买单被完全满足,或者其价格不再优于卖方报价。
  6. 入簿(Posting): 如果新买单在吃掉所有可成交的卖单后仍有剩余(或者一开始就无法与任何卖单匹配,比如出价太低),那么这个剩余的买单将被按照价格优先、时间优先的原则,插入到买方订单簿(Bid Book)的相应位置,成为新的流动性。

连续竞价是一个贪心算法,它在每个时间点都做出局部最优决策,即立即撮合所有可以撮合的订单。它保证了市场的即时响应和高流动性,但价格发现过程是碎片化的,容易受到大单冲击。

系统架构总览:撮合引擎的位置与交互

理论必须落地到架构。一个典型的交易系统,撮合引擎是其心跳中枢,但它并非孤立存在。其上下游通常包括:

交易系统简化架构图,展示了从网关到行情发布的完整流程

(此处应有一幅架构图,文字描述如下)

上图描绘了一个简化的交易系统架构。订单流转路径如下:

  1. 接入网关(Gateway): 系统的门户。负责处理客户端连接(通常使用标准化的FIX协议或自定义的TCP/UDP协议)、会话管理、认证鉴权和初步的格式校验。网关是用户态进程,通常可以水平扩展以应对大量连接。
  2. 订单管理系统(OMS): 订单的生命周期管理者。它负责对进入的订单进行风控检查(如保证金、仓位限制、黑名单等)、分配订单ID、并将合法订单送往撮合引擎。成交回报也经由OMS处理后,更新用户资产并返回给客户端。OMS通常需要与数据库交互,保证订单状态的持久化。
  3. 撮合引擎(Matching Engine): 系统的核心。它在内存中维护着所有交易对的订单簿。上述的集合竞价与连续竞价算法就在此运行。为了追求极致性能,撮合引擎通常是单线程或基于CPU核心绑定的多线程模型,且几乎不进行任何磁盘I/O操作。所有状态变更(新订单、取消、成交)会以事件的形式输出。
  4. 持久化与复制: 撮合引擎产生的事件流(如Aeron、Kafka或自定义日志)被下游系统消费。一个关键消费者是持久化服务,它将这些事件写入数据库或分布式账本,确保系统崩溃后可以从日志中恢复订单簿状态。这也是实现高可用的基础(主备引擎通过复制日志来同步状态)。
  5. 行情发布器(Market Data Publisher): 另一个关键消费者。它订阅撮合引擎的事件流,生成深度行情(L1/L2/L3 Market Data)和逐笔成交(Trade Ticker),并通过UDP多播或WebSocket等低延迟方式广播给所有市场参与者。

从用户下单到收到成交回报的核心路径延迟(Critical Path Latency),主要消耗在:网络传输 -> 网关 -> OMS风控 -> 撮合引擎处理 -> OMS更新 -> 网关 -> 网络传输。其中,撮合引擎的处理延迟是衡量系统性能的最关键指标,通常要求在微秒(μs)级别。

核心模块设计与实现:Order Book 与撮合逻辑

(极客工程师声音)

Talk is cheap. Show me the code. 理论再漂亮,最终也要落实到高效、无锁、对CPU Cache友好的代码上。撮合引擎的核心是订单簿的实现。

订单簿的数据结构

用什么数据结构来表示订单簿?一个naive的实现可能是用`std::map>`(C++)或类似的结构。这在功能上可行,但性能堪忧。map的红黑树实现每次操作都是`O(log P)`(P是价格档位数量),list的节点在内存中不连续,会导致大量的Cache Miss。

在一线系统中,更常见的选择是结合多种数据结构,或者使用更底层的定制化结构。一个经典的组合是:

  • 用一个平衡二叉搜索树(如红黑树)或跳表来索引价格水平(Price Level)。这保证了能以`O(log P)`的复杂度快速定位、插入、删除一个价格档位。
  • – 卖单簿使用一个以价格为key的最小堆(Min-Heap)结构的树,买单簿则用最大堆(Max-Heap)结构的树。

  • 在每个价格水平节点上,挂载一个双向链表来存储该价格的所有订单。这保证了新订单追加到队尾是`O(1)`,订单成交或取消从链表中移除也是`O(1)`(需要一个哈希表或数组通过OrderID快速定位订单节点)。

下面是一个简化的Go语言数据结构示意:


// Order 代表一个具体的订单
type Order struct {
    ID        uint64
    Side      Side // BUY or SELL
    Price     int64  // 使用int64避免浮点数精度问题,例如 10.01元 存为 100100
    Quantity  uint64
    Timestamp int64  // 用于实现时间优先

    // 以下用于在双向链表中快速操作
    level *PriceLevel // 指向所属的价格档位
    Prev  *Order
    Next  *Order
}

// PriceLevel 代表一个价格档位上的所有订单
type PriceLevel struct {
    Price       int64
    TotalVolume uint64 // 该价格档位的总订单量
    OrderCount  int
    Head        *Order // 指向订单链表的头
    Tail        *Order // 指向订单链表的尾
}

// OrderBook 包含买卖双方的订单簿
// 内部的bids和asks通常用红黑树或类似的高效结构实现
type OrderBook struct {
    bids *RedBlackTree // Key: Price, Value: *PriceLevel
    asks *RedBlackTree // Key: Price, Value: *PriceLevel
    orders map[uint64]*Order // 通过OrderID快速查找订单 O(1)
}

连续撮合实现

连续撮合的逻辑直截了当,就是一个循环。关键在于高效地遍历对手方订单簿并更新状态。


// processLimitOrder 是连续撮合的核心逻辑
func (ob *OrderBook) processLimitOrder(newOrder *Order) []Trade {
    var trades []Trade
    if newOrder.Side == BUY {
        // 遍历卖方订单簿,从价格最低的开始
        iterator := ob.asks.Iterator() // 假设红黑树提供了迭代器
        for iterator.Next() {
            level := iterator.Value().(*PriceLevel)
            if newOrder.Price < level.Price {
                // 新买单价格低于最低卖价,无法成交,直接跳出
                break
            }
            
            // 遍历该价格档位上的订单(FIFO)
            for bookOrder := level.Head; bookOrder != nil; bookOrder = bookOrder.Next {
                if newOrder.Quantity == 0 { break }
                
                tradeQuantity := min(newOrder.Quantity, bookOrder.Quantity)
                trades = append(trades, createTrade(newOrder, bookOrder, level.Price, tradeQuantity))

                newOrder.Quantity -= tradeQuantity
                bookOrder.Quantity -= tradeQuantity
                level.TotalVolume -= tradeQuantity

                if bookOrder.Quantity == 0 {
                    // 对手方订单完全成交,从链表中移除
                    ob.removeOrder(bookOrder)
                }
            }
            if level.OrderCount == 0 {
                // 该价格档位已空,从红黑树中移除
                ob.asks.Remove(level.Price)
            }
        }
    } else { // SELL side logic is symmetric
        // ...
    }

    if newOrder.Quantity > 0 {
        // 新订单未完全成交,将其加入订单簿
        ob.addOrder(newOrder)
    }
    return trades
}

这段代码的性能瓶颈在于循环。每一次循环都涉及内存访问,如果数据结构设计不当,CPU Cache的换出换入会是巨大的性能杀手。这就是为什么在极致性能场景,有人会用数组+指针代替标准库的树和链表,实现所谓的“Mechanical Sympathy”。

集合竞价实现

集合竞价的实现分为两步:价格确定和成交执行。代码更偏向于数据分析而非实时事务处理。


// determineCallAuctionPrice 决定集合竞价的清算价格
func (engine *MatchingEngine) determineCallAuctionPrice() (clearingPrice int64, matchVolume uint64) {
    // 1. 获取所有订单簿中出现过的价格,并去重、排序
    prices := engine.getUniquePrices()
    sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] })

    var bestPrice int64 = -1
    var maxVolume uint64 = 0
    var minImbalance uint64 = math.MaxUint64

    // 2. 遍历每个可能的价格,计算成交量和未匹配量
    for _, p := range prices {
        // getCumulativeVolumeAt(price) 是预先计算好的累计供需量
        buyVolume := engine.getCumulativeBuyVolumeAt(p)
        sellVolume := engine.getCumulativeSellVolumeAt(p)
        currentVolume := min(buyVolume, sellVolume)
        
        if currentVolume > maxVolume {
            maxVolume = currentVolume
            bestPrice = p
            minImbalance = abs(buyVolume - sellVolume)
        } else if currentVolume == maxVolume {
            // 成交量相同,应用tie-breaking规则
            imbalance := abs(buyVolume - sellVolume)
            if imbalance < minImbalance {
                minImbalance = imbalance
                bestPrice = p
            } else if imbalance == minImbalance {
                // 更多规则,如比较与参考价的差距等...
            }
        }
    }
    return bestPrice, maxVolume
}

// 确定价格后,再遍历一次订单簿,执行所有符合条件的成交
func (engine *MatchingEngine) executeCallAuctionTrades(clearingPrice int64) {
    // ...
}

这里的坑在于 `getUniquePrices` 和 `getCumulative...Volume` 的效率。在订单量巨大时,全量计算非常耗时。实践中,这些累计值可以在订单入池时增量维护,以空间换时间,避免在竞价时刻进行大规模计算。

性能、公平性与风险对抗

选择哪种模式,或如何组合它们,是一系列深刻的Trade-off。

  • 延迟 vs 吞吐量:
    • 连续竞价: 对单个订单的延迟极低。如果能立即成交,响应时间在微秒级。但其吞吐量受限于单线程处理能力,因为对订单簿的修改必须严格序列化。一个繁忙的交易对可能每秒处理几万到几十万笔订单,再高就会成为瓶颈。
    • 集合竞价: 单个订单的延迟很高(必须等待窗口关闭)。但其吞吐量是脉冲式的,在清算瞬间可以处理掉百万甚至千万级别的订单。计算过程可以利用多核并行(例如,并行计算不同价格档位的累计量)。
  • 价格发现 vs 波动性:
    • 连续竞价: 价格发现是连续、渐进的。但也因此,容易受到“胖手指”错误或恶意大单的冲击,引发闪崩(Flash Crash)。价格轨迹更“毛糙”。
    • 集合竞价: 价格发现是集中的、全局的。它能过滤掉瞬时的噪声,形成一个市场“共识价”,天然地抑制了开盘/收盘的剧烈波动
  • 公平性 vs 高频交易(HFT):
    • 连续竞价: 是高频交易(HFT)的主战场。胜负手在于谁的速度更快——更近的服务器托管(Co-location)、更快的网络(微波/激光)、更优化的代码(Kernel Bypass、FPGA)。这导致了军备竞赛,对普通投资者可能不公平。
    • 集合竞价: 在很大程度上抹平了速度优势。只要你的订单在窗口期内提交,无论是第一秒还是最后一秒,在价格计算上都是平等的(时间优先仅在清算价格上生效)。这使得市场更为“民主”。一些交易所甚至引入随机结束时间,以防止HFT在最后一毫秒“狙击”竞价。

架构演进与落地路径

一个交易系统并非一蹴而就,它会随着业务规模和性能要求的提升而演进。

  1. 阶段一:单体MVP(Minimum Viable Product)

    适用于初创项目或内部系统。一台物理机,所有模块(网关、OMS、撮合引擎)都在一个进程内。撮合引擎只实现连续竞价模式。状态通过定时快照+操作日志的方式持久化到PostgreSQL或MySQL。简单、易于开发,但存在单点故障,性能和扩展性有限。

  2. 阶段二:服务化与高可用

    业务量增长,必须拆分服务。网关、OMS、撮合引擎、行情服务各自成为独立进程。撮合引擎采用主备(Active-Passive)模式。主引擎处理所有订单,并将操作日志(比如使用Kafka或自研的低延迟消息队列)实时复制给备引擎。备引擎在内存中同步回放日志,保持与主引擎状态一致(Hot Standby)。当主引擎宕机,可通过Zookeeper等协调服务实现秒级切换。

  3. 阶段三:多交易对扩展与混合模式实现

    系统需要支持成百上千个交易对。单个撮合引擎实例成为瓶颈。此时需要对撮合能力进行水平扩展(Sharding)。可以基于交易对名称(如`hash(symbol) % N`)将不同的交易对分配到不同的撮合引擎实例上。每个实例依然是主备模式。在此阶段,撮合引擎内部的状态机需要升级,实现对集合竞价和连续竞价模式的定时切换,从而支持完整的开盘、盘中、收盘流程。

  4. 阶段四:追求极致性能与全球化部署

    面向全球顶级交易所的性能要求。延迟从微秒级压向纳秒级。软件层面,使用DPDK/Solarflare等技术绕过内核网络协议栈,直接在用户空间收发网络包。撮合逻辑用C++或Rust重写,并进行极致的内存布局优化和CPU Cache优化。硬件层面,部分最核心的逻辑(如订单簿操作)可能会用FPGA(现场可编程门阵列)实现,达到硬件级的处理速度。在全球部署多个接入点(PoP),通过专线网络连接到核心撮合中心,服务全球客户。

从简单的连续撮合算法,到支持混合模式、高可用、可扩展的全球化交易平台,这条演进之路不仅是技术复杂度的提升,更是对市场规则、系统可靠性和性能极限的不断探索与妥协。

延伸阅读与相关资源

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