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=Nfor partial fills - DO NOT treat partial as full and arm TP yet. _last_tick_atmay 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.
| Trigger | Condition | Reason code |
|---|---|---|
| Price SL | option_price ≤ avg_cost × (1 - stop_loss_pct) | PRICE_STOP |
| Thesis-invalid | BULL/CALL pos: score ≤ 30; BEAR/PUT pos: score ≥ 70 | THESIS_INVALID |
| Time close | wall_clock ≥ 14:15 America/Chicago | TIME_CLOSE |
| Software TP | broker TP not resting AND option_price ≥ avg_cost × 1.10 | TP_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()callsreconcile_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_remainingvia 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-machineopen 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-modesopen threads).
See also:
concepts/position-state-machine- state diagram + invariantsconcepts/exit-path-failure-modes- failure surface + auditfeedback_dual_tp_defense_in_depth- why software TP fallbackfeedback_no_hwm_trailing_language- TP is single-shot, not trailingpostmortems/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.