Skip to content

Order lifecycle

Every order Strix knows about lives in one of seven states, aligned with FIX 4.2 OrdStatus semantics. The strix.order and strix.cancel blocks drive most of the transitions; executions arriving via strix.ingest_execution drive the rest.

States

State Meaning
PENDING_NEW Order recorded, pre-trade risk check passed, broker not yet acked.
NEW The strix.order(...) block exited cleanly — broker accepted.
REJECTED Pre-trade risk check failed, or the user code inside the order block threw. Terminal.
PARTIALLY_FILLED At least one execution applied, more qty outstanding.
FILLED Cumulative filled_qty == qty. Terminal.
PENDING_CANCEL Cancel requested via strix.cancel(...), broker not yet acked. Still counts as open at the broker (executions can still arrive).
CANCELLED Broker confirmed cancel (clean exit from strix.cancel(...)). Terminal.

REJECTED, FILLED, and CANCELLED are terminal. Once an order is in any of these, no further transitions occur for that order. (REJECTED and CANCELLED orders also reject further ingest_execution calls — see below.)

The diagram

strix.order(...)
     │  construct Order(PENDING_NEW)
     │  run pre-trade risk check
     │      │
     │      ├── risk fails  ──► REJECTED   (StrixRiskError raised, order recorded)
     │      └── risk passes
     │  expose Order to block body
     │      │
     │      ├── body throws    ──► REJECTED  (reject_reason = exception)
     │      └── body clean     ──► NEW
     ├─ (later) strix.ingest_execution(...)
     │      │
     │      ├── filled_qty < qty   ──► PARTIALLY_FILLED
     │      └── filled_qty == qty  ──► FILLED
     └─ (later) strix.cancel(order_id=...)
            │  validate status ∈ {PENDING_NEW, NEW, PARTIALLY_FILLED}
            │  transition ──► PENDING_CANCEL
            │  expose to block body
            │      │
            │      ├── body throws  ──► revert to prior status
            │      │                     (+ CancelAttemptFailed event)
            │      └── body clean   ──► CANCELLED
            │  (race: execution arrives during PENDING_CANCEL — fill wins,
            │   status goes to PARTIALLY_FILLED/FILLED instead)

The order block in detail

with strix.order(symbol="AAPL", side=Side.BUY, qty=100) as o:
    broker.submit(o.order_id, o.symbol, o.side, o.qty)

