4、撮合引擎核心算法:价格优先-时间优先原则、连续竞价与集合竞价、盘口深度维护、大单拆分逻辑

各位同学,今天我们来聊聊撮合引擎。这是双边报价系统的“心脏”。

我见过不少团队,把撮合引擎想得太简单。觉得不就是“买一卖一匹配一下”吗?真做起来,坑多得很。我自己就踩过不少。今天把这部分的核心逻辑拆开揉碎,讲给你听。

4.1 价格优先-时间优先原则:最朴素的公平

这个原则,说白了就是两句话:

  • 价格优先:买单出价高的优先,卖单出价低的优先。
  • 时间优先:价格一样时,先来的先成交。

听起来简单吧?但实现起来有个细节。我刚开始做的时候,就犯过一个低级错误:把价格和时间分开排序。结果呢?价格高的买单,因为时间戳晚,反而排到了后面。这不对。

正确的做法是:先按价格排序,再按时间排序。 这是一个复合排序。在代码里,我习惯用一个比较器来实现:

// 伪代码示例:订单排序比较器
function compareOrders(orderA, orderB) {
    // 买单:价格高的优先
    if (orderA.side === 'BUY') {
        if (orderA.price !== orderB.price) {
            return orderB.price - orderA.price; // 降序
        }
        return orderA.timestamp - orderB.timestamp; // 时间升序
    }
    // 卖单:价格低的优先
    if (orderA.side === 'SELL') {
        if (orderA.price !== orderB.price) {
            return orderA.price - orderB.price; // 升序
        }
        return orderA.timestamp - orderB.timestamp; // 时间升序
    }
}
我的经验: 时间戳的精度很重要。在高频场景下,毫秒都不够用。我建议用纳秒级时间戳,或者用全局递增的序列号。否则,同一微秒来的两个订单,谁先谁后?系统会“蒙圈”。

4.2 连续竞价与集合竞价:两种模式,两种玩法

交易所有两种撮合模式。你想想看,为什么需要两种?

4.2.1 连续竞价

这是最常见的模式。订单一来,立刻尝试匹配。能成交就成交,不能成交就挂单。就像菜市场,随时有人买,随时有人卖。

实现上,就是一个循环匹配的过程:

// 连续竞价核心逻辑(简化版)
function matchContinuous(order) {
    while (true) {
        // 1. 找到对手方的最优订单
        let bestOpponent = findBestOpponent(order);
        if (!bestOpponent) break; // 没有对手单了

        // 2. 判断是否能成交
        if (order.side === 'BUY' && order.price >= bestOpponent.price) {
            // 成交!
            executeTrade(order, bestOpponent);
        } else if (order.side === 'SELL' && order.price <= bestOpponent.price) {
            executeTrade(order, bestOpponent);
        } else {
            break; // 价格不匹配,退出
        }

        // 3. 如果订单还有剩余数量,继续匹配
        if (order.quantity === 0) break;
    }
    // 4. 如果还有剩余,挂入订单簿
    if (order.quantity > 0) {
        addToOrderBook(order);
    }
}

4.2.2 集合竞价

集合竞价不一样。它不立刻成交,而是先收集订单。等一段时间后,统一撮合一次。目的是找到一个“最大成交量”的价格。

我参与过一个期货系统的开发。开盘前的集合竞价,那叫一个刺激。所有订单在几秒内涌入,系统压力巨大。我们当时用了一个技巧:先按价格聚类,再按时间排序。这样能快速算出每个价位的买卖总量。

集合竞价的核心算法:

  1. 收集所有订单,按价格分组。
  2. 对于每个价格,计算累计买单量和累计卖单量。
  3. 找到累计买单量 ≥ 累计卖单量的最高价格,或者累计卖单量 ≥ 累计买单量的最低价格。
  4. 这个价格就是“集合竞价价格”。
注意: 集合竞价时,价格优先原则依然有效。但时间优先原则只对“同一价格”的订单有效。不同价格的订单,价格高的买单永远优先于价格低的买单,不管谁先来。

4.3 盘口深度维护:别让数据“打架”

盘口深度,就是买卖双方的挂单情况。买一到买五,卖一到卖五。维护好它,是撮合引擎的基本功。

我见过最糟糕的实现:每次成交后,把整个订单簿重新排序一遍。数据量一大,系统直接卡死。

正确的做法是增量更新。 只修改受影响的那几个价位。比如:

  • 一个新买单进来,如果价格比买一高,它就成了新的买一。原来的买一变成买二。
  • 一个卖单被吃掉,卖一消失了。卖二自动变成新的卖一。

这里我画了一张图,帮你理解盘口的变化过程:

盘口深度变化示意图 初始盘口 卖一: 10.02 (100股) 买一: 10.01 (200股) 新买单: 10.02 (50股) 新买一: 10.02 (50股) 成交后盘口 卖一: 10.02 (50股) 买一: 10.01 (200股) 新买单与卖一价格相同,部分成交后,卖一数量减少

你看,新买单以10.02的价格进来,和卖一价格一样。成交50股后,卖一还剩50股。买一还是原来的10.01。整个过程,只更新了卖一这一个价位。效率高得多。

4.4 大单拆分逻辑:别让市场“地震”

大单,就是数量特别大的订单。比如一个100万股的买单。如果直接砸进去,盘口瞬间被吃掉好几层。价格会剧烈波动。这对自己不利。

所以,很多系统会提供“冰山订单”功能。只显示一部分数量,剩下的藏在“冰山”下面。

我曾经帮一个客户优化过这个逻辑。他原来的做法是:把大单拆成固定大小的碎片。比如100万股,拆成1000个1000股的小单。结果呢?市场波动大的时候,这些小单全被“钓鱼”了。对手方一看,每隔几秒就来一个1000股的买单,明显是冰山。直接反向挂单,把客户坑惨了。

我的建议是:随机化拆分。 不要用固定大小。可以用一个随机算法,让每次露出的数量不一样。比如:

// 大单拆分逻辑(随机化版本)
function splitLargeOrder(order) {
    let remaining = order.quantity;
    let minSlice = 100;   // 最小切片
    let maxSlice = 1000;  // 最大切片
    
    while (remaining > 0) {
        // 随机生成切片大小
        let sliceSize = Math.floor(Math.random() * (maxSlice - minSlice + 1)) + minSlice;
        sliceSize = Math.min(sliceSize, remaining);
        
        // 提交这个小单
        submitOrder({
            price: order.price,
            quantity: sliceSize,
            side: order.side
        });
        
        remaining -= sliceSize;
        
        // 随机等待一段时间,避免被识别
        sleep(Math.random() * 1000 + 500); // 500-1500毫秒
    }
}
核心要点:
  • 价格优先-时间优先是基础,别搞错排序顺序。
  • 连续竞价是“即时匹配”,集合竞价是“定时撮合”。
  • 盘口深度要增量更新,别全量重建。
  • 大单拆分要随机化,别用固定模式。

嗯,撮合引擎这部分,今天就讲到这里。内容不少,但都是实战中必须掌握的。你回去可以自己试着写一个简单的撮合引擎,跑一跑测试。遇到问题,欢迎来交流。


专注资料整理