Position state machine

Compiled truth (2026-04-27, post-83ba9eb): A Position has exactly three states. Code paths that act on Positions (reconciler, watchdog, exit triggers) MUST gate on state - acting on the wrong state has caused real money loss (see exit-path-failure-modes). State changes are routed through a single transition helper that emits STATE_TRANSITION log lines.

States

                  ┌──────────────────┐
   open_position()│  PENDING_ENTRY   │
       ────────► │                  │
                  │ entry order      │
                  │ working;         │
                  │ broker_qty may   │
                  │ < target         │
                  └────────┬─────────┘
                           │ entry order Filled AND remaining_qty == 0
                           ▼
                  ┌──────────────────┐
                  │      OPEN        │
                  │                  │
                  │ contracts_       │
                  │ remaining ==     │
                  │ filled qty;      │
                  │ TP placed;       │
                  │ exit triggers    │
                  │ armed            │
                  └────────┬─────────┘
                           │ TP fill / exit fill / EXTERNAL_CLOSE / time-close /
                           │ MANUAL_SELL (operator)
                           ▼
                  ┌──────────────────┐
                  │     CLOSED       │
                  │ terminal         │
                  └──────────────────┘

Who is allowed to act on each state

SubsystemPENDING_ENTRYOPENCLOSED
reconcile_with_broker()NO-OPactNO-OP
Watchdog DEAD MAN’S SWITCHNO-OPact*NO-OP
Exit trigger (price SL / score / time)NO-OPactNO-OP
TP placement (_place_tp_limit)NO-OPactNO-OP
confirm_fill() (BUY fill)act + flipactNO-OP
confirm_exit_fill() (SELL fill)NO-OPact + flipNO-OP
updatePortfolio callbackobserve onlyactNO-OP
Operator UI modify_tp_limit()NO-OPactNO-OP
Operator UI modify_sl_price()NO-OPactNO-OP
Operator UI manual_market_exit()NO-OPact + flip (CLOSED, MANUAL_SELL)NO-OP
Operator UI cancel-order on tp_order_idNO-OPact (clears tp_order_id, re-arms software TP fallback)NO-OP

*Watchdog dead-man’s-switch additionally requires _last_tick_at > 0 (brand-new positions with no tick yet are not “blind”, they’re just not subscribed yet).

Critical invariants

  1. PENDING_ENTRY → OPEN happens ONLY when the entry order’s remaining_qty == 0 (full fill complete). Partial fills do NOT transition. This is the rule that broke today’s #186 + #187.
  2. broker_qty == 0 is EXPECTED during PENDING_ENTRY. It does not imply EXTERNAL_CLOSE.
  3. OPEN positions are the only ones the reconciler can finalize as EXTERNAL_CLOSE. A PENDING_ENTRY position whose entry never fills is finalized by the entry-fill-timeout path, not by reconcile.
  4. State changes always emit STATE_TRANSITION log lines. This is the audit trail. Format: STATE_TRANSITION position=N old_state -> new_state reason=X.
  5. Orphan deltas don’t get assigned to live positions. If broker_qty > sum(engine.contracts_remaining) for a contract, log ORPHAN_DELTA (CRITICAL). No live position absorbs the delta.

Where this is enforced (file:line, post-83ba9eb)

  • State enum: src/cortana/positions/store.py:13
  • Transition helper: src/cortana/positions/manager.py:_transition_state
  • PENDING_ENTRY → OPEN gate: src/cortana/positions/mixins.py (in _on_entry_complete - only flips when remaining_qty == 0).
  • Reconciler gate: src/cortana/positions/manager.py:reconcile_with_broker (skips non-OPEN positions).
  • Watchdog gate: src/cortana/watchdog.py:_check_position_safety (skips non-OPEN positions and positions with _last_tick_at <= 0).
  • Orphan handling: src/cortana/positions/mixins.py - ORPHAN_DELTA and ORPHAN_BROKER_POSITION log paths.

Open threads:

  • Add EXIT_PENDING as an explicit transient state? Today exit-in-progress is tracked by an _exit_in_progress flag on the Position rather than a state. Works, but a 4th state would make the audit trail cleaner.
  • Persistent state-transition history (one row per transition in DB) vs in-memory + log lines only. Post-mortems would benefit; not yet worth the schema migration.

Timeline:

2026-04-27 | Concept page created after 83ba9eb shipped the state-machine guards. Crystallized after today’s #186 (orphaned by reconcile race) and #187 (TP for wrong qty + dead-man’s-switch hair trigger). PENDING_ENTRY existed in store.py since fdcf6ad but wasn’t actually USED as a gate; reconciler and watchdog acted on it as if it were OPEN. 83ba9eb corrects that and adds the orphan- delta path.

2026-04-24 | Original simplified PM at fdcf6ad. PENDING_ENTRY introduced as an enum value but not gated.

2026-04-27 | Operator UI added (GH #49). New state-mutating subsystems documented: modify_tp_limit, modify_sl_price, manual_market_exit, operator-driven cancel-order on tp_order_id. New exit reason MANUAL_SELL joins TP/PRICE_STOP/THESIS_INVALID/TIME_CLOSE/TP_SOFTWARE/EXTERNAL_CLOSE. All operator actions audit-logged to ~/cortanaroi-data/audit/manual_overrides.log and Telegram-alerted.