What happens:

  1. Construct. An immutable Order is built with status=PENDING_NEW. A UUIDv7 order_id is generated unless the caller passed one.
  2. Risk check. The configured pre-trade risk checks run against the projected state (current positions + this order's signed delta). If any check fails:
    • A REJECTED order is recorded in the order book (so it's visible in the dashboard).
    • StrixRiskError(reason, order_id=...) is thrown. The block body never runs.
  3. Register. The PENDING_NEW order is appended to the event log.
  4. Expose. The Order is exposed to the block body. This is where you call your broker.
  5. Exit.
    • Clean exit → status transitions to NEW. Broker accepted.
    • Exception in body → status transitions to REJECTED with reject_reason set to a string of the form <ExceptionType>: <message>, and the exception is re-raised.

The exposed Order is a snapshot at PENDING_NEW. After the block exits, look up the latest state via strix.get_order(o.order_id) (works for any status, including terminal FILLED / REJECTED / CANCELLED) or via strix.open_orders() (open statuses only) — the snapshot reference still shows PENDING_NEW. (Order is immutable; transitions replace the stored object, they don't mutate the snapshot.)

Why REJECTED covers both risk and broker-exception cases

Both share the lifecycle outcome: the order does not survive into a working state. Adding a distinct RISK_BLOCKED status wouldn't buy anything — reject_reason already carries the discriminator:

  • Risk reject: reject_reason is the StrixRiskError.reason string (e.g. "qty 2000 exceeds max_qty_per_order 1000").
  • User-exception reject: reject_reason is a string of the form <ExceptionType>: <message>.

Risk-blocked orders are deliberately recorded in the order book (not just raised-and-forgotten). That way the dashboard can show "blocked-by-risk" attempts as first-class signal — usually more interesting than the orders that went through cleanly.

Executions and partial fills

strix.ingest_execution(Execution(
    order_id=o.order_id,
    symbol="AAPL",
    side=Side.BUY,
    qty=40,
    price="150",
))

Each execution adds to order.filled_qty and updates order.avg_fill_price (volume-weighted across executions). The new status depends on the cumulative total:

  • filled_qty < qtyPARTIALLY_FILLED
  • filled_qty == qtyFILLED

The same execution also updates the position book (see Positions).

Validation on ingest_execution

The broker is authoritative on positions and cash. So an execution that doesn't match the local order book still moves the position — Strix applies it to the position book regardless. What changes is how the order book reacts and whether the call raises.

Categories of mismatch:

Condition Category
order_id not in book missing-order
Execution symbol ≠ order symbol symbol-mismatch
Execution side ≠ order side side-mismatch
Order is FILLED, REJECTED, or CANCELLED terminal-order
filled_qty + execution.qty > order.qty (overfill) overfill

For every mismatch:

  1. The position book is updated using the execution's own symbol / side / qty / price (broker-authoritative).
  2. The order book is not modified — the local lifecycle view is preserved as-is, and the divergence is recorded as data.
  3. An ExecutionAnomalyDetected event is appended to the log (carries the full execution, the category, a human-readable detail, and the order_id Strix tried to match). The dashboard surfaces this as a first-class signal.
  4. The SessionConfig.on_invalid_execution policy decides what happens to the caller:
    • "raise" (default): strix.ingest_execution(...) raises StrixInvalidExecutionError after steps 1–3. The position effect and the audit event are not undone — the exception is loud-failure for the integration bug, not a rollback.
    • "warn": log a WARNING. No raise.
    • "silent": no log, no raise. The event in the log is the only signal.

strix.reconcile(broker) always uses non-raising semantics during its broker-fetch loop regardless of on_invalid_execution — broker-pulled fills are authoritative and a single anomaly should not abort the batch. Anomaly events are still emitted and a WARNING is still logged per anomaly.

Overfill handling: the order is left in its prior status (typically NEW or PARTIALLY_FILLED); filled_qty and avg_fill_price do not advance. The "extra" qty lives only in the position book, where the broker reality is captured. If filled_qty < qty after enough such anomalies that the position implies a fully-filled (or beyond) order, that gap is the audit trail — reconcile or close the order manually.

The cancel block in detail

with strix.cancel(order_id=o.order_id):
    broker.cancel(o.order_id)

What happens:

  1. Validate. Order must exist in the book and be in PENDING_NEW, NEW, or PARTIALLY_FILLED. Unknown order → StrixUnknownOrderError (subclass of both StrixCancelError and KeyError). Wrong status → StrixOrderNotCancellableError (subclass of both StrixCancelError and ValueError, carries current_status). Catch the umbrella StrixCancelError to handle both with one branch.
  2. Transition. Status goes from prior → PENDING_CANCEL. An OrderStatusChanged event is appended.
  3. Expose. Control yields to the block body — typically your broker cancel call.
  4. Exit.
    • Clean exit → status transitions to CANCELLED. Broker confirmed.
    • Exception in body → status reverts to the prior status (NEW or PARTIALLY_FILLED). A CancelAttemptFailed event is appended with the exception in reason. The exception is re-raised.

Fill-wins race (FIX 4.2 scenario D4)

If an Execution arrives via ingest_execution while the order is in PENDING_CANCEL (the broker filled before the cancel reached the venue), the execution applies normally and the order moves to PARTIALLY_FILLED or FILLED. The cancel block then exits as a no-op — broker reality wins.

with strix.cancel(order_id=o.order_id):
    # Async: a fill arrives from the broker thread
    strix.ingest_execution(Execution(order_id=o.order_id, ...))   # → FILLED
# On exit: order is FILLED, NOT CANCELLED. The cancel was too late.

What if the body throws but a fill already won the race?

The exception is re-raised, but no CancelAttemptFailed is emitted — the order is no longer in PENDING_CANCEL, so there's nothing to revert. The fill stands.

Inspecting orders

  • strix.open_orders() — snapshot of orders in open statuses (PENDING_NEW, NEW, PARTIALLY_FILLED, PENDING_CANCEL). Terminal orders (REJECTED, FILLED, CANCELLED) are excluded from this view but remain in the order book.
  • strix.get_order(order_id) — single-order lookup by id. Returns the current Order (any status, including terminal) or None if the id is unknown. The canonical way to read state after a strix.order(...) block has exited, since the yielded snapshot stays frozen at PENDING_NEW.
  • PENDING_CANCEL counts as open because the broker still has the order — it may still fill.
  • The dashboard (post-v1) will surface the full set including terminal statuses.

What's not modeled in v1

  • Expiry. No EXPIRED state; no auto-expire-DAY-orders-at-close logic.
  • Time in force. Strix doesn't record TIF. Your broker submission code decides the venue-level TIF.

Both are deliberately deferred. They ship together when the lifecycle work is scheduled. See DESIGN.md §11 for the reasoning.