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万。

你想想看,这对撮合引擎意味着什么?订单簿上显示的只是「冰山一角」,但引擎必须记住整个「冰山」。

我处理冰山订单的思路是这样的:

  1. 订单簿上只展示「显示量」(peak)
  2. 引擎内部维护一个「冰山订单表」,记录完整数量
  3. 每次显示量被吃光,就从冰山订单表中补充新的显示量
  4. 补充时,订单的时间优先级不变(这点很重要!)

我曾经踩过一个坑:冰山订单补充显示量时,重新插入了队列尾部。结果一个大户的冰山订单永远排在最后,气得他直接打电话投诉。后来改成「原地复活」——补充的显示量保持原订单的时间戳和序列号。

数据结构设计:

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订单不进入订单簿。它们就像「过客」,来了就撮合,撮合不完就走。

我建议的处理流程:

  1. 收到FOK/IOC订单,先不加入队列
  2. 模拟撮合,计算可成交数量
  3. 对于FOK:如果可成交数量 < 订单总量,直接拒绝
  4. 对于IOC:按可成交数量执行撮合,剩余部分发取消通知
  5. 如果成交,生成成交记录;如果取消,生成取消记录

代码示例:

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 整体架构图

下面这张图展示了撮合引擎的核心流程。我习惯用「管道+过滤器」模式,每个环节独立运行,通过消息队列串联。

撮合引擎核心流程 订单输入 订单校验(价格/数量/风控) 订单类型判断(普通/FOK/IOC/冰山) FOK/IOC:模拟撮合 普通/冰山:入队列 价格-时间优先撮合 + 自成交预防 成交/取消通知

嗯,这张图基本涵盖了咱们今天聊的所有内容。从订单输入开始,经过校验、类型判断,最后进入撮合核心。自成交预防是贯穿始终的,但最关键的拦截点还是在撮合环节。

个人建议:别把自成交预防放在最前面。先让订单通过校验和类型判断,最后在撮合时再检查。这样性能更好,因为大部分订单根本走不到撮合那一步就被拒绝了。

好了,撮合引擎的核心逻辑就这些。说白了,就是「规则+数据结构」的组合。规则定好了,数据结构选对了,剩下的就是优化性能了。我见过有些团队把撮合引擎写得花里胡哨,结果一压测就崩。其实,简单、稳定、可维护,才是撮合引擎的王道