TP rejected at placement → infinite exit loop (2026-04-24)
Incident: Position #184 (BULL 87 × SPY 709C entered 09:14:19 @ $2.28)
could not exit despite thesis invalidation firing four times between 09:16
and 09:19. Engine spammed TP_LMT_CANCEL_REQUEST → TP_LMT_CANCEL_REJECTED → OVERSELL_GUARD_BLOCK ~10/sec for 5+ min. Position remained open.
Root cause - two bugs in series:
-
Placement-verify is missing. After
placeOrder(tp_lmt @ $2.50)at 09:14:20, IBKR returned Error 201 in 85 ms:YOUR ORDER IS NOT ACCEPTED. IN ORDER TO OBTAIN THE DESIRED POSITION YOUR PREVIOUS DAY EQUITY WITH LOAN VALUE [994451.77] MUST EXCEED THE INITIAL MARGIN [1255918.34].PM recordedtp_order_id=964andtp_limit_status=REJECTEDbut did not cleartp_order_idor alert. Position then ran for 2+ minutes with NO working TP. This silently violates the single-shot +10% TP invariant (PR #43) any time margin is tight. -
Cancel-guard blocks on terminal state.
manager.py:688-697:cancel_status = self._cancel_tp_limit_and_wait(pos) if pos.tp_order_id else "" if cancel_status not in ("", "CANCELLED"): log.warning("OVERSELL_GUARD_BLOCK reason=tp_cancel_%s", cancel_status.lower()) returnREJECTED is a terminal broker state holding zero contracts - there is nothing to oversell. But the cancel-of-rejected itself returns REJECTED, so the guard aborts and the SELL never reaches the wire. Retry runs on every monitoring cycle (~100ms) forever.
Why margin was tight: unclear. Only one position (#184) in the engine’s
view per /api/state, but IBKR demanded 994k equity. Possibly stale safety STOPs not being released from prior
positions, or BP reserved by working orders that persisted across the
engine restart earlier today. Needs investigation - separate thread.
What should happen:
- TP placement must verify broker acceptance (poll orderStatus N ms). On
REJECTED/Inactive, clear
tp_order_id, alert Telegram (TP_PLACE_FAILED), proceed with safety STOP as sole protection. - Cancel path must classify order state as ACTIVE (cancel needed) vs TERMINAL (skip cancel, proceed to SELL).
- Retry loop needs a hard max (e.g., 5 attempts) before escalating to market-sell.
- Consider: refuse new entries when margin headroom cannot accommodate TP.
Filed 2026-04-24. GH #46. Code fix deferred to after market close per project convention. User chose to observe the live failure mode rather than emergency-flatten.
Related:
concepts/impulse-bypass-does-not-skip-score-gate.md(today’s earlier miss - different failure, same day)project_early_and_right_mandate.md- PR #43 (single-shot +10% TP)
src/cortana/positions/manager.py:626(_cancel_tp_limit_and_wait)src/cortana/positions/manager.py:685(exit path, the guard at 695-697)
Timeline addendum - 2026-04-24 09:21 CT (whipsaw observation):
Dashboard state while pos #184 is still stuck EXIT_PENDING:
- Composite score: 56 NEUTRAL MEDIUM
- Impulse engine: BULLISH conv=0.60 trigger=strike_stack (call_655, $4.9M premium, count=3, ask_ratio=0.871)
- SPY: $710.2 (recovered from 708.5 low)
- Pos #184: BULLISH, EXIT_PENDING, -$957 (-4.8%)
Full chop-day whipsaw sequence observed today:
- 08:55 - impulse BEAR conv=0.91 hiro+strike_stack during
710.5→708.5 sell climax. Missed (score-band gate, see
concepts/impulse-bypass-does-not-skip-score-gate.md). - 09:14 - BULL signal #69 fires, enters 87 × 709C @ $2.28 near the top of a pullback bounce.
- 09:14:20 - TP @ $2.50 rejected by IBKR margin (Error 201). Position runs with safety STOP only. Upstream bug (GH #46).
- 09:16 - score crosses 42, thesis_invalid fires. Exit enters infinite cancel-rejected loop. Downstream bug (GH #46).
- 09:17-09:20 - four more THESIS INVALID alerts, no exit.
- 09:21 - impulse flips back to BULL conv=0.60 on a weak strike-stack signal. Position is still locked EXIT_PENDING, cannot take advantage even if the flip is correct.
What this demonstrates for design:
- The stacking of bugs matters. The score-band gate (defending against chop) collaborated with the TP-placement silent-fail to produce a locked position on the wrong side of the move.
- During a chop day, the signal apparatus flips direction multiple times in minutes. The position manager MUST be able to exit cleanly on every thesis invalidation, not maybe. Exit reliability is a bigger win-rate lever than entry quality on chop days.
- Related:
project_losses_april16_chop- same regime, same pattern. The chop-day defense is incomplete as long as exit can hang.
No fix deploys during market hours. This entry is observational.
Timeline addendum - 2026-04-24 09:39 CT (V-recovery):
Sharp reversal while pos #184 still locked EXIT_PENDING:
- SPY: 710 resistance - was $709.89 at 09:29)
- Score: 55 NEUTRAL LOW (was 39 at 09:34)
- Impulse: flipped to BEARISH conv=0.33 - a weak reversal read at the new high
- Pos #184: mkt 1,044 (-5.3%), new HWM 9.0% ($2.485)
- Peak recovered from -1,044 (09:39) = +$2,871 swing in 5 min
**Critical observation - HWM 9.0% = 2.50 (+9.65%). Current HWM is 0.015 from TP fill price. If the TP had actually been resting at the broker, this cycle would have come within 1.5 cents of filling. Two separate bugs are costing us here:
- TP placement rejected at entry (margin) - no working TP ever existed.
- Even now with position in a strong intraday bounce, PM cannot exit because cancel-loop is still grinding on the rejected order id.
If the TP had been live and the bounce carried one more 1.5-cent tick, we’d be flat or up on the day. Instead we’re stuck long into whatever comes next.
Design implication for the PM ↔ IBKR audit (GH #46):
The “peak recovery during stuck exit” pattern is exactly the scenario where market-sell-escalation (audit item #3) earns its weight. If, after 5 retries, the PM had flipped to MarketOrder("SELL", 87), we would have exited at ~700 loss instead of sitting through the full range. Retry cap is not optional - it’s the failsafe that makes the invariant real.
Clarification (user correction, 2026-04-24):
HWM language in earlier entries is misleading. System is single-shot +10% fixed TP, no trailing. The only exit-relevant question is: did price print the configured TP level?
Entry 2.508**. Peak observed premium (09:39) = 0.023 (0.9%).
Result would have been identical if HWM were not tracked at all. The trade was 0.9% short of a full winner. Two PM↔IBKR bugs (GH #46) meant even if price HAD printed $2.508, there was no resting order to fill it
- and we still couldn’t have exited manually via thesis-invalid because the cancel loop was blocking SELL placement.
User directive: log data, review later. Next month’s fix cycle will address this properly.
Addendum - 09:41 theta decomposition (user question “why am I down when SPY is up?“):
| Metric | Entry 09:14 | Now 09:41 | Δ |
|---|---|---|---|
| SPY | ~$710.50 | $710.735 | +$0.24 |
| Premium | $2.28 | $2.13 | −$0.15 |
| Intrinsic (SPY − 709) | $1.50 | $1.735 | +$0.235 |
| Extrinsic (premium − intrinsic) | $0.78 | $0.395 | −$0.385 |
| IV | 16.3% | 16.1% | −20 bps |
| Time to expiry | 5h 46m | 5h 19m | −27 min |
Decomposition of the -$0.15 move:
- Delta-attributed gain from +0.22 (delta≈0.70 for 0.5 ITM call with 5h to expiry)
- Theta/vega decay over 27 min: -$0.385 of extrinsic bled
- Net: +0.385 ≈ -0.15)
Teaching: This is the core 0DTE theta trap. Position was RIGHT on direction (+2.508), the underlying must move fast enough that delta × Δspy > theta_burn + any_iv_drift. On this contract: roughly 1.20+ move. It made 2.485 - the push was not fast enough and came partly BEFORE we were fully filled.
Design implication: This reinforces the “early and right” mandate from CLAUDE.md. Late entries into 0DTE calls lose twice: once to the delay (missed early move), again to the theta bled while confirming. Every minute of signal-latency is a real P&L cost, not abstract.
Related learnings to file after close:
concepts/0dte-theta-tax.md(new) - the quantitative framework: delta gain must beat theta burn, with worked example from today.concepts/early-and-right-has-a-price.md(new) - the cost of each minute of signal latency, empirically.
Timeline addendum - 2026-04-24 09:44 CT (further recovery):
- SPY: 1.04 in 10 min from 09:34 low of $709.98)
- Score: 61 NEUTRAL MEDIUM (up from 55)
- Impulse: BULL conv=0.60
- Pos #184: premium 435 (-2.2%)** - crossed above -$500 threshold
- TP target 0.278 remaining (+12.5% more needed on premium)
- Total swing: -435 (09:44) = +$3,480 in 10 min
- Still 87/87 EXIT_PENDING; cancel loop still running
Observation: SPY has now rallied +2.23 from entry 3,500 better on direction (vs 09:34) is only -0.278 (~12.5% of premium) to cross the +10% line.
Addendum - 09:45 dashboard vs TradingView divergence (user question):
User: “Things in my trading view seem to be up but my dashboard seems to be down… need to understand why.”
Snapshot:
- SPY: $711.07 (TradingView: clearly up from AM lows)
- Dashboard composite: 60 NEUTRAL MEDIUM
- Premium: 0.235 in 60s despite SPY flat tick)
Component decomposition (sum=11 raw → 60 normalized):
| Component | Dir | Score | Why |
|---|---|---|---|
| Premium Flow | BULL | +6 | Net prem +74%, short tide +79%, but tide crashed 7%→0% |
| Smart Money | BULL | +3 | NCP/NPP 41:1 → 10.6:1 (thinning buyers) |
| Volume Sentiment | BEAR | -8 | P/C ratio 1.34 (puts > calls - hedging the top) |
| Dealer Position | NEUTRAL | +4 | Long γ / charm bullish / vanna bearish (IV spike → dealers sell Δ) |
| Volatility | BULL | +4 | SPY IV 16.1%, term backwardation 1.12 |
| Price Trend | NEUTRAL | +2 | Above VWAP but 3/5 recent bars RED, -$0.14 |
The divergence explained:
TradingView shows trend (cumulative price). Dashboard shows internals (flow, hedging, momentum vector). Price can be extended while internals already fade. Today’s signature:
- Put/Call ratio 1.34 = institutional hedging of the rally
- NCP/NPP decay 41:1 → 10:1 = aggressive bidders stepping back
- Tide 7% → 0% = premium intake stalled
- 3/5 red bars = momentum rolling even while above VWAP
That’s “stall at highs.” Not a reversal signal yet (no clear BEAR), but the BULL thesis that got us into the trade is weakening. The score crossing 42/38/40 territory is exactly this weakening being reflected.
Premium drop from 1.995 in 60s with SPY flat:
Not theta (that’s ~0.235/min). This is quote-mid
repricing - market makers widened bid/ask as the upside move stalled
and upside interest faded. Dashboard market_price uses mid which is
hyperreactive to MM spread changes. The actual tradeable exit price
changed less dramatically; the mid is showing the MM’s fear of adverse
selection.
Design implication: dashboard is not a price-mirror; it is a flow-and- internals-mirror. Treat divergence between TV and dashboard as signal. When TV says “up” and dashboard says “stalling,” the position is at risk even if still nominally green on price.
2026-04-24 09:47 CT - user flag: “this should be a profitable trade”
At 09:45 SPY broke up BULL on TradingView. Dashboard composite stayed at 56 NEUTRAL. Position #184 still underwater, still in exit-pending loop.
User directive: log it as a potential issue. Two hypotheses:
- H1 (DB right): internals legitimately bearish despite price breakout - P/C 1.34, NCP 41→10, tide 7%→0% say “hollow breakout.” Composite correctly refused to fire. Teaching: price without flow = trap.
- H2 (DB wrong): scoring lagged the breakout. Score-band gate ate a real
signal. This is the concrete version of the
#30latency complaint.
Filed as GH #47. Post-close analysis answers which hypothesis holds by
replaying the 09:45:00–09:46:00 component tape. This is a signal-quality
concern, separate from #46 which is about exit-path execution reliability.
Current state of #184: still EXIT_PENDING, still no SELL at IBKR, still bleeding on theta. The 9:45 breakout does not help because:
- Exit is already triggered - position manager intends to close, not hold
- PM↔IBKR seam is broken - even if composite flipped BULL to cancel the exit, the cancel wouldn’t reach the broker either (same bug)
Two independent failures in one trade:
#46- PM can’t send SELL on exit intent#47- composite may not be catching the BULL that would have made this profitable in the first place
2026-04-24 09:50 CT - H2 confirmed: “The trade isn’t taken”
User screenshot: impulse BULLISH, composite 55 NEUTRAL, SPY still in the
09:45 breakout, no trade entered. This is exact impulse-bypass-does-not-skip- score-gate behavior in live market: impulse fires, score-band gate holds
at 55 ∈ (35, 65), SKIPPED_SCORE, no entry.
This makes the day a double miss:
- Still stuck in losing #184 because PM↔IBKR seam is broken (#46)
- Can’t enter the correct BULL trade to recover because score-band gate is eating the signal (#47)
The gate was hardened 2026-04-22 specifically to avoid chop-day losses
(project_losses_april16_chop). It’s now blocking a clean directional
breakout. The tradeoff is explicit and needs post-close analysis:
- How many 35-65-band skips on 2026-04-24 were real misses vs correctly avoided chop?
- Does conv≥0.80 + impulse BULL/BEAR + flow agreement warrant overriding the band gate?
- Or does the composite need a breakout-detector component that moves the score itself rather than bypassing the gate?
2026-04-24 ~10:00 CT - CLOSED AS WINNER via reqGlobalCancel
After flatten_all.py + force_close_184.py both failed with Error 201 margin,
wrote scripts/nuke_close_184.py that runs ib.reqGlobalCancel() first to
sweep ALL orders at the broker level (bypassing per-client orderId scope).
Result:
- reqGlobalCancel cleared the ghost STP orderId=960 clientId=1
- Post-cancel margin state: InitMargin=0, Avail=974K, BP=3.9M (unchanged because it was already “clean” on paper - the ghost was invisible to summary but visible to the order-check risk engine)
- LMT @ 2.00: rejected Error 202 “too aggressive, mid is 2.94”
- MKT SELL 87 openClose=C account=DUP696099: FILLED @ 2.94
P&L: (2.94 − 2.28) × 87 × 100 = **+2,262) if the engine had fired at $2.508. Forced hold through the recovery turned it into a bigger winner.
The actual fix for #46
The exit path in manager.py must do ONE of:
- Track the safety-STOP orderId persistently and
cancelOrder(stp_order)before placing the SELL (requires state reconciliation across restarts) - Call
ib.reqGlobalCancel()on exit intent - simpler, but kills ALL orders across the account (fine for single-position engine, wrong for multi-position future)
Cross-client cancelOrder(foreign_order) returns Error 10147 silently
and is NOT a valid path. reqGlobalCancel() IS.
Lessons
- IBKR paper 201 reject on close ≠ actual margin issue. It’s a phantom-order-holding-reservation issue. Real margin was clean all along.
- openClose=C is NOT enough. The broker’s risk engine ran the margin check including the phantom STP. Removing the phantom was the fix.
- reqGlobalCancel is the escape hatch. Every exit-retry loop should escalate to this if N SELL attempts fail.
2026-04-24 10:03 CT - reconciliation bug confirmed
Position #184 closed at broker via nuke_close_184.py at ~10:00:45 CT. Broker state: 0 positions (verified clientId=5).
Engine state: STILL spamming every 15s:
10:02:55 Position #184 (SPY 709C) - no price tick for infs. Attempting to resubscribe market data.
10:02:55 DEAD MAN'S SWITCH: Position #184 already EXIT_PENDING - skipping to avoid double-sell.
The engine has not detected the external close after 3+ minutes.
This is audit item #12 (State reconciliation on external close) in live
production. Reasons this is a P0:
- Watchdog keeps trying to resubscribe market data for a dead contract (expires today - contract will cease to exist at 15:00 CT)
- DEAD MAN’S SWITCH is blocked permanently because EXIT_PENDING sticky flag never clears
- Next entry signal will be evaluated with a phantom “already have position” check - may block legitimate new entries
- Dashboard still shows the position - user sees incorrect state
- P&L accounting won’t record the +$5,742 winner
The engine should have a periodic reconcile loop that:
- Every 30s, compare
ib.positions()vs local position store - For each LOCAL position not in broker: transition to CLOSED, fetch last fill from broker exec log, record P/L, clear EXIT_PENDING
- For each BROKER position not in local: emit WARNING (unexpected position)
Currently none of this exists.
2026-04-24 10:05 CT - smoking gun: TP orderId=964 REJECTED at 09:14:20
Pulled dashboard /api/state for #184 (engine still has stale record):
state: EXIT_PENDING
entry: 2.28
tp_limit_price: 2.50
tp_limit_order_id: 964
tp_limit_status: REJECTED
tp_limit_submitted_at: 2026-04-24 09:14:20
trailing_hwm: 33.77%
market_price: 2.87 (last tick before feed died)
The TP never rested at the broker. It was REJECTED 18 seconds after entry (09:14:20), almost certainly because the STP (orderId=960, placed at 09:14:02) was already holding the same margin reservation - Error 201.
Peak price touched +33.77% (= 2.508) was silently crossed because there was nothing resting to catch it.
Sequence of failure:
- 09:14:02 - entry fill + STP SELL @ 2.28 placed (orderId=960) - succeeded
- 09:14:20 - TP LMT SELL @ 2.50 placed (orderId=964) - REJECTED, not detected by engine, recorded locally as “placed”
- Price moved up, touched +10% then +20% then +33% - no exit order resting
- 09:17 - thesis_invalid fires → PM_EXIT_REQUEST →
_cancel_tp_limit_and_waitreturns terminal (it was already REJECTED) → OVERSELL_GUARD_BLOCK halts the emergency MKT SELL - 09:17→10:00 - infinite retry loop, Telegram spam, price bled down then recovered
- ~10:00 - external force-close via reqGlobalCancel @ 5,742
Two bugs compounded:
- Audit #1: TP placement-verify missing - REJECTED status should have triggered immediate re-placement or fallback to MKT SELL at TP trigger
- Audit #2: Cancel-guard returning on terminal status blocks the only path to emergency exit when TP is already dead
Both need fix before next session. Also exposes audit #12 (reconcile): engine still shows #184 open in dashboard 5+ min after broker close.
11:20 - Engine restarted, dashboard reconciled
- launchctl kickstart -k gui/$UID/com.cortanaroi.mk2 → new PID 18518
- Dashboard /api/state: 0 open positions (was #184 EXIT_PENDING pre-restart)
- Fix commits: cd48ecf (manager.py fall-through + watchdog.py external-close reconcile)
- On main + feature branch, pushed to GH
Next live fire: either TP fills normally, or watchdog catches the external-close case and finalizes without requiring human intervention.
10:21:56 - Reconcile fired on cold-start recovery (fix cd48ecf verified live)
After launchctl kickstart respawned the engine at PID 18518, engine cold-start
picked up #184 from DB (still is_open=1 because the prior engine persisted it
during its shutdown at 10:21:31 before my manual DB update re-fired, or the engine
wrote over my manual SQL). The recovery path ran through _close_remaining:
10:21:56 INFO Recovered 1 open position(s) from database
10:21:56 INFO PM_EXIT_REQUEST position=184 reason=SCORE_AWARE_EXIT
10:21:56 INFO TP_LMT_CANCEL_REQUEST position=184 order=964
10:21:56 WARN TP_LMT_CANCEL_REJECTED position=184 order=964 status=UNKNOWN
10:21:56 WARN TP_LMT_TERMINAL position=184 cancel_status=unknown
- falling through to broker reconcile ← NEW fall-through log
10:21:56 INFO BROKER_REMAINDER_CHECK position=184 qty=0 ← broker confirms flat
10:21:56 WARN OVERSELL_GUARD_BLOCK position=184 reason=broker_flat
(misleading name - this branch calls _finalize_close at line 708)
10:21:56 WARN Trade tracker close failed for #184: Trade 45 is already closed
Post-reconcile state (verified 10:25):
- IBKR broker: position=0.0, realizedPNL=$5,625.58
- paper_trades.db: state=CLOSED, is_open=0, contracts_remaining=0
- Dashboard /api/state: 0 open positions
- Engine log: zero further #184 activity since 10:21:56
The “OVERSELL_GUARD_BLOCK reason=broker_flat” log name is a trap - it sits
directly above _finalize_close in manager.py:707-708. The WARNING-level
log plus “OVERSELL_GUARD_BLOCK” phrasing reads like a continued block, but
the code DOES finalize. Future cleanup: rename log to BROKER_FLAT_FINALIZE
to match reality.
Watchdog RECONCILE_EXTERNAL_CLOSE never fired because the manager path
caught it first during the recovery cycle. That’s a correct outcome - the
manager is upstream of the watchdog dead-man’s-switch on restart.
Loop status (10:25)
- Score band stuck 43-51 (impulse BEAR conv=0.33 no-bypass, #47 score-gate pattern continues to suppress entries; no BULL attempt yet since restart)
- Zero phantom-blocked entries
- No BUY placements since the morning’s #184
10:40-10:42 - #47 live evidence: bypass=True cluster suppressed by score-band gate
Three consecutive BULL impulse triggers with bypass=True fired over ~90s, all suppressed by the neutral-band score gate. This is the clearest live evidence of the GH #47 pattern since it was filed.
| time | conv | bypass | score | price trend detail | outcome |
|---|---|---|---|---|---|
| 10:40:15 | 0.60 | True | 44 | BEAR -4 (at VWAP $711.9, 4/5 red) | SKIPPED_SCORE |
| 10:41:00 | 0.66 | True | 55 | BULL +8 (VWAP reclaim $711.9) | SKIPPED_SCORE |
| 10:41:46 | 0.60 | True | 56 | BULL +8 (VWAP reclaim $711.9) | SKIPPED_SCORE |
By 10:41:00 the engine explicitly flipped Price Trend to BULL +8 with a “VWAP reclaim” annotation - the textbook early-and-right tell. Impulse agreed at conv 0.66. Zero entry.
Root cause is what #47 describes: trigger=impulse:strike_stack launches a
scoring evaluation but does NOT short-circuit the score-band gate at
policy.py:80. Bypass only decides “should we score on this flow event” - not
“should we bypass the score-output gate”. Thus abs(score-50) < (min_score-50)
remains the hard filter even when impulse has strong directional conviction.
Cost inference: each bypass=True beat is a free early-signal the engine threw away. Over a day this compounds directly against the 80% win-rate mandate (CLAUDE.md) and “early and right” (CLAUDE.md). Fix priority: raise the bypass path to actually bypass the output gate when conv >= threshold.
Filed as live evidence on existing GH #47. No code change this session - market-open repair budget reserved for exit-path correctness.
10:43-10:47 - watchlist hits: score breakout + 3 more bypass=True suppressions
(b) Score breakout + persistence-gate block - 10:45:56
The score engine finally broke out of the 35-65 band. Zero trade.
10:45:56 PF boost: +13 × 1.5x = +6 (short_tide=0.33 confirms)
10:45:56 Time-of-day modulation: flow-bypass 0.95x (score 80 → 75)
10:45:56 Strong flow override check: pre_gate_bias=BULLISH,
stack_put=$16.0M, stack_call=$36.6M, pf_score=13
10:45:56 0DTE model: win_prob=0.71 exp_ret=0.00 conf=PRIOR (n=0)
10:45:56 0DTE result: BULLISH score=75 confidence=MEDIUM errors=0/6
10:45:56 Price Trend=BULL:+12 [VWAP reclaim $712.0 | +$1.62 (3/5 green)]
10:45:56 0DTE: flow PASS-THROUGH - insufficient flow data
($6,265 < $250,000) - passing through with low conviction
10:45:56 0DTE: score 75 not persistent - waiting ← NEW GATE HIT
Next cycle (10:46:11): score regressed to 64 (NEUTRAL) because the Price Trend component normalized from +12 to +4 (“above VWAP 0.14%” instead of “VWAP reclaim”). Persistence window was never satisfied.
This is a different gate from #47:
- #47 = score-band output gate (
abs(score-50) < threshold) - This = persistence gate (score must hold >=65 for N consecutive samples before entering)
The combined effect: single-sample breakouts from fast-moving flow events (exactly the “early and right” mandate from CLAUDE.md) are filtered out by the persistence requirement. This trade setup had:
- pf_score=13 (max-like reading)
- NCP/NPP 3.4:1 strong
- Strong flow override flag tripped
- Price Trend BULL +12 VWAP reclaim +$1.62
- win_prob model output 0.71
…and was dropped because the score only held one sample at 75.
(e) Three more BEAR impulse bypass=True suppressions
10:43:59 conv=0.60 bypass=True → score 43 SKIPPED_SCORE 10:44:45 conv=0.60 bypass=True → score 45 SKIPPED_SCORE (also promoted LOW→MEDIUM) 10:46:39 conv=0.60 bypass=True → score 64 SKIPPED_SCORE
Total today since restart: 3× BULL bypass=True + 3× BEAR bypass=True = 6 high-conviction impulse signals dropped by score-band gate.
Summary of dropped signals since 10:21:53 restart
- 6× bypass=True impulse → SKIPPED_SCORE (the #47 bug)
- 1× BULLISH score=75 one-sample breakout → persistence gate block (new)
The system is behaving exactly as described in the 80%-win-rate-mandate critique: it waits for the move to confirm across multiple samples, by which time the option has already moved and theta is biting.
10:52-10:57 - Max-conviction impulse BULL suppressed
Score meandered 39-50 for the window (trend: drifting bearish). Impulse:
- 10:54:00 BEAR conv=0.60 bypass=True → score 41 SKIPPED_SCORE
- 10:54:46 BEAR conv=0.60 bypass=True → score 41 SKIPPED_SCORE
- 10:55:31 BEAR conv=0.60 bypass=True → score 39 SKIPPED_SCORE
- 10:56:17 BULL conv=1.00 bypass=True trigger=hiro+strike_stack → score 40 SKIPPED_SCORE
- 10:57:03 BEAR conv=0.80 bypass=True → score 43 SKIPPED_SCORE
The 10:56:17 BULL is the strongest impulse reading of the day (conv=1.00 is the maximum). It combined both triggers (hiro + strike_stack) and was suppressed by a score of 40 sitting firmly in the neutral band.
project_impulse_latency_analysis.md memory flags BEAR conv=1.00 as a
climax-reversal anti-signal. BULL conv=1.00 behavior is not yet cataloged -
this is the first same-day empirical evidence. Tentatively: a BULL conv=1.00
that fires after a ~10min score decline (46→39) may be a reversal signal
rather than continuation. Worth a brain page if a follow-up BULL trade
would have been profitable (requires tick replay).
Running total since 10:21:53 restart (≈35 min):
- 17 bypass=True impulse signals dropped by score-band gate
- 1 score-75 breakout dropped by persistence gate
- 0 trades opened
11:53–11:56 - Sustained BULL cluster suppressed (5× bypass=True in 4min)
Densest directional cluster of the session. Score held 60 MEDIUM throughout - ticked up from the 53-57 deadband but never cleared the 65 breakout.
| Time | Direction | Conv | Trigger | Score at gate |
|---|---|---|---|---|
| 11:53:29 | BULLISH | 0.60 | strike_stack | 60 MEDIUM |
| 11:54:15 | BULLISH | 0.60 | strike_stack | 60 MEDIUM |
| 11:55:00 | BULLISH | 0.65 | strike_stack | 60 MEDIUM |
| 11:55:46 | BULLISH | 0.60 | strike_stack | 60 MEDIUM |
| 11:56:32 | BULLISH | 0.60 | strike_stack | 60 MEDIUM |
All 5 dropped by SKIPPED_SCORE - score 60 within neutral band. This is the
clearest live evidence yet for #47 promotion to P0: impulse engine firing
sustained directional conviction (5× in 4min, all same direction), and the
gate silently swallows every one because score is 5 points shy of 65.
Structural implication: the gate’s binary threshold destroys the signal that sustained same-direction impulse IS itself a bias confirmation. Impulse persistence should lower the score bar, not be ignored by it.
12:55:31 - Signal #70 fired but NO BUY placed (new undocumented gate + listener crash loop)
The score breakout we’ve been waiting all day for happened. Score=76 BULLISH MEDIUM + BULL impulse conv=0.60 bypass=True passed all three known gates:
- Score-band (#47): 76 > 65 ✅
- Persistence gate: BYPASSED by IMPULSE BYPASS (logged at 12:55:31)
- Streak gate: BYPASSED (same)
Telegram alert SENT for signal #70 at 12:55:32. Then nothing. No policy
decision logged, no action='BUY', no placeOrder. The signal died silently
downstream of the scoring engine’s emit.
New gate discovered 8 seconds later at 12:55:40:
Regime CHOP: score 73 not strong enough (need 25+ distance), forcing NEUTRAL
Score suppressed: raw 73 → 50 (reason=regime_chop, bias forced NEUTRAL)
This is a FOURTH undocumented gate on top of #47’s three. It suppresses
scoring results when |raw_score - 50| < 25 under a “regime_chop” classifier.
Raw 73 → forced 50. That means our score-breakout threshold isn’t 65 at all
in practice - it’s ≥75 OR ≤25 when regime_chop is active.
The 12:55:31 score=76 squeaked past only because it hit ≥75. The very next
tick at raw=73 got clamped. That’s a 2-second lucky break on a gate we
weren’t tracking.
Secondary finding: flow PASS-THROUGH - insufficient flow data ($405 < $250,000)
- flow verification bypassed because UW data was below threshold. This is normal during low-flow minutes but combined with the regime_chop gate means the system was deep in “don’t trust anything” mode.
Tertiary finding - listener crash loop:
12:55:33 [cortana.alerts.telegram] INFO: Telegram listener starting
12:55:33 [cortana.alerts.telegram] ERROR: Telegram listener crashed: Event loop is closed
Repeated ~15 times in 30s. Probably unrelated to the missing BUY (Telegram alert went through before the crash loop started at 12:55:33, and the BUY path doesn’t go through Telegram) but worth isolating.
Open question: why no BUY? The logs don’t show any policy-layer decision
- no REJECT, BLOCK, cooldown, or already_open. The scoring engine emitted, Telegram got it, and the position manager / entry path never fired. This is the core “signal → trade” break that today’s 111-impulse-to-1-trade ratio traces to. The fourth gate (regime_chop) is why we never converted.
Scorecard update:
- Total signals fired today (Telegram-alert-level): 2 (#44 at 09:14, #70 at 12:55)
- Of those, trades opened: 1 (#44 only)
- Signal→trade conversion: 50% of emitted signals reach IBKR
14:32 - Ghost PnL confirmed: broker has been flat since 10:36, UI has been hallucinating for 4h
Discovery during afternoon watchdog sweep.
Broker truth vs engine UI
ib_async.wrapper updatePortfolio callback for the 709C contract since resolution:
| Time | position | realizedPNL | marketPrice | marketValue |
|---|---|---|---|---|
| 10:36:45 | 0.0 | 5625.58 | 3.16 | 0.0 |
| 11:03:45 | 0.0 | 5625.58 | 4.73 | 0.0 |
| 13:30:45 | 0.0 | 5625.58 | 4.16 | 0.0 |
| 14:00:45 | 0.0 | 5625.58 | 4.76 | 0.0 |
| 14:21:45 | 0.0 | 5625.58 | 5.04 | 0.0 |
| 14:30:45 | 0.0 | 5625.58 | 4.64 | 0.0 |
IBKR confirms position=0 since 10:36:45 broker-side (15+ minutes after the engine-side resolution at 10:21:56, which fired BROKER_REMAINDER_CHECK position=184 qty=0 + OVERSELL_GUARD_BLOCK reason=broker_flat).
Realized PnL locked at **1M paper = 0.5626%.
So what’s climbing?
The engine UI showing “climbing ghost PnL” on #184 is driven by a stale in-memory tracker state that’s multiplying the last-known qty (before broker close) by the current marketPrice of the 709C option. As the underlying moved the option premium 5.04 over four hours, the ghost position’s “unrealized” pnl climbed with it even though position=0.0 and marketValue=0.0 are the broker truth.
This is a mirror of the GH #46 invariant - same trust failure, different direction:
- GH #46: engine says “exit placed/successful” + broker is NOT flat → alert-without-action. P0.
- Today’s bug: broker IS flat + engine says “EXIT_PENDING with growing unrealized pnl” → status-without-truth. Equally P0 for user trust.
Proposed name: TRACKER_DRIFT_UNRESOLVED. Same invariant: “what the UI shows about position state must match IBKR.” When updatePortfolio reports position=0 realizedPNL=X, the engine-side tracker for that symbol/strike/right/expiry MUST finalize to CLOSED with realized=X and drop the EXIT_PENDING sentinel - not continue to recompute unrealized from marketPrice ticks.
Watchlist item (a) - already resolved
Reconcile-clearing #184: happened twice today, both well before this watch session:
- 10:21:56 engine-side -
BROKER_REMAINDER_CHECK qty=0 → OVERSELL_GUARD_BLOCK reason=broker_flat. (Misleading log name - nothing is being guarded; position is already flat. See deferred “rename to BROKER_FLAT_FINALIZE” task.) - 10:36:45 broker-side first confirmation, then repeated every 3 min through 14:30:45.
The engine-side tracker, however, never finalized - it still emits watchdog entries for #184 and the dashboard shows the climbing ghost. This is the fix target for 2.1.
Watchlist items (b)–(e) - not firing because entry window is closed
cortana.engines.scoring has been emitting 0DTE: past entry window - skipping as the dominant path since ~13:00 CT. Even the impulse engine’s BEARISH triggers at 14:30:44 (conv=0.42) and 14:31:29 (conv=0.46) route to this skip. The entry-window gate hard-closes afternoon entries; no score breakout, no phantom block, no BUY placement will occur in this session.
Count since session start at ~08:30: 2,907 combined past entry window / IMPULSE TRIGGER / HEDGING FILTER lines - the late-session behavior is almost entirely these three.
Still-running: Telegram listener crash loop
Telegram listener crashed: Event loop is closed is still firing every ~5 seconds (14:31:42, :47, :52, :57, 14:32:02, :07, :12, :17, :22, :28, :33 all observed). Continued from the 12:55 entry above. Listener restart → crash → restart loop is steady-state. Engine functions fine without the listener (commands /kill /pause /resume /status /results /brief unavailable), but log volume is ugly.
Day summary (pre-close snapshot, 14:32 CT, ~28 min to close)
- 1 trade (entry 09:14 pre-restart, exit 10:21 via broker, realized $5,625.58)
- 0 entries after restart despite 223 impulse triggers and 1 score-gate passer (#70 at 12:55)
- 0.5626% day on $1M paper
- Two P0 tracker/invariant bugs confirmed: silent policy-layer drop of signal #70 (emit without act), and stale ghost PnL for #184 (broker-flat without finalize)
Resolution - 2026-04-27 commit fdcf6ad
Position manager rewritten to a single OPEN/CLOSED state machine per
user-defined two-rule design (TP +10% broker-resting LMT, software SL
at -30%, score-aware 30/70, time close 14:15 CT). All exit triggers
route through one unified path. The 6 P0 audit findings + 1 P1 are
closed. manager.py is 511 lines (was 1022). 15 new PM tests pass,
full suite 410 passed. Pushed to cDSe2403/-MK2.1 and main. GH #46
stays open pending paper-market validation today.
Concept page filed: concepts/exit-path-failure-modes.md.
Recurrence - 2026-04-27, position #185, first trade post-fdcf6ad
The fix held its safety promise (no wedge, no infinite loop) but the upstream margin-rejection bug class converted a +17% peak into a -30% loss. New bug pattern surfaced: TP qty oversize.
Timeline
08:49:32- Entry SELL? no - BUY 100 SPY 714P @ $1.82 (orderId=996, fully filled by 08:49:33).08:49:40- PM placed TP LMT for 180 contracts at $1.99 (orderId=1000) for a 100-contract position. 1.8x oversize bug.08:49:40- IB Error 201:equity $999,731 < initial margin $1,155,769. TP rejected.08:49:40- PM correctly clearedtp_order_id, loggedTP_PLACE_FAILED, fell back to software-only. ✅ fdcf6ad behaved as designed.08:49:40-RECONCILE_QTY_DRIFT position=185 broker_qty=100- reconciler caught the qty mismatch.08:49:33 → 08:55:11- Position rode unhedged. MFE recorded at +17.03% (price peaked above $2.13, would have triggered +10% broker TP if it had rested).08:54:20–08:55:05- Watchdog error spam:cannot convert float infinity to integer(×4). Separate bug.08:55:11-PM_MARKET_EXIT_SUBMIT position=185 order=1002 qty=100 reason=PRICE_STOP- software SL tripped at $1.27 = entry × 0.70.08:55:21–22- Filled at $1.21 (slippage past SL trip).EXTERNAL_CLOSE broker_qty=0finalized.- Realized: 100 × (1.21) × 100 = −$6,100.
- MAE recorded: −30.22%. The full retrace.
Bugs (in order of damage)
- P0 - TP qty oversize (NEW). New PM placed SELL LMT for
180against position100. Source unknown; needs grep of_place_tp_limitinmixins.py. Hypothesis: leftover state from prior trade or a bad qty=contracts_remaining + something_elsecalc. The 1.8x oversize triggered the IBKR margin rejection that would have rejected ANY oversized SELL. - P0 - No BP precheck before TP placement (KNOWN, deferred). Same root cause as Friday #184. Even at correct qty=100, a SELL LMT against a long position can spike init margin. The user accepted this as “future enhancement” (item 7 of the audit, called out in
concepts/exit-path-failure-modes.mdopen threads). Today it bit on trade #1. - P1 - No software-TP fallback when broker TP fails. By design (per user spec). When the broker TP fails to place, position runs with only software SL/score/time exits. No software +10% capture. Today the broker TP failed → +17% peak unrealized was never captured → rode the swing to -30% SL.
- P1 - Watchdog error spam (NEW, tangential).
cannot convert float infinity to integer×4 at 08:54-08:55. Probably in watchdog price-staleness math when a position has no price tick yet. Not blocking but noisy.
What fdcf6ad got right
- TP rejection didn’t wedge the position. PM cleared
tp_order_id, logged once, fell back gracefully. RECONCILE_QTY_DRIFTdetected the broker_qty=100 vs whatever-the-engine-thought drift.- Software SL fired at $1.27 cleanly.
PRICE_STOPreason logged. Market SELL submitted, filled,EXTERNAL_CLOSEfinalized. No phantom 184-style ghost. - The exit path executed end-to-end. Trust restored on the cancel-guard / state-machine class of bug.
What it didn’t fix
The upstream cause of TP rejection is unaddressed. fdcf6ad makes the rejection safe but not prevented. Today’s qty-oversize bug + recurring margin issue mean TPs don’t actually rest at IBKR for the typical position. Without a resting TP, the +10% mandate is unenforced and we eat the full swing.
Action items (urgent, before next trade)
- Halt trading until qty bug fixed. Each accepted signal can become another -30% or worse if TP rejection is the steady state.
- Find the 180 vs 100 qty bug in
src/cortana/positions/mixins.py_place_tp_limit. Likely a cumulative-qty bug that includes prior position state. - Add BP precheck before TP placement. If projected init margin > equity, skip TP and go straight to software-only with a loud alert. (Was deferred as “future”; promote to P0.)
- Decide on software-TP fallback. With BP precheck + qty fix, broker TPs should rest. But for the residual rejection cases, do we want a software +10% exit or do we accept “no TP = ride to SL or score”? User’s call.
- Fix watchdog math: float inf → int conversion. Quick win.
2026-04-27 - Round 2: Entry-window races (#186 + #187)
After 9a118b1 shipped (closed Friday’s exit-path bugs + added BP
precheck, software TP fallback, qty reconcile assertion, watchdog
inf-fix), two NEW failure modes fired on the next two trades.
Position #186 - orphan from reconcile-during-entry
09:36:30- BUY 100 SPY 713C @ $1.75 placed (Telegram-approved entry, signal #72, score 70).09:36:33- Reconciler ran 3s later. Broker hadn’t filled yet, sobroker_qty=0. Reconciler called_finalize_close(EXTERNAL_CLOSE)on the PENDING_ENTRY position #186.09:36:40- BUY actually filled at IBKR (100 contracts across SAPPHIRE/MERCURY/ISE). Engine had no record. Orphan: 100 contracts at IBKR, no engine management, no TP, no SL, no time-close.
Position #187 - orphan-contaminated TP qty + dead-man’s-switch hair-trigger
09:38:30- BUY 100 SPY 713C @ $1.59 placed (signal #73, score 70, SAME contract as #186 orphan).09:38:43-44- Reconciler ran during fills. Saw broker_qty climbing 101→131→200 (the 100 from orphan #186 + new fills landing). Took broker as truth → set enginepos.contracts_remaining = 200for #187.09:38:44- TP placed forqty=200@ $1.74. Wrong qty (1.5 min earlier we had9a118b1qty-reconcile in place; the assertion passed because contracts_remaining had already been updated to 200 by the reconciler).09:38:47- Watchdog DEAD MAN’S SWITCH fired. New contract had_last_tick_at=0(subscription pending),age = inf,inf > 120is True,market_price > 0was True (initialized from entry $1.59). Force-closed all 200 contracts at MKT.- Broker realized: -1,015 (computed off #187’s $1.59 cost only - wrong). The orphan from #186 effectively got “lucky-cleared” by the dead-man’s-switch firing on #187. Broker is flat.
Root cause
PENDING_ENTRY state existed in store.py since fdcf6ad but
wasn’t actually USED as a gate by reconcile_with_broker() or
watchdog._check_position_safety(). Both treated PENDING_ENTRY
positions as if they were OPEN. ALSO: confirm_fill() was flipping
state to OPEN on the FIRST partial fill, so even a “gate on OPEN”
spec wouldn’t have protected - positions transitioned to OPEN
before all fills landed.
Three sub-bugs all from the same root:
- Reconciler acted on PENDING_ENTRY → orphaned #186 on broker_qty=0.
- Reconciler took broker as truth on #187 even though broker_qty included orphan #186’s contracts → TP for qty=200.
- Watchdog DEAD MAN’S SWITCH fired on
_last_tick_at=0(no subscription tick yet) → force-close 7s after entry.
Fix landed: 83ba9eb
confirm_fill()no longer flips state to OPEN on partial fills. PENDING_ENTRY persists until full fill (remaining_qty == 0).reconcile_with_broker()skips positions where state ≠ OPEN.- Watchdog DEAD MAN’S SWITCH requires state == OPEN AND
_last_tick_at > 0. - Reconciler now logs
ORPHAN_DELTA(CRITICAL) when broker_qty exceeds sum across engine positions for a contract; does NOT contaminate any single position. - New
ORPHAN_BROKER_POSITIONlog for unmatched broker positions on startup. - All state mutations route through a single
_transition_statehelper that emitsSTATE_TRANSITIONlog lines. - 8 new tests: state-gate, orphan-delta, watchdog-before-first-tick.
Net day P&L (paper)
| # | Position | Gross | Path |
|---|---|---|---|
| 185 | SPY 714P | -1,800 per user directive - TP would have fired absent the qty-double-count bug) | Friday’s bug class manifested today |
| 186 | SPY 713C orphan | unknown (broker - folded into #187 close) | Reconciler-during-entry race |
| 187 | SPY 713C | +419.75 (broker, correct - sold 200 contracts including #186 orphan at MKT slippage) | Dead-man’s-switch hair-trigger |
Engine-reported daily total ≠ broker-realized total today. Operator should treat broker realized as truth: -419.75 for the day. (Plus the manual override on #185 to +1,800 represents the trade we should have had absent the bug; for analysis purposes only.)
What’s still open
- End-of-day Monday validation: does the engine run the rest of
the day cleanly with
83ba9eb? - New companion concept pages filed:
position-state-machine,position-lifecycle. Updated:exit-path-failure-modes(added Class 3).