Version: 1.8 | Last Updated: April 27, 2026 | Status: ✅ APPROVED
⚠️ AMC Exit Time: 3:57 PM (not 3:58 PM)
This provides a 1-minute buffer before BMO entries at 3:58 PM to prevent order execution conflicts.
| # | Edge Case | Handling | Alert |
|---|---|---|---|
| 1.1 | Market Holidays (NYSE closed) | Skip trading, check calendar | None |
| 1.2 | Early Close Days (1 PM close) | Exit at 12:57 PM (AMC) / 12:58 PM (BMO entry) | None |
| 1.3 | Late Open Days (delayed open) | Wait for open + 2 min for BMO exit | None |
| 1.4 | Trading Halts (stock-specific) | Alert, retry on resume | ⚠️ WARNING |
| Date | Holiday | Market Status |
|---|---|---|
| Jan 1 | New Year's Day | CLOSED |
| Jan 20 | MLK Day | CLOSED |
| Feb 17 | Presidents Day | CLOSED |
| Apr 3 | Good Friday | CLOSED |
| May 25 | Memorial Day | CLOSED |
| Jun 19 | Juneteenth | CLOSED |
| Jul 3 | Independence Day Eve | EARLY CLOSE 1 PM |
| Jul 4 | Independence Day | CLOSED |
| Sep 7 | Labor Day | CLOSED |
| Nov 26 | Thanksgiving | CLOSED |
| Nov 27 | Day after Thanksgiving | EARLY CLOSE 1 PM |
| Dec 24 | Christmas Eve | EARLY CLOSE 1 PM |
| Dec 25 | Christmas | CLOSED |
| # | Edge Case | Handling | Alert |
|---|---|---|---|
| 2.1 | 3+ day weekend hold | Trade with 50% position size (canonical — signal_generator.py symbol long_weekend_multiplier = 0.5 if is_long_weekend else 1.0 (cite the symbol, not a line number — line refs rot); reconciled Apr 26, 2026 — was 75% in this doc, but live bot has always used 50% per STRATEGY.md §5.2.1) |
None |
| 2.2 | Earnings date moved after entry | Exit at market immediately | ⚠️ WARNING |
| # | Edge Case | Handling | Alert |
|---|---|---|---|
| 3.1 | No shares available to short | Skip trade, log reason | ⚠️ WARNING |
| 3.2 | Extended hours SL/instant exit | Accept slippage for safety | None |
| 3.3 | Entry/exit during market closed | Entry/scheduled exit: gate to regular hours (IBKR liquidHours); instant exit: GTC LIMIT only when fully closed | ⚠️ WARNING |
| 3.4 | Fractional shares not available | Fall back to whole shares (round down) | None |
| 3.5 | Stock not on eligible exchange | Use whole shares, log reason | None |
| 3.6 | Exit order not filled (low liquidity) | GTC LIMIT 5s → MARKET fallback (Jan 11 fix) | None |
| 3.7 | Exit verification failed | Keep DB open, alert for manual close | 🚨 CRITICAL |
| 3.8 | External trade detected | Log to external_trades table, alert | ⚠️ WARNING |
| 3.9 | Orphaned DB position | Detect on startup, alert for resolution | 🚨 CRITICAL |
| 3.10 | Position mismatch (DB vs IBKR) | Log discrepancy, alert for investigation | ⚠️ WARNING |
| 3.11 | Stop-loss in extended hours (Finding #59, UPDATED #179) | Use STOP-LIMIT for ALL exchanges (not just NYSE). Pure STP gets IBKR Warning 2109 on NASDAQ (outsideRth ignored, won't trigger after hours). HOOD lost 11% vs 3% intended. STP LMT respects outsideRth on all exchanges. | None |
| 3.12 | Timezone-naive datetime comparison (Finding #60) | All datetimes must be timezone-aware; ensure_timezone_aware() helper converts naive to ET |
None |
| 3.13 | Stop fill detection (Phase 4D) | Real-time via IBKR orderStatusEvent fill callbacks through EventBus. Periodic reconciliation (5 min) serves as backup for missed callbacks or downtime. Solves CBOE/MCHP incident. |
None |
| 3.14 | Duplicate order from market fallback (Feb 9, 2026) | Before ANY retry/fallback order, check IBKR position first. If position already exists at expected size, ABORT the fallback. Defense-in-depth guard in batch_order_manager._apply_market_fallback and order_executor.place_guaranteed_fill. Solves PFE/CME/ON duplicate pattern. |
None |
| 3.15 | Stale earnings date in DB (Feb 9, 2026; rebuilt Jul 29, 2026 — Finding #349; lookahead/#442 Sep 13, 2026) | verify_earnings_dates() runs before each signal check (AMC 10:30 AM, BMO 3:50 PM). Votes across DB row + NASDAQ + Finnhub (+ yfinance tiebreak): unique ≥2-vote consensus ⇒ correct + stamp date_confidence='confirmed' (locks the date against Polymarket-derived sync overwrites); no majority ⇒ 'disputed' (row untradeable). Telegram alert on corrections/disputes; changes audited in earnings_date_audit including confirm-in-place vote sets (#442). Window and official-calendar fetch are 21 days (#442 — a 5-day window confirmed CBRL Sep 14 while NASDAQ listed Sep 23). NASDAQ+Yahoo pair may rewrite a locked PM date; hardcoded IR map is last-resort. Upcoming PM join is (ticker, date). Timeline DATE SPLIT when plan ≠ calendar. The old single-source NASDAQ auto-correct is gone — it lost a 2-day overwrite war with the hourly sync on SBUX (instant entry/exit). |
Rows with no external calendar coverage stay unverified (NULL confidence) and trade on end_time-consistent dates only (earnings_schedule_mismatch guard). |
| 3.16 | IBKR connectivity loss blocking trading (Feb 9, 2026) | Error 1100 no longer persists trading block to disk. Errors 1101/1102 (connectivity restored) now properly clear the block. Health check auto-clears connection-related blocks on success. | None |
| 3.17 | Stop triggered but fill lags (Feb 15, 2026) | Track first stop-breach timestamp; if hardware stop remains unfilled past timeout, escalate to instant_exit:stop_timeout_escalation. |
🚨 CRITICAL |
| 3.18 | Extended-hours stop fills + RTH gap risk (Apr 25, 2026 — Finding #228) | Hardware STP-LMT carries outsideRth=True (Finding #179) so stops fire in pre/post-market when hit. The Finding #228 simulator quantified this on 51 stopped trades: blocking the broker fill outside RTH (the proposed v3 Finding A "FA_hard" variant) would force the position to gap-open at the worse RTH-open price 22 of 51 times, costing −$417 vs. live. The 9 recoveries (+$204) don't outweigh it. Operative behaviour: keep extended-hours stops. RuleB (±90 min no-stop window) initially survived the 4-check bias gauntlet but was REFUTED gap-aware (#330, Jul 5 2026) and its apparatus REMOVED (#380, Aug 21 2026) — NO stop-loss rule variant currently survives. Do NOT change extended-hours stop policy without re-running scripts/analyze_finding_a_full.py. |
None |
| 3.19 | Halt-suspect cases in extended hours (Apr 25, 2026) | The Finding #228 simulator flags any extended-hours bar gap >30 min combined with a >3 % price jump as halt-suspect. Two real cases observed: ARES Feb 5 (134-min gap, +3.71 %) and NYT Feb 4 (41-min gap, +3.92 %). Current behaviour: hardware STP-LMT may fill at the gap-through price. No software intervention required — the structural protection is the STP-LIMIT order's limit price (Finding #179). Future research: tighter halt detection if frequency increases. | None (informational) |
| # | Edge Case | Handling | Alert |
|---|---|---|---|
| 4.1 | Gateway disconnected | Attempt reconnect, check 2FA | 🚨 CRITICAL |
| 4.2 | Sunday 2FA (weekly) | Approve on IBKR app at 10:00 AM Abu Dhabi | ⚠️ WARNING |
| 4.3 | Session invalidated | 2FA required - approve on phone | 🚨 CRITICAL |
| 4.4 | Server reboot | systemd auto-start, IBC handles login | ⚠️ WARNING |
| 4.5 | Database connection lost | Fallback to local state | 🚨 CRITICAL |
| 4.6 | Mon-Sat restart | IBC in-process AutoRestart keeps the session (#437); ibkr-monitor pages if :4001 stays down >180 s (#448); the old gateway_restart_manager.sh is retired (#476) |
None |
Session Invalidation Causes:
- Gateway process killed externally (kill, systemctl stop)
- Login from IBKR Portal, TWS, or Mobile app (same username)
- VNC service crash affecting Gateway
- Server reboot without IBC auto-start
(SSOT: src/earnings/risk_manager.py class docstring + src/earnings/session_stop_breaker.py. Updated Aug 21, 2026 per CLB-1/A-6, Finding #366: the consecutive-loss breaker — old row 5.5, "5+ consecutive losses" — is RETIRED; daily loss is the sole loss brake, with sizing tiers re-keyed to day P&L.)
| # | Edge Case | Handling | Alert |
|---|---|---|---|
| 5.1 | Daily loss -5% (DAILY_LOSS_LIMIT) |
Sole loss brake — stop for the day; day P&L DB-anchored; resumes at the true next ET market open; survives restarts (A-6) | ⚠️ WARNING |
| 5.2 | Drawdown 30%+ | Reduce position sizes 50% (40%: 75%; 50%: full pause) | 🚨 CRITICAL |
| 5.3 | VIX > 35 | No new entries (VIX > 30: sizes ×0.50) | ⚠️ WARNING |
| 5.4 | S&P -5% intraday | No new entries, hold existing | 🚨 CRITICAL |
| 5.5 | Day P&L ≤ −2.5% / ≤ −4% | Sizes ×0.50 (ELEVATED) / ×0.25 (HIGH) — re-keyed from the retired loss-streak tiers | None |
| 5.6 | 4 stop-exits in one ET day (session-stop breaker, session_stop_breaker.py) |
Pause signal generation for the rest of the session (#312; threshold 3→4 per #335.b) | ⚠️ WARNING |
| # | Edge Case | Handling | Alert |
|---|---|---|---|
| 6.1 | Position stuck (no exit after expected time +15 min) | Force market exit | 🚨 CRITICAL |
| 6.2 | Multiple orders at same time | Execute exits first (3:57 PM), then entries (3:58 PM) | None |
| Event | Time (ET) | Notes |
|---|---|---|
| AMC Signal Check | 10:29 AM | Verify conviction (5 min before entry) |
| BMO Exit | 10:32 AM | 1h after open (captures first-hour gains) |
| AMC Entry | 10:34 AM | 1h 4 min after open |
| BMO Signal Check | 3:53 PM | Verify conviction |
| AMC Exit | 3:57 PM | 1 min buffer before BMO entry |
| BMO Entry | 3:58 PM | 2 min before close |
| # | Edge Case | Handling | Alert |
|---|---|---|---|
| 7.1 | Missing logger import | All modules must have import logging + logger = logging.getLogger(__name__) |
🚨 POST-MORTEM |
| 7.2 | Silent exception in signal generation | Caught exception causes 0 signals - check logs for "Error processing market" | 🚨 CRITICAL |
| 7.3 | API rate limiting | Retry with exponential backoff, cache data | ⚠️ WARNING |
| 7.4 | Database schema mismatch | Run migrations before deploy | 🚨 CRITICAL |
| 7.5 | Wrong volume field in threshold check | Use volume_total (cumulative) NOT volume_24h (24hr rolling) |
🚨 POST-MORTEM |
| 7.5b | Trader vs dashboard volume gate drift | One SIGNAL_MIN_VOLUME_TOTAL from Config; dashboard upcoming Polymarket uses DB volume_total + same config — Finding #222 |
🚨 POST-MORTEM |
| 7.6 | Dashboard filtering ignores SHORT signals | When filtering tradeable signals, check BOTH ≥70% (LONG) AND ≤30% (SHORT) | 🚨 POST-MORTEM |
| 7.7 | Conviction query for future entry time | When entry time is in future, use _get_latest_conviction() for most recent data |
🚨 POST-MORTEM |
| 7.8 | Schema mismatch in conviction query | Conviction is in price_snapshots.yes_price, NOT markets.yes_price |
🚨 POST-MORTEM |
| 7.9 | Missing stocks in market cap lookup | Always have yfinance fallback + cache for 24h. Apr 26, 2026 (Finding #249): chain is now yfinance → Polygon → Finnhub. Polygon Stocks Starter ($29/mo) gives ~98 % hit rate with separate quota from Yahoo + Finnhub. | 🚨 POST-MORTEM |
| 7.10 | pg8000 mis-parses :name::cast (Finding #263, Apr 27, 2026) |
pg8000 driver parses :: as a parameter-prefix continuation in SQL like :exit_time::timestamptz, raising syntax error 42601. Always use CAST(:name AS type) instead. Phase-2's hold_duration_hours writer used :exit_time::timestamptz and silently broke the entire primary close path for every trade, falling through to a fallback UPDATE that didn't write hold_duration_hours, conviction_at_exit, prediction_correct, or financing aggregates. Same-pattern audit: rg ':\w+::' src/ earnings_dashboard/ clean. |
🚨 POST-MORTEM |
| 7.11 | Yahoo's GAAP scrape can override confirmed Polymarket non-GAAP signal (Finding #264, Apr 27, 2026) | VZ earnings BMO: Polymarket said YES=99.95% (BEAT — company beat non-GAAP), trader had restarted ~30s before Yahoo polled, in-memory _confirmed_polymarket dict was empty during confirmation window, Yahoo's $1.20 vs $1.21 GAAP scrape (-1¢) won. Trader exited a CORRECT LONG position. Two-layer defense in earnings_detector: (a) Yahoo contradiction now BLOCKS when pm_confirmed exists (matches PRNewswire/BusinessWire); (b) new _live_polymarket_extreme_outcome() reads price_snapshots.yes_price directly so post-restart extreme prices (≥99% / ≤1%) still block Yahoo regardless of in-memory state. |
🚨 POST-MORTEM |
| 7.12 | Dashboard volume display vs trader gate misalignment (Finding #265 + #265.v2, Apr 27, 2026) | Trader gate uses effective_cumulative_volume_usd(db_volume_total, gamma_volume) (cumulative). Dashboard widget had been using gamma_volume directly (24h rolling per Polymarket Gamma's wonky labelling). VZ rendered volume: 1226 / volume_ok: false / SKIP while the trader correctly entered with cumulative $3,920. Both branches of get_polymarket_earnings_from_db (gamma success + except-fallback) now use effective_cumulative_volume_usd. New transparency keys (volume_24h, volume_cumulative, volume_db_total, volume_gamma_raw) surface all four metrics on the API response. Step-5 lesson: v2 fix was needed because the original v1 fix only patched the success branch; agent didn't curl /api/earnings-calendar to verify VZ post-deploy. Going forward every fix MUST end with a probe of the changed output. |
🚨 POST-MORTEM |
| 7.13 | pg_dump OOM on growing DB (Finding #267, Phase 5 fix, Apr 27, 2026) |
Old code: process.communicate() loaded the entire pg_dump output (~32 GB raw) into Python bytes before writing to gzip → OOM-killed every run after DB grew past ~6 GB on a 7.8 GB host. Fixed: stream pg_dump.stdout in 1 MB chunks directly into gzip.open(). Memory footprint now O(chunk_size) regardless of DB size. polymarket-backup.service was OOM-killed Apr 25 / 26 / 27 04:00 UTC before the fix shipped 13:43 UTC; first proven success Apr 28 04:00 UTC. |
🚨 POST-MORTEM |
| 7.14 | Polymarket Gamma volume24hr field intermittently NULL (Finding #272, Apr 27, 2026) |
Live probe: volume24hr=None on every market in /events/pagination response. volume, volume1wk, volume1mo, volume1yr remain populated. Our continuous_collector was writing m.get('volume24hr') or 0 to markets.volume_24h → 88 % of recently-synced markets had volume_24h=0 → signal_generator.get_market_volume() treated 0 as falsy → signal.volume_24h=None → INSERT wrote NULL. 76 consecutive trades since Feb 16, 2026 had volume_24h_at_entry=NULL before the fix. signal_generator.get_market_volume() now falls back to most-recent positive price_snapshots.volume_24h within last 6 hours. Backfilled 114/117 (97%) historical rows. Note: trader's volume gate uses volume_total (cumulative) which is unaffected — only the 24h companion was lost. |
🚨 POST-MORTEM |
| 7.15 | Integrity monitor must watch batch-service exit codes, not just timer fires (Finding #268, Apr 27, 2026) | Phase 1 made polymarket-backup, polymarket-resolver, polymarket-trade-reconcile, polymarket-equity-bars, polymarket-data-backfill, polymarket-earnings-sync exit non-zero on real failure. Phase 3's monitor watched systemd:* (always-on) and systemd_timer:* (timer-fire freshness only). Net: polymarket-backup was OOM-killed 3 nights running with zero Telegram alerts. Fix: new _check_systemd_unit_last_result() helper reads Result + ExecMainStatus + ExecMainExitTimestamp so a unit's most-recent failed exit is monitored even when currently inactive. 6 new systemd_last_run:* rules. |
🚨 POST-MORTEM |
Post-Mortem: Finding #29 (Jan 12, 2026)
- conviction_calculator.py used logger without importing - caused ALL signal generation to silently fail
- Impact: Missed 3 BMO trades (JPM, BK, DAL) for Jan 13 earnings
- Fix: Added import logging and logger = logging.getLogger(__name__)
- Prevention: Code review checklist must include logger import verification
Post-Mortem: Finding #30 (Jan 13, 2026)
- Signal generator checked volume_24h instead of volume_total for the volume threshold (the gate was $2,000 at the time of this post-mortem; the current live volume gate is $1,000 per Finding #311)
- volume_24h was stale/zero for many markets (DAL=$0, BK=$0, WFC=$0)
- volume_total (cumulative market volume) was correctly populated ($32K, $11K, $23K)
- Impact: Multiple trades incorrectly skipped due to "$0 volume" when they had sufficient volume
- Fix: Changed signal_generator.py line 874 to use volume_total for threshold check
- Prevention: Always verify field semantics (24h rolling vs cumulative total) match business logic
Post-Mortem: Finding #31 (Jan 13, 2026) - Dashboard "Pending Opportunities" section only filtered for conviction ≥70% (LONG signals) - SHORT signals (conviction ≤30%) like WFC were completely ignored in the timeline - Signal generator, priority calculator, and risk manager all correctly handle SHORTs - Impact: WFC SHORT signal (30% conviction) not displayed in timeline, could cause missed trades - Fix: Updated Jinja2 template to filter for BOTH ≥70% (LONG) AND ≤30% (SHORT) - Prevention: When filtering tradeable signals, always consider BOTH directions
Post-Mortem: Finding #32 (Jan 13, 2026) - THREE CRITICAL BUGS Pre-trade simulation discovered 3 bugs that would have blocked ALL BMO trades:
get_conviction_at_entry_time() queried for future entry time (e.g., tomorrow 3:53 PM)None_get_latest_conviction() to return most recent data when target is in futureFile: src/earnings/conviction_calculator.py
Trading Orchestrator Schema Mismatch
_get_current_conviction_and_volume() queried markets.yes_priceyes_price column is in price_snapshots table, NOT marketsprice_snapshots for conviction, markets for volumeFile: src/earnings/trading_utils.py (moved from trading_orchestrator.py in Phase 4A)
Market Cap Returns None for Major Banks
None → defaulted to MID cap classificationFile: src/earnings/signal_generator.py
Discovery: Pre-trade readiness simulation at T-12 hours
Last updated: April 27, 2026 (added Cat 7.10-7.15 from Apr 27 evening rigorous §B audit — Findings #263 / #264 / #265.v2 / #267 / #268 / #272)