Position lifecycle

Compiled truth (2026-04-27, post-83ba9eb): A Position has three states (PENDING_ENTRY, OPEN, CLOSED - see position-state-machine). This page walks the lifecycle in order and names the timing windows where reconciler and watchdog must stand down.

Entry flow

1. Signal fires (composite + impulse + persistence + score-band gate)
2. Operator (or auto-execute) approves
3. PM.open_position() creates Position with state = PENDING_ENTRY
4. Broker.place_lmt_buy() submits BUY LMT (orderId = N)
5. Broker streams fills:
     - confirm_fill() accumulates contracts_remaining
     - state STAYS PENDING_ENTRY through partial fills
6. Order status -> Filled, remaining_qty == 0:
     - _on_entry_complete() runs
     - state transitions PENDING_ENTRY -> OPEN
     - STATE_TRANSITION log line emitted
     - TP placement begins (see TP placement below)
7. Position is now armed for exit triggers (see Exit triggers below)

Critical timing window: steps 4-6. This is where today’s #186 + #187 went wrong. During this window:

  • Broker may report position=0 (BUY hasn’t filled yet) - DO NOT treat as EXTERNAL_CLOSE.
  • Broker may report position=N for partial fills - DO NOT treat partial as full and arm TP yet.
  • _last_tick_at may be 0 (market data subscription pending) - DO NOT trigger DEAD MAN’S SWITCH.

The reconciler and watchdog gates on state == OPEN enforce this.

TP placement (during PENDING_ENTRY OPEN transition)

Triggered by _on_entry_complete immediately after state flips to OPEN.

1. Reconcile against broker truth: read broker.get_positions().
   If broker_qty for this contract differs from engine
   contracts_remaining, log RECONCILE_QTY_DRIFT and update engine
   to broker number. Edge case: orphan contamination - if broker_qty
   exceeds sum across all engine positions for the contract, log
   ORPHAN_DELTA (CRITICAL); do NOT credit it to this position.
