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 集合竞价
集合竞价不一样。它不立刻成交,而是先收集订单。等一段时间后,统一撮合一次。目的是找到一个“最大成交量”的价格。
我参与过一个期货系统的开发。开盘前的集合竞价,那叫一个刺激。所有订单在几秒内涌入,系统压力巨大。我们当时用了一个技巧:先按价格聚类,再按时间排序。这样能快速算出每个价位的买卖总量。
集合竞价的核心算法:
- 收集所有订单,按价格分组。
- 对于每个价格,计算累计买单量和累计卖单量。
- 找到累计买单量 ≥ 累计卖单量的最高价格,或者累计卖单量 ≥ 累计买单量的最低价格。
- 这个价格就是“集合竞价价格”。
4.3 盘口深度维护:别让数据“打架”
盘口深度,就是买卖双方的挂单情况。买一到买五,卖一到卖五。维护好它,是撮合引擎的基本功。
我见过最糟糕的实现:每次成交后,把整个订单簿重新排序一遍。数据量一大,系统直接卡死。
正确的做法是增量更新。 只修改受影响的那几个价位。比如:
- 一个新买单进来,如果价格比买一高,它就成了新的买一。原来的买一变成买二。
- 一个卖单被吃掉,卖一消失了。卖二自动变成新的卖一。
这里我画了一张图,帮你理解盘口的变化过程:
你看,新买单以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毫秒
}
}
- 价格优先-时间优先是基础,别搞错排序顺序。
- 连续竞价是“即时匹配”,集合竞价是“定时撮合”。
- 盘口深度要增量更新,别全量重建。
- 大单拆分要随机化,别用固定模式。
嗯,撮合引擎这部分,今天就讲到这里。内容不少,但都是实战中必须掌握的。你回去可以自己试着写一个简单的撮合引擎,跑一跑测试。遇到问题,欢迎来交流。