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
accumulating available quantity until it either covers the order 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.
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->leaves_quantity += order_qty - order->quantity; // restore any earlier reduction
if (order->leaves_quantity < 0) order->leaves_quantity = 0;
order->quantity = order_qty;
order->price = order_price;
publishOrderModify(order, order_price, order_qty, request_time);
The delta arithmetic preserves fills: an order for 10 that has filled 4 and is amended to 7 ends up
with leaves_quantity == 3. 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:
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 |
Delete the resting order and continue matching against the next one |
CANCEL_NEWEST |
Cancel the incoming order, return OrdRejectReason::SMP, no trades |
The mode is per-Symbol (smp_mode_), passed into match() by Symbol::addOrder.
SMP is inert in production
Order::client_id defaults to -1 and nothing outside the unit tests ever assigns it, so
isSelfMatch always returns false on a live run. Symbol::smp_mode_ is likewise initialised to
NONE and has no configuration key. The feature is fully implemented and tested
(tests/unit/matching_engine/test_matching_engine_smp.cpp) but unreachable from a running
simulator.
Note also that Symbol::modifyOrder calls match() without an SMP argument, so amendments always
use the NONE default even on a symbol configured otherwise.
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.
trade_id comes from MatchingEngine::nextTradeId(), a process-global atomic shared across all
venues.
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 drifts
avg_fill_price is recomputed by converting to double, doing the weighted average, and
converting back, on every fill — in both the engine and OrderBook::executeOrder. Errors
compound across partial fills. See
Known gaps.
What the engine does not do¶
post_onlyis not enforced. The flag is parsed from Coinbase'slimit_limit_gtc.post_only, stored on theOrder, 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.
AddOrderMessagecarriestake_profit_priceandstop_loss_price; both gateways always set them toNULL_PRICE, and nothing reads them. The Hyperliquid gateway explicitly downgradestriggerorders to plain limits. GOOD_TILL_DATEnever expires.AddOrderMessage::expire_timeis always written as 0 and never evaluated.- There is no market state. No open/close/halt, so
MARKET_CLOSEDandMARKET_HALTEDare unreachable. - There are no fees in the engine.
Order::feestays 0; the Coinbase WS gateway computes a display-only fee from afee_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_coinbase_market_data_matching.cpp |
The overload-2 market-data path |