Skip to content

fix: honor post-only on LIMIT_MAKER amends; processUpdate matched as a taker (#92)#104

Merged
emrebulutlar merged 1 commit into
mainfrom
fix/match92-limit-maker-amend
Jul 7, 2026
Merged

fix: honor post-only on LIMIT_MAKER amends; processUpdate matched as a taker (#92)#104
emrebulutlar merged 1 commit into
mainfrom
fix/match92-limit-maker-amend

Conversation

@emrebulutlar

Copy link
Copy Markdown
Member

Mechanism

Engine.processUpdate is cancel-and-replace and unconditionally routed EVERY replacement through the matching engine.processLimitOrder(...) path, never reading cmd.getOrderType(). SbeDemuxer.handleUpdateOrder already decodes and sets the order type on the command, but the engine ignored it. So an amended LIMIT_MAKER whose new price now crosses the spread executed as a taker, silently stripping the post-only guarantee (e.g. resting ask @60000; post-only bid @59000 amended to 61000 filled 5 @60000 and went FILLED, with no rejection).

Fix

processUpdate now honors LIMIT_MAKER, mirroring processCreate:

  1. Pre-cancel would-cross check (shared wouldCrossOpposite helper, extracted so create and amend decide crossing with the exact same expression). It runs AFTER the existing price-validity and match#91 quantity checks (both still fire first) and BEFORE the cancel. If the amended price would cross, it publishes REJECTED for the OLD orderId/omsOrderId and returns, leaving the old order resting and tradable. This is exact for the single-threaded engine: the old order rests on the same side as the replacement, so cancelling it cannot move the opposite best the check reads.
  2. Non-crossing: cancel + CANCELLED as before, then rest the replacement via the no-match addOrderNoMatch path (never processLimitOrder). A could-not-rest result (LEVEL_FULL/BOOK_FULL) is a loud terminal REJECTED (the old order is already gone; FIFO position is forfeit on any amend). Success publishes NEW for the full new quantity.
  3. Any other/absent order type: the plain LIMIT amend path is byte-identical (matching cancel-and-replace).

Coupling to oms#67

This bug is masked today by the OMS amend bug (oms#67) and is unmasked the moment the OMS amend fix ships, so this must be active on the cluster first. Ship this before oms#67 goes live.

Tests

  • EngineTest (4 new):
    • Crossing LIMIT_MAKER amend (59000 to 61000 with ask @60000) publishes REJECTED, the old bid @59000 stays resting, and a later MARKET sell fills against it at 59000.
    • Non-crossing amend (59000 to 59500, ask @60000): CANCELLED(old) + NEW(new), zero trades, replacement rests at 59500.
    • Replacement cannot rest: forced LEVEL_FULL on the direct engine's 64-orders/level cap produces a loud REJECTED, not a phantom NEW. (The array engine cannot fail a same-side amend, since the cancel frees the slot the re-add reclaims, so this terminal branch is exercised on the direct impl.)
    • Plain LIMIT amend regression: a crossing plain amend still matches and fills (proves the fix touches LIMIT_MAKER only).
  • New determinism scenario limit_maker_update.scenario + golden covering both branches (crossing amend REJECTED with the old order surviving and later filled as maker; non-crossing amend CANCELLED+NEW). A/B convergent (DeterminismAbEquivalenceTest), and no existing golden changed (verified with git diff, including update_price_then_match).
  • ScenarioRunner.buildUpdate gained optional type= support (default LIMIT = prior behavior), following how CREATE lines express types.
  • Full match-cluster suite green: 416 tests across 22 classes, 0 failures/0 errors, excluding the embedded-Aeron-infra EmbeddedClusterTest/ArchiveHousekeepingTest.

Closes #92

🤖 Generated with Claude Code

…a taker (#92)

Engine.processUpdate is cancel-and-replace and used to route EVERY replacement
through the MATCHING processLimitOrder path, never reading cmd.getOrderType().
So an amended LIMIT_MAKER that now crosses executed as a taker, silently
stripping the post-only guarantee (SbeDemuxer.handleUpdateOrder already decodes
and sets the order type).

processUpdate now honors LIMIT_MAKER, mirroring processCreate:
- A pre-cancel would-cross check (shared wouldCrossOpposite helper). If the
  amended price would cross, publish REJECTED for the OLD orderId/omsOrderId and
  return, so the old order stays resting and tradable. Exact for the single-
  threaded engine: the old order rests on the same side, so cancelling it cannot
  move the opposite best the check reads.
- Otherwise cancel + CANCELLED as before, then rest the replacement via the
  NO-MATCH addOrderNoMatch path (never processLimitOrder). Could-not-rest
  (LEVEL_FULL/BOOK_FULL) is a loud terminal REJECTED (old order already gone);
  success publishes NEW for the full new quantity.

Plain LIMIT amends are byte-identical (verified: update_price_then_match golden
unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@emrebulutlar
emrebulutlar merged commit f80f252 into main Jul 7, 2026
2 checks passed
@emrebulutlar
emrebulutlar deleted the fix/match92-limit-maker-amend branch July 7, 2026 20:14
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: LIMIT_MAKER amend silently strips the post-only guarantee

1 participant