Matching¶
Two books, one interface¶
Matching needs individual orders -- to keep time priority, cancel by id, and
refill icebergs -- so the matcher's resting books are distinct from
NLevelOrderBook, which aggregates per-level totals for market data. The
performance book LadderBook lives in core (flox/book/ladder_book.h); the
reference MatchingBook lives in the module (flox-venue/matching_book.h).
| Book | Shape | Use |
|---|---|---|
MatchingBook |
std::map + std::list, allocates |
Reference oracle. Easy to reason about. |
LadderBook |
tick-indexed dense ladders, intrusive FIFO over a node pool, occupancy bitmap | Performance path. O(1) best, O(1) next level, no steady-state allocation. |
They are interchangeable (MatchingEngine<MatchingBook> /
MatchingEngine<LadderBook>), and a differential fuzz keeps them
observationally identical; see Verification.
LadderBook book(LadderBook::Config{
.basePriceRaw = 0,
.tickRaw = Price::fromDouble(0.01).raw(),
.numLevels = 20000, // price band height, in ticks
.maxOrders = 1 << 20}); // node pool capacity
Matching policies¶
Matcher<Book> implements the allocation rule:
- Price-time (FIFO), the default. Best price first; within a price, oldest order first.
- Pro-rata. Allocation at a level is proportional to resting size, with the
usual leftover pass. Last-look orders are refused at admission on pro-rata
instruments (
LastLookUnsupported): a held slice cannot be carved out of a proportional round, and silently filling the maker as firm -- the old behaviour -- misrepresented the quote. Defensive second line: a restinglastLookmaker met by a pro-rata allocation (reachable only if a caller bypassedvalidate()) is skipped, never filled as firm -- it is excluded from the level total and from the allocation, the remaining firm participants split the aggressor exactly as they otherwise would, andMatcher::skippedLastLookProRata()(surfaced asMatchingEngine::skippedLastLookProRata()) counts the event. A level whose firm size is zero stops the sweep, so the aggressor residual follows its TIF instead of a fabricated fill.
Matcher<MatchingBook> m(MatchPolicy::ProRata);
m.setStpGroup(/*account*/ 10, /*firm*/ 1); // firm-scope self-trade prevention
Firm-group STP membership is engine state, not startup wiring: runtime
changes arrive as the sequenced SetStpGroup command (control-plane
setStpGroup verb), so they journal, ride checkpoints and replay with the
rest of matching. group = 0 removes a membership.
Self-trade prevention¶
Four modes, applied when the aggressor and the maker share an STP scope (the
account, or the firm group registered via setStpGroup):
| Mode | Effect |
|---|---|
CancelNewest |
the incoming order is killed |
CancelOldest |
the resting order is pulled, matching continues |
CancelBoth |
both go |
Decrement |
the smaller leg is removed, the larger is reduced by that size; no trade |
STP interacts with FOK: an all-or-none order cannot count liquidity it would
never be allowed to trade with, so the precheck excludes same-scope resting
size.
A mass quote carries the mode too, and both of its legs inherit it -- so a maker that quotes rather than sending individual orders is not left without the control.
Last look, and the option inside it¶
A held fill is an option the maker holds for the length of the window, and an option nobody can see the exercise of is one that gets exercised in one direction. Over enough holds, a maker free to answer as it likes fills the ones that moved its way and refuses the ones that did not; the taker sees only a reject rate and cannot tell that from an honest wide tolerance.
Two controls address that, and they work together.
lastLookToleranceRaw is a symmetric price tolerance applied by the venue,
on magnitude alone. Outside the band the fill is rejected whoever it would have
favoured — including when the move was in the maker's favour, which is the half
a cherry-picking maker would otherwise keep. Inside the band the maker's answer
still stands: the tolerance caps the option rather than abolishing last look.
0 disables the check.
MatchingEngine::lastLookStats() reports per maker how many holds it saw, how
many it refused, and — the number that matters — the split by which way the
price had moved. A maker applying a symmetric rule refuses about as often when
the move favoured it as when it did not. One taking the free option refuses
almost only when it was losing. That split makes the conduct visible without
anyone seeing the maker's code, which is what a reject rate on its own can
never do.
lastLookAcceptOnTimeout decides what silence means, and it is the same
argument: with false, a maker that stalls and says nothing pays nothing, so
stalling is free; with true, it costs the maker the trade.
STP also interacts with last look: the maker it pulls may have a hold open, so the hold is resolved (rejected, liquidity restored) before the order is removed -- the same order every engine-side cancel path follows. Removing first would release the collateral the hold still had to settle from.
Live risk limits¶
The band, the fat-finger caps, the per-account order cap, the position cap and
the margin requirement are changed with the sequenced SetRiskLimits command
(control-plane verb setRiskLimits). A field mask says which limits the record
carries, so raising one cannot zero another by omission.
The direct setters on the engine remain for pre-start wiring. On a running engine they apply immediately and ride nothing: a restart reverts them and a replica replaying the journal never sees the change. Use the command.
Delisting¶
AdminAction::Delist withdraws an instrument from trading and pulls the
resting book with it. A halt promises the instrument comes back and a closed
session promises the next one; delisting promises neither, so leaving orders
resting would leave them waiting for an open that is not coming. New orders are
rejected with InstrumentDelisted, which says that rather than promising a
return.
It outranks halt, session and auction state: reopening the session does not
make a delisted instrument tradeable. Relist reverses it -- an irreversible
operator action is one mistake away from needing a restart to undo.
Admission profiles¶
Counterparties have different rights. One routes flow it manages itself and should never leave an order resting here; another posts and pulls quotes, where cancel/replace is the main operation. The rights are set per account and checked on entry.
| Field | Meaning |
|---|---|
allowedTypes |
bitmask over OrderType; 0 = no restriction |
allowedTif |
bitmask over TimeInForce; 0 = no restriction |
deny |
DenyResting, DenyAmend, DenyCancel, DenyQuote |
An absent profile permits everything, so an engine never given one behaves as
before. DenyResting rejects GTC, GTD and post-only on admission rather than
killing the residual afterwards: an order resting here that the sender does not
track will not be reconciled, and nothing looks wrong until it fills. The
rejection happens before the clientOrderId is consumed, so a corrected
message can be resent under the same id.
It also refuses every order from that counterparty while the instrument is in a call auction, whatever its time in force. An auction rests everything it admits -- there is no matching to be immediate about, so an IOC accumulates in the book like any other order and sits there until the uncross. A counterparty that does not track resting orders therefore cannot take part in one: its order would sit in the book for the length of the auction while it believes the order filled or died on arrival, and at the uncross it could trade against its own other side.
The profile arrives as the sequenced SetAdmissionProfile command
(control-plane verb of the same name), so it journals, survives checkpoints,
enters the state hash and replays. MatchingEngine::admissionRejects() counts
the rejections; a non-zero value means a counterparty is sending something its
profile does not allow.
Order types and time in force¶
NewOrder carries the full venue vocabulary:
- Types:
LIMIT,MARKET,STOP_MARKET,STOP_LIMIT,TAKE_PROFIT*,TRAILING_STOP. - TIF:
GTC,IOC,FOK,GTD(withexpiryNs),POST_ONLY. - Iceberg.
visibleQuantityshows a peak and hides the rest; the hidden reserve is real liquidity for matching and stays out of the public feed. - Peg.
PegRef::{Bid,Ask,Mid}plus a signed offset; repriced at each submit boundary, tick-aligned, clamped so it never crosses. - OCO.
ocoGroup; a fill on one leg cancels its siblings. - Reduce-only. Perp orders that may only reduce a position, re-capped on submit, trigger, modify -- and re-measured at fill time against the position as it is then (see Risk).
The engine¶
MatchingEngine<Book> owns the lifecycle around the matcher: validation, risk
gates, stops, expiry, pegs, auctions, clearing, and event emission.
SymbolConfig cfg;
cfg.id = 1;
cfg.tickSize = Price::fromDouble(0.01);
cfg.minPrice = Price::fromDouble(1); // 0 = unchecked
cfg.maxPrice = Price::fromDouble(1000);
cfg.baseAsset = 0;
cfg.quoteAsset = 1;
MatchingEngine<MatchingBook> venue(cfg, [](const OutboundEvent& e) { publish(e); });
venue.submit(InboundCommand{order}, tsNs);
Every state-mutating input is an InboundCommand: NewOrder, CancelOrder,
ModifyOrder, MassCancel, Quote, LastLookDecision, SetMark,
ApplyFunding, AdminCmd, TimeTick (idle time sweep). That is what makes
deterministic replay possible; see Runtime and recovery.
Pre-trade risk¶
Configured on SymbolConfig and adjustable live, so an operator can tighten
limits during volatility without a restart:
| Control | Config | Live setter |
|---|---|---|
| Tick / lot / min quantity | tickSize, lotSize, minQty |
-- |
| Price band (collar) | minPrice, maxPrice |
-- |
| Fat finger | maxOrderQty, maxOrderNotional |
setFatFinger |
| LULD volatility band | luldBps, luldHaltNs |
setLuldBps |
LULD (limit-up/limit-down) gates both sides of a trade. A limit order priced
outside the band around the last price is rejected pre-trade (LuldBreach) and
trips a timed pause. A market order has no limit to gate it, so it can sweep the
book and print outside the band; that breaching print stands, but it trips the
same pause so subsequent trading halts (the exchange-style volatility
interruption) -- a stop cascade that prints out of band trips it the same way.
Before the first trade there is no reference price, so no band exists yet.
| Max resting orders per account | maxOpenOrders | setMaxOpenOrders |
| Max position (perp) | maxPositionQty | setPositionLimit |
| Margin requirement | initialMarginBps, maintenanceMarginBps | setMarginBps |
| Halt | halted | setHalted |
Market-maker features¶
- Last look.
lastLookWindowNs: the maker holds a fill and answers withLastLookDecision. See the full lifecycle below. - MMP.
setMmp(account, qtyLimit, windowNs): if an account is filled faster than its limit inside the window, its book is pulled andMmpTriggeredfires. - Mass quote.
Quotereplaces both sides of a two-sided quote atomically, by id: the engine cancelsbidIdandaskIdand re-posts them at the new prices, so a maker keeps one pair of ids and reuses it. The quote carries its ownstpandlastLook, which both legs inherit. A maker quoting continuously is the participant that most needs self-trade prevention, and the one whose quotes are tight enough to want holding -- without those two fields it had to choose between the primitive built for two-sided quoting and the controls that make two-sided quoting safe.
Last-look lifecycle¶
lastLookWindowNs > 0 enables last look venue-wide; 0 disables it entirely
(the matcher hook is never installed, so a lastLook-flagged order fills like
any other maker). When an aggressor hits a resting lastLook maker, the hit
size is reserved OUT of the book, FillHeld is emitted (with heldId and the
maker's makerDisplayAfter for the public feed), and the maker has the window
to answer with LastLookDecision{heldId, accept}.
- Ownership. Only the maker account that owns the held quote may decide;
any other account gets
OrderRejected{NotOrderOwner}and the hold stands. - Accept prints the trade at the held price/size and settles normally.
- Reject / timeout (
lastLookAcceptOnTimeout=false) destroys no liquidity: - The maker's held quantity returns to its price level at the tail (as-if the maker re-entered; time priority is lost). If the maker order was canceled between hold and reject there is nothing to restore -- but the cancel paths below resolve holds first, so that case cannot arise: a missing maker here means it was fully held out of the book, and it is rebuilt.
- The taker's held quantity returns and follows the order's TIF: GTC/GTD
rest at the taker's limit (tail; combined with any already-resting
remainder via
OrderModified), IOC/FOK/MARKET residuals are canceled with the matching residual reason. The restored taker rests passively -- it does not re-aggress, so the book may be transiently crossed against the rejecting maker until new flow arrives. FillRejected{heldId, takerId, makerId, price, qty}reports what did not happen.- Timeout on a quiet symbol. Hold expiry runs on every submit AND on
tick(nowNs)(idempotent sweep). UnderSequencedShard, passidleSweepIntervalNs > 0to arm the idle sweeper: while holds are open it injects aTimeTickcommand through the sequenced stream (journaled, so replay reproduces the timeout at the same point). - Cancel-while-held. Every path that removes or reshapes an order --
CancelOrder,ModifyOrder,Quotereplace, GTD expiry, OCO, peg reprice,MassCancel/MMP/liquidation (account scope),HaltAndCancelAll(all) -- deterministically resolves (rejects) the affected holds FIRST. An accept after the cancel is impossible (UnknownOrder), and reservations release exactly once: the held slice stays reserved until its hold resolves, then either settles (accept) or is released/re-rests (reject). - FOK vs last look. Last-look liquidity is non-firm: it never counts
toward the FOK all-or-none precheck, and a FOK whose crossing range contains
ANY last-look maker is rejected
FillOrKillUnfulfillableoutright -- the sweep is strict price-time, so a last-look maker inside the range could be hit before the FOK completes, and a partial execution followed by a hold would violate all-or-none. A last-look maker priced outside the crossing range does not affect the FOK. - Public feed.
MarketDataPublishershrinks the maker's level onFillHeld(bymakerDisplayAfter) and follows the restore events (OrderModified/OrderAccepted) on reject -- after any sequence of hold/accept/reject the published depth equals the matching book. - Wire. SBE templates 17 (
FillHeld) / 18 (FillRejected) inorder-entry-sbe.xml; REST/JSON{"type":"fillHeld"|"fillRejected", ...}. FIX has no honest ExecType for a pending held fill, soFillHelduses the documented custom value150=UandFillRejecteduses150=H(Trade Cancel), both with custom tags20001=heldId,20002=makerId. - Pro-rata instruments do not honour last look (documented matcher scope
limitation), and admission refuses the combination. If one appears anyway
(admission bypassed), the pro-rata allocation SKIPS that maker rather than
filling it as firm, bumping
skippedLastLookProRata()-- see the pro-rata policy above.
Client order id dedup¶
clientOrderId (FIX tag 11, SBE/REST clientOrderId) is deduplicated per
account at the engine: a NewOrder whose clientOrderId was already seen by
that account rejects with DuplicateClientOrderId and leaves the book
untouched -- whether the original is still resting, filled, or canceled. That
is what makes a client resend after an ambiguous disconnect safe.
clientOrderId == 0 means "not set" and is never deduplicated. The dedup
window is the engine session (uptime): the index is rebuilt by the same
submits during journal replay, so post-restart behaviour is identical to live;
rotating/compacting the index is a future checkpoint concern.
Internal OrderIds (NewOrder.id) are a separate namespace: the engine
requires them to be globally unique across accounts (duplicates reject only
while the earlier order is alive). Generating unique ids is the
gateway's/client's responsibility -- that is the honest boundary.
Auctions and halts¶
Sequenced through AdminCmd, so they survive replay:
BeginPreOpen: accumulate without matching (a crossed book is allowed).OpenContinuous: uncross at the single volume-maximising price, then resume.ResumeAuction: clear a halt into a re-opening auction.HaltAndCancelAll: emergency halt that also pulls the resting book.Halt/Resume.
Order paths and the gates on them¶
Eight paths admit or re-admit an order into matching, and each applies the pre-trade checks itself. A stop that fires is not covered by the validation its submission passed: the instrument may have halted in between.
flowchart TD
NEW[NewOrder] --> DEDUP{clOrdId<br/>already used?}
DEDUP -->|yes| REJ[Reject]
DEDUP -->|no| COND{conditional?}
COND -->|yes| VC[validateConditional<br/>tick / band / lot on the trigger]
VC -->|fails| REJ
VC -->|ok| PARK[park in the stop book]
COND -->|no| CAP{maxOpenOrders}
CAP -->|exceeded| REJ
CAP -->|ok| PERP[perpRiskGate<br/>reduce-only, position cap]
PERP -->|fails| REJ
PERP -->|ok| VAL[validate<br/>state, tick, lot, band, LULD]
VAL -->|fails| REJ
VAL -->|ok| FUND[reserveFunds]
FUND --> CRED{credit hook<br/>external risk owner}
CRED -->|refuses| REJ
CRED -->|allows| RES[reserve collateral<br/>if a ledger is bound]
RES -->|insufficient| REJ
RES -->|ok| MATCH[matcher.cross]
MARK[mark / last price moves] --> TRIG{instrument<br/>trading?}
TRIG -->|halted, closed,<br/>paused, auction| STOPPED[no trigger]
TRIG -->|yes| POP[pop triggered stops]
POP --> PERP2[perpRiskGate] --> FUND2[reserveFunds] --> MATCH
MOD[ModifyOrder] --> HOLDS[resolve open holds] --> VM[tick / band / lot]
VM --> PERP3[perpRiskGate] --> FUND3[reserveFunds] --> MATCH
QUOTE[Quote] --> HOLDS2[resolve open holds] --> LEGS[each leg through NewOrder]
LEGS --> DEDUP
PEG[reference moves] --> HOLDS3[resolve open holds] --> FUND4[reserveFunds<br/>at the new price] --> BOOK[re-enter book]
MATCH --> FR{fill-time risk<br/>perp only}
FR -->|blocked| PULL[pull the blocked leg]
FR -->|ok| TRADE[Trade]
AUCT[auction uncross] --> FR
ACCEPT[hold accepted] --> FR
The table below is the same thing checked against the source, including gates inherited through delegation -- a quote runs every new-order gate because each leg goes through that path.
| path | state | dedup | tick/lot/band | perp risk | credit hook | collateral | self-trade | fill-time risk | holds |
|---|---|---|---|---|---|---|---|---|---|
| new order | yes | yes | yes | yes | yes | yes | yes | - | - |
| conditional (parked) | yes | - | yes | yes | yes | yes | - | - | - |
| stop trigger | yes | - | yes | yes | yes | yes | yes | - | - |
| modify | yes | - | yes | yes | yes | yes | yes | - | yes |
| quote (per leg) | yes | yes | yes | yes | yes | yes | yes | - | yes |
| peg reprice | - | - | yes | yes | yes | yes | yes | - | yes |
| auction uncross | - | - | - | yes | - | - | yes | yes | - |
| hold accept | - | - | - | - | - | - | - | yes | yes |
Self-trade prevention applies under both matching policies and in the auction,
and where it reads the mode from differs. In continuous trading it comes off
the aggressor: pro-rata resolves every same-scope maker at the level before
computing the split, price-time resolves each one as the sweep reaches it, and
both call applySelfTradePrevention. A modify carries the mode of the order it
replaces. An auction has no aggressor, so the mode comes off each resting order
(the book stores it for that) and applies from its owner's side.
Two blanks in the table are intentional. A peg reprice does not re-check instrument state: it only runs on a submit, and that submit is rejected first when trading is stopped. The auction uncross does not re-validate prices or take collateral, because every order in the book passed both on admission and the uncross only picks the clearing price. Both cases are covered by tests.
test_venue_gate_coverage.cpp asserts these as properties rather than as a
list: each admission path must ask the credit hook for the order it admits,
nothing may print while the instrument is not trading (under both trigger
references), and a conditional order must obey tick, band and lot. Removing any
one guard makes it fail -- verified by mutation, one guard at a time.