2. Compute TP qty: position.contracts_remaining (assertion that this
   matches the entry's filled qty, post-reconcile).
3. Compute TP price: avg_cost × 1.10, rounded down to tick.
4. BP precheck: broker.get_account_summary(). If
   projected_init_margin > available_funds, skip TP placement, set
   tp_status = SKIPPED_BP, log TP_BP_INSUFFICIENT. Software TP
   fallback (see below) handles +10% capture in this case.
5. broker.place_lmt_sell(qty, price) → orderId.
6. Verify TP placement: poll get_order_status() up to 500ms.
   - On REJECTED / Inactive: clear tp_order_id, set tp_status,
     log TP_PLACE_FAILED. Software TP fallback covers +10%.
   - On accepted (PENDING / Submitted): record tp_order_id, set
     tp_status = PENDING.

Exit triggers (only fire when state == OPEN)

PM monitors continuously via _process_price_tick and other event handlers. All triggers route through ONE unified exit path.

TriggerConditionReason code
Price SLoption_price ≤ avg_cost × (1 - stop_loss_pct)PRICE_STOP
Thesis-invalidBULL/CALL pos: score ≤ 30; BEAR/PUT pos: score ≥ 70THESIS_INVALID
Time closewall_clock ≥ 14:15 America/ChicagoTIME_CLOSE
Software TPbroker TP not resting AND option_price ≥ avg_cost × 1.10TP_SOFTWARE
Broker TP fill(broker fills the resting LMT directly)TP (broker-driven)

Software TP fallback fires only when broker TP is not resting (tp_order_id == "" OR tp_status in {REJECTED, INACTIVE, SKIPPED_BP}). Defense in depth - if broker LMT failed or was skipped, software captures +10%. See feedback_dual_tp_defense_in_depth.

Exit path (unified, one method handles all triggers)

1. Cancel resting TP (if any). Use ACTIVE-vs-TERMINAL classifier:
     - ACTIVE statuses (PENDING, CANCELLING, UNKNOWN, PARTIAL):
       wait/poll for terminal; do NOT submit market SELL while live
       (avoids double-sell).
     - TERMINAL statuses (FILLED, CANCELLED, REJECTED, INACTIVE,
       ApiCancelled): proceed.
     - If TP terminal-state is FILLED, position is already flat from
       the broker TP - finalize CLOSED with realized from TP fill.
2. broker.place_market_sell(remaining_qty) → exit_order_id.
3. Wait for fill. Retry cap: ≤ 5 cycles or 30s.
     - On Filled, remaining == 0: finalize CLOSED with broker
       realized PnL.
     - On REJECTED / CANCELLED: increment _exit_retries, retry.
4. Past retry cap: broker.reqGlobalCancel() + escalation Telegram
   alert + one final market SELL. Past that, log CRITICAL and
   surface to operator.

Broker reconciliation (only acts on OPEN positions)

Triggered:

  • On engine startup (_recover_positions() calls reconcile_with_broker())
  • Every ~30s during normal operation (or on positionEvent / updatePortfolio callbacks)
For each tracked Position:
    if pos.state != PositionState.OPEN: skip   # PENDING_ENTRY / CLOSED
    broker_qty = lookup broker position for this contract
    if broker_qty == 0:
        finalize CLOSED with EXTERNAL_CLOSE reason
        realized = updatePortfolio.realizedPNL if available
    elif broker_qty > engine_qty:
        if broker_qty > sum(engine.contracts_remaining for matching contracts):
            log ORPHAN_DELTA CRITICAL
            do NOT credit delta to any single position
        else:
            log RECONCILE_QTY_DRIFT, set engine_qty = broker_qty
    elif broker_qty < engine_qty:
        log RECONCILE_QTY_DRIFT, set engine_qty = broker_qty
        (broker fills are authoritative - broker says we have less)

For each broker position not matching ANY tracked Position:
    log ORPHAN_BROKER_POSITION (CRITICAL)
    do NOT auto-adopt - operator decides

Watchdog DEAD MAN’S SWITCH (only acts on OPEN positions with prior tick)

Guards against subscription drops mid-trade. Force-closes at last known price after 120s of no ticks. Triple-gated:

For each Position:
    if pos.state != PositionState.OPEN: skip
    if pos._last_tick_at <= 0: skip            # never had a tick - not blind
    if pos.market_price <= 0: skip
    age = now - pos._last_tick_at
    if age > 60: warning + market data resubscribe attempt
    if age > 120 and pos.market_price > 0:
        if pos.exit_order_id and broker_qty > 0: skip (avoid double-sell)
        log + escalate + market SELL at last known price

Acceptance bar (must hold to call lifecycle correct)

  • ✅ Brand-new BUY: PENDING_ENTRY through partial fills, no reconciler action, no watchdog action, transitions to OPEN only on full fill.
  • ✅ Reconciler during entry-fill window: no-op.
  • ✅ Reconciler with broker-flat OPEN position: finalizes EXTERNAL_CLOSE.
  • ✅ Reconciler with orphan contamination: ORPHAN_DELTA log, no contamination of live position qty.
  • ✅ TP placed: qty matches contracts_remaining via post-reconcile assertion. BP precheck before placement.
  • ✅ TP rejected: software TP fallback at +10% covers the gap.
  • ✅ Watchdog before first tick: no-op on _last_tick_at == 0.
  • ✅ Watchdog after 120s blind: force-close at last known price.

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

  • Entry: src/cortana/positions/manager.py:open_position, src/cortana/positions/mixins.py:_on_entry_complete, src/cortana/positions/mixins.py:confirm_fill.
  • TP placement: src/cortana/positions/mixins.py:_place_tp_limit.
  • Exit triggers: src/cortana/positions/manager.py:_process_price_tick.
  • Exit path: src/cortana/positions/manager.py:_close_remaining.
  • Reconcile: src/cortana/positions/manager.py:reconcile_with_broker.
  • Watchdog: src/cortana/watchdog.py:_check_position_safety.

Open threads:

  • EXIT_PENDING as an explicit 4th state (see position-state-machine open threads).
  • Persistent state-transition history table for postmortems.
  • BP-precheck math is approximate (uses qty * limit_price * 100). Should query IBKR’s actual init_margin computation if available.
  • Circuit-breaker recovery for UW endpoint outages (see concepts/exit-path-failure-modes open threads).

See also:

  • concepts/position-state-machine - state diagram + invariants
  • concepts/exit-path-failure-modes - failure surface + audit
  • feedback_dual_tp_defense_in_depth - why software TP fallback
  • feedback_no_hwm_trailing_language - TP is single-shot, not trailing
  • postmortems/tp-rejected-infinite-exit-loop-2026-04-24 - incident log

Timeline:

2026-04-27 | Page created consolidating entry-flow + exit-flow + reconcile-rules into one lifecycle reference. Pulls in the 83ba9eb state-machine fix. Today’s #186 (orphaned by reconcile race) and #187 (TP for wrong qty + dead-man’s-switch hair-trigger) are the empirical motivations for the timing-window rules.