12. 撮合引擎深度:价格-时间优先算法,冰山订单处理,FOK/IOC订单逻辑,自成交预防
做市商系统的核心,说白了就是撮合引擎。我见过不少团队,业务逻辑写得飞起,结果撮合引擎一压测就崩。嗯,今天咱们就把这块硬骨头啃下来。
我个人习惯把撮合引擎比作一个「裁判」。它不关心谁赢谁输,只关心规则是否被执行。这个规则,就是咱们要聊的四个核心点:价格-时间优先、冰山订单、FOK/IOC、自成交预防。
核心原则:撮合引擎的第一性原理是「公平」与「效率」。价格优先是公平,时间优先是效率。所有复杂订单类型,都是在这两个基础上做的扩展。
12.1 价格-时间优先算法
这是最基础的撮合逻辑。说白了就是:买方出价高的先成交,卖方出价低的先成交。价格一样,谁先来谁先得。
我在项目中遇到过一个问题:当行情剧烈波动时,同一价格可能有上千笔订单同时涌入。如果时间精度只到毫秒,根本分不清先后。后来我们统一用了纳秒级时间戳,配合原子递增的序列号,才算彻底解决。
实现上,我建议用两个优先队列(Priority Queue):
- 买单队列:按价格降序排列,价格相同按时间升序
- 卖单队列:按价格升序排列,价格相同按时间升序
代码示例(简化版):
// C++ 伪代码 - 价格时间优先队列
struct Order {
uint64_t id;
double price;
uint64_t quantity;
uint64_t timestamp; // 纳秒级
uint64_t seq; // 全局递增序列号
};
struct BuyComparator {
bool operator()(const Order& a, const Order& b) {
if (a.price != b.price) return a.price < b.price; // 价格高的优先
return a.timestamp > b.timestamp; // 时间早的优先
}
};
// 使用 priority_queue 或 multiset
小技巧:别用浮点数存价格。我习惯把价格乘以一个精度因子(比如10000),转成整数。这样比较快,也不会出现浮点精度问题。
12.2 冰山订单处理
冰山订单,顾名思义——只露出水面一小部分。比如你想买100万个BTC,但不想让市场知道你的真实意图,就只显示1万个。成交完1万,再露出下一个1万。
你想想看,这对撮合引擎意味着什么?订单簿上显示的只是「冰山一角」,但引擎必须记住整个「冰山」。
我处理冰山订单的思路是这样的:
- 订单簿上只展示「显示量」(peak)
- 引擎内部维护一个「冰山订单表」,记录完整数量
- 每次显示量被吃光,就从冰山订单表中补充新的显示量
- 补充时,订单的时间优先级不变(这点很重要!)
我曾经踩过一个坑:冰山订单补充显示量时,重新插入了队列尾部。结果一个大户的冰山订单永远排在最后,气得他直接打电话投诉。后来改成「原地复活」——补充的显示量保持原订单的时间戳和序列号。
数据结构设计:
struct IcebergOrder {
Order base; // 基础订单信息
uint64_t total_qty; // 总数量
uint64_t peak_qty; // 显示量
uint64_t displayed; // 当前已显示的数量
};
// 撮合时,如果冰山订单的显示量被吃光
void onPeakConsumed(IcebergOrder& order) {
uint64_t remaining = order.total_qty - order.displayed;
if (remaining > 0) {
uint64_t new_peak = std::min(remaining, order.peak_qty);
order.displayed += new_peak;
// 重新插入订单簿,但保持原时间戳
orderBook.add(order.base, new_peak);
}
}
注意:冰山订单的「显示量」不能太小。如果显示量小于最小交易单位,撮合引擎会陷入无限循环。我一般会在订单校验阶段就拦截这种非法参数。
12.3 FOK/IOC订单逻辑
FOK(Fill or Kill)和IOC(Immediate or Cancel)是两种「急性子」订单。
- FOK:要么全部成交,要么全部取消。不能部分成交。
- IOC:能成交多少就成交多少,剩下的立即取消。
处理逻辑其实不复杂,但有个细节要注意:FOK/IOC订单不进入订单簿。它们就像「过客」,来了就撮合,撮合不完就走。
我建议的处理流程:
- 收到FOK/IOC订单,先不加入队列
- 模拟撮合,计算可成交数量
- 对于FOK:如果可成交数量 < 订单总量,直接拒绝
- 对于IOC:按可成交数量执行撮合,剩余部分发取消通知
- 如果成交,生成成交记录;如果取消,生成取消记录
代码示例:
bool processFOK(Order& order, OrderBook& book) {
uint64_t can_fill = book.simulateFill(order);
if (can_fill >= order.quantity) {
book.executeFill(order); // 全部成交
return true;
}
// 拒绝订单,发送取消通知
sendCancel(order.id, "FOK cannot be fully filled");
return false;
}
bool processIOC(Order& order, OrderBook& book) {
uint64_t filled = book.executeFill(order); // 能成交多少就多少
if (filled < order.quantity) {
// 剩余部分取消
uint64_t remaining = order.quantity - filled;
sendCancel(order.id, "IOC partial fill, remaining cancelled");
}
return filled > 0;
}
性能优化:模拟撮合时,别真的修改订单簿。我习惯用「快照+游标」的方式,只遍历不修改。等确认成交了,再批量更新。
12.4 自成交预防
自成交,就是同一个交易员的买单和卖单自己跟自己成交了。这在合规上是大忌,交易所会罚钱的。
为什么会发生自成交?常见场景:
- 做市商同时挂了买单和卖单,行情波动时两边都触发了
- 算法交易的不同子策略互相打架
- 手动交易和自动交易冲突
我处理自成交的思路分三层:
| 层级 | 方法 | 说明 |
|---|---|---|
| 第一层 | 订单级别 | 每个订单带一个「交易员ID」,撮合时检查买卖双方ID是否相同 |
| 第二层 | 策略级别 | 同一策略的订单不能互吃,即使交易员ID不同 |
| 第三层 | 账户级别 | 同一账户下的所有订单都不能自成交 |
我曾经遇到过一个奇葩问题:两个不同交易员的订单,因为用了同一个API Key,被系统判定为自成交。后来我们加了一个「白名单」机制,允许特定组合的订单互吃。
实现上,我建议在撮合引擎的「匹配」环节加一个过滤器:
bool isSelfTrade(const Order& buy, const Order& sell) {
// 检查交易员ID
if (buy.trader_id == sell.trader_id) return true;
// 检查策略ID
if (buy.strategy_id == sell.strategy_id) return true;
// 检查账户ID
if (buy.account_id == sell.account_id) return true;
// 白名单检查
if (isInWhitelist(buy.trader_id, sell.trader_id)) return false;
return false;
}
重要:自成交预防不能影响正常的市场流动性。如果两个做市商都挂了同样的价格,他们之间应该能成交。所以「白名单」机制是必须的,不能一刀切。
12.5 整体架构图
下面这张图展示了撮合引擎的核心流程。我习惯用「管道+过滤器」模式,每个环节独立运行,通过消息队列串联。
嗯,这张图基本涵盖了咱们今天聊的所有内容。从订单输入开始,经过校验、类型判断,最后进入撮合核心。自成交预防是贯穿始终的,但最关键的拦截点还是在撮合环节。
个人建议:别把自成交预防放在最前面。先让订单通过校验和类型判断,最后在撮合时再检查。这样性能更好,因为大部分订单根本走不到撮合那一步就被拒绝了。
好了,撮合引擎的核心逻辑就这些。说白了,就是「规则+数据结构」的组合。规则定好了,数据结构选对了,剩下的就是优化性能了。我见过有些团队把撮合引擎写得花里胡哨,结果一压测就崩。其实,简单、稳定、可维护,才是撮合引擎的王道。