Skip to content

Matching engine

FifoMatchingEngine implements price/time priority matching. It is the only engine that exists — MatchingEngine::Type::ProRata is declared but unimplemented.

One engine instance is shared by every symbol on a venue; it holds no per-symbol state, taking the OrderBook as a parameter on each call. Per-symbol configuration (currently just the SMP mode) is passed in from Symbol.

Two entry points

The interface has two match overloads, and they exist for genuinely different reasons.

// 1. A client order arrives.
std::tuple<OrdRejectReason, std::vector<TradeSummaryInfo>>
match(Order* order, price_t order_price, qty_t order_qty, OrderBook& book,
      time_t request_time, time_t event_time, uint64_t seq_num = 0,
      SelfMatchPreventionMode smp_mode = SelfMatchPreventionMode::NONE);

// 2. Phantom liquidity arrives from the market-data feed.
std::vector<TradeSummaryInfo>
match(Side side, uint64_t order_id, price_t price, qty_t& qty, OrderBook& book,
      time_t event_time, uint64_t seq_num = 0);

Overload 1 is the client path, called from Symbol::addOrder and Symbol::modifyOrder. It owns a real Order, publishes acks and execution reports, and honours time-in-force and SMP.

Overload 2 is the market-data path, called whenever the feed introduces a new price level. Before a phantom order can rest at a price, the engine checks whether that price crosses any simulator order resting on the opposite side — if the real market just quoted 100.00 bid and you have a resting offer at 99.99, you would have been filled on the real venue, so you get filled here. It takes qty by reference and decrements it: whatever is left after sweeping your orders is what actually rests as phantom liquidity. No ack is published (there is no client order to ack), and fully-filled simulator orders are deleted from the book inside the loop.

Both dispatch on side into a shared template<Side SIDE> implementation, where SIDE is the resting side being swept — so the aggressor is opposite_side<SIDE>(). A buy order matches against match<Side::SELL>.

The client matching path, step by step

Step 0 — ack

If order->status == PENDING_NEW, publishOrderAck fires immediately, before any matching. The client's NEW execution report therefore always precedes its fills.

Step 1 — fill-or-kill pre-check

For TimeInForce::FILL_OR_KILL, canFillCompletely<SIDE>() walks the opposite side of the book drawing down the order's remaining quantity until it either reaches zero or hits a price level worse than the limit. The scan is SMP-aware: under CANCEL_NEWEST any self-match aborts the scan and returns false; under CANCEL_RESTING self-matched orders are skipped, because they would be cancelled rather than filled. Under NONE they are counted like any other resting order — the order really would trade against itself.

Resolving a resting book entry to its full Order is a hash lookup, and it exists only to compare client ids, so the scan skips it entirely when SMP is off and stays inside the price level's own array.

If the scan fails, publishOrderCancel fires and the engine returns FOK_CANNOT_FILL with no trades.

Step 2 — apply a pending replace

If order->status == PENDING_REPLACE, the new quantity and price are applied before matching:

order->quantity = order_qty;
order->price    = order_price;
order->leaves_quantity = order->cum_quantity >= order_qty
                       ? 0                                 // amended at or below what already filled
                       : order_qty - order->cum_quantity;
publishOrderModify(order, order_price, order_qty, request_time);

Leaves is recomputed from the cumulative fills rather than adjusted by a delta, so it cannot drift from the fill state: an order for 10 that has filled 4 and is amended to 7 ends up with leaves_quantity == 3, and one amended to 3 ends up at 0 rather than negative. If the amendment drives leaves to zero, the order is cancelled and the engine returns immediately.

Step 3 — the matching loop

while (order_qty > 0) {
    auto best_level = book.getBestLevel<SIDE>();
    if (!best_level ||
        (order_price != NULL_PRICE && !isPriceBetterOrEqual<SIDE>(best_level->price, order_price)))
        break;

    auto& book_order = *best_level->orders.begin();   // FIFO: front of the level
    // … SMP check …
    qty_t trade_qty = std::min(order_qty, book_order.quantity);
    // … update aggressor fields, publishOrderExecution(order) …
    book.executeOrder(book_order_id, trade_qty, event_time, seq_num, order_qty == 0);
    // … accumulate trade summary …
}

Time priority comes from always taking orders.begin() — the front of the price level, ordered by the priority counter. Price priority comes from getBestLevel<SIDE>() walking levels best-first.

NULL_PRICE doubles as the market-order sentinel: when order_price == NULL_PRICE the price test is skipped entirely and the order sweeps until filled or the book is empty.

