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
| Subsystem | PENDING_ENTRY | OPEN | CLOSED |
|---|---|---|---|
reconcile_with_broker() | NO-OP | act | NO-OP |
| Watchdog DEAD MAN’S SWITCH | NO-OP | act* | NO-OP |
| Exit trigger (price SL / score / time) | NO-OP | act | NO-OP |
TP placement (_place_tp_limit) | NO-OP | act | NO-OP |
confirm_fill() (BUY fill) | act + flip | act | NO-OP |
confirm_exit_fill() (SELL fill) | NO-OP | act + flip | NO-OP |
updatePortfolio callback | observe only | act | NO-OP |
Operator UI modify_tp_limit() | NO-OP | act | NO-OP |
Operator UI modify_sl_price() | NO-OP | act | NO-OP |
Operator UI manual_market_exit() | NO-OP | act + flip (CLOSED, MANUAL_SELL) | NO-OP |
| Operator UI cancel-order on tp_order_id | NO-OP | act (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
- 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. broker_qty == 0is EXPECTED during PENDING_ENTRY. It does not imply EXTERNAL_CLOSE.- 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.
- State changes always emit
STATE_TRANSITIONlog lines. This is the audit trail. Format:STATE_TRANSITION position=N old_state -> new_state reason=X. - Orphan deltas don’t get assigned to live positions. If
broker_qty > sum(engine.contracts_remaining)for a contract, logORPHAN_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 whenremaining_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_DELTAandORPHAN_BROKER_POSITIONlog paths.
Open threads:
- Add EXIT_PENDING as an explicit transient state? Today exit-in-progress
is tracked by an
_exit_in_progressflag 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.