Order lifecycle
Every order folds through one state machine with one mutator, must reach exactly one terminal state, and may never silently vanish — and a shared harness proves that against every venue's real decoder.
An order in Vike Trader is a ManagedOrder — crates/vike-exec/src/order.rs — and its status is
a finite state machine. ManagedOrder::apply is the only mutator of order state: it looks the
transition up in a table, returns InvalidOrderTransition on an illegal edge, and accumulates
fills into filled_qty and a running volume-weighted avg_fill_px. The one other writer is
ManagedOrder::resolve_pending_cancel, documented as the ONE restore: the engine fold's
cancel-race guard calls it immediately before apply to recompute a PendingCancel order's
pre-cancel status from its own fill stream, and it is a no-op from every other state.
The states
| state | meaning |
|---|---|
Initialized | built locally, not yet sent |
Submitted | sent; emitted by the Rust side synchronously at submit |
Accepted | the venue acknowledged a resting order |
Triggered | a conditional order's trigger condition was met |
PartiallyFilled | some quantity executed, some resting |
PendingCancel | a cancel is in flight |
Emulated | a conditional order held client-side, for venues without a native one |
Released | an emulated conditional released to the venue |
Liquidated | a perpetual force-close — a live state, deliberately not a terminal |
Filled · Canceled · Rejected · Denied · Expired | the five terminal states |
Rejected is the venue's refusal; Denied is the risk gate's veto, pre-venue.
OrderStatus::is_terminal names exactly those five, and once an order is in one of them any
further lifecycle event for it is an illegal transition — dropped idempotently at the engine's
fold, never applied. Liquidated is excluded from that set on purpose, yet OrderStatus::is_live
— the cancel-send gate, "is a cancel still worth sending?" — excludes it too; the two disagree on
that one state by design, and a test pins the disagreement.
The transition table
order.rs's transition is the table, verbatim:
| event | allowed from | to |
|---|---|---|
OrderSubmitted | Initialized | Submitted |
OrderAccepted | Submitted | Accepted |
OrderRejected | Initialized, Submitted | Rejected |
OrderDenied | Initialized | Denied |
OrderTriggered | Accepted | Triggered |
OrderPartiallyFilled | Accepted, Triggered, PartiallyFilled | PartiallyFilled |
OrderFilled | Accepted, Triggered, PartiallyFilled | Filled |
OrderCanceled | Accepted, Triggered, PartiallyFilled, PendingCancel | Canceled |
OrderExpired | Accepted, Triggered, PartiallyFilled | Expired |
OrderLiquidated | Accepted, Triggered, PartiallyFilled | Liquidated |
Two edges look wrong and are not: PartiallyFilled → Canceled and Accepted → Canceled are both
allowed, because a venue may cancel the sibling leg of a one-cancels-other pair server-side while
that leg has already partly filled. Three events are not transitions at all. OrderModified is a
self-transition legal only in the modifiable set (Accepted, Triggered, PartiallyFilled): it
rewrites the resting quantity and the limit or trigger price — whichever the order actually rests
on — and leaves the status alone. OrderCancelRejected and OrderModifyRejected are
non-terminal advisories: the order stays live and keeps its terms, and they are legal only while
the request they refuse could have been in flight.
Two sets that coincide today and are deliberately kept apart
CAN_RECEIVE_CANCEL answers "may this order receive OrderCanceled?" and is_live answers "is a
cancel worth sending?", and they differ on Submitted: a cancel may be sent to a submitted order,
yet a venue-honoured cancel of it returns an illegal transition and is dropped — masked in practice
by in-order delivery, since the accept lands first. CAN_RECEIVE_LIQUIDATION exists because that
question was once answered in two places that disagreed: the table spelled the set inline while
the engine's liquidation picker selected by negation and admitted five states the FSM refuses, so
the pick landed on an order apply then rejected. Both pairs are pinned by tests, so a coincidence
of membership is observed rather than assumed.
The three rules a venue may not break
The state machine is shared; what varies is the venue's decoder. Three rules pin what a decoder must do, and they are the contract every bridge is held to:
- Emitter split. The Rust side emits
OrderSubmittedsynchronously at submit. The venue side emitsOrderAccepted,OrderRejected,OrderCanceled,OrderFilledandOrderPartiallyFilled. - No silent vanish. A dead venue path must synthesize a terminal
OrderRejected. An order that was submitted and never heard from again is a bug, not a state. - Exactly one terminal. The state machine must reach exactly one terminal state. Illegal or out-of-order events — a late cancel replay after a fill, a duplicate fill on reconnect — are dropped without corrupting the terminal already reached.
The harness that holds them
The rules used to be verified ad hoc, per venue. They are now one scenario table run against every
covered venue's real event_mapper plus the shared execution plumbing:
crates/vike-bridge-core/tests/bridge_conformance.rs. The invariant oracle is the real state
machine — vike_exec::ManagedOrder::apply, the same code the live core folds through — wrapped by
ContractOrder, which mirrors the engine's thin per-order policy: the bare Fill goes to the
account side and never touches the FSM, the wrapping OrderPartiallyFilled/OrderFilled advance
it deduplicated by trade_id, and a terminal event that fails to apply while the order is still
live is counted as a genuine loss. Each venue plugs in only its venue-native frames and its
fixture-tested decoder; the scenarios, the folds and the assertions are shared.
| scenario | what it proves |
|---|---|
| Lifecycle | submit → accept → partial fill → full fill reaches Filled, one terminal |
| TransportDeath | a dead venue path still synthesizes a terminal, through the exact seam that venue uses — the command actor or the ambiguous-submit resolver |
| CancelAfterClose | a late venue OrderCanceled replay and a late OrderCancelRejected after the order closed are both dropped without corrupting the terminal |
| ReconnectMidOrder | driven through the real shared pump with a scripted drop and reconnect that replays the mid-order partial: no duplicate terminal, no double-counted fill, no lost terminal |
Every roster venue is either covered or deferred with a reason
conformance_roster_is_exhaustive checks the covered and deferred lists against
vike_model::VENUES, so a new bridge crate cannot join the roster and silently escape the harness
— it reddens until it is wired in or deferred with a written reason. The crypto venues with a
fixture-tested private-WS decoder run the cumulative-fill variant; the FX, CFD and equity venues
run a whole-fill variant, because their decoders have no partial-fill path at all. One native-SDK
venue has no wire: its fill and cancel envelopes are JSON a C++ shim builds, and its row says
explicitly what a green result does not say about the SDK behind it, which no CI machine can
compile. The harness prints the whole matrix, so no coverage fraction is written here.
What comes next
Each event the state machine consumes has its own page under
events, generated from the Event enum so the list cannot drift from
the code. The ExecutionClient seam is where those events
come from; the account is where the bare fill goes instead; and the
reconciliation pages are what happens when the venue's view and
this state machine's view disagree.
The ExecutionClient seam
The one trait every venue bridge implements — only submit and cancel are required, every state change comes back as an event, and one method exists precisely so a venue never overrides it.
Risk and arming
The gates between an order and the wire, in the order they run — the per-venue arming ceiling read before any credential, the credential gate behind it, the pre-trade RiskGate on every order, and the halt — with paper the default under all of them.