Skip to content

fix: terminate taker at the match cap instead of resting a crossed remainder (#93)#106

Merged
emrebulutlar merged 1 commit into
mainfrom
fix/match93-match-cap
Jul 7, 2026
Merged

fix: terminate taker at the match cap instead of resting a crossed remainder (#93)#106
emrebulutlar merged 1 commit into
mainfrom
fix/match93-match-cap

Conversation

@emrebulutlar

Copy link
Copy Markdown
Member

Problem

MAX_MATCHES_PER_ORDER (=10_000, in both ArrayMatchingEngine and DirectMatchingEngine) caps the number of matches a single command may generate, bounding per-command work on the single consensus thread. When that cap fired mid-sweep, both engines returned early leaving takerRemaining > 0, and back in Engine.processCreate / processUpdate that leftover was treated as "ran out of liquidity":

  • LIMIT: the remainder RESTED on the book even though crossing liquidity still sat at levels the sweep never reached. Result: a crossed book (a bid resting above unreached asks) with guaranteed-matchable liquidity left unmatched.
  • MARKET: processCreate only branched matchCount == 0 (REJECTED) vs else (FILLED), so a market order capped mid-sweep was reported FILLED, hiding a partial fill from OMS.

Fix: terminate at the cap

The cap stays 10_000; it exists to bound per-command work, so the right behavior is to stop the taker at the cap, never to rest a crossed remainder or hide a partial.

  • Both impls: a matchLimitReached flag, reset at the start of each order, SET at the cap early-return sites (only reachable with taker quantity still remaining, since the loop guard proved it), and it breaks the outer per-level loop. Exposed as wasMatchLimitReached() on the MatchingEngine interface.
  • LIMIT path: when the sweep ends with a remainder and the flag is set, the remainder is NOT rested; instead lastRestRejectReason = OrderRejectReason.MATCH_LIMIT. Engine's existing restReason != NONE branch then publishes the loud terminal status (CANCELLED with the true filledQty when matchCount > 0) with zero new Engine branches, for both processCreate and processUpdate.
  • MARKET path: matchCount > 0 && engine.wasMatchLimitReached() publishes CANCELLED with the true filled quantity. We trust the explicit flag, not a leftover-budget heuristic (the budget/price-precision break also leaves budget > 0). matchCount == 0 with the flag set is impossible (cap >= 1).
  • Boundary: an order that fully fills in exactly the cap's matches exits its loop naturally (taker hits 0) without reaching the early-return, so the flag stays clear and the order stays FILLED.
  • New OrderRejectReason.MATCH_LIMIT = 7 (+ describe). Codes 0-6 are unchanged (INVALID_QUANTITY=6 from fix: reject quantity<=0 at order admission — silent loss + LIMIT_MAKER poison pill (#91) #103 kept its number).

Why terminate, not continue in chunks?

The cap is a determinism/liveness guard on the consensus thread. Continuing the sweep across multiple commands (or looping past the cap) would either defeat the bound or require carrying taker state across log records, adding a re-entrancy surface to the replicated state machine for a case that only arises with 10_000+ resting orders swept by one order. Terminating at the cap keeps the state machine a pure per-command function and gives OMS a truthful terminal status (CANCELLED + true filled) that it already knows how to reconcile.

Determinism

Pure state-machine change. The prod cap remains a hardcoded deterministic constant (never an env var: a divergent cap between replicas would fork the state machine). Triggering it at the prod cap needs 10_000+ command lines, so per the agreed approach the termination path is covered at unit level via a package-private constructor overload that injects a small cap (=4); the committed determinism corpus goldens are unchanged (verified with git diff after running the corpus).

Tests

New MatchLimitCapTest (17 cases, exercised against both the array and direct impls, injected cap=4):

  1. Capped LIMIT (impl + Engine level): exactly 4 trades, remainder not rested, best bid < best ask (never crossed), terminal CANCELLED published with filledQty = 4.
  2. Capped MARKET buy and sell: CANCELLED with the true filled quantity (not FILLED); budget/quantity confirms the cap, not exhaustion, stopped it.
  3. Exact boundary: an order fully filling in exactly 4 matches stays FILLED, flag not set (impl + Engine level).
  4. A/B equivalence at the impl level with the small cap (identical trades, cap flag, and rest/no-rest decision).
  5. Crossed-book invariant assertion helper (bestBid < bestAsk when both sides non-empty) applied throughout.

Full match-cluster suite green (433 tests, excluding EmbeddedClusterTest / ArchiveHousekeepingTest which need the live stack); determinism corpus goldens unchanged.

Closes #93

🤖 Generated with Claude Code

…mainder (#93)

MAX_MATCHES_PER_ORDER (=10_000) bounds per-command work on the single consensus
thread, but when it fired mid-sweep both engines treated the leftover taker quantity
as "ran out of liquidity". A LIMIT remainder then RESTED on the book even though
crossing liquidity existed at unreached levels (a crossed book: a bid resting above
unreached asks), and a capped MARKET order fell into the else branch and was
mis-reported FILLED, hiding a partial fill from OMS.

Terminate at the cap instead. Both matching-engine impls now carry a matchLimitReached
flag (reset per order, set at the cap early-return sites, breaks the per-level loop).
The LIMIT path surfaces the truncation via the existing rest-reject mechanism
(lastRestRejectReason = MATCH_LIMIT) so Engine publishes a terminal CANCELLED with the
true filled quantity through its existing "restReason != NONE" branch, with no new
Engine branches. The MARKET path publishes CANCELLED with the true filled quantity when
wasMatchLimitReached() (trusting the explicit flag, not a leftover-budget heuristic). An
order that fully fills in exactly the cap's matches does not set the flag and stays
FILLED.

The prod cap stays a hardcoded deterministic constant (never an env var: a divergent cap
between replicas would fork the state machine). A package-private constructor overload
injects a small cap so the termination path is unit-testable with a handful of orders.
New OrderRejectReason.MATCH_LIMIT = 7 (codes 0-6 unchanged).

Tests (new MatchLimitCapTest, 17 cases across both impls): capped LIMIT terminates with
the remainder not rested and the book not crossed; capped MARKET buy and sell report
CANCELLED with the true filled qty; exact-cap full fill stays FILLED (flag not set);
array/direct A/B equivalence at the small cap. Full match-cluster suite green (433) minus
embedded infra; determinism corpus goldens unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@emrebulutlar
emrebulutlar merged commit 3d38899 into main Jul 7, 2026
2 checks passed
@emrebulutlar
emrebulutlar deleted the fix/match93-match-cap branch July 7, 2026 20:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Engine: MAX_MATCHES_PER_ORDER cap rests leftover unconditionally → crossed book + market order mis-reported FILLED

1 participant