Order lifecycle
The order lifecycle is the per-order state machine that runs underneath the position lifecycle. Each order — market, limit rung, take-profit, stop-loss — has its own placement / fill / cancel sequence with its own idempotency anchor (exchange_order_id). The position-level orchestration coordinates these orders, but the per-order state changes themselves are owned at this layer.
This is the lifecycle lens for the order-level flow. For the per-order business rules, see the Orders chapter.
States
┌──────────┐
│ NEW │ (Order row created in DB, not yet at exchange)
└────┬─────┘
│ Place*OrderJob
▼
┌──────────┐
│ PLACED │ (exchange accepted; exchange_order_id set)
└────┬─────┘
┌──────────┼──────────┐
▼ ▼ ▼
FILLED CANCELED EXPIRED
│ │ │
▼ ▼ ▼
observer observer observer
updates updates updates
Position Position Position
FILLED triggers different downstream workflows depending on the order type. CANCELED and EXPIRED are shape-equivalent — both mean the order is no longer live at the exchange.
The placement step
Every Place*OrderJob follows the same idempotent shape:
- Idempotency check — if the local
Orderrow already has anexchange_order_id, short-circuit and return. - Pre-flight validation — notional check (rejects below exchange
min_notionalfor the symbol), leverage bracket check. - Exchange API call with a deterministic
clientOrderId. - Persist
exchange_order_idatomically.
If the worker crashes between steps 3 and 4, the next retry uses the clientOrderId to query the exchange, finds the half-placed order, and adopts it (writes the exchange_order_id from the exchange's response). No duplicate.
The fill path
Binance fill ──► user-data WS daemon
│
▼ ProcessUserDataEventJob
api_data_stream (raw audit row)
│
▼
Order::updateSaving(status='FILLED', filled_qty, ...)
│
▼
OrderObserver::updated()
│
┌─────────┼─────────────┐
▼ ▼ ▼
LIMIT PROFIT STOP-MARKET
FILLED FILLED FILLED
│ │ │
▼ ▼ ▼
ApplyWap ClosePosition ClosePosition
Job Job Job
Three observer arms, three downstream block dispatches. Each block is itself a step-dispatcher orchestration with its own idempotency hooks (see position lifecycle).
The cancel / expire path
CANCELED / EXPIRED arrive via the same WS push (or the 5-min polling safety net). The observer routes them to PrepareOrderCorrectionJob if the order was load-bearing (TP / SL on an active position), or noops if it was a benign rung-cancel during a position close.
A specific high-frequency case: manual close detection. When a reduce-only FILL arrives for an order Kraite doesn't own against a position Kraite does own, the daemon dispatches PreparePositionReplacementJob immediately — not waiting for the polling sync to catch the EXPIRED legs of the kraite-owned TP / SL.
Why orders carry their own anchor
Architectural decision
A position with 6 LIMIT rungs + 1 TP + 1 SL is 8 orders. If retry semantics lived only at the position layer, every retry would re-attempt every order — and the only way to make that safe would be to delete-and-re-place the whole order set on every retry. Operationally hostile, exchange-rate-limit hostile. By making exchange_order_id the per-order anchor, retries become local: only the failed order is re-placed, the seven intact ones are untouched. This is what makes the open / close blocks tolerant to partial failures.
Cross-lens links
- Position lifecycle — the parent flow that orchestrates these orders
- Orders — the domain rules for each order type
- WebSocket streams — the push path that delivers fill events
- Eos + Iris + Nyx (workers) — where every
Place*OrderJobactually runs