The is_last_in_batch argument to executeOrder is order_qty == 0, so observers see the batch close on the final fill.

Step 4 — immediate-or-cancel remainder

After the loop, if the TIF is IMMEDIATE_OR_CANCEL and quantity remains, publishOrderCancel fires. The order is not removed from anything here — it was never added to the book. Symbol::addOrder handles that:

bool should_rest = (tif == DAY || tif == GOOD_TILL_CANCEL || tif == GOOD_TILL_DATE);

Only those three rest. IOC and FOK remainders are dropped, and — because the test is a positive allow-list — so are AT_THE_OPENING and GOOD_TILL_CROSSING.

Finally, Symbol::addOrder returns the Order to the object pool if leaves_quantity == 0.

Self-match prevention

SMP compares Order::client_id between the incoming order and each resting order:

if (incoming->client_id == -1 || book_order->client_id == -1) return false;
return incoming->client_id == book_order->client_id;
Mode Behaviour on a self-match
NONE Allow the match
CANCEL_RESTING Cancel the resting order — removed from the book, CANCELED sent to its owner, Order returned to the pool — then carry on matching against the next one
CANCEL_NEWEST Reject the incoming order with OrdRejectReason::SMP, no trades

CANCEL_NEWEST decides before it touches anything

The check runs at the top of match(), ahead of the order ack, ahead of the PENDING_REPLACE mutation, and ahead of the FOK pre-validation. wouldSelfMatch walks the opposing side as far as the incoming quantity could actually reach and asks whether any of it is the order's own.

That ordering is the whole point. The matching loop mutates before it matches — an amendment writes the new price and quantity onto the stored Order and reports REPLACED, and a new order is acked — so deciding mid-loop would mean telling the client its order was replaced or working and then rejecting it, with the amendment half-applied to an order that never moved on the book. Deciding first means a rejected order leaves no trace: the client sees its pending, then one REJECTED, and the stored order keeps its original price and quantity.

Running before the mutation does mean the pre-check has to derive the quantity the loop is about to use rather than reading it afterwards. The order_qty argument is the order's total quantity; for a partially filled amendment the loop will match only leaves_quantity + (order_qty - quantity). Scanning by the total would reach past what the order can actually trade and reject against a resting order it would never have touched, so STEP 0 mirrors that arithmetic without applying it.

It also means the reject reason is SMP rather than FOK_CANNOT_FILL for a fill-or-kill order that could have filled but for the self-match — the liquidity was there, and SMP is why it could not trade.

The mode is per-Symbol (smp_mode_), passed into match() by both Symbol::addOrder and Symbol::modifyOrder — an amendment re-enters the matching loop, so it is prevented on the same terms as a new order.

Enabling it

SMP is off by default. Set self_match_prevention on an exchange to "cancel_resting" or "cancel_newest" and every symbol that venue creates inherits it — see Configuration. An unrecognised value logs a warning and leaves SMP disabled rather than aborting startup.

What counts as "self"

Order::client_id is the identity SMP compares, and Exchange::clientIdFor assigns it from the order's user_id — the authenticated account, which is how real venues scope SMP. Ids are handed out on first sight of a user_id and stay stable for the life of the process, so the hot matching loop compares an int rather than a string.

An order with an empty user_id gets -1, which isSelfMatch treats as "no identity" and never matches — including against another -1. An unauthenticated client is therefore left out of SMP rather than lumped together with every other unauthenticated one.

Modifies re-enter the matching loop

Symbol::modifyOrder does not adjust the book in place and stop. It calls the full match() path with the new price and quantity, which means an amendment that makes the order marketable trades immediately. Only afterwards does it call order_book_->modifyOrder(...) to reposition whatever is left.

Queue priority is handled by the book, not the engine:

// OrderBook::modifyOrder
else if (new_price != order->price) {
    order->price = new_price;
    order->priority = nextOrderPriority();   // price change ⇒ lose queue position
}
order->quantity = new_qty;

A pure quantity change keeps its place in the queue; a price change goes to the back of the new level. That matches real venue behaviour. Note the asymmetry: quantity increases also keep priority, which most real venues would not allow.

Trade summaries

Each match() call returns std::vector<TradeSummaryInfo> — the public print of what happened, which the exchange forwards to md_queue_ via publishTradeSummary.

Summaries are aggregated per price level. While consecutive fills happen at the same price the engine merges them into the current summary:

