Skip to content

ccp: a cancel rejected as unknown retires the order (ibx#252) - #379

Open
userFRM wants to merge 2 commits into
deepentropy:mainfrom
userFRM:fix/unknown-order-cancel-reject-v2
Open

ccp: a cancel rejected as unknown retires the order (ibx#252)#379
userFRM wants to merge 2 commits into
deepentropy:mainfrom
userFRM:fix/unknown-order-cancel-reject-v2

Conversation

@userFRM

@userFRM userFRM commented Jul 30, 2026

Copy link
Copy Markdown

Stacked on #322 — review the tip commit only.

Problem

FIX CxlRejReason 1 is UnknownOrder: the gateway is stating that the order does not exist on its side.

handle_cancel_reject restored it to working anyway. The restore ran first and unconditionally, and only afterwards did anything branch on the reason — then it removed the shared-cache row. Engine and cache were left asserting opposite things.

The engine's own view governs subsequent cancels, modifies and reconnect bookkeeping, so a phantom order persisted there with no cache row to make it visible — the inverse of the usual stale-cache problem and harder to notice. It compounds after a reconnect, where a cancel aimed at an order that no longer exists is the expected shape.

What this changes

The reason is read before the status is chosen, and read as a positive statement rather than an absence. A missing or unparseable tag 102 is synthesized as -1 and says nothing, so it takes the same path as the reasons that do mean the order is still working and the cancel arrived at the wrong moment.

An unknown order is retired — terminal status, removed from the book, cache row dropped.

Holding the record in a non-working status instead is not an option: those are excluded from the open-order count that guards instrument reclamation, so the slot could be handed to another contract while a retained order still pointed at it, and a late fill would move the wrong position.

A fill that races the rejection is recoverable, on the terms the untracked-fill path from #322 sets — hence the stacking. The execution has to carry its contract id, because nothing else says which instrument moved, and it must not be resend-marked, because a replayed execution for an order this session does not track is history rather than news. One carrying neither is dropped, as it already was for any order removed from the book ahead of a late fill.

No synthetic status update is queued. The cancel-reject is the report, and both dispatchers drain fills ahead of order updates; an update queued here would reach a caller after the fill that raced it, stating the order was gone when they had just been told it filled.

Both dispatchers retire the client's own record when they deliver that rejection. collect_open_orders unions the client's records with the cached view, so retiring only the engine's left the order being reported by the very surface this issue is about.

Tests

  • an_unknown_order_rejection_retires_the_order — order and cache row gone, cancel-reject delivered, no synthetic update.
  • an_execution_racing_an_unknown_order_rejection_still_books — the fill books and the position moves.
  • any_other_rejection_leaves_the_order_in_place — stated reasons that mean it is working, and the absent/unparseable case.

Each fails by name against a compiling reversion of the production change it covers.

Closes #252.

Test plan

  • Mutation: treating UnknownOrder like every other reason fails an_unknown_order_rejection_retires_the_order by name.
  • Mutation: leaving the order in the book fails the same test.
  • Mutation: retiring on any non-zero reason fails any_other_rejection_leaves_the_order_in_place.
  • an_execution_racing_an_unknown_order_rejection_still_books covers the case retiring the record could have cost.
  • cargo check --offline clean on --lib, --lib --features python, --bins, --examples, and each integration target individually.
  • tests/ib_paper_compat compared against a clean checkout of the base commit — identical sorted diagnostic sets.
  • cargo test --offline --lib — only the two known config::expiry_tests failures, which fail on the base commit for missing legacy tzdata (fixed separately in config: resolve the legacy timezone names IB states its times in (ibx#335) #336).

userFRM and others added 2 commits July 29, 2026 16:43
The fill block recorded the ExecID and then booked the fill only if the order was already in `context`. There was no other branch, so a fill for an order the session does not track was dropped in full: no `Fill`, no execution detail, no position update, and nothing in the log to say a fill had been seen at all.

The ExecID had already been consumed by then, which made the loss permanent. The replay the gateway pushes after a reconnect — the thing that would have recovered it — is rejected as a duplicate.

An order is missing from `context` in ordinary ways. It reached a terminal status and was removed, which is the cancel/fill race: the cancel ack is processed first and the fill arrives to find nothing. Or it was placed from another client, or in an earlier session; the recovery insert does not pick those up, since it only fires for a New/New report carrying a contract id and a quantity. What is left is a position the account holds and the engine does not, disagreeing with the gateway's own position feed, with no error raised either way.

The execution report already carries what booking needs, so the fill is now placed from the report when the order is unknown. Both the contract and the side must be on it: a guessed side moves the position the wrong way, which is worse than reporting that the fill could not be placed, so a report without tag 54 is refused rather than defaulted. Registration goes through the fallible path, because a full instrument table is a condition to report and not one to abort an inbound message on.

A replayed execution does not open a new position on this path. On a fresh process the gateway resends prior executions carrying `97=Y` and their original ExecIDs, for orders no session tracks, and booking those would build a position out of history on top of the one the position feed already reports. Within a process the ExecID window covers the reconnect burst, so the marker is consulted only where the order is unknown. It does not reach a replay whose order the recovery insert has already tracked — that path books as it did before, and is worth separating out (ibx#320).

The ExecID is recorded once the fill can be booked rather than before. An execution that could not be placed stays replayable.

Each reason for refusing to book is logged where it is decided; one message for all of them misdiagnoses two of the three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FIX `CxlRejReason 1` is UnknownOrder: the gateway is stating that the order does not exist on its side. The handler restored it to working anyway — the restore ran first and unconditionally, and only afterwards did anything branch on the reason — and then removed the shared-cache row. Engine and cache were left asserting opposite things.

The engine's own view is the one that governs subsequent cancels, modifies and reconnect bookkeeping, so a phantom order persisted there with no cache row to make it visible. It compounds after a reconnect, where a cancel aimed at an order that no longer exists is the expected shape.

The reason is read before the status is chosen, and read as a positive statement rather than an absence: a missing or unparseable tag 102 is synthesized as -1 and says nothing, so it takes the same path as the reasons that do mean the order is still working and the cancel arrived at the wrong moment.

An unknown order is retired — terminal status, removed from the book, cache row dropped. Holding the record in a non-working status instead is not an option: those are excluded from the open-order count that guards instrument reclamation, so the slot could be handed to another contract while a retained order still pointed at it, and a late fill would move the wrong position.

A fill that races the rejection is recoverable on the terms the untracked-fill path sets: the execution has to carry its contract id, because nothing else says which instrument moved, and it must not be resend-marked, because a replayed execution for an order this session does not track is history rather than news. One carrying neither is dropped — as it already was for any order removed from the book ahead of a late fill.

No synthetic status update is queued. The cancel-reject is the report, and both dispatchers drain fills ahead of order updates — an update queued here would reach a caller after the fill that raced it, stating the order was gone when they had just been told it filled.

Both dispatchers retire the client's own record when they deliver that rejection. The open-order snapshot unions the client's records with the cached view, so retiring only the engine's left the order being reported by the surface the issue is about.

Closes deepentropy#252.
@userFRM
userFRM force-pushed the fix/unknown-order-cancel-reject-v2 branch from d5adcb5 to 25dbaac Compare July 30, 2026 17:16
userFRM added a commit to userFRM/ibx that referenced this pull request Aug 3, 2026
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.

cancel-reject: an UnknownOrder rejection force-restores the order to Submitted in the engine while removing it from the cache

1 participant