Skip to content

orders: distinguish Rejected from Inactive once the ibapi string collapses them (ibx#250) - #394

Open
userFRM wants to merge 1 commit into
deepentropy:mainfrom
userFRM:fix/rejected-inactive-collapse
Open

orders: distinguish Rejected from Inactive once the ibapi string collapses them (ibx#250)#394
userFRM wants to merge 1 commit into
deepentropy:mainfrom
userFRM:fix/rejected-inactive-collapse

Conversation

@userFRM

@userFRM userFRM commented Jul 31, 2026

Copy link
Copy Markdown

Problem

The wire distinguishes 39=8 (Rejected) from 39=I (Inactive), and the engine models them as distinct states — but order_status_str collapses both to the string "Inactive", correctly, because ibapi has no Rejected string. From that point a caller cannot tell a dead order from a held one, and three consequences follow.

The reason is dropped. Tags 58 and 103 are parsed and logged, but reach the API only for 39=8, through OrderState.completed_status. A 39=I transition surfaces as a bare status change with the reason nowhere a caller can reach.

The order disappears from the snapshot, and it has to. is_open_status omits "Inactive" — and cannot simply admit it, because after stringification that value is also every rejected order. Widening it would resurrect genuinely dead orders. So a reactivatable order is hidden by a filter defending itself against the conflation above, contradicting the engine one layer down, which deliberately models Inactive as non-terminal and reactivatable.

A replace the gateway parks looks like success. Every client-visible surface asserts the modify succeeded while req_open_orders returns nothing for that order.

What this changes

order_status_str is untouched — both still map to "Inactive", which is the ibapi contract.

completed_status is the discriminator. It is already non-empty only for a terminal status and empty otherwise, and a rejection falls back to the literal "Rejected" when tag 58 is absent, so it is never empty for one. is_open_or_reactivatable(status, completed_status) admits "Inactive" only when it is empty. is_open_status itself is not widened, which is the trap the issue names.

TrackedOrder carries whether it was rejected, set at its one write choke point. That function now takes the OrderStatus enum rather than the already-collapsed string, so the distinction is read before it is lost and the compiler enforces the call sites.

A 39=I transition delivers its reason through the wrapper's error() callback — the same path a cancel reject already uses — alongside the status change.

Note for merging

This changes ClientCore::update_order_status's signature. #362 changes the same function for an unrelated reason (an order recovered from an earlier session has no local entry to update). The two are independent but will want resolving together.

Tests

Eleven, covering the discriminator in all four quadrants, both collect_open_orders admission sites, the drain_open_orders filter, the TrackedOrder write, the ccp gating condition including the Rejected/Inactive conflation trap, the reason formatting, and the dispatch plumbing.

Each production line touched was independently reverted and the intended test confirmed to fail by name.

Closes #250.

…apses them (ibx#250)

The wire distinguishes 39=8 (Rejected) from 39=I (Inactive) and the engine already models them as separate OrderStatus values, but order_status_str collapses both to the ibapi string "Inactive" — correctly, since ibapi has no Rejected string. Everything downstream of that string therefore saw one value for two different situations: a dead order and a parked one that can still reactivate.

completed_status already carries the discriminator the two need: it is non-empty for every terminal status (Filled, Cancelled, Rejected) and stays empty for Inactive, since an Inactive order is not completed. That field is now read wherever the collapsed string is read back apart from OrderStatus itself — is_open_or_reactivatable checks it beside is_open_status, and TrackedOrder carries its own rejected flag for the path that only has a status string and no RichOrderInfo to consult. Widening is_open_status to admit "Inactive" outright was the trap to avoid, since that string is also every rejected order; the new function and flag exist so the open-order snapshot can tell the two apart without touching is_open_status.

drain_open_orders and collect_open_orders both used to filter on is_open_status alone, so a genuinely-Inactive order vanished from req_open_orders along with the rejected ones it is indistinguishable from once stringified. Both now call is_open_or_reactivatable, and collect_open_orders's locally-tracked path sets TrackedOrder.rejected at the same place its status string is written, update_order_status, which now takes the pre-stringification OrderStatus for exactly that reason.

The reason a 39=I transition carries — tags 58 and 103, already parsed alongside the reject-only warn log — had nowhere to go: completed_status stays empty for Inactive by design, so a caller reading OrderState never saw why the gateway parked the order. It now reaches Wrapper::error the same way a cancel/modify reject already does, through a small queue on OrderState that the engine populates only for a genuine Inactive transition, and both the Rust and Python dispatch loops drain it into an error callback alongside the existing order_status "Inactive".

Closes deepentropy#250.
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.

orders: Rejected and Inactive collapse to one status string, so the reason is dropped and a parked order vanishes from the open-order snapshot

1 participant