if (last_fill_price != price) {
    // new summary: num_orders = 2, Trades = [aggressor, resting]
} else {
    last_summary.num_orders += 1;
    last_summary.qty        += trade_qty;
    last_summary.Trades.front().qty += trade_qty;   // grow the aggressor's leg
    last_summary.Trades.emplace_back(Trade{book_order_id, trade_qty});
}

So Trades[0] is always the aggressor with its running total, and Trades[1..] are the resting orders it hit. num_orders counts participants, not trades — it starts at 2 and increments by one per additional resting order.

TradeSummaryInfo::trade_id comes from MatchingEngine::nextTradeId(), a process-global atomic shared across all venues — so it is an internal id only. The public trade id is stamped by Exchange::publishTradeSummary from a counter the venue owns, which is what makes each venue's ids an independent sequence.

phantom_qty records how much of a summary's qty was matched against phantom liquidity rather than the simulator's own resting orders (our_order == nullptr in the match loop). The exchange uses it to avoid reducing a price level twice — see venue trade prints.

Note

num_orders is used by Exchange::publishTradeSummary to size the queue reservation (sizeof(TradeSummary) + num_orders * sizeof(Trade)), while the copy loop iterates trade_summary.Trades.size(). The two agree because num_orders is incremented exactly once per Trades entry after the initial pair — but they are independent counters, so any future change to one must update the other.

Fixed-point arithmetic

Prices and quantities are int_fast64_t scaled by DOUBLE_MULTIPLIER = 1e8 (types.hpp):

using price_t = int_fast64_t;
using qty_t   = int_fast64_t;
constexpr int32_t DOUBLE_MULTIPLIER = static_cast<int32_t>(1e8);
constexpr price_t NULL_PRICE = std::numeric_limits<price_t>::max();

to_price_t(1.23)      // → 123000000
to_price_double(…)    // → back to double

Eight decimal places suits crypto; a price of 123.456 is exact, and quantities down to one satoshi are representable. All comparisons in the matching loop are integer comparisons via utils::isPriceBetterOrEqual, which flips direction on side.

NULL_PRICE is INT64_MAX. Because it is also a valid price_t, code must test for it explicitly before doing arithmetic — the matching loop does, but be careful when adding new paths.

Average fill price

avg_fill_price is derived from a running notional rather than from the previous average, so it never accumulates rounding error across partial fills:

order->cum_value    += static_cast<cum_value_t>(price) * static_cast<cum_value_t>(trade_qty);
order->cum_quantity += trade_qty;
order->avg_fill_price = order->cum_quantity > 0
    ? static_cast<price_t>(order->cum_value / static_cast<cum_value_t>(order->cum_quantity))
    : 0;

cum_value_t is boost::multiprecision::uint128_t. It has to be wider than price_t: both operands are already scaled by 1e8, so their product carries 1e16 and a realistic price × quantity overflows 64 bits.

Two caveats remain. The division truncates rather than rounds, so the result can be one unit of the 1e8 scale below the exact average — bounded, and not compounding, because each fill recomputes from the cumulative totals. And the same three lines appear twice: here for the aggressor, and in OrderBook::executeOrder for the resting side. They have to stay in step.

What the engine does not do

  • post_only is not enforced. The flag is parsed from Coinbase's limit_limit_gtc.post_only, stored on the Order, and echoed in execution reports, but the matching loop never checks it. A post-only order that crosses will trade.
  • Stop and trigger orders do not exist. AddOrderMessage carries take_profit_price and stop_loss_price; both gateways always set them to NULL_PRICE, and nothing reads them. The Hyperliquid gateway explicitly downgrades trigger orders to plain limits.
  • GOOD_TILL_DATE never expires. AddOrderMessage::expire_time is always written as 0 and never evaluated.
  • There is no market state. No open/close/halt, so MARKET_CLOSED and MARKET_HALTED are unreachable.
  • There are no fees in the engine. Order::fee stays 0; the Coinbase WS gateway computes a display-only fee from a fee_rate_ when building execution reports.

Tests

The engine is the best-covered part of the codebase:

File Covers
tests/unit/matching_engine/test_fifo_matching_engine.cpp Core price/time priority, partial fills
tests/unit/matching_engine/test_matching_engine_tif.cpp DAY/GTC resting, IOC remainder cancel, FOK pre-check
tests/unit/matching_engine/test_matching_engine_smp.cpp All three SMP modes, including the FOK interaction
tests/unit/matching_engine/test_feed_liquidity_matching.cpp The overload-2 market-data path