← Back to Dashboard

Polymarket Earnings Trading Strategy

Version: 6.0 | Last Updated: February 15, 2026 (long-form rules) · Apr 25, 2026 (cross-refs added) · Apr 27, 2026 (watchdog-v3 → integrity-monitor + cross-refs to Findings #234-#274 from Strategy Analysis Revamp + Database Integrity Audit) · May 31, 2026 (strategy-rule refresh — Findings #304/#305/#311/#312/#313/#317 et al. folded into the long-form rules below) | Status: ✅ PRODUCTION

Oct 4, 2026 — Findings #486 and #487 DEPLOYED (unit polymarket-deploy-20261004T120553Z-812717079, SHA 7c9d6b0, 12:05 UTC: NASDAQ calendar GET timeout=8 and a 45s budget; orderbook size FAIL names a finished drain; trader 12:06 UTC, integrity monitor 12:08 UTC; Gateway not restarted). Sep 26, 2026 — Sep 27, 2026 — Findings #481/#482 DEPLOYED (deploy #14 28b572b 00:33 UTC: #481 quarantines a permanently failing live-trade row once and marks it seen, market-data poison is WARN only; deploy #15 860af84 02:43 UTC: #482 migration 042 widens trades.token_label to varchar(64), the typmod probe is scoped to 042, and the three quarantined payloads — two distinct trades — were released, spool empty; the deploy-#14 dashboard restart paged once again, fix PR #9 pending; no trading change). Reliability Batch B3 — Findings #454/#455 and migration 039 — DEPLOYED Sep 26, 2026 23:29 UTC (deploy #13 00607c9, the exact approved SHA, pure fast-forward: paced IBKR in-process recovery with reset/latch truth; a durable DB-write spool for entry/exit/execution/reconciliation/live-trade writes; 039 idempotency keys; the live-trades poison-row loop (trades.token_label varchar(20) vs a 22-character outcome; 26 CRITICAL alerts 23:32:47–23:37:46 UTC; polymarket-live-trades stopped 23:39:08 by operator decision) was fixed by #481 (deploy #14 28b572b, Sep 27 00:33 UTC: poison rows quarantined once and marked seen, market-data poison WARN only, collector restarted) and #482 (deploy #15 860af84, 02:43 UTC: migration 042 widened token_label to varchar(64); the quarantined trades were released and the spool was empty at 02:51); the #454 soak (next 04:13 reset and 06:00 AutoRestart) is pending; no strategy change). The #479 sampler-grace race hardening DEPLOYED Sep 26, 2026 22:39 UTC (deploy #12 2ecb02c: one systemctl show snapshot per evaluation, a restart-loop FAIL that overrides the grace, the untrusted-address WARN checked before the stale grace; the deploy's own dashboard restart still raced the new monitor's first cycle and paged once, resolved five minutes later — follow-up OPEN; no trading change). Finding #467 and the #479 sampler restart-grace fix DEPLOYED Sep 26, 2026 22:19 UTC (deploy #11 3138aca: dashboard response secret-redaction filter; FastAPI auto-docs off and the three inert write endpoints removed; the IBKR account, wallet and server identifiers masked on public pages, docs included (one open gap: /api/btc5m/docs/doc_map still shows the raw WALLET_NAGI value, the six-character prefix of the third-party benchmark maker's wallet (not a full address, not Marius's wallet); a code follow-up is OPEN); the sampler rules tolerate a deploy restart for 120 s; no trading change). Findings #477/#480 DEPLOYED Sep 26, 2026 21:56 UTC (deploy #9 0e985dd: guarded data migrations 040 then 041 applied — #477 one P&L definition for closes and corrections plus a reconciler P&L-consistency check, BBW 274/307 −121.52 → −282.10 and SNOW 75 net −9.96 → −1.16; #480 pg8000 covered-channel lookup typed, the unaudited legacy sync retired, the four false leak rows relabelled timing_channel_mismatch; the Phase-1 net from the DB is now −$922.28 (n=13), matching the record; no strategy or admission change). Findings #457/#478/#479 DEPLOYED Sep 26, 2026 20:55 UTC (deploy #8 614a29d: #457 committed secrets scrubbed from HEAD + hash tripwire, the doadmin reset DONE 20:49:21 UTC before the deploy; #478 C-Q2/G-RS count trigger, 2/12; #479 per-service application_name + DB activity sampler — the post-deploy evaluator defect (security:db_client_activity could not evaluate, so the foreign-client page was inactive 20:58–21:33 UTC) is fixed DEPLOYED Sep 26, 2026 21:30 UTC (deploy #10 f28c0da); the rule reads ok; no live trading behaviour change). Findings #473/#474/#475 reliability follow-ups + docs sweep DEPLOYED Sep 26, 2026 19:05 UTC (deploy #7 a695050: #473 requirements pinned to the prod venv — the deploy never runs pip, prod pip freeze equals constraints.txt; #474 migration-baseline check — prod --check-baseline 5/5, the deploy ran no DDL; #475 C-Q2 detection replay, offline research tooling; docs sweep — Phase-1 P&L record, dashboard findings parser, drift-audit C10/C12, comment-only code edits; no live trading behaviour change). Finding #468 prod checkout hygiene DEPLOYED Sep 26, 2026 15:15 UTC (deploy #5 f130113; .gitignore + OPERATIONS §8.A2 + test only, no runtime change; prod git status --porcelain empty; prod moves DONE 14:59 UTC). Reliability Batch A DEPLOYED Sep 26, 2026 (deploy #1 884e528 10:18 UTC; #445 fix + F-7/F-8 deploy #2 3d7f1b1 11:48 UTC; apt-daily-upgrade hold ended 11:27 UTC): Finding #453 Monday 28 Sep stall stopgap (300 s dated watchdog budget, self-expiring Tue 00:00 ET); Finding #445 dashboard hypertable SQL bounded + Sep 25 read-only incident record (7-day coverage cached 6 h; per-statement transactions; 60 s statement timeout); Finding #444 deploy migration ledger (029 never re-runs; DDL under lock_timeout; output logged); Finding #443 needrestart covers the live xvfb.service/x11vnc.service (deploy-time coverage check; apt timer hold ended operator-attended at 11:27 UTC). Previous production baseline was #442 (DEPLOYED Sep 13, 2026).

Sep 13, 2026 — Finding #442 earnings-date join + 21-day verifier lookahead + timeline DATE SPLIT DEPLOYED Sep 13, 2026 (CBRL: upcoming Wed 23 no longer wears the Sep 14 confirmed market; verify window 5→21 days; votes persist on confirm-in-place; #349/#424 leftover). Finding #441 1102 no nested util.run; attach-bounce after IB() reset DEPLOYED Sep 9, 2026 (04:14 1102 then 5h reset loop; farm sticky blocked bounce; #440 leftover). Finding #440 same-thread req after 2FA DEPLOYED Sep 7, 2026 (heartbeat/reconnect must not util.run on another loop; #87 leftover; 03:57 RESTORED then 40s Mode B). Finding #439 weekly last-resort 2FA budget DEPLOYED Sep 5, 2026 (one Python recycle per Sunday week; 7d cooldown; Sunday 06:00–18:00 UTC quiet; #438 leftover). Finding #438 last-resort recycle DEPLOYED Sep 5, 2026 (P1 req first; P2 2FA only Sunday or after IB() reset failed; #435 age/cooldown; #437 leftover). Finding #437 Saturday stay-connected DEPLOYED Sep 5, 2026 (recycle-off periodic IB() reset; heartbeat keeps probing; weekday AutoRestart reattach no bounce; attach down_since continuous; #436 leftover). Finding #436 weekly-only Sunday 2FA DEPLOYED Sep 3, 2026 (recycle-off default; Mode A/B keep probing; Mon–Sat session preserve; Sunday/crash attach-only; #434 leftover). Finding #435 recycle-once DEPLOYED Sep 3 (Mode B in-place first; no 2FA while Gateway young / cooldown / late-watch; 06:27/06:48/07:14 leftover). Finding #434 stay connected first DEPLOYED Sep 3 (recycle-on even with a live book; farm window still no-2FA; #430 leftover). Finding #433 keep probing when weekly 2FA holds recycle off DEPLOYED Sep 3 (Mode B exhaust-and-stop has no next step; header follows request_path_ok; 3 Sep 05:16 leftover). Finding #432 Mode A daily reconnect without 2FA DEPLOYED Sep 3 (1100 sticky farm probe until durable success; 900s timer no longer exhausts; Mode B leftover AMENDED by #433; recycle stays off). Finding #431 channel-window already-evaluated DEPLOYED Sep 2 (AEO C-1 generate_signal covers; any non-timing row; true timing-only still FAIL). Finding #430 weekly 2FA contract DEPLOYED Sep 2 (standing Gateway recycle off; midweek ibgateway-ibc restart was the extra 2FA; Sunday 06:00 stays; true needs GUARD-OVERRIDE-UNTIL). Finding #429 late-login quiet + Mode B window DEPLOYED Sep 2 (2FA resets 1800s quiet; 8 heartbeat fails/600s exhaust). Finding #428 late-login attach DEPLOYED Aug 30 (2FA abort keeps watching IBC+:4001; systemd-run bounce; flat-book re-check in that call). Aug 29, 2026 — Finding #427 farm-recovery + standing recycle DEPLOYED Aug 29 (1100 keeps 900s probe window — no 61s zombie-exhaust; no-1100 TCP-up still #426 exhaust; flat-book auto-recycle Gateway then trader; IBC 06:00 is not recycle). Aug 28, 2026 — Finding #426 Gateway zombie DEPLOYED Aug 28 (durable-only healthy; TCP-up trip exhausts not 300s loop; no client 99 on dead path; recycle CONFIRMED 11:22 UTC). Finding #425 etfstack VIX fallbacks DEPLOYED Aug 28 (Yahoo chart KeyError → history → Cboe → last-good cache). Finding #424 ASO official IR DEPLOYED (Sep 1 disputed → Sep 9 BMO confirmed). Finding #423 trade-reconcile BBW#274 DEPLOYED (live log + LMT fill + IBKR ingest). Finding #422 Trader Session READY DEPLOYED (Connected, not Unknown). ** Finding #421 IBKR one session machine DEPLOYED (TCP-up always in-place; connect/1102 clears REAL_DROP; bind errorEvent before connect; 1102 probes in place; STILL DISCONNECTED only if TCP down; PATH_DEAD/EXHAUSTED win the header; empty live book no ghost last-good stops; #420 1100-misclassify leftovers AMENDED; trader bounce). Aug 27, 2026 — Finding #420 IBKR nightly-1100 leftovers DEPLOYED (CancelledError swallow; 1100 skips in-place exhaust; skip VIX/snapshot flood; UNREADABLE not on live book; last-good stops kept; recon-guard 1800 after exhaust; #419 AMENDED; trader bounce). Aug 26, 2026 — Finding #419 IBKR in-place reconnect escape hatch DEPLOYED (quiet heartbeat while TCP up; one IB() reset then stop; no infinite 60s; leftover timeout cancel; UNREADABLE not on live book; #418/#416 AMENDED; trader bounce). Finding #418 IBKR reconnect amplifier DEPLOYED (keep TCP session on durable-health fail; last-good book; skip 412 reload; #415 AMENDED; trader bounce). Finding #417 needrestart + overnight trades freshness DEPLOYED (live-trades + integrity-monitor excluded; overnight 180 min / RTH 60 min). Finding #416 unverifiable IBKR ≠ missing DEPLOYED (IBKR UNREADABLE vs MISSING). Finding #415 IBKR latch leftover DEPLOYED (re-trip only inside durable probe; 300s reconnect while dead; #413 AMENDED; trader bounce). Aug 25, 2026 — Finding #414 channel-window leak already-admitted suppress DEPLOYED (ignore leak when same market already emitted/admitted; DKS false #384; trader + integrity bounced). Finding #413 IBKR request-path latch hysteresis DEPLOYED (3 successes incl. qualify to clear; 5 min reconnect cooldown; no ibkr_disconnect on #412 trips; re-trip leftover AMENDED by #415). Finding #412 IBKR request-path latch DEPLOYED (3 consecutive bounded_request failures → block 412 + Telegram + dashboard red + integrity; clear/reconnect/alert AMENDED by #413). Aug 24, 2026 — Finding #411 orderbook-archive dual-limit pacing DEPLOYED (18 chunks/run, TimeoutStartSec=21600, 00:35+06:35 UTC). Trader not bounced (mid-session; no trading-path change). Aug 22, 2026 — Finding #410 ship-verification letter-audit (record only; no trading-logic change). Finding #409 DONE-CLAIM + C10/C11 DEPLOYED Aug 22, 2026 15:23:13 UTC (SHA defe78f). Finding #408 leftover-orchestrator twins DEPLOYED Aug 22, 2026 14:49:44 UTC (SHA 4e4ffa1). One persist helper + one missed-exit/scheduled-exit helper (not a trading-rule change). This docs-stamp is a later SHA than the trader process. Finding #407 HTTP quota + five lean memory.md §F/§H clauses DEPLOYED Aug 22, 2026 13:39:29 UTC (SHA 7fbfe05).** #401–#406 DEPLOYED Aug 22, 2026 07:53:22 UTC (SHA 88014f4). Reservation now credits 10:32 exits at 10:30 (#401). INTRADAY/unknown timing skips — never AMC (#403). PDT reserve is one Config.PDT_RESERVE_CAPITAL (#405). CapitalManager deleted (#402). Deferred T2.2 T2.6 T2.8 T2.11 T2.13 T2.15 ON-HOLD on the Action Queue (#406). #393–#400 remain live from the Aug 21 weekend restarts. Do not rewrite the #366 history below.

PKG-357 REWRITE (Aug 20, 2026, Finding #366 — DEPLOYED Aug 21, 2026 04:34 UTC): the revalidation package rewrites the core rules below: A-7 affordability admission · FUND-1 PDT reserve $25,000→$17,450 (~$10k trading capital) · S2c equal-split shrink-to-fit sizing (≥$9k gate) · B-4 3-tier priority (0-3; 0-10 scale + volume bumps DELETED) · C-1 LONG cap re-closed at 0.90 · RES tonight-only observed-set reservation + rank-3 carve-out (multi-day lookahead + ≥95% override DELETED, A-10) · CLB-1 consecutive-loss brake DELETED (daily-loss = sole loss brake, persisted, A-6) · WAVE-RT batch-prep deadline scaling + per-batch slippage counting · B-5 small-cap LONG 0.80-0.95 pilot · M-VOL V-BLEND shadow logging. Sections rewritten in place with supersession banners; pre-#366 text below any such banner is history.

Strategy rules UPDATED through Jul 11, 2026. (The earlier "unchanged since Apr 25" note was superseded by the May-28→May-31 Phase 3 sprint.) Shipped rule changes now reflected below: Rule #1 Skip-LONGs-in-downtrend (#304), RuleB ±90 min no-stop window Phase-1 DRY-RUN (#305), volume gate $2k→$1k (#311), daily session stop breaker (#312 at 3 stops; threshold raised to 4 by #335.b Jul 11 2026), priority scale 0-6→0-8 (#313), daily exposure collapsed to a single uniform cap (#317; 140% then, raised to 150% Jul 11 2026 — #335, with AMC/BMO admission freed-capital + BMO-reservation admission fixes in the same finding), and position sizing FLAT 25% all mcap tiers (#314.f, Jul 6 2026 — was 30/22/15 per #211), and LONG conviction cap RELAXED ≥0.90 → ≥0.95 (#333, Jul 11 2026 — the 0.90-0.95 band now trades; ≥0.95 still skipped; ⚠️ the band FAILED its contractual re-gauntlet on real fills — #357 — and the C-1 re-close to 0.90 was DEPLOYED Aug 21, 2026 — #366; the 0.90–0.95 band no longer trades, would-be fills are shadow-logged by M-C1) with priority re-tiered to 0-10 (#334, same day — base 0-8: Large 8/7/6, Mid 5/4/3, Small 2; the reopened 90-95 band ranks LOWEST within each mcap group; small caps stay 70-80% LONGs only). Jul 5, 2026 (Finding #330): RuleB Phase-2 active flip CANCELLED — the gap-aware simulator fix flipped RuleB to −$194/100 ex-top-2 (FAILED-GAUNTLET); Phase-1 dry-run code remained, logging only, until Aug 21, 2026 (#380): the RuleB apparatus was REMOVED entirely (operator A-12 option (b) — twice refuted, and the dry-run harness had been structurally blind since mid-June). Jul 14-15, 2026 (Findings #336/#337/#339/#341/#342): earnings-detection stack rebuilt around a UNIFIED EXIT ARBITER — sources (PM price, wires, Yahoo) are witnesses, not decision-makers: PM is exit-only contra-position (#341); content-source contra claims need crowd agreement, a second source, or 5-min persistence, and are VETOED when the live crowd median contradicts them (#342, PNC dividend-as-EPS incident); NO detection signal writes actual_beat (EPS resolver #227 is the sole settler) or stops monitoring. See Detection Methods table below. See docs/STRATEGY_PLAYBOOK.md §1 for current verdict and Action Queue.

⚠️ Read this first — where the current strategy plan lives

This file is the long-form reference for trading rules, schedules, sizing, exchanges, and execution mechanics. It does NOT contain the currently-ranked improvement queue. For:

Apr 25, 2026 update: v3 audit's Finding A (block stops outside RTH) was RETRACTED via the comprehensive simulator (Finding #228). All stop-loss recommendations now go through scripts/analyze_finding_a_full.py per the true-simulator rule. The §5.4 / §5.5 / §6 stop-loss content below describes the OPERATIVE state — extended-hours stops remain enabled (STP-LIMIT, Finding #179) because removing them would cost −$418 / 100 trades per the simulator. RuleB (±90 min no-stop window) was believed to be the only stop-loss tweak surviving the bias gauntlet and SHIPPED as Phase-1 DRY-RUN (Finding #305, May 28, 2026) — but the Jul 5, 2026 gap-aware simulator fix (Finding #330) flipped it to FAILED-GAUNTLET (−$194/100 ex-top-2, n=129); the Phase-2 active flip is CANCELLED. NO stop-loss rule variant currently survives. Phase-1 dry-run code was REMOVED Aug 21, 2026 (#380; SoT: FINDINGS #380 / changelog A-12). See docs/STRATEGY_PLAYBOOK.md §3 #7 + docs/FINDINGS.md #330 + #380.


Table of Contents

  1. Executive Summary
  2. Strategy Rules
  3. Signal Prioritization ← NEW
  4. Position Sizing
  5. Entry & Exit Timing
  6. Risk Management
  7. Execution Quality ← NEW
  8. IBKR Integration
  9. Expected ROI
  10. Data Analysis ← NEW
  11. Quick Reference Card

1. Executive Summary

What This Strategy Does

Trade stocks based on Polymarket earnings prediction markets: - High conviction (≥70%) beat predictions → LONG the stock before earnings - High conviction (≥70%) miss predictions → SHORT the stock before earnings - Exit after earnings announcement based on actual results

Key Performance Metrics (Validated)

Metric Value Source
Sample Size 364 resolved markets Q3 2025 season
70%+ Conviction Accuracy 82.6% Gamma API backtest
Long Win Rate 49.5% Database validation
Long Avg Win/Loss +7.12% / -2.62% Database validation
Short Win Rate 60.3% Database validation
Short Avg Win/Loss +4.63% / -3.38% Database validation
EV per Trade (Long) +2.43% Calculated
EV per Trade (Short) +0.88% Calculated

Quick Decision Tree

Is conviction ≥70%?
├── NO → Do not trade
└── YES → Check earnings timing
    ├── BMO (Before Market Open)
    │   └── Enter day before @ 3:58 PM, Exit earnings day @ 10:32 AM
    └── AMC (After Market Close)
        ├── Is there BMO tomorrow?
        │   ├── YES → Only trade if AMC conviction ≥90%
        │   └── NO → Trade all 70%+ signals
        └── Enter earnings day @ 10:34 AM, Exit next day @ 3:57 PM

2. Strategy Rules

2.1 Signal Generation

Condition Rule
Conviction Threshold LONG: 70% to <90% — cap RE-CLOSED at 0.90 (C-1, Finding #366; DEPLOYED Aug 21, 2026): the #333 relax to 0.95 FAILED its contractual re-gauntlet on real fills (#357). Would-have-been 0.90-0.95 LONGs are shadow-logged (M-C1), zero trading impact. SHORT: ≤30% (uncapped). ≥90% LONG skipped — stock priced in (#202).
Direction Beat prediction 70% to <90% → LONG (cap 0.90, C-1 #366); Miss prediction ≥70% → SHORT
Data Source Polymarket Gamma API outcomePrices field
Market Filter Open earnings markets only, date/quarter matched

Config Overrides (optional): - SIGNAL_LONG_THRESHOLD (default 0.70) - SIGNAL_SHORT_THRESHOLD (default 0.30)

All three band edges (with SIGNAL_LONG_MAX_THRESHOLD) are one definition, src/earnings/signal_gates.py (#450): the signal builder, TradingSignal.is_valid, the priority filter and the dashboard TRADE?/qualifies all call it. An override is therefore honoured end to end (before #450, is_valid and priority floored LONG/SHORT at 0.70/0.30); loosening either edge is a GUARD_REGISTRY guard (dated # GUARD-OVERRIDE-UNTIL: required).

2.2 BMO vs AMC Priority

✅ REWRITTEN Aug 20, 2026 (RES M1+M5 + A-10, Finding #366 — DEPLOYED Aug 21, 2026). The conviction-threshold override (last at ≥95%, dead code for LONGs since #333) is DELETED from both twins (A-10); the rank-3 carve-out below is its evidence-backed successor.

Situation Action
Tomorrow has qualifying BMO signals (OBSERVED at the 10:30 ET count) Reserve B × 25% of capital for tonight's observed BMO set (B = count of already-qualifying BMO signals; tonight ONLY — no multi-day component)
Today's AMC signal, reservation would be breached SKIP (reservation block, logged) — UNLESS the AMC signal is rank 3 (LONG 70–75, the one cell with proven live edge): the carve-out lets it consume reserved capital (logged)
15:50 ET BMO entry check Reservation recomputed FROM SCRATCH from the then-current qualifying set, shrink-to-fit into free capital
No qualifying BMO tomorrow No reservation — AMC signals split the full free budget
Circuit breaker active at the 10:30 count Reservation computed anyway (INDEPENDENT of breaker state, #363 — a paused morning must not silently release tomorrow's BMO capital)

2.3 Short Selling Rules

Rule Value
Conviction Threshold ≤30% (miss prediction ≥70%)
Confidence Calculation Confidence = 1 - Conviction
Allowed Market Caps Large + Mid ONLY
Small Cap Shorts NEVER (squeeze risk) - Score 0/SKIP
Max Short Positions 2 at a time
Max Leverage for Shorts 5x (not 6x)
Priority Scoring Rank 1 on the B-4 3-tier table (Finding #366, Aug 20 2026): every qualifying SHORT is an "other qualifier" — ranks 2 and 3 are LONG-only cells. Miss-confidence ≥90% stays SKIP.

✅ Updated Aug 20, 2026 (B-4, Finding #366 — history: pending deploy at write time; DEPLOYED Aug 21, 2026): the 0-10 scale (base 0-8 #313/#334 + volume bump) is DELETED. SHORTs compete for capital at rank 1 alongside the other qualifiers; under S2c ordering only matters when the wave is rationed. History: scale 0-6 (Jan 10) → 0-8 (#313, May 30) → 0-10 (#334, Jul 11) → 0-3 (#366).

2.4 Volume Filter

Historical analysis suggested conviction alone can be a strong filter; for live operations we enforce a minimum cumulative Polymarket volume so illiquid markets are not traded when the DB cannot confirm depth of interest.

Item Behavior
Source of truth PostgreSQL markets.volume_total (cumulative USD), same field the signal path reads
Config SIGNAL_MIN_VOLUME_TOTAL — default 1000 (USD; lowered $2k→$1k May 30, 2026 per Finding #311 SHIP). Set 0 in .env to disable the gate (e.g. research).
Effective cumulative volume Positive markets.volume_total wins; if missing/zero, Gamma API cumulative volume is used for the same min-volume check as the trader (Finding #224). Keeps dashboard MIN VOLUME / TRADE? aligned with live liquidity.
Dashboard Uses volume_policy with the same rule as get_market_volume (no volume_ok vs displayed $ mismatch).

2.5 INTRADAY Earnings

INTRADAY earnings are identified but NOT traded.

Timing Action
BMO (Before Market Open) ✅ Trade
AMC (After Market Close) ✅ Trade
INTRADAY (During Market Hours) ❌ Skip

Approximately 89/364 markets (~25%) had mid-day releases. These are logged for analysis but skipped for trading due to different market dynamics.

determine_earnings_timing must skip INTRADAY / unknown — it must never fall through to AMC (Finding #403 / T2.12). classify_earnings_timing_token is the one mapper; calculate_entry_exit_times refuses anything that is not BMO/AMC.

Dashboard TRADE? uses the same known-timing definition (Config.timing_is_known — BMO/AMC only; Finding #399). INTRADAY / TBA / missing timing must never render ✓ TRADE on first paint or JS refresh (Jinja and JS both consume e.timing_known).


3. Signal Prioritization

3.1 Priority Score System (B-4 3-Tier Table, 0-3) - Rewritten Aug 20, 2026 (Finding #366; DEPLOYED Aug 21, 2026)

When multiple trading opportunities exist, signals are ranked on the B-4 3-tier table (0–3). The old 0-10 scale — base 0-8 market-cap × conviction (#211/#334) plus the volume-tier bump (#313) — is DELETED: the volume→win-rate gradient inverted on re-test (#357 row F-002) and the market-cap spine double-counted the #207/#211 gates. Under S2c sizing, priority no longer decides position SIZE either (the batch_order_manager score→size multiplier is FLATTENED to 1.0, #366) — rank matters only when the wave must be rationed (admission order / fill order).

  1. Rank 3 — LONG 70–75% conviction, any market cap (the only cell with a live-validated edge)
  2. Rank 2 — LONG 80–85% conviction, large/mid cap only
  3. Rank 1 — every other qualifying signal (all other LONG cells, all qualifying SHORTs, and B-5 pilot small-caps — pilot cells are rank 1, NEVER 2)
  4. Rank 0 — SKIP (fails a gate)
  5. Volume / exchange — not scoring factors (bumps deleted #366; exchange-neutral since #185)

Canonical implementation: src/earnings/priority.py (calculate_priority_score). This table mirrors it — keep both in lockstep (memory.md §M). ⚠️ The LONG over-confidence cap has ONE live definition: Config.SIGNAL_LONG_MAX_THRESHOLD. priority._long_max_conviction() reads it lazily (#399 / F29 — the #334 numeric twin LONG_MAX_CONVICTION = 0.90 is gone). C-1 (#366) re-closed the cap at 0.90 + .env guidance. Changing the env/config is enough; do not re-introduce a constant. ⚠️ Tripwire: if the 0.90–0.95 LONG band is ever reopened, it ranks LAST (never rank 2) — see the M-C1 shadow monitor.

The B-4 table (mirrors priority.py:PRIORITY_TABLE — SHORT confidence = 1 − conviction)

Rank Cell Action
3 LONG 70-75%, any mcap EXECUTE FIRST (live-validated edge; also the one cell allowed to consume the BMO reservation — the RES carve-out, §2.2)
2 LONG 80-85%, large/mid Execute second
1 Every other qualifier: LONG 75-80% any mcap · LONG 85-90% large/mid · qualifying SHORTs (confidence 70-90%, large/mid) · B-5 pilot small-cap LONGs 0.80-0.95 (never rank 2) Execute last (funded after ranks 3/2 when rationed)
0 LONG <70%, or LONG ≥0.90 (C-1 re-close #366; was ≥0.95 per #333) · small-cap LONG outside its band (#207 / B-5 pilot band) · any small-cap SHORT (squeeze) · SHORT miss-confidence ≥90% SKIP

SHORT Signal Priority (Added Jan 10, 2026)

For SHORT signals, confidence = 1 - conviction (miss probability): - Conviction ≤30% → Confidence ≥70% (eligible for trading) - Example: 25% conviction = 75% confidence → uses 70-80% bucket - Example: 15% conviction = 85% confidence → uses 80-90% bucket

Direction Conviction Confidence B-4 Rank (#366)
LONG 73% 73% 3 (the validated cell)
LONG 82% 82% 2 (large/mid) / 1 (B-5 pilot small-cap)
SHORT 25% 75% 1 (all qualifying SHORTs are rank 1)
SHORT 15% 85% 1
SHORT 5% 95% SKIP (miss-conf ≥90%)

⚠️ CRITICAL: All small cap shorts are ALWAYS SKIPPED (score 0) due to squeeze risk, regardless of confidence level.

⚠️ KEY INSIGHT: The 70-80% conviction bucket has the highest average return (+2.68%) - higher than 80%+ buckets! This is why we prioritize 70-80% over higher conviction levels.

Color Legend (0-3 post Finding #366; mirrors priority.py:PRIORITY_COLORS)

Color Rank Meaning
🟢 Green (darker) 3 LONG 70-75 any mcap (gauntlet PASS)
🟢 Green 2 LONG 80-85 large/mid (weak evidence)
🟡 Yellow 1 Other qualifiers (neutral tie pool)
⚫ Gray 0 SKIP - does not meet criteria

Exchange Rationale (Current)

NYSE and NASDAQ now use the same hardened stop/instant-exit policy.
Exchange remains tracked for analytics/routing visibility but does not change priority score.

Small Cap Rules

Conviction Bucket Performance (from backtest data):

Conviction Sample Accuracy Avg Return
70-80% 53 77.4% +2.68%
80-90% 103 82.5% +1.04%
90-95% 44 97.7% +1.38%
95%+ 47 97.9% +0.64%

Note: All conviction levels ≥70% are tradeable. The priority score helps allocate capital when multiple opportunities exist simultaneously.

3.2 Daily Opportunity Sizing

(SUPERSEDED by #317, May 31, 2026: daily exposure is now a single uniform cap on ALL days — MAX_DAILY_EXPOSURE, 140% at the time, RAISED to 150% Jul 11 2026 per Finding #335 so exactly 6 flat-25% positions fit (140% stranded 15%/night under #314.f sizing). The old tiered table below — rare 30% / normal 50-70% / busy 70% — is no longer operative.

316 showed the rare-day discount was near-inert, so OPPORTUNITY_EXPOSURE_LIMITS

was collapsed to a uniform cap; "more opportunities ≠ more ROI" — the ROI lever is concentration, not looser caps. Rare/normal/busy labels survive as observability-only annotations, not exposure controls.)

Max exposure is uniform across all days (current operative state):

Opportunities Level Max Exposure Strategy
1-2 Rare 150% (#335) Concentrate in best trades
3-5 Normal 150% (#335) Standard sizing
6+ Busy 150% (#335) BMO reservation first; AMC splits the remainder (rank-3 carve-out only — the 95% override was deleted by A-10, #366)

The uniform cap (#317, superseding the old tier rationale that "prevents over-concentration on low-opportunity days") reflects that the ROI lever is position concentration, not day-conditioned exposure tiers.


4. Position Sizing

✅ S2c EQUAL-SPLIT SIZING (Finding #366, Aug 20 2026 — DEPLOYED Aug 21, 2026; K-S2C ARMED). When computed trading capital ≥ $9,000 (S2C_MIN_ACTIVATION_CAPITAL, GUARD_REGISTRY-bounded), the flat-25% profile below is replaced by enter-everything equal-split shrink-to-fit: every admitted signal in the wave gets min(25%, exposure_budget / n_signals) of capital — nothing is dropped for sizing reasons; positions shrink instead. The 25% per-position ceiling, the 1.50 exposure cap, and the whole-share + 57%-flex floor (§4.1.1/§4.1.2) are unchanged. If the equal split can't buy 1 share even with flex, that signal is skipped at admission (A-7). Below the $9k gate, flat-25 behavior is exactly as documented below (the gate exists because equal-split at ~$2.7k capital would shrink slots below the 1-share floor and silently re-create the old drop behavior). Priority rank no longer scales position size — the batch score→size multiplier is flattened (B-4, #366).

4.0 TESTING_PROD Mode → Phase 1 (Current Live Profile)

🚀 Phase 1 (Finding #367, Aug 21, 2026): at the PKG-357/#366 deploy the operator moved the project from "TESTING IN PRODUCTION" to Phase 1 trading with a P&L fresh start. The mode enum stays TESTING_PROD (no code-path change); the PHASE is Phase 1, displayed as such on the dashboard. The authoritative boundary is the single config constant Config.PHASE1_START_ET (default 2026-08-21, env-overridable; set/verified by the deployer at the PKG-357 deploy — see the deploy checklist in IMPLEMENTATION_PLAN_2026_08.md). The dashboard trade counter counts Phase-1 trades from zero (split on entry_time against the boundary); the testing-era counter is frozen at its cutover value (193 at Aug 21, 2026) and its P&L is preserved in the History tab's Testing-in-Production ROI window.

✅ Sizing behavior (unchanged by the phase label): full configured profile (TESTING_PROD_SIZE_MULTIPLIER=1.0; the 50%-reduced era ended Feb 2026).

Parameter Value Notes
Mode Name TESTING_PROD Live trading mode enum (unchanged); phase = Phase 1 since Aug 21 2026 (#367)
Phase boundary PHASE1_START_ET (2026-08-21) Single source (#334 lesson) for the header label, Phase-1 counter, and ROI windows
Position Size Full configured profile TESTING_PROD_SIZE_MULTIPLIER=1.0
Trade Count Phase-1 counter from zero (#367) Round-trip (entry + exit) = 1 trade; the testing era closed at 193 trades — its 150-trade target was milestone-only since #347 (auto-pause removed)
Stop-Loss Same as Phase 1 sizing model -3% Large, -4% Mid, -5% Small (Finding #202)
Max Exposure 150% MAX_DAILY_EXPOSURE=1.50 (raised 1.40→1.50 Jul 11 2026, #335)
Completion Action ~~Pause & confirm~~ Continue trading (#347) Operator passed 150 DB-counted trades Jul 29 2026 and chose to continue; the phase transition itself happened Aug 21 2026 (#367)

Position Sizing (current configured profile):

Market Cap Threshold Position % TESTING_PROD % $ Amount*
Large >$100B 25% 25% Finding #314.f Jul 6, 2026 (was 30% per #211)
Mid $10B-$100B 25% 25% Finding #314.f Jul 6, 2026 (was 22% per #211)
Small <$10B 25% 25% Finding #314.f Jul 6, 2026 (was 15% per #211; LONG 70-80% only per #207)

*Dollar amounts calculated from actual account NetLiquidation value

Transition Process (canonical; CLOSED at the Phase-1 cutover — Finding #367, Aug 21, 2026):

Historical record. This process governed the TESTING-IN-PRODUCTION era (Jan 2026 → Aug 21, 2026, 193 trades). The transition it anticipated happened at the PKG-357/#366 deploy: the operator declared Phase 1 with a P&L fresh start (#367). The full-strategy re-validation at n=183 (#357, Aug 16-19 2026) served as the milestone review; its verdicts shipped as the

366 package (S2c sizing, B-4 priority, C-1 band re-close, RES reservation,

CLB-1 breakers). Phase-1 performance is monitored via the #366 deploy contracts (K-S2C / K-B4 / K-RES / K-B5 / M-C1), not a trade-count target.

The original transition trigger was 100 round-trip trades. After the v3 audit work in March-April 2026 surfaced that the live n was still small relative to the 5-outlier curse (memory §K), the transition trigger was extended to 150 trades to give the bias gauntlet a full sample.

  1. Complete 150 round-trip trades in TESTING_PROD mode (extended from 100, Mar 9 2026). ✅ Passed Jul 29, 2026 (#347).
  2. ~~At trade #150 system automatically pauses and sends Telegram alert.~~ AMENDED (Finding #347, Jul 29 2026): the operator passed 150 DB-counted trades and explicitly chose to CONTINUE trading — the auto-pause was removed (it had also never actually fired: the in-memory counter reset to 0 on every restart, per the #335 flag). The milestone became log/alert only.
  3. Review performance metrics. ✅ Done as the #357 full re-validation (n=183, 9 workstreams, Aug 16-19 2026).
  4. Decision tree: ✅ Resolved — the operator shipped the #366 package on PKG-GO and declared Phase 1 at its deploy (#367) rather than extending the testing window.
  5. The 100-trade milestone is preserved in code only as a pause-and-review checkpoint (slippage / order-quality alerts) — it does NOT trigger transition.

Mode persistence hardening (Feb 2026): - Trading mode is persisted via shared helper src/earnings/trading_mode_persistence.py. - All mode readers/writers now use the same code path: - run_earnings_trader.py - earnings_dashboard/app.py - src/data_tracker/collectors/trade_reconciler.py - Default/fallback mode is TESTING_PROD (not PAPER). - If trading_mode.txt is missing or invalid, the helper auto-heals by writing testing_prod. - This ensures dashboard and trader remain mode-consistent after restart or file loss.


4.1 Phase 1: Standard Capital Sizing Profile

Naming note (#367): "Phase 1" in this section title is the ORIGINAL launch-plan name for the standard-capital SIZING PROFILE (pre-dating Aug 2026). Since Aug 21, 2026 "Phase 1" also names the live trading PHASE that started at the PKG-357 deploy (§4.0, PHASE1_START_ET). The two coincide in practice — the phase trades this profile.

The 25% figures below are now the per-position CEILING under S2c (capital ≥ $9k): the actual slot is min(25%, budget / n_signals). Below the gate they remain the flat size.

Market Cap Threshold Position % $ Amount* Stop-Loss
Large >$100B 25% 25% of capital (Finding #314.f, Jul 6 2026; was 30% per #211) -3% (Finding #202)
Mid $10B-$100B 25% 25% of capital (Finding #314.f; was 22% per #211) -4% (Finding #202)
Small <$10B 25% 25% of capital (Finding #314.f; was 15% per #211; 70-80% only per #207) -5% (Finding #202)

*Position sizes are percentages of total capital (NetLiquidation)

Exposure Limit: 150% max daily exposure (MAX_DAILY_EXPOSURE=1.50, raised 1.40→1.50 Jul 11 2026 — Finding #335: exactly 6 flat-25% positions fit)

4.1.1 Whole Shares Only (Updated Jan 20, 2026)

⚠️ IMPORTANT: The system uses WHOLE SHARES ONLY. IBKR's cashQty feature has too many restrictions to be reliable.

Feature Description
Order Type Whole shares only (totalQuantity=N)
Minimum Order 1 share
Capital Efficiency ~95-99% (small waste from rounding down)
Position Skip If target value < stock price, position is skipped

How it works: 1. Calculate ideal shares: target_value / price 2. Round DOWN to whole shares: int(ideal_shares) 3. If whole shares = 0, skip the position (too small) 4. Place order with totalQuantity=N (integer)

Why NOT use cashQty (Jan 20, 2026 Finding #51): - Error 10244: "Cash Quantity cannot be used for this order" - cashQty does NOT work with extended hours trading (outsideRth=True) - cashQty has stock-specific restrictions - cashQty fails silently on some order types

Bug History: - Jan 17, 2026 (Finding #48): Implemented cashQty for fractional shares - Jan 20, 2026 (Finding #51): REVERTED - cashQty unreliable, switched to whole shares only

Implication for Small Accounts: | Target | Price | Shares | Result | |--------|-------|--------|--------| | $100 | $90 | 1 | ✅ Order placed | | $72 | $111 | 0 | ❌ Skipped (too small) | | $72 | $146 | 0 | ❌ Skipped (too small) |

A-7 — Affordability-aware ADMISSION (formal rule, shipped Aug 20, 2026, Finding #366): The "skip if too small" outcome above is now enforced at admission time, not just at order prep. In all 4 admission loops (orchestrator BMO/AMC + capital_manager twins), a signal is REJECTED with skip stage min_share_size when:

stock_price > slot × (1 + POSITION_SIZE_FLEX_MAX)      # i.e. can't buy 1 share even with +57% flex

The check lives in src/earnings/affordability.py:is_unexecutable_at_size and replicates the ibkr/client.py:calculate_fractional_shares whole-share + flex math exactly. The freed slot + capital backfill to the next-ranked candidate in the same greedy-fill pass (#285). Pre-A-7, an LLY-class signal (price ≫ slot × 1.57) consumed an admission slot and then silently produced 0 shares at batch prep (W6 Bug A). Fail-soft: if no price is available at admission, the signal is NOT skipped (batch prep keeps its own 0-share guard).

4.1.2 Position Size Flex (Added Jan 26, 2026, Fixed Jan 28, 2026, Updated Jan 28, 2026)

💡 FLEX SIZING: When standard position size can't afford 1 whole share, the system automatically increases position size by up to +57% to enable the trade.

This feature enables trading expensive stocks (TSLA, MSFT, MA, etc.) with smaller accounts.

Parameter Value Notes
Max Flex +57% Maximum increase from standard size
Trigger shares < 1 Only applies when can't afford 1 share
Actual Flex Minimum needed Uses exactly 1 share worth, not full +57%

Implementation (Fixed Jan 28, 2026 - Finding #74): - Flex logic applied in ibkr/client.py:calculate_fractional_shares() - Applied at order execution time when both target_value AND stock_price are known - Works for both single orders and batch orders - Updated Jan 28, 2026: Increased from 50% to 57% to enable MA trading at $521

How it works: 1. Calculate standard position size (e.g., 25% of ~$10,000 trading capital = ~$2,500 under the S2c per-name cap — Finding #366; history: the original example was 30% of the pre-FUND-1 ~$2,500 capital) 2. Calculate shares: position_size / stock_price, e.g. $735 / $438 = 1.67 → 1 whole share 3. If 0 shares: check if 1 share is within flex range: stock_price < position_size × 1.57 4. If within range: set position size to exactly 1 share 5. Place order for 1 share

Examples with $2,694 capital (TESTING_PROD_SIZE_MULTIPLIER=1.0 → 100% of configured sizing; NOT 12.5%/50% reduced):

Stock Price Standard 1 Share Flex Needed Within +57%? Result
TXN $196 $337 $196 -42% ✅ N/A 1 share @ standard
TSLA $438 $337 $438 +30% ✅ Yes 1 share @ $438
MSFT $473 $337 $473 +40% ✅ Yes 1 share @ $473
MA $521 $337 $521 +54% ✅ Yes 1 share @ $521
META $674 $337 $674 +100% ❌ No SKIP

Risk Considerations: - Flex positions are larger than standard sizing - More concentrated exposure in expensive stocks - Same stop-loss rules apply (no change to risk management) - Flex is logged: 📦 SYMBOL: FLEX SIZING applied in logs

Log Example:

📦 MA: FLEX SIZING applied - 1 share @ $521.15 (+54% flex from $338.73 standard)

4.2 Phase 2: Scaled Capital, 6x Portfolio Margin

Conviction Win Rate Half Kelly % $ Position
70-80% 47% 13.7% $15,070
80-90% 51% 17.6% $19,360
90-95% 55% 20.9% $22,990
95%+ 58% 23.7% $26,070

4.3 Capital Allocation Rules

Rule Phase 1 Phase 2
Max total exposure 150% uniform all days (MAX_DAILY_EXPOSURE=1.50, #317/#335) 150% uniform all days (MAX_DAILY_EXPOSURE=1.50, #317/#335)
Position limit None (150% cap only; ≤25%/position under S2c) None (150% cap only)
BMO priority Reserve B × 25% for tonight's OBSERVED BMO set at 10:30 ET (RES M1+M5, #366) Same mechanism
AMC vs reservation AMC splits the remainder; only rank-3 AMC may consume reserved capital (carve-out — the 95% override was deleted, A-10 #366) Same mechanism

4.4 Tonight-Only Observed-Set BMO Reservation (RES M1+M5 — Rewritten Aug 20, 2026, Finding #366; DEPLOYED Aug 21, 2026)

✅ The multi-day (3-5 day lookahead, 100%/50%-weighted) reservation described in the history blocks below is DELETED. It reserved against forecast BMO sets that mostly didn't materialize, and its escape hatch (the ≥95% conviction override) had been dead code for LONGs since #333. Current mechanism (arms K-RES):

  1. 10:30 ET (AMC admission time): count tonight's already-qualifying BMO signals (the OBSERVED set, B). Reserve B × 25% of capital against the free book after crediting positions that exit at 10:32 (overnight BMO leaving — Finding #401 / T2.4; same #335 netting admission already did; helper src/earnings/exposure.py, cost-basis shares * entry_price, 300s window). Tonight only — no multi-day component.
  2. AMC signals split the remainder (S2c equal-split over the unreserved budget).
  3. Rank-3 carve-out: an AMC signal in the LONG 70-75 cell (the one live-validated cell) MAY consume reserved capital; every carve-out use is logged. This replaces the deleted ≥95% override (both twin branches removed = A-10).
  4. 15:50 ET (BMO entry time): the reservation is thrown away and recomputed from the then-current qualifying set; entries shrink-to-fit into actually-free capital.
  5. Breaker independence (#363): the 10:30 reservation is computed even when a circuit breaker has trading paused — a paused morning must not silently hand tomorrow's BMO capital to afternoon AMC signals.
  6. Logging: reservation amount + observed set, every reservation block, every carve-out, and end-of-day reservation utilization are all logged.

The Conflict Explained (unchanged motivation)

Day 1 (Today)                     Day 2 (Tomorrow)
─────────────────────────────────────────────────────────
  10:34 AM   3:57 PM   3:58 PM     10:32 AM
     │          │         │           │
     ▼          │         ▼           │
  AMC ENTRY     │      BMO ENTRY      │
     │          │         │           │
     │          │         │           ▼
     └──────────┴─────────┼───────────┘
         AMC HELD         │      BMO EXIT
                          │
            CONFLICT: Both need capital at 3:58 PM!

Decision Tree (current, #366)

At 10:30 AM ET, admitting AMC entries:
│
├── Count tonight's OBSERVED qualifying BMO signals → B
│   └── reservation = B × 25% × trading capital   (computed even if a breaker is active, #363)
│
├── AMC budget = strategy budget − reservation
│   ├── IF the wave fits → S2c equal-split over the AMC budget
│   └── IF an AMC signal would breach the reservation:
│       ├── rank 3 (LONG 70-75)  → TAKE from reserved capital (carve-out, logged)
│       └── any other rank       → SKIP (reservation block, logged)
│
At 3:50 PM ET, entering BMO positions:
└── Recompute from the CURRENT qualifying set; shrink-to-fit into free capital
    (the 10:30 reservation is a morning planning number, not an entry entitlement)

Note (Finding #335, Jul 11 2026): the strategy budget is MAX_DAILY_EXPOSURE × trading capital − effective exposure, bounded by IBKR buying power. It was previously RAW day-trade buying power (~4× net_liq), which made this check permanently pass.

History — fixes to the RETIRED multi-day mechanism (kept for context; the lookahead itself is deleted, #366)

Batch Sizing Fix (Jan 27, 2026 - Finding #56)

Critical Fix: The batch sizing was incorrectly using the FULL 5-day BMO reservation when calculating available budget for AMC entries. This caused valid AMC trades to be skipped even when capital would be freed before future BMO entries.

Root Cause:

AMC Entry: Today 10:34 AM → Exit: Tomorrow 3:57 PM
BMO Entry (Day 3+): Tomorrow 3:58 PM → Capital already freed!

The system was blocking today's AMC with Day 3+ BMO capital, 
even though AMC exits BEFORE those BMO entries happen.

Fix Applied: - Signal filtering: Uses effective_reservation (100% tonight + 50% future) - Batch sizing: Now uses ONLY tonight's BMO reservation (overlapping capital) - Future BMO (Day 2+) no longer blocks AMC batch sizing

Code Change (trading_orchestrator.py):

# BEFORE: reserved_for_bmo=self._reserved_capital_for_bmo (full 5-day)
# AFTER:  reserved_for_bmo=overlapping_bmo_reserve (tonight only)
tonight_bmo_date = self._market_calendar.get_next_trading_day(now_et)
overlapping_bmo_reserve = self._bmo_reservations_by_date.get(tonight_bmo_date, 0)

Capital Timing Fix (Jan 28, 2026 - Finding #72)

Problem: Position sizes calculated BEFORE exits don't account for capital freed from imminent exits.

10:30 AM: Calculate AMC sizes → T ($323) counted in current_exposure
10:32 AM: T exits → $323 freed
10:34 AM: AMC entries → should have access to that freed $323

Fix Applied: - Added capital_freed_from_exits parameter to calculate_batch_position_sizes() - Added _calculate_capital_freed_from_imminent_exits() for AMC cycle (9:30 AM) - Added _calculate_capital_freed_before_bmo_entry() for BMO cycle (3:50 PM)

⚠️ Completed Jul 11, 2026 (Finding #335): the Jan-28 fix covered batch SIZING only — the ADMISSION check (can_open_position in the 10:30/15:50 signal loops) still counted the about-to-exit book, so a full 5-position overnight book auto-rejected every next-morning AMC signal. Admission now takes the same capital_freed_from_exits and nets it out of exposure.

Code Change (risk_manager.py):

# BEFORE: 
max_new_exposure = (max_exposure * total_capital) - current_exposure

# AFTER:
effective_exposure = max(0, current_exposure - capital_freed_from_exits)
max_new_exposure = (max_exposure * total_capital) - effective_exposure

Weekend/Holiday Handling (current)

Friday is a critical day with extended holds:

Friday Trade Entry Exit Hold Duration
Friday AMC Fri 10:34 AM Mon 3:57 PM ~77 hours
Monday BMO Fri 3:58 PM Mon 10:32 AM ~65 hours

Example (current mechanism, #366)

Scenario: $10K trading capital, Tuesday. At the 10:30 ET count, 2 BMO signals for Wednesday morning already qualify (B = 2); 4 AMC signals qualify for tonight, one of them rank 3 (LONG 73%).

Reservation:  2 × 25% × $10,000            = $5,000  (tonight only)
AMC budget:   $15,000 strategy budget − $5,000 − current exposure
S2c split:    AMC budget / 4, capped at 25%/position
Rank-3 AMC:   if its equal-split slot would breach the $5,000 wall,
              it may still take reserved capital (carve-out, logged);
              the other 3 AMC signals would be reservation-blocked instead.
15:50 ET:     reservation discarded; if only 1 BMO signal still qualifies,
              it shrink-to-fits into whatever capital is actually free.

4.5 Live Capital Sync (Stop-Loss Awareness)

The system uses actual IBKR buying power instead of calculated values to ensure accurate capital allocation. This is critical because:

  1. Stop-losses free capital immediately - When a stop-loss triggers, IBKR buying power increases instantly
  2. No double-booking - Using live data prevents allocating the same capital twice
  3. Real-time accuracy - Market moves affecting margin requirements are reflected immediately

How It Works

Every 30 seconds during trading:
│
├── Sync from IBKR: get_account_info()
│   ├── buying_power: Actual available for new trades
│   ├── net_liquidation: Total account value
│   └── unrealized_pnl: Current position P&L
│
└── Capital allocation uses the STRATEGY BUDGET (MAX_DAILY_EXPOSURE ×
    trading capital − effective exposure), bounded above by IBKR
    buying_power, MINUS the BMO reservation.
    ⚠️ Fixed Jul 11, 2026 (Finding #335): previously this used RAW
    day-trade buying power (~4× net_liq), so the BMO reservation never
    actually bound at admission — it only worked via batch sizing.

Example: Stop-Loss Frees Capital

Initial State:
  - Account: $100,000
  - AAPL position: $25,000 (BMO from yesterday)
  - Buying power: $75,000

09:30 AM - Checking AMC signals:
  - NVDA AMC opportunity: $25,000 needed
  - JPM BMO reserved: $25,000
  - Calculated available: $75,000 - $25,000 = $50,000
  - NVDA fits ✓

09:31 AM - AAPL stop-loss triggers:
  - AAPL closed at loss: -$500
  - IBKR buying power: $99,500 (freed $24,500)

10:32 AM - System syncs from IBKR:
  - ACTUAL buying power: $99,500
  - Can now take additional positions!

10:34 AM - AMC entries:
  - NVDA: $25,000 ✓
  - MSFT: $25,000 ✓ (now possible because AAPL freed capital)

⚠️ CRITICAL: The system ALWAYS uses IBKR buying_power when available. Calculated values are only used as fallback when IBKR client is disconnected.


5. Entry & Exit Timing

5.1 Timing Schedule

Earnings Type Conviction Check Entry Time Exit Time
BMO 3:53 PM (day before) 3:58 PM (day before) 10:32 AM (earnings day)
AMC 10:29 AM (earnings day) 10:34 AM (earnings day) 3:57 PM (next day)

⚠️ CRITICAL: AMC exit is at 3:57 PM (not 3:58 PM) to ensure a 1-minute buffer before any BMO entries at 3:58 PM. This prevents order execution conflicts when exiting AMC positions and entering BMO positions on the same day.

Timing Change (Feb 2026) - Extended BMO Hold:

Change Old New Rationale
BMO Exit 9:32 AM 10:32 AM Capture first-hour gains (+0.67% per trade)
AMC Entry 9:34 AM 10:34 AM Allow BMO exit first (no ROI impact - pre-earnings noise)
AMC Conviction Check 9:29 AM 10:29 AM 5 min before new entry time

Analysis basis (from Section 10.2): - First hour after open captures +0.67% of the intraday return - BMO exit at 10:32 AM instead of 9:32 AM adds ~0.67% per trade - AMC entry timing is pre-earnings noise (no systematic pattern) - Net impact: +0.67% per BMO trade, ~0% per AMC trade

5.2 Weekend Hold Trades (DECISION: Trade with Exposure)

Some trades require holding positions over the weekend:

Scenario Entry Exit Weekend Exposure
Monday BMO Friday 3:58 PM Monday 10:32 AM ✅ ~65 hours
Friday AMC Friday 10:34 AM Monday 3:57 PM ✅ ~77 hours

Policy Decision: We TRADE these opportunities (Option A) because: 1. Polymarket conviction already factors in weekend uncertainty 2. High-conviction trades (≥70%) have strong expected value 3. Missing ~20% of trades would significantly reduce ROI

Risk Tracking: - All weekend-hold trades are flagged in the dashboard with ⚠️ indicator - Database captures weekend_hold and weekend_gap_pct fields - Separate performance analysis will be run quarterly to validate decision

If weekend trades underperform: - After 50+ weekend trades, compare win rate vs non-weekend - If win rate is >10% lower, consider reducing position size by 25-50% - Document findings in trade analysis reports

5.2.1 Long Weekend / Holiday Trades (DECISION: Trade with 50% Size)

When a market holiday falls on Monday (e.g., MLK Day, Presidents Day), Tuesday BMO earnings require a 3+ calendar day hold:

Scenario Entry Exit Hold Time Action
Normal BMO (Tue) Mon 3:58 PM Tue 10:32 AM ~18 hours Full size
Weekend BMO (Mon) Fri 3:58 PM Mon 10:32 AM ~65 hours Full size
Long Weekend BMO (Tue after Mon holiday) Fri 3:58 PM Tue 10:32 AM ~89 hours 50% size

Policy Decision: We TRADE these opportunities with 50% position size because: 1. Extended gap exposure (3+ days) increases adverse news risk 2. Still capturing opportunity vs skipping entirely (0% ROI) 3. Polymarket conviction factors in some uncertainty but not 3-day risk 4. Reduced size limits max loss while maintaining EV-positive participation

Implementation (Added Jan 16, 2026): - market_calendar.py: calculate_hold_calendar_days() and is_long_weekend_trade() - signal_generator.py: Sets hold_calendar_days, is_long_weekend_trade, long_weekend_size_multiplier - risk_manager.py: Applies 50% multiplier in calculate_position_size() - Dashboard: 🌙 indicator + "50%" badge for long weekend trades

Affected Holidays (2026): - Jan 19 (Mon) - MLK Day → Tue BMO affected - Feb 16 (Mon) - Presidents Day → Tue BMO affected - May 25 (Mon) - Memorial Day → Tue BMO affected - Sep 7 (Mon) - Labor Day → Tue BMO affected

5.2.2 Scheduled Exit Check (Fixed Jan 20, 2026)

The system automatically checks for positions that need scheduled exits every second in the main trading loop.

How it works: 1. exit_mgr.check_scheduled_exits() runs every second in main loop (run() callee — #402/#408) 2. For each position, calculates: time_to_exit = scheduled_exit_time - now 3. If time_to_exit <= 120 seconds (2 min window), triggers exit_mgr.execute_scheduled_exit() 4. Exit order is placed with position info passed directly (shares/direction), not a second IBKR lookup

Critical Fix (Finding #51): - Bug: check_scheduled_exits() was defined but NEVER CALLED in main loop - Impact: BMO positions scheduled for 10:32 AM exit were not executing - Fix: Added self.check_scheduled_exits() call to main loop after position monitoring - Second Fix: place_scheduled_exit() now accepts shares and direction directly, with IBKR fallback

Files Changed: - src/earnings/trading_orchestrator.py - Added call to check_scheduled_exits() in main loop - src/earnings/order_executor.py - place_scheduled_exit() accepts shares/direction, queries IBKR if needed

5.3 Order Execution Flow

Tiered Limit Buffer by Market Cap

Market Cap Buffer Rationale
Large Cap 0.1% Very liquid (JPM, AAPL) - tight buffer saves money
Mid Cap 0.2% Moderate liquidity (JEF, etc.) - balanced
Small Cap 0.3% Lower liquidity - wider buffer ensures fills
ENTRY ORDER:
1. Confirm IBKR liquidHours = regular session (skip if not regular)
2. Get tiered buffer: Large=0.1%, Mid=0.2%, Small=0.3%
3. Place LIMIT order at ask + buffer% (buy) or bid - buffer% (sell)
4. Wait 30 seconds for fill
5. If not filled → convert to MARKET order (regular hours only)
6. Attach STOP order at stop-loss price (TIF=GTC)
7. Submit entry+stop atomically; if stop not accepted, cancel entry
8. If stop placement fails after retries, trigger instant exit + alert
9. Enforce per-symbol entry lock to prevent duplicate orders

**IBKR Error Blocking (Finding #5):**
- Critical IBKR errors (connectivity/transport) block ALL order placement
- A block creates `[hidden]/trading_block.txt`
- Remove the file to explicitly recover and re-enable trading

SCHEDULED EXIT:
1. Confirm IBKR liquidHours = regular session (skip if not regular)
2. Get tiered buffer based on position's market cap
3. Place LIMIT order at bid - buffer% (sell) or ask + buffer% (cover)
4. Wait 45 seconds for fill
5. If not filled → convert to MARKET order (regular hours only)
6. Cancel attached stop order

INSTANT EXIT (wrong prediction detected):
1. Keep stop order active until exit is verified
2. Determine market status (regular / extended / fully closed)
3. Regular hours: MARKET (IOC)
4. Extended hours: NYSE = LIMIT ±2% (GTC), NASDAQ = LIMIT ±1% then MARKET fallback
5. Fully closed (8 PM - 4 AM ET or weekends): GTC LIMIT only (no MARKET)
6. Log exit reason for audit

5.4 Extended Hours (CRITICAL)

IBKR Extended Hours (outsideRth=True on ALL orders):
├─ Pre-market: 4:00 AM - 9:30 AM ET
├─ Regular: 9:30 AM - 4:00 PM ET  
├─ After-hours: 4:00 PM - 8:00 PM ET
└─ Total: 16 hours of coverage

Gate by IBKR Hours (Finding #4): - Entry + scheduled exit orders are placed only during regular hours (IBKR liquidHours) - Stop-loss + emergency exits may execute during extended hours (IBKR tradingHours) - If IBKR market hours are unavailable, fall back to the static NYSE calendar (scheduling only)

5.5 NYSE vs NASDAQ - Exchange Limitations (CRITICAL FINDING)

⚠️ DISCOVERED JAN 8, 2026: NYSE stocks CANNOT use MARKET orders in extended hours. LIMIT orders route to ECNs (NASDAQ, ARCA) and can execute.

Key Insight: During regular trading hours (9:30 AM - 4:00 PM ET), both NYSE and NASDAQ use identical order types. NYSE only requires special handling during extended hours.

Regular Trading Hours (9:30 AM - 4:00 PM ET) - Both Exchanges

Order Type NASDAQ NYSE Notes
Entry LIMIT LIMIT Same for both
Stop-Loss STOP-LIMIT (2% buffer) STOP-LIMIT (2% buffer) ALL exchanges use STP LMT (Finding #179)
Instant Exit LIMIT (GTC, 1%) → MARKET fallback LIMIT (GTC, 1%) → MARKET fallback Same for both (open market)
Scheduled Exit LIMIT → MARKET LIMIT → MARKET Same for both

Note (Feb 10, 2026 - Finding #179): ALL exchanges now use STOP-LIMIT with 2% buffer. Pure STP orders get IBKR Warning 2109 on NASDAQ (outsideRth ignored), meaning stops only trigger during RTH. HOOD lost 11% vs intended 3% because STP didn't trigger after hours. STP LMT respects outsideRth and triggers in extended hours. Software backup layer maintained for additional safety.

Extended Hours (Pre-Market / After-Hours)

Exchange Pre-Market After-Hours Stop-Loss Instant Exit
NASDAQ ✅ 4:00 AM ✅ 8:00 PM STOP-LIMIT (2% buffer) LIMIT ±1% → MARKET fallback
NYSE ✅ 4:00 AM ✅ 8:00 PM STOP-LIMIT (2% buffer) LIMIT ±1% → MARKET fallback

Examples: - NASDAQ: AAPL, MSFT, NVDA, AMZN, META, GOOGL, NFLX, TSLA, AMD, INTC - NYSE: JPM, JEF, GS, BAC, WMT, HD, KO, PG, XOM, CVX

Unified Extended Hours Order Handling (Updated Feb 15, 2026):

Order Type Regular Hours (Both) Extended Hours
Entry LIMIT + buffer LIMIT + buffer (same)
Stop-Loss STOP-LIMIT (2% buffer) STOP-LIMIT (2% buffer)
Instant Exit LIMIT (GTC, 1%) → MARKET fallback LIMIT (GTC, 1%) → MARKET fallback
Scheduled Exit LIMIT → MARKET N/A (during RTH)

STOP-LIMIT Configuration (ALL EXCHANGES - Finding #179): - Stop trigger price: Set per position sizing (typically 3%) - Limit price: 2% below stop (for sells) or 2% above (for buys) - Example: Stop @ $25.00 → Limit @ $24.50 (sell) - Rationale: Pure STP orders get Warning 2109 on NASDAQ (outsideRth ignored) - ALWAYS used for ALL exchanges - regardless of when stop order is placed - Why not pure STP: IBKR ignores outsideRth → stops only trigger during RTH → gap risk after hours

Instant Exit Configuration (All Exchanges): - Order type: LIMIT (GTC) with fallback to MARKET when market is open - Buffer: 1% below bid (sells) / above ask (buys) - Rationale: balances fill speed with bounded slippage

Closed Market Instant Exit (All Exchanges): - Order type: GTC LIMIT only - Buffer: ±1% - Rationale: MARKET orders do not fill when the market is fully closed

Strategy Impact: - Exchange-specific asymmetry in stop/instant-exit behavior removed - Residual risk is session/liquidity driven, not NYSE/NASDAQ driven - STOP-LIMIT trade-off remains: gap-through-limit can delay fill without software escalation

Tracking Requirements (ongoing; the testing-era 150-trade review window closed at the Phase-1 cutover, #367): - Track primary_exchange for every position - Record actual slippage for unified LIMIT exits vs 1% buffer - Analyze fill rates and timing for STOP-LIMIT orders - Buffer reviews: testing-era milestones (50/100/150) completed; Phase-1 reviews ride the #366 deploy contracts (K-S2C et al.)

Required Actions: 1. ✅ Track primary_exchange (NYSE/NASDAQ) for every position 2. ✅ Prioritize by market cap + confidence only (exchange-neutral) 3. ✅ Use STOP-LIMIT for ALL stop-loss orders ALWAYS (Finding #179 - Feb 10, 2026) 4. ✅ Use LIMIT (GTC, 1%) with MARKET fallback for instant exits while market is open 5. ✅ Add stop-trigger timeout escalation when hardware stop remains unfilled past threshold 6. Review 1% instant-exit and 2% stop-limit buffers after testing period


6. Risk Management

6.1 Circuit Breakers

Breaker Trigger Action
Daily Loss -5% of capital (day P&L, DB-anchored) SOLE loss brake (CLB-1, Finding #366, Aug 20 2026): pause until the TRUE next ET market open (market calendar); state survives restarts (A-6 derive-from-DB, trade_audit_log net P&L per #331)
Day P&L ≤ −2.5% Daily-loss sizing tier (ELEVATED) Sizes ×0.50 (re-keyed from the retired ≥3-loss escalation)
Day P&L ≤ −4% Daily-loss sizing tier (HIGH) Sizes ×0.25 (re-keyed from the retired ≥5-loss escalation)
Drawdown 30% 30% from peak Reduce sizes 50%
Drawdown 40% 40% from peak Reduce sizes 75%
Drawdown 50% 50% from peak Pause trading
VIX > 30 VIX level Reduce sizes 50%
VIX > 35 VIX level No new positions
S&P 500 -5% Intraday drop Pause all entries
Session stop breaker 4 stop-exits in an ET day (#312/#335.b) Abort new entries for the session (unchanged by CLB-1)

Consecutive-loss breaker RETIRED (CLB-1, Finding #366, Aug 20 2026). The 3/5/7-loss pause ladder and the loss counter itself are deleted: under S2c batch entry a "streak" mostly counted ONE correlated wave as N events (~70% same-wave in the package replay; 49 firings/47wk, 28 blocking nothing). The counter is gone entirely (operator decision, flag N17 option b) — no display-only remnant. The hidden sizing escalation it drove is re-keyed to the day-P&L tiers above.

Market Data Sources (UPDATED Jan 28, 2026):

Data Primary Source Fallback Notes
VIX IBKR (^VIX index) yfinance IBKR is rate-limit-free, real-time
S&P 500 IBKR (SPY quote) yfinance Uses previous close for % change
Stock Price IBKR (get_quote) yfinance For stop loss checks
Market regime (Finding #227) MarketRegimeProvider (shared) yfinance SPY MA20/50/200 Uptrend / sideways / downtrend; persisted on every trade_audit_log and polymarket_opportunity_log row

Regime / VIX capture (Finding #227): src/earnings/market_regime_provider.py is the single source of truth for regime + VIX + SPY % change at signal time (memory.md §M). All risk-by-regime, regime-aware sizing, and circuit-breaker calibration must read from this provider rather than re-classifying. Captured columns are populated automatically on every new row; historical rows are backfilled by scripts/backfill_regime_for_audit_log.py. Canonical narrative: docs/FINDINGS.md Finding #227.

yfinance Rate Limit Handling: - yfinance calls are logged and counted for monitoring - When rate limited (429 error), backs off for 5 minutes - Rate limit count tracked in logs: 📊 yfinance usage: X calls since startup - Goal: Minimize yfinance usage by preferring IBKR data

Collector Retry/Backoff (Finding #6): - All collector + backfill HTTP calls retry with exponential backoff on 429/5xx errors - Failures are logged explicitly to avoid silent data gaps

6.2 Instant Exit Rules

Exit immediately when earnings released shows wrong prediction: - We predicted BEAT but actual = MISS → Exit LONG immediately - We predicted MISS but actual = BEAT → Cover SHORT immediately

Why Instant Exit Matters: - 82% of high-conviction wrong predictions are already negative at 1h - Average savings: ~0.5% per wrong trade - Per 100 trades: 13 wrong × 0.5% = ~6.5% total savings

Detection Methods — sources are WITNESSES under ONE exit arbiter (Finding #342; supersedes first-to-detect-wins; updated per #264/#336/#337/#339/#341/#342):

Witness Signal Authority (all decided by the arbiter)
Polymarket price EXIT-ONLY (#341): emits ONLY signals that CONTRADICT the held position (LONG → MISS side, SHORT → BEAT side). Statistic = median of the FRESH prints among the last 5 price_snapshots rows — ≤ CONTENT_CROWD_MAX_AGE_SEC old, at least CONTENT_CROWD_MIN_FRESH_PRINTS, else a live Gamma read (#358; median statistic #339). Two grades: resolution-grade (from window open): median ≥0.98 = BEAT / ≤0.02 = MISS; decision-grade (only after the TYPICAL release — BMO 7:00 ET / AMC 16:05 ET, #339): median ≤0.25 = MISS / ≥0.90 = BEAT. Re-judged on clean post-#358 prints Sep 26, 2026 — all four values KEPT by operator decision (#478): 0 false exits, no priced benefit, underpowered; next re-judgment at 12 wrong-held positions or 2026-12-15 (contract:cq2_grs_detection_rejudgment, dashboard-only) 300s SUSTAINED confirmation with 60s grace (#341) — on clean prints genuine verdicts sustained ≥122 min with 0 whipsaws at 0.25/0.90 (#478; the 0.1–4 min whipsaws #341 tuned against were largely the pre-#358 WebSocket bug) — behind the release-window gate (#337): ignored before 4:00 AM ET (BMO) / 3:30 PM ET (AMC) on the earnings date
PRNewswire / BusinessWire RSS (press-release EPS parse) EPS extracted with #342-hardened parser (number-first "…$4.81 Diluted EPS" shapes; dividend/buyback/book-value "per share" figures excluded — the PNC incident parsed the dividend as EPS) CONTRA claims go through the #342 arbiter (below); same-side claims are informational
Yahoo Finance reported EPS EPS scrape vs consensus (consensus falls back to earnings_markets.eps_estimate — #339) Same arbiter as the wires

The #342 exit arbiter (one policy, all content sources — _arbitrate_content_claim):

Content source claims CONTRA (e.g. MISS vs our LONG) Live PM crowd median (last 5 prints, fresh ≤15 min) Decision
MISS agrees (≤0.25) / BEAT: (≥0.90) Exit immediately — two independent witnesses (faster than the PM-only 5-min path)
MISS neutral / stale / thin book Wait for corroboration: a 2nd independent source with the same claim, OR the claim persisting 300s un-vetoed (CONTENT_EXIT_CORROBORATION_SEC)
MISS contradicts (≥0.90) / BEAT: (≤0.10) VETO — a parse error until proven otherwise (ON $0.06-vs-$0.64, VZ GAAP scrape, PNC dividend). Veto also resets the persistence clock; monitoring continues

Structural rules (#342): 1. No single source may exit a position uncorroborated (the arbiter is the only path to a content-source exit) 2. Ground truth has ONE writer: no detection signal — any source, any grade — writes earnings_markets.actual_beat; the EPS resolver (#227) settles the record post-hoc (detection signals poisoned it in #337/#341/#342) 3. Monitoring never dies from a signal: only the exit flow (position close) removes monitoring, so a later witness flip is always caught; callbacks fire once per outcome and re-fire on a FLIP; the reconciler re-registers positions even when an outcome exists (closing the #337 gap where JPM lost protection after restart)

Detection Flow (Implemented in earnings_detector.py): 1. Polymarket price checked first — exit-intent only (#341): fires only against the position, after the release window opens (#337), with 5-min sustained consensus 2. Parse PRNewswire/BusinessWire RSS for earnings press releases (6-hour recency window, #342-hardened EPS extraction) 3. Yahoo Finance EPS as cross-check 4. Content-source CONTRA claims pass the #342 arbiter (crowd agree / corroborate / veto); admitted claim → instant exit via MARKET order 5. earnings_markets.actual_beat is settled ONLY by the EPS resolver (#227); the exit keeps its full audit trail on the position row (earnings_detection_source, EPS, outcome, corroboration in logs)

Tracking Data Captured: - actual_earnings_time: Exact timestamp when earnings detected - actual_eps: Reported EPS value - earnings_outcome: beat/miss/meet/unknown - earnings_detection_source: yahoo/prnewswire/yahoo+prnewswire - price_at_announcement: Stock price when earnings detected - price_change_at_announcement: % change from entry to announcement - detection_to_exit_seconds: Time from detection to exit execution

Dashboard Display: - Active positions timeline shows earnings release time with 📢 indicator - Shows BEAT/MISS outcome with color coding - Shows ⚠️ WRONG indicator if prediction was incorrect - Tooltip shows EPS and price response details

6.3 Stop-Loss by Market Cap

Market Cap Stop-Loss Rationale
Large Cap -3% (was -2%) Widened Mar 9, Finding #202. Moderate widening improves total P&L by +$115.
Mid Cap -4% (was -3%) Same analysis.
Small Cap -5% (was -4%) Same analysis.

6.4 Profit-Taking Policy

Do NOT implement fixed profit-taking thresholds. Analysis shows: - 70.5% of winners continue improving from 1h to close - Big spikes do NOT fade significantly - Hold to close for all winning trades


7. Execution Quality

7.1 Slippage Tracking

All trades track slippage for monitoring and optimization:

Field Purpose
quote_at_signal Price when we decided to trade
order_type_used LIMIT or MARKET_FALLBACK
limit_price What we asked for
filled_price What we actually got
slippage_pct (filled - quote) / quote × 100
time_to_fill_sec Seconds from order to fill

This data enables future optimization of tiered limit buffers and timeout values (30s vs 45s).

Slippage Optimization Milestones

Trades Action
20 First review - check if Large caps fill at 0.1% buffer
50 Full review - analyze by market cap tier, adjust if needed
100 Comprehensive analysis - refine tiers, consider time-of-day

Telegram alerts are sent at each milestone with: avg slippage %, limit fill rate, fallback rate.

7.2 Execution Quality Circuit Breaker

Condition Threshold Action
Single fill slippage >1% Warning logged
Consecutive high slippage 3 batches in a row with ≥1 fill >1% (EQ-1, Finding #366 — was 3 trades; a 12-order S2c wave's correlated readings count once; single-order paths keep per-trade semantics) Pause 1 hour

Quality Levels: - Excellent: <0.3% average slippage - Good: 0.3-0.7% average slippage - Acceptable: 0.7-1.0% average slippage - Poor: >1.0% average slippage

7.3 Order Type Decisions (FINALIZED)

ENTRY:       LIMIT ORDER, ask + 0.3%, TIF=DAY, outsideRth=True
             Fallback to MARKET after 30s if not filled

STOP-LOSS:   TWO-LAYER SYSTEM (UPDATED Feb 10, 2026 - Finding #179)

             LAYER 1: HARDWARE STOP (IBKR)
             - ALL EXCHANGES: STP LMT order with 2% buffer (Finding #179)
             - TIF=GTC, outsideRth=True
             - Placed immediately when entry fills
             - Executes even if our service is down
             - TRIGGERS in pre-market and after-hours (verified live)

             ALL exchanges use STOP-LIMIT because:
             - Pure STP orders get Warning 2109 on NASDAQ (outsideRth IGNORED)
             - Pure STP stops ONLY trigger during RTH (9:30 AM - 4:00 PM)
             - HOOD lost 11% instead of 3% due to STP not triggering after hours
             - STP LMT → outsideRth IS respected → triggers in extended hours
             - Verified: CVS, KO, NET, RDDT, RL all triggered pre/post market as STP LMT

             LAYER 2: SOFTWARE BACKUP (Python)
             - Runs every loop iteration (~1 second)
             - Checks current price vs stop price
             - If stop triggered AND no hardware stop active, executes exit
             - Catches cases where hardware stop failed to place

             FIX (Jan 27, 2026 - Finding #57): order.orderId vs order_id
             ib_insync Trade object uses trade.order.orderId, not trade.order_id
             Fixed in order_executor.py and batch_order_manager.py

EXIT:        LIMIT ORDER, bid - 0.3%, TIF=DAY
             Fallback to MARKET after 45s if not filled

INSTANT:     LIMIT ORDER + MARKET FALLBACK (updated Jan 11, 2026)
             1. Keep stop order active until exit is verified
             2. Place LIMIT at quote ± 1% (GTC for persistence)
             3. Wait 5 seconds for fill
             4. If not filled, cancel and place MARKET (regular/extended only)
             5. If market is fully closed, use GTC LIMIT only (no MARKET)

             Note: IOC was replaced with GTC+fallback to prevent
             order cancellation in low-liquidity pre-market conditions.

7.4 Exit Verification (Added Jan 11, 2026)

Every exit order is verified against IBKR position to prevent DB/IBKR desync.

Step Action Failure Handling
1. Place exit LIMIT or MARKET Log error, retry
2. Wait for fill Up to 30s Fallback to MARKET
3. Query IBKR positions Check shares = 0 Alert if shares remain
4. Verify flat Update DB status Keep DB open if not flat

Exit Verification Results:

Result exit_verified DB Action Alert
Position = 0 TRUE Mark closed None
Position > 0 FALSE Keep open 🚨 CRITICAL
Query error FALSE Keep open ⚠️ WARNING

7.5 Position Reconciliation (Added Jan 11, 2026, Enhanced Jan 16, 2026)

Note (Phase 4D - Feb 7, 2026): Stop fills are now detected in real-time via IBKR fill callbacks (orderStatusEvent). Reconciliation serves as backup for positions closed during service downtime.

On startup, bot reconciles DB positions with IBKR positions:

Check Detection Action (Jan 16 Enhancement)
Orphaned DB In DB but not IBKR Auto-close in BOTH tables
External IBKR In IBKR but not DB Auto-add to BOTH tables
Mismatch Share counts differ Update DB to match IBKR
Duplicates Multiple entries for same symbol Merge into single entry

CRITICAL DATA INTEGRITY (Finding #47, Jan 16, 2026):

All reconciliation actions MUST update BOTH tables to maintain synchronization: - trading_positions - Current state (source of truth for dashboard) - trade_audit_log - Complete trade history (source of truth for analytics)

Guarded Reconciliation Policy (Feb 2026): 1. External positions → detect and alert for manual review (no blind inserts) 2. Orphaned positions → execution-ledger auto-close only when strict invariants pass and RECONCILIATION_AUTO_CLOSE=true 3. Duplicate positions → detect and alert (manual resolution) 4. After reconciliation → run validate_table_synchronization() for open and closed consistency checks

7.6 Stop-Loss Reconciliation (Added Feb 2026)

Primary detection (Phase 4D): Stop fills detected in real-time via IBKR fill callbacks. Positions cleaned up immediately via EventBus -> main loop queue.

Backup reconciliation validates stop-loss protection against IBKR:

  1. Confirms each open position has an active IBKR stop order
  2. Auto-fixes missing stop_order_id/details in DB when unambiguous
  3. Re-places missing stop orders when safe (direction + shares + stop price known)
  4. Updates trade_audit_log stop fields to keep tables in sync

Reconciliation Schedule: - On service startup (automatic) - Every 5 minutes via health check (future) - Manual trigger via API (future)

7.6 Table Synchronization Validation (Added Jan 16, 2026)

After every reconciliation, validate that: 1. All open positions in trading_positions have matching open entries in trade_audit_log 2. No orphaned audit entries without corresponding positions 3. Closed-trade counts and null exit fields are consistent across both tables

If desync detected: - Log 🚨 CRITICAL alert - Auto-insert missing trade_audit_log entries - Send notification for investigation

7.7 Data Retention & Backup Policy (Added Jan 19, 2026)

The trades table (Polymarket trade data) grows continuously and requires active management to prevent database memory exhaustion.

Retention Policy:

Data Type Retention Period Storage Location Purpose
trades (raw) 30 days TimescaleDB Active analysis
trades (archived) Indefinite [hidden]/backups/ Historical reference
trading_positions Indefinite PostgreSQL Trade history
trade_audit_log Indefinite PostgreSQL Compliance/audit

Backup Procedure:

  1. Backup Script: scripts/backup_old_trades.py
  2. Exports all trades > 30 days to gzip-compressed CSV
  3. Creates metadata file with row count and date range
  4. Runs automatically before deletion

  5. Backup Files:

  6. Location: [hidden]/backups/
  7. Format: trades_archive_YYYYMMDD_HHMMSS.csv.gz
  8. Metadata: trades_archive_YYYYMMDD_HHMMSS_metadata.txt

  9. Deletion Script: scripts/delete_old_trades.py

  10. Day-by-day deletion using time index (efficient for TimescaleDB)
  11. Handles managed database memory limits with retry logic
  12. Runs after successful backup

Storage Metrics (Jan 19, 2026):

Table Size Rows Notes
trades ~14 GB ~7M → ~1.8M After 30-day retention
Backup ~800 MB ~5.3M Compressed CSV

Manual Backup Command:

cd [hidden] && source venv/bin/activate
python3 scripts/backup_old_trades.py
python3 scripts/delete_old_trades.py

Restoration:

# Decompress and import if needed
gunzip -c backups/trades_archive_YYYYMMDD.csv.gz > trades_restore.csv
# Use COPY command to import (requires DBA access)

7.8 Database Connection Pooling (Added Jan 20, 2026)

All services share a centralized database connection pool to prevent connection leaks and reduce memory usage.

Connection Pool Configuration:

Parameter Value Purpose
pool_size 3-5 Base connections per service
max_overflow 5-10 Additional connections if needed
pool_recycle 1800 Recycle connections every 30 min
pool_pre_ping True Check connection health before use
pool_timeout 30 Wait up to 30s for a connection

Shared Connection Module: src/db/connection.py

from src.db.connection import get_engine, get_connection

# Get the shared engine
engine = get_engine()

# Use a connection (auto-returned to pool)
with get_connection() as conn:
    result = conn.execute(text("SELECT 1"))

Services Using Shared Pool: - earnings-trader.service (TradingOrchestrator, SignalGenerator, AuditLogger) - polymarket-collector.service - polymarket-dashboard.service - polymarket-live-trades.service - polymarket-websocket.service - polymarket-integrity-monitor.service (NEW Apr 27, 2026 — replaces decommissioned polymarket-watchdog-v3.service per Finding #258) - All backfill services

Impact (Jan 20, 2026 deployment): - Connections reduced: 36+ → 13 (64% reduction) - Memory usage: 35% → 18% (17% reduction) - Swap usage: 39% → 2% (37% reduction)

Monitoring:

# Check connection count
python3 -c "from src.db.connection import get_pool_status; print(get_pool_status())"

Earnings dashboard & strategy reporting (Mar 22–31, 2026 — Findings #215–#219): - Cold start: First load of / avoids the heaviest DB stats queries until the background cache warm completes (earnings_dashboard/app.py). - Strategy Analysis tab: Data file scripts/strategy_analysis_report.json. Refresh with python3 scripts/strategy_performance_analysis.py (requires DATABASE_URL) after material changes to closed trades so quintiles and other sections stay accurate. - Managed Postgres lock/memory pressure: See docs/POSTGRES_OPERATIONS.md (Finding #216). - Upcoming table MCAP: Uses earnings_markets.market_cap_category as the first source (DB is source of truth for displayed tier); sync/backfill via src/db/earnings_sync.py and post–Gamma-sync backfill in earnings_market_fetcher; Yahoo + Finnhub via yfinance_limiter when the column is NULL (Finding #219, docs/FINDINGS.md). See docs/DATABASE_DIRECTIVES.md and memory.md §L in Cursor. - Upcoming table BMO/AMC: DB column earnings_time is enriched by sync using calendar + centralized defaults/overrides only (not web → DB). Periodic backfill_upcoming_null_earnings_time(); dashboard shows NEEDS TIMING until BMO/AMC is known, even if conviction/volume checks are green — aligns UI with batch eligibility (Finding #220, docs/FINDINGS.md). Add verified tickers to DEFAULT_TIMINGS or timing_data/overrides.json; one-off scripts/backfill_ticker_earnings_timing.py for NULL/TBD rows.


7.9 Timezone-Aware Datetime Handling (Fixed Jan 27, 2026 - Finding #60)

Critical Stability Fix: The trading orchestrator was crashing during scheduled exit checks due to a timezone mismatch between scheduled_exit_time (timezone-naive) and now (timezone-aware UTC).

Root Cause: - signal_generator.py created exit_datetime using datetime.combine() without timezone info - trading_orchestrator.py used datetime.now(timezone.utc) for comparisons - Subtracting naive from aware datetime raises TypeError

Crash Pattern:

TypeError: can't subtract offset-naive and offset-aware datetimes
File ".../trading_orchestrator.py", line 2395, in check_scheduled_exits
    time_to_exit = (position.scheduled_exit_time - now).total_seconds()

Impact (Jan 27, 2026): - Service crashed 4 times in 5 minutes during trading window - Each restart triggered a new order for TXN - Result: 4 TXN shares instead of intended 1 share (4x position)

Fix Components:

  1. Helper Function (trading_orchestrator.py):
def ensure_timezone_aware(dt: datetime, assume_tz: timezone = None) -> datetime:
    """Ensure a datetime is timezone-aware (assumes ET if naive)."""
    if dt is None:
        return None
    if dt.tzinfo is None:
        return dt.replace(tzinfo=assume_tz or ET)
    return dt
  1. Fixed Comparisons (3 locations):
  2. check_scheduled_exits() - line 2445
  3. run_exit_cycle() - line 2566
  4. _execute_batch_exit() - line 2703

  5. Fixed Signal Generation (signal_generator.py):

  6. All datetime creations now include tzinfo=ET
  7. Entry/exit times are always timezone-aware

  8. Fixed DB Loading (_calculate_scheduled_exit_time_from_db):

  9. All returned datetimes are normalized to timezone-aware

Prevention: - All datetime comparisons now use ensure_timezone_aware() helper - Signal generator creates timezone-aware datetimes at source - DB loader normalizes any naive datetimes from database


8. IBKR Integration

8.1 Server Architecture

Trading runs on DigitalOcean server with IB Gateway (not TWS):

Component Server Port Purpose
IB Gateway (Live) [hidden] 4001 Live trading
IB Gateway (Paper) [hidden] 4002 Paper trading
VNC (Live) [hidden] 5901 GUI access
VNC (Paper) [hidden] 5902 GUI access

8.2 Account Requirements

Component Requirement
Account Type Margin account
Live Account U2…13 (Net Liq ~$27.5K, ~$10,000 trading capital after the $17,450 PDT reserve — FUND-1 capital step Aug 20, 2026 (#366); history: the earlier ~$2,500 figure reflected the pre-step $25,000 reserve)
Paper Account DU…91 ($1M test)
Gateway Ports 4001 (live), 4002 (paper)
Library ib_insync
Extended Hours Enabled in Gateway + outsideRth=True

8.3 Leverage Options

Type Leverage Requirement Overnight?
Reg T Margin 2x $2,000+ ✅ Yes
Day Trading 4x $25,000+ PDT ❌ No
Portfolio Margin ~6x $110,000+ ✅ Yes

⚠️ CRITICAL — original assumption corrected: An early version of the strategy assumed 10× leverage is achievable; this turned out to be wrong. IBKR's actual offerings are 2× (Reg T) at $2K+ and ~6× (Portfolio Margin) at $110K+. The current operative leverage cap is LEVERAGE_MAX_EFFECTIVE_LEVERAGE=2.0 (Reg T territory), with the live account also meeting the $25K PDT reserve requirement so the bot stays out of pattern-day-trader violations. §9 ROI projections in this doc still derive from older leverage assumptions and should be treated as a model baseline, not expected returns — see docs/STRATEGY_ANALYSIS_ARCHITECTURE.md §0.2 row #23-24 for the explicit re-baselining note. Phase 5 of the Strategy Analysis Revamp will replace these with simulator-derived numbers.

Leverage Impact on Returns:

Scenario 10x (Assumed) 2x (Reg T) 6x (PM)
2% stock move +20% ROI +4% ROI +12% ROI
-2% stop loss -20% loss -4% loss -12% loss

Current operational stance: - 2x rollout intent is implemented as 2x position sizing vs prior testing profile, not mandatory borrowed-margin usage. - Leverage guardrails remain active for safety (LEVERAGE_MAX_EFFECTIVE_LEVERAGE=2.0), but position sizing is driven by configured exposure/sizing caps. - Margin account options above remain broker capability references, not a requirement for baseline strategy operation.

Implementation rollout status (Feb 2026): - Phase A (read-only) deployed: leverage/margin telemetry is computed and exposed for monitoring. - Phase B (schema foundation) deployed: financing and margin ledger schema in place; legacy rows remain nullable-safe. - Phase C (financing ledger) deployed in code: daily accrual and close-time net PnL aggregation implemented behind flags. - Phase D (margin blockers) deployed in code: projected margin gates implemented with fail-closed behavior when enforcement is enabled. - Phase E (reporting + alerts) deployed in code: financing-aware reporting and leverage alert categories available; remaining live validations tracked in docs/FINDINGS.md. - Dashboard earnings visibility hardening deployed: upcoming-earnings table now live-refreshes, retains last known-good rows during transient API noise, and surfaces DB truth counters for operator confidence.

8.4 Order Types

Scenario Order Type TIF Notes
Entry LIMIT → MARKET fallback DAY outsideRth=True
Stop-Loss STOP-LIMIT (2% buffer, Finding #179) GTC Attached to entry; triggers -3% Large / -4% Mid / -5% Small (Finding #202)
Scheduled Exit LIMIT → MARKET fallback DAY MOC if possible
Instant Exit LIMIT → MARKET fallback GTC / IOC Keep stop until exit verified; closed market = GTC LIMIT only

8.5 Session Management & 2FA

Day Restart Time 2FA Required? Action
Mon-Sat 6:00 UTC (10:00 AM Abu Dhabi) ❌ No IBC AutoRestart is in-process and keeps the session (#437); ibkr-monitor pages once if :4001 stays down >180 s (#448). The old gateway_restart_manager.sh is retired (#476)
Sunday 6:00 UTC (10:00 AM Abu Dhabi) ✅ Yes Approve on IBKR app within 3 min

Architecture (IBC-Managed with Smart Restart): - ibgateway-ibc.service: ENABLED - Manages Gateway lifecycle, auto-login, 2FA triggering - ibc_smart_restart.sh: Wrapper that delays overnight restarts to 09:00 AM Abu Dhabi - ibgateway.service: DISABLED - Prevents conflict with IBC - ibkr-monitor.service: live :4001 SLA pager, page-only, installed by the deploy from deploy/systemd/ (#448/#456). gateway_restart_manager.sh (Mon-Sat 05:58 PID killer) is retired (#476, OD5)

Weekly 2FA Flow (Sunday Only): 1. 5:50 UTC (09:50 AM Abu Dhabi): the one Telegram reminder (sunday_2fa_reminder.sh, deploy/cron/root.crontab, #476; the 05:55 duplicates are retired) 2. 6:00 UTC (10:00 AM Abu Dhabi): Gateway's internal AutoRestart triggers 3. IBC enters credentials automatically 4. IBKR sends 2FA notification to phone 5. 6:00-6:03 UTC: User approves 2FA on IBKR app (3 min window) 6. Gateway connected and ready for trading; the trader attaches itself (#441) 7. If the push is still unapproved after 20 min, ibkr-monitor pages once; if IBC hangs without a 2FA (Sep 13/20 'Gateway' dialog), or a later Sunday crash asks for its own 2FA, it pages after 180 s with the systemctl restart ibgateway-ibc lever (#456). The monitor never restarts anything (OD5). The trader's #438 automatic last-resort recycle stays on (operator decision Sat 26 Sep 2026 20:39 UTC, #456) behind the #435/#439 guards, never inside Sunday 06:00–18:00 UTC; every recycle needs a 2FA approval on the phone (#430). A crash inside 06:00–06:30 without the scheduled restart's 2FA evidence pages at 180 s.

Smart Restart Wrapper (Added Jan 26, 2026):

IBKR Gateway shuts itself down at ~23:45 UTC daily. The smart restart wrapper prevents 2FA prompts at 3 AM:

Scenario Behavior
Gateway exits during day (05:00-22:00 UTC) Immediate restart, 2FA if needed
Gateway exits overnight (22:00-05:00 UTC) Delays restart until 05:00 UTC (09:00 AM Abu Dhabi)

Login Attempt Limiting (Added Jan 19, 2026):

To prevent IBKR account lockout from excessive failed login attempts:

Setting Value Purpose
Max Attempts 3 Attempts before auto-lockout
Lockout Duration 30 min Cooldown after max attempts
State File [hidden]/login_attempt_state.json Persists across restarts

Commands:

# Check current login state
python scripts/auto_login_gateway.py --status

# Reset login attempts (after manual intervention)
python scripts/auto_login_gateway.py --reset

8.6 Connection Health Monitoring (Added Feb 2026)

Proactive health monitoring to detect stale connections before they cause issues.

Service entrypoint hardening (Feb 2026): - ibkr-monitor.service (unit in deploy/systemd/, EnvironmentFile=[hidden]/.env, installed and restarted by the deploy — Finding #448) runs [hidden]/run_gateway_monitor.py - This script wraps src/ibkr/gateway_monitor.py (TCP checks every 30 s, no IBKR API session) and src/ibkr/gateway_pager.py (the live :4001 SLA pager, page-only) - Missing entrypoint causes monitor restart loops and can hide true gateway-health signals; integrity alerting:ibkr_monitor pages if the monitor is inactive or its heartbeat is >5 min old

Connection State Machine:

DISCONNECTED → CONNECTING → CONNECTED → DEGRADED → DISCONNECTED
State Description
DISCONNECTED Not connected to IBKR
CONNECTING Connection attempt in progress
CONNECTED Healthy connection, heartbeat passing
DEGRADED Connected but heartbeat failing (3+ failures)

Heartbeat Configuration:

Parameter Value Purpose
heartbeat_interval 30s Check frequency
degraded_threshold 3 Failures before DEGRADED state
max_consecutive_failures 10 Failures before reconnect attempt

Reconnection Strategy: - Exponential backoff: 3s → 6s → 12s → 24s → 60s (capped) - After 5 attempts, continues indefinitely every 60s - Heartbeat restarts automatically after successful reconnect

Usage:

from src.ibkr.client import IBKRClient, ConnectionState

client = IBKRClient(paper=False, use_gateway=True, client_id=99)
client.connect()  # Heartbeat starts automatically

# Check health status
status = client.get_health_status()
# Returns: {state, is_connected, last_heartbeat_success, consecutive_failures, ...}

# Get connection state
state = client.get_connection_state()  # ConnectionState.CONNECTED

Alerts: - Warning sent when only 1 attempt remaining - Critical alert when lockout is activated - Instructions provided for manual VNC login if needed

Mon-Sat Session Preservation Flow: 1. 6:00 UTC (10:00 AM Abu Dhabi): IBC's AutoRestart restarts the session inside the same JVM (Re-starting session), and :4001 is back in ~20-40 s with no 2FA (#437) 2. The trader reattaches in place, or attach-bounces once (#441) 3. ibkr-monitor pages only if :4001 stays down >180 s (#448) 4. Retired (#476, OD5): gateway_restart_manager.sh used to kill any "newer" Gateway process at 05:58-06:05. The AutoRestart is in-process, so its premise was obsolete, and a pattern matching the IBC JVMs would have killed the live Gateway

Key Config (/opt/ibc/config.ini):

AutoRestart=yes
AutoRestartTime=06:00
SecondFactorAuthenticationTimeout=180
ReloginAfterSecondFactorAuthenticationTimeout=yes
ExistingSessionDetectedAction=primary
StoreSettingsOnServer=no

Crontab Entries (managed: deploy/cron/root.crontab, installed by the deploy — Finding #476):

50 5 * * 0 /bin/bash [hidden]/scripts/sunday_2fa_reminder.sh
0 4 * * * /bin/bash [hidden]/scripts/ibkr_schedule_audit.sh

⚠️ CRITICAL - Actions That Invalidate Session Token: - Killing Gateway process externally (kill, systemctl stop ibgateway) - Logging in from IBKR Client Portal, TWS, or Mobile app (same username) - Server reboot without proper IBC restart - IBKR's internal shutdown at ~23:45 UTC (handled by smart restart) - Any of these require 2FA even on Mon-Sat


9. Expected ROI

9.1 Phase 1: $25,000 Capital

Metric Value
Capital $25,000
Leverage 2x (Reg T)
Trades per Season ~160
Expected Profit +$12,645
Expected ROI +50.6%

9.2 Phase 2: $110,000 Capital

Metric Value
Capital $110,000
Leverage 6x (Portfolio Margin)
Trades per Season ~126
Expected Profit +$182,816
Expected ROI +166.2%

9.3 Monte Carlo Simulation (1000 runs)

Percentile Phase 1 ROI Phase 2 ROI
5th (worst) +12.3% +45.2%
25th +35.8% +112.4%
50th (median) +50.6% +166.2%
75th +68.4% +220.1%
95th (best) +92.1% +295.3%

10. Data Analysis

Live analysis pipeline (Finding #227): All sections below are validated continuously by the dashboard Strategy Analysis tab (memory.md §K). Three new sections added April 2026 — Cost of skipped signals, Volume gate counterfactual, and Performance by regime / VIX bucket — are populated by the polymarket-resolver daily timer (canonical narrative in docs/FINDINGS.md Finding #227). Re-run python3 scripts/strategy_performance_analysis.py after material DB changes; the dashboard reads the resulting strategy_analysis_report.json.

10.1 Conviction Bucket Performance

Historical analysis of 364 resolved markets (Q3 2025 season):

Conviction Bucket Markets Accuracy Avg Day Return EV
50-60% 20 60.0% -0.60% -0.36%
60-70% 29 89.7% +0.91% +0.62%
70-80% 53 77.4% +2.68% +1.55% ⭐
80-90% 103 82.5% +1.04% +0.50%
90-95% 44 97.7% +1.38% +1.31%
95%+ 47 97.9% +0.64% +0.60%

Key Finding: 70-80% conviction offers the best risk/reward (+2.68% avg return).

10.2 Exit Timing Analysis

Bucket Exit 1h Exit 2h Exit 4h Exit Day Best Exit
70-80% +1.43% +1.43% +1.49% +1.72% Day ⭐
80-90% +0.26% +0.33% +0.30% +0.33% 2h/Day
90-95% +1.20% +1.50% +1.47% +1.43% 2h
95%+ +0.25% +0.44% +0.81% +0.56% 4h

Decision: Hold to close for all trades (maximizes returns for 70-80% bucket).

10.3 Volume Analysis

Volume Quartile Markets Accuracy Avg Volume
Q1 (Lowest 25%) 91 83.5% $5,836
Q2 (25-50%) 91 83.5% $11,729
Q3 (50-75%) 91 82.4% $18,577
Q4 (Top 25%) 91 82.4% $62,093

Key Finding: Volume does NOT correlate with accuracy. Low volume + high conviction = 89.8% accuracy (best!).

10.4 Kelly Criterion Analysis

Strategy Parameters (Validated Backtest):

Parameter Value
Win rate (p) 49.5%
Loss rate (q) 50.5%
Avg win +7.12%
Avg loss -2.62%
Win/loss ratio (b) 2.72

Kelly Calculation:

f* = (p × b - q) / b
f* = (0.495 × 2.72 - 0.505) / 2.72
f* = 0.309 (30.9% of capital at RISK)

FULL KELLY  = 30.9% risk/trade
HALF KELLY  = 15.5% risk/trade (Phase 2)
QUARTER KELLY = 7.7% risk/trade

10.5 Short Squeeze Analysis

Market Cap Miss Predictions Severe Squeezes (>5%) Risk Level
Large Cap 6 0 ✅ SAFE
Mid Cap 5 0 ✅ SAFE
Small Cap 50 5 ❌ DANGEROUS

Decision: No small cap shorts (squeeze risk too high).

10.6 Market Discovery & Data Infrastructure

The system uses robust, API-based market discovery to ensure ALL earnings markets are captured.

Critical Design Decision: Single Source of Truth

⚠️ IMPORTANT: The earnings_markets table is the AUTHORITATIVE SOURCE for all earnings market detection.

Data Sources

Source Endpoint Purpose Frequency
Gamma API Pagination /events/pagination?tag_id=1013 Market discovery Hourly
Gamma API Markets /markets/{id} Price/volume updates Every 30s
CLOB API /book Orderbook depth Every 1s
Polymarket data-api data-api.polymarket.com Live/closed trades source (replaced Dome — EOL Apr 28, 2026; Finding #289) As needed

Key Implementation Details

Market Discovery (earnings_market_fetcher.py): - Uses the correct Gamma API pagination endpoint with tag_id=1013 (Earnings category) - Fetches up to 500 events in a single call - Parses ticker, earnings date, and EPS estimate from slugs - Syncs to earnings_markets table (authoritative) and markets table (raw data) - Runs hourly via polymarket-earnings-sync.timer

Signal Generation (signal_generator.py): - JOINs with earnings_markets table to find active earnings markets - Uses em.earnings_date for date range filtering (not text parsing) - Gets ticker directly from earnings_markets.ticker (pre-parsed)

Earnings-date trust model (Finding #349, Jul 29 2026): - earnings_markets.earnings_date carries a date_confidence stamp written by the multi-source verifier (verify_earnings_dates, runs before each signal cycle): every voice gets one vote — the DB row (Polymarket-derived), NASDAQ calendar, Finnhub calendar, yfinance (tiebreak only). A unique ≥2-vote winner ⇒ confirmed (date corrected first if the DB voted differently); external voices with no majority ⇒ disputed. Single-source auto-correction was removed. - Confirmed dates are LOCKED: the three Polymarket-derived sync writers (earnings_market_fetcher, earnings_scraper, earnings_sync) cannot overwrite a confirmed date/timing (CASE guard in their upserts). The verifier's consensus, a NASDAQ+Yahoo official-calendar pair, a last-resort official-IR map (#424/#442), or the operator can move a confirmed date. - Six admission guards on the live signal path (1–3 from #349; 4 from #359; 5–6 from #362): 1. earnings_date_disputed — disputed rows never trade until resolved. 2. earnings_schedule_mismatch — earnings_date ≠ end_time::date on an UNconfirmed row never trades; on a confirmed row the verified date wins and the entry/exit schedule is built on the verified day (not end_time). 3. scheduled_exit_not_future — any signal whose computed exit is <30 min in the future is refused (born-dead schedule; the SBUX instant-exit shape). Fires at signal BUILD/entry time. 4. born_dead_schedule — at ADMISSION (Finding #359, Aug 16 2026 — operator-approved B-3 of the #357 revalidation): a signal whose INTENDED entry time is already MORE than 5 minutes past (Config.STALE_SIGNAL_MAX_ENTRY_LATE_MINUTES) is rejected in check_bmo_signals/check_amc_signals BEFORE it consumes an admission slot or capital — guard 3 catches the same #349 class only AFTER the slot is spent (W6: the stale SBUX leftover wasted a Jul-28 slot; ~1 per 4 weeks). Compares the intended entry_datetime (timezone-aware ET), so prior-evening BMO admissions with entries hours ahead pass untouched. 5. schedule_outside_window — in the signal generator (Finding #362, Aug 18 2026): after resolve_schedule_basis picks the schedule day, the day must still fall inside the window that SELECTED the market. Closes the #333↔#349 seam: a market pulled in via its (stale) end_time whose confirmed date resolves to a different day is skipped and re-selected on its true day (VIK Aug-17: selected for Monday's tomorrow=Aug-18 window via end_time, resolved to Aug-19 → pre-fix a Tuesday-dated signal sat in Monday's batch). 6. wrong_entry_date — at ADMISSION + batch scheduling + execution (Finding #362, Aug 18 2026): a signal may only be admitted/executed on its own intended entry DAY (ET calendar-day equality — the EARLY twin of guard 4, which only rejects LATE entries). Wired like #359 into all four admission loops before can_open_position, plus _filter_batch_signals_for_today at batch scheduling (killing the "All BMO/AMC have same entry time" assumption that executed VIK a day early) and a last-line refusal in _execute_batch_entry. Same-day BMO/AMC admissions pass untouched; the BMO reservation path (capital planning on tomorrow-morning signals — tonight-only since #366) deliberately does not run this guard. - Every date/timing change lands in earnings_date_audit; integrity rules consistency:earnings_dates_disputed (WARN) and consistency:earnings_date_churn (HIGH — ≥3 changes/7d = a writer is fighting the verifier) make the war pattern self-detecting. Dashboard shows a "⚠ DATE?" badge on disputed rows. - Finding #442 (Sep 13, 2026): verifier DB window and official-calendar fetch are 21 days (was 5 — CBRL NASDAQ 23 Sep was invisible when confirming the Sep 14 PM row). Confirm-in-place writes votes= to earnings_date_audit. NASDAQ+Yahoo agreement may rewrite a locked PM date before the hardcoded #424 IR map (CBRL Sep 14→23 is last-resort). Upcoming PM fields join on (ticker, date), never ticker alone. Open-position timeline keeps the scheduled-exit plan date and flags DATE SPLIT when NASDAQ/market disagree.

Dashboard Queries (app.py): - Earnings count: SELECT COUNT(*) FROM earnings_markets - Category breakdown: Uses earnings_markets.condition_id lookup

Why This Approach? - The /markets?tag=Earnings endpoint is broken (returns stale/limited data) - The markets.tags array is unreliable (only ~7% have 'Earnings' as first tag) - The /events/pagination endpoint returns ALL earnings markets (400+ historical, 30+ active) - API-based approach with dedicated table is 100% reliable

Database Tables: - markets: Raw market data (condition_id, token_ids, question, tags) - earnings_markets: AUTHORITATIVE earnings data (ticker, earnings_date, eps_estimate, condition_id)

Service Architecture

polymarket-earnings-sync.timer (hourly @ :05)
    └── earnings_market_fetcher.py
            ├── Fetch from Gamma API
            ├── Parse market metadata
            └── Sync to PostgreSQL

polymarket-collector.service (continuous)
    └── continuous_collector.py
            ├── Orderbook snapshots (1s)
            ├── Price snapshots (30s)
            └── Market sync (15min)

10.7 Earnings Timing Source Accuracy (HYBRID Approach with Smart Fallback)

Determining whether earnings are BMO (Before Market Open) or AMC (After Market Close) is critical for entry/exit timing. Through deep analysis of 392 resolved markets, we identified a 10.8% error rate with simple hour-based inference.

Analysis Summary

Metric Value
Sample Size 392 resolved Polymarket markets
Ground Truth 87 major stocks with known consistent patterns
Baseline Hour Accuracy 89.2%
With HYBRID Approach 97%+
With Smart Fallback (v4.9) 99%+

Root Cause of 10.8% Errors

The hour-based inference fails in the 13:00-15:00 UTC window (8-10 AM ET):

Scenario Resolution Time Hour-Based Inference
BMO same-morning 13:00-14:00 UTC Correctly inferred as BMO ✅
AMC next-morning 13:00-14:00 UTC Incorrectly inferred as BMO ❌

Both BMO resolution (after market open) and AMC resolution (next morning) occur in the same time window.

HYBRID Solution with Smart Web Fallback (99%+ Accuracy)

The system now uses a 5-tier priority approach (updated Jan 28, 2026):

Priority Source Coverage Accuracy
1 (Primary) Yahoo EarningsCalendar Real-time Variable
2 (Secondary) Centralized earnings_timing.py 150+ major stocks 97%+
3 (Tertiary) Database earnings_time From Polymarket endDate 89%
4 (Quaternary) Text parsing Keywords in question Low
5 (Smart Fallback) Multi-Source Web Lookup All NASDAQ stocks 99%+

NEW (Jan 27, 2026): Smart Web Fallback - When all other sources fail, the system now automatically searches the web using: - finance-calendars (NASDAQ earnings calendar) - Most comprehensive - yfinance (Yahoo Finance) - Backup source - Alpha Vantage (if API key configured) - Tertiary web source

Results are cached to [hidden]/data/earnings_timing_cache.json and logged for future lookup table additions.

Critical Fix (Jan 15, 2026): When EarningsCalendar returns time-not-supplied, the system now returns None to allow fallthrough to Priority 2 instead of incorrectly defaulting to INTRADAY. This fixed international ADRs like TSM.

Centralized Earnings Timing (Single Source of Truth)

UPDATED Jan 28, 2026: All timing data is now centralized in src/earnings/earnings_timing.py. This replaced duplicate hardcoded lookups across multiple files.

Major companies have consistent timing patterns across all earnings reports:

# Import from centralized module (never hardcode elsewhere!)
from src.earnings.earnings_timing import get_timing, get_all_timings

# Get timing for a single ticker
timing = get_timing("AAPL")  # Returns "AMC"

# Get all timings (defaults + overrides)
all_timings = get_all_timings()

# Examples from the centralized lookup:
# AMC (After Market Close) - ~80% of tech stocks
# "AAPL": "AMC", "MSFT": "AMC", "GOOGL": "AMC", "AMZN": "AMC"...

# BMO (Before Market Open) - Banks, healthcare, consumer staples  
# "JPM": "BMO", "BAC": "BMO", "WFC": "BMO", "GS": "BMO"...

# BMO - International ADRs (report during foreign market hours)
# "TSM": "BMO", "ASML": "BMO", "SAP": "BMO", "NVO": "BMO"...

To fix a timing (persists across restarts):

python scripts/check_earnings_timing.py --set-override SBUX BMO "Changed per NASDAQ"

Implementation Files

File Change
earnings_timing.py NEW (Jan 28) - SINGLE SOURCE OF TRUTH for all timing data
timing_data/overrides.json NEW (Jan 28) - Persistent timing overrides
check_earnings_timing.py NEW (Jan 28) - CLI tool for checking/fixing timings
earnings_market_fetcher.py Now imports from earnings_timing.py
signal_generator.py Now imports from earnings_timing.py
app.py (Dashboard) Now imports from earnings_timing.py
earnings_timing_fallback.py Multi-source web lookup for unknown stocks

Enhanced Hour Logic (for unknown stocks)

When a stock is not in the lookup table:

- hour >= 20 UTC → AMC (clear same-day after close)
- hour <= 12 UTC → BMO (clear morning resolution)
- hour 13-19 UTC → UNKNOWN/TBD (ambiguous window)

10.8 Concurrent Trade Execution (Batch Orders)

Added: January 11, 2026

When multiple signals trigger at the same time (e.g., 5 BMO entries at 3:58 PM), the system now uses batch execution for fair capital allocation and rapid order placement.

Architecture

Signal Check (3:50 PM)
    │
    ├── Collect all signals
    ├── Prioritize by B-4 rank (0-3, Finding #366; was 0-10 #334 + volume bump #313)
    └── Pre-calculate batch sizes ─────────────────────┐
                                                        │
Entry Time (3:58 PM)                                    │
    │                                                   │
    ├── Rapid LIMIT submission (all in <1 sec)          │
    ├── Parallel fill monitoring (12 sec timeout)      │
    ├── MARKET fallback for unfilled                    │
    ├── Place stop-loss for each fill                   │
    └── Persist with batch_id ◄─────────────────────────┘

Batch Position Sizing Algorithm (Fixed Jan 15, 2026)

CRITICAL FIX: The batch sizing now uses individual calculate_position_size() targets instead of distributing the entire budget proportionally. This prevents oversized positions.

Updated Aug 20, 2026 (S2c + B-4, Finding #366 — DEPLOYED Aug 21, 2026): with capital ≥ $9k the per-signal target is the S2c equal split min(25%, budget / n) rather than flat 25%, and the old priority score→size multiplier in batch_order_manager is FLATTENED to 1.0 (rank never scales size). The proportional shrink-to-fit and the whole-share floor below are unchanged.

Updated Aug 22, 2026 (Finding #398): batch calculate_position_size receives the same s2c_split_pct admission uses (HIGH ×0.25 / ELEVATED ×0.50 cut the split, not flat-25). BMO/AMC cycles recompute sizes AFTER _filter_batch_signals_for_today so survivors are not undersized as equal-split of the pre-filter n.

def calculate_batch_sizes(signals, available_budget, total_capital, reserved_for_bmo=0):
    # Step 1: Calculate TARGET size for each signal using strategy rules
    # S2c equal-split (Finding #366) when capital >= $9k; FLAT 25% below the gate (#314.f)
    # #398: pass the same s2c_split_pct admission uses (HIGH/ELEVATED cut the split)
    target_sizes = {s.symbol: calculate_position_size(s, s2c_split_pct=s2c_split_pct) for s in signals}
    total_target = sum(target_sizes.values())

    # Step 2: Subtract BMO reservation from available budget
    effective_budget = available_budget - reserved_for_bmo

    # Step 3: If targets fit in budget, use as-is; else scale down proportionally
    if total_target <= effective_budget:
        sizes = target_sizes  # Full target allocation
    else:
        scale_factor = effective_budget / total_target
        sizes = {s: size * scale_factor for s, size in target_sizes.items()}

    # Step 4: Cap at 30% of total capital per position
    for symbol in sizes:
        sizes[symbol] = min(sizes[symbol], total_capital * 0.30)

    return sizes

Old Bug (pre-Jan 15): Distributed entire available budget proportionally, causing ~20-24% positions instead of target 9-12.5%. See Dashboard Finding #39.

Integration with AMC/BMO Reservation

The batch sizing system respects existing capital reservation rules:

Available Budget = (Max Exposure × Capital) - Current Exposure - BMO Reservation

Performance Comparison

Metric Sequential (Old) Batch (New)
10 trades execution 5+ minutes ~20 seconds
Capital allocation Uneven Fair/weighted
Budget efficiency ~60% ~100%
IBKR API compliance ✓ ✓ (50 orders/sec limit)

Database Schema

New columns added via migration 011:

Table Column Description
trading_positions batch_id 8-char UUID prefix
trading_positions batch_type BMO, AMC, EXIT
trading_positions used_market_fallback True if LIMIT timed out
trade_audit_log batch_id Links all orders in batch
trade_audit_log batch_size Total orders in batch
trade_audit_log batch_position Order index (1-indexed)

Files

File Purpose
src/earnings/batch_order_manager.py Core batch execution logic
src/earnings/trading_orchestrator.py Integration with trading cycles
src/earnings/risk_manager.py calculate_batch_position_sizes()
migrations/011_batch_order_tracking.sql Database schema

11. Quick Reference Card

┌────────────────────────────────────────────────────────────────┐
│                 EARNINGS TRADING QUICK REFERENCE                │
├────────────────────────────────────────────────────────────────┤
│                                                                 │
│  SIGNAL RULES:                                                  │
│  • Conviction ≥70% → Trade                                      │
│  • Beat ≥70% → LONG | Miss ≥70% → SHORT                        │
│  • BMO reservation = B×25% observed set; rank-3 AMC carve-out  │
│  • INTRADAY earnings → SKIP (identified but not traded)         │
│  • Skip LONGs in downtrend regime (Rule #1, #304)              │
│  • RuleB ±90min window → REFUTED (#330) + REMOVED (#380)       │
│  • Depth/spread "WIDE SPREAD" gate → KEEP (#319)               │
│                                                                 │
│  SIGNAL PRIORITIZATION (B-4 3-tier, #366):                      │
│  ★ RANK 3: LONG 70-75% any mcap (the validated cell)           │
│  RANK 2:   LONG 80-85% large/mid                               │
│  RANK 1:   other qualifiers (SHORTs, B-5 pilot small-caps)     │
│  SKIP:     <70% / LONG ≥90% (C-1) / Small shorts / INTRA       │
│                                                                 │
│  POSITION SIZING (S2c, #366; flat-25 #314.f below $9k gate):   │
│  • capital ≥$9k: equal-split min(25%, budget/n), rank ≠ size   │
│  • Stops unchanged: -3% L / -4% M / -5% S                      │
│  • Small (<$10B): LONG 70-80% + B-5 pilot 80-90%, NO SHORTS    │
│  • Max exposure: 150% UNIFORM all days (#317, raised #335)     │
│                                                                 │
│  TIMING:                                                        │
│  • BMO: Enter 3:58 PM day before, Exit 10:32 AM earnings day   │
│  • AMC: Enter 10:34 AM earnings day, Exit 3:57 PM next day     │
│  • ⚠️ AMC exit 3:57 PM (1 min buffer before BMO entry 3:58 PM) │
│  • ⚠️ Mon BMO & Fri AMC = Weekend Hold (trade with exposure)   │
│                                                                 │
│  CIRCUIT BREAKERS (CLB-1 #366: daily-loss = sole loss brake):  │
│  • Daily -5% → pause until next ET open (restart-safe, A-6)    │
│  • Day P&L ≤-2.5% → sizes ×0.50 | ≤-4% → sizes ×0.25           │
│  • VIX >35 → No new positions | S&P -5% → Pause                │
│  • 3 batches in a row w/ >1% slippage → 1hr pause (EQ-1)       │
│  • 4 stop-exits in a session → pause entries (#312/#335.b)     │
│                                                                 │
│  INSTANT EXIT:                                                  │
│  • Wrong prediction detected → MARKET order immediately         │
│                                                                 │
└────────────────────────────────────────────────────────────────┘

Appendix A: Market Cap Classification

Large Caps (>$100B) — Full Trading

AAPL, MSFT, GOOGL, AMZN, NVDA, META, TSLA, JPM, JNJ, V, UNH, MA, HD, 
PG, XOM, CVX, LLY, MRK, PFE, KO, PEP, COST, AVGO, CSCO, MCD, ABT,
NFLX, AMD, CRM, ORCL, ADBE, INTC, QCOM, TXN, IBM, CAT, BA, HON

Mid Caps ($10B-$100B) — Full Trading

COIN, HOOD, DKNG, DASH, UBER, LYFT, ABNB, RBLX, PINS, ETSY, CHWY,
ZM, DOCU, OKTA, CRWD, DDOG, SNOW, MDB, PANW, SHOP, PYPL, SOFI

Small Caps (<$10B) — LONGS ONLY, Reduced Size

All others. No shorts on small caps.


Appendix B: Technical Implementation

Module Architecture

src/earnings/
├── trading_orchestrator.py    # Thin coordinator - creates and wires managers
├── position_manager.py        # Position CRUD, DB persistence, stop orders
├── exit_manager.py            # All exit paths: scheduled, instant, batch, stop
├── exposure.py                # Freed-capital + reservation free-book (one helper, #401)
├── reconciliation_manager.py  # IBKR/DB reconciliation, table sync
├── trading_utils.py           # Shared utilities: price, time, scheduling
├── position_state.py          # Position state machine (PENDING/OPEN/CLOSING/CLOSED/FAILED)
├── ibkr_position_cache.py     # IBKR position cache (5s TTL, source of truth for live state)
├── event_bus.py               # Publish/subscribe event system (real-time stop fill detection)
├── risk_manager.py            # Circuit breakers, position sizing
├── signal_generator.py        # Generate BMO/AMC trading signals
├── order_executor.py          # IBKR order execution
├── batch_order_manager.py     # Concurrent batch order execution
├── earnings_detector.py       # Yahoo/PRNewswire detection
├── conviction_calculator.py   # Orderbook → conviction at entry time
├── config.py                  # Centralized configuration with validation
├── types.py                   # Shared types (Result pattern)
├── audit_logger.py            # Event ledger for postmortems
├── alerts.py                  # Telegram notifications
└── market_calendar.py         # NYSE calendar (2024-2028 holidays)

Note (Phase 4A - Feb 7, 2026): trading_orchestrator.py was refactored from a 4878-line monolith into 6 focused modules using composition with dependency injection. Each manager owns its state and receives dependencies via __init__.

Key Features

Feature Implementation
Signal prioritization Large+70-80% first, sorted by conviction
Opportunity sizing 150% uniform cap all days (#317 uniform; #335 raised 140%→150%); rare/normal/busy are observability-only labels
INTRADAY skip Identified and logged but not traded
Slippage tracking All fills recorded with metrics
Execution quality breaker Pause on 3x >1% slippage
Tolerant scheduling Time windows (not exact minute)
Date-safe market matching Quarter/date filtering
Recency filtering PRNewswire 6-hour max age
Continuous VIX/SPX Updated every minute
Crash recovery Position state persisted to DB
Holiday handling US market holidays + early closes
Two-source confirmation Yahoo + PRNewswire for exits

Future Enhancements (Documented)

Feature Purpose Status
Pre-earnings snapshots Track conviction at 24h, 12h, 6h, 1h, 30min before ✅ Implemented (Jan 27, 2026)
Hourly conviction tracking conviction_history table with BMO/AMC separation ✅ Implemented (Jan 27, 2026)
Entry-time conviction Record exact conviction at strategy entry times ✅ Implemented (Jan 27, 2026)
All-earnings tracking Track ALL earnings, not just traded ones ✅ Implemented (Jan 27, 2026)
Slippage optimization Tiered buffers implemented (Large 0.1%, Mid 0.2%, Small 0.3%) ✅ Implemented
Slippage review alerts Telegram alerts at 20, 50, 100 trades ✅ Implemented
Weekend trade analysis Separate performance tracking 📋 After Q1 data

7.10 Conviction Data Tracking (Implemented Jan 27, 2026 - Finding #61)

Problem: Conviction data at key times was not being captured, preventing strategy refinement and Polymarket reliability analysis.

Solution: Created dedicated earnings-conviction-tracker.service that:

  1. Tracks ALL earnings markets - Not just traded ones, for complete data set
  2. Captures at key intervals: 24h, 12h, 6h, 1h, 30min before earnings
  3. Records entry-time conviction: Exact conviction at 3:58 PM ET (BMO) and 10:34 AM ET (AMC)
  4. BMO/AMC separation: Clear labeling for timing-based analysis
  5. Hourly snapshots: Stored in conviction_history table

Data captured: - earnings_markets.prob_24h_before - Conviction 24h before earnings - earnings_markets.prob_12h_before - Conviction 12h before earnings
- earnings_markets.prob_6h_before - Conviction 6h before earnings - earnings_markets.prob_1h_before - Conviction 1h before earnings - earnings_markets.prob_30min_before - Conviction 30min before earnings - conviction_history table - Hourly snapshots with BMO/AMC tags

Historical backfill completed: 428 past earnings processed, 307 hourly records created.


This document consolidates: EARNINGS_TRADING_STRATEGY.md, FINANCIAL_ASSUMPTIONS_REVIEW_v2.md, EARNINGS_STRATEGY_COMPLETE.md, PROJECT_TODO_MASTER.md

Version: 5.6 | Last updated: January 28, 2026


7.11 Earnings Timing Sync Fix (Implemented Jan 28, 2026 - Finding #62)

Problem: Companies can change their earnings timing (BMO→AMC or vice versa) after initial market creation. The database sync only captured timing on INSERT, never updating existing records.

Example: SBUX was synced as AMC on Jan 23, but NASDAQ later changed it to BMO. The live EarningsCalendar (NASDAQ) correctly had BMO, but our stale database had AMC.

Solution: Modified earnings_sync.py to also update earnings_time for existing records:

# Before: Only updated earnings_date
# After: Also updates earnings_time from live NASDAQ data
current_timing = self.get_earnings_timing(ticker, earnings_date)
UPDATE earnings_markets SET earnings_time = COALESCE(:etime, earnings_time) ...

Data Source Priority (Confirmed Correct): 1. EarningsCalendar (NASDAQ live) - PRIMARY (real-time, reflects changes) 2. Centralized earnings_timing.py - SECONDARY (single source of truth, with overrides) 3. Database earnings_time - TERTIARY (may be stale) 4. Polymarket question text - LAST RESORT


7.12 Centralized Earnings Timing System (Implemented Jan 28, 2026 - Finding #63)

Problem: SBUX was incorrectly showing as AMC in the dashboard despite being fixed. Root cause: timing data was duplicated in 4+ files, and only one was updated.

Solution: Created a centralized timing system with a single source of truth.

Architecture:

src/earnings/earnings_timing.py          # SINGLE SOURCE OF TRUTH
src/earnings/timing_data/overrides.json  # Persistent overrides

How It Works: 1. Default Timings: Hardcoded baseline for major companies (~100 tickers) 2. Overrides File: Persistent JSON file for corrections and changes 3. All modules import from earnings_timing - never hardcode timings elsewhere

Usage:

# Get timing for a ticker
from src.earnings.earnings_timing import get_timing
timing = get_timing("SBUX")  # Returns "BMO"

# Get all timings (merged defaults + overrides)
from src.earnings.earnings_timing import get_all_timings
timings = get_all_timings()

# Set an override (persists to file)
from src.earnings.earnings_timing import get_timing_manager
manager = get_timing_manager()
manager.set_override("SBUX", "BMO", "Changed from AMC per NASDAQ Jan 2026")

Command-line tool for checking/fixing:

# Check all timings
python scripts/check_earnings_timing.py

# Auto-fix discrepancies
python scripts/check_earnings_timing.py --auto-fix

# Set an override manually
python scripts/check_earnings_timing.py --set-override SBUX BMO "Reason here"

# Show current overrides
python scripts/check_earnings_timing.py --show-overrides

Files Updated: - earnings_dashboard/app.py - Now imports from centralized module - src/earnings/signal_generator.py - Now imports from centralized module - src/data_tracker/collectors/earnings_market_fetcher.py - Now imports from centralized module

Benefits: 1. Single source of truth - No more duplicate data to maintain 2. Persistent overrides - Corrections survive code updates 3. Easy to update - Command-line tool or direct JSON editing 4. Discrepancy detection - Can check against live sources


7.13 Active Positions Timeline Display Fix (Jan 29, 2026 - Finding #77)

Problem: Active positions (IBM, MA) were not visible in the Dashboard's "Active Positions Timeline & Prioritization" section.

Root Cause: 1. Active positions section was disabled in template with {% if false and ...%} 2. Position data from get_active_positions_from_db() lacked required fields: priority_score, earnings_date, scheduled_exit_time 3. Timeline bar logic used day_offset which wasn't being calculated

Fix Components:

  1. Backend (earnings_dashboard/app.py):
  2. Added priority_score, scheduled_exit_time, condition_id to database query
  3. Calculate priority_label from score (★HIGH, HIGH, NORMAL, LOW, SKIP)
  4. Calculate earnings_date from scheduled_exit_time and earnings_timing
  5. Added status: 'open' for template compatibility

  6. Frontend (dashboard.html):

  7. Re-enabled active positions section (removed false and)
  8. Replaced complex day_offset bars with date-matching using timeline_days
  9. Shows "HELD" labels with Entry/Exit times for each position bar
  10. Fixed conviction display (was showing decimal, now shows percentage)

Verification: - IBM (AMC trade) shows correctly with Exit 15:57 on next day - MA (BMO trade) shows correctly with Exit 10:32 on earnings day - Both positions display P&L, conviction, and timing in sidebar


7.14 Yahoo Finance Rate Limit Bypass (Jan 29, 2026 - Finding #78)

Problem: Dashboard was slow (~2 min load time) due to Yahoo Finance rate limiting when fetching market data for earnings calendar.

Solution Components:

  1. User-Agent Rotation:
  2. Pool of 10 different browser/device user agents (Chrome, Firefox, Safari, Edge)
  3. Windows/Mac/Linux/Mobile variations
  4. Makes requests appear from different users/devices
  5. Rotates through pool to avoid fingerprinting

  6. Session Rotation:

  7. Creates fresh HTTP sessions every 10 API calls
  8. Each session has different headers (User-Agent, Accept, Cache-Control)
  9. Prevents session-based tracking/rate limiting

  10. Finnhub Fallback (Alternative Data Source):

  11. Free tier: 60 API calls/minute
  12. Provides: current price, market cap, exchange data
  13. Automatically used when Yahoo Finance returns 429/rate limit
  14. API key stored in .env as FINNHUB_API_KEY

  15. Increased Rate Limit Threshold:

  16. From 2 calls/min to 5 calls/min (more sustainable with rotation)

Files Updated: - src/utils/caching.py - YFinanceRateLimiter class enhanced with rotation + Finnhub - .env.example - Added FINNHUB_API_KEY documentation

Stats Tracked: - user_agent_rotations - Number of times UA pool cycled - session_rotations - Number of fresh sessions created - finnhub_calls - Total Finnhub API calls - finnhub_fallbacks - Successful Finnhub fallback uses


7.15 P&L Audit and Database Integrity Fix (Jan 29, 2026 - Finding #79)

Problem: Dashboard P&L numbers were inconsistent: - History tab: $60.59 - Header: $87.33 - Trading tab unrealized: $28.74

Root Cause Analysis (IBKR as Source of Truth): 1. Corrupted P&L values in trade_audit_log: - TXN: DB had $42.76, should be $12.39 (off by $30.37) - T: DB had $5.25, should be $4.32 (commission not deducted) 2. Reconciliation trades with no actual exit were counted 3. Missing commission data for most trades (IBKR API doesn't retain historical executions) 4. Missing earnings market data (company_name, market_cap_category, earnings_time)

Fixes Applied: 1. Recalculated P&L using formula: (exit - entry) × shares - commission - LONG: (exit_price - entry_price) × shares - SHORT: (entry_price - exit_price) × shares 2. Marked reconciliation trades (PNC #7, STT #8) with notes, P&L=$0 intentionally 3. Filled missing earnings data from Finnhub API: - 72 markets updated with company_name and market_cap_category - 22 markets set to earnings_time='UNKNOWN' (future dates not yet available) 4. Manual fixes: BRKA (Berkshire Hathaway), UHALB

After Fix: - Total Realized P&L: $29.29 (correct) - Discrepancy: $0.00 - 492 earnings markets with 0 missing data fields

Commission Data Gap: - IBKR API only provides executions from current session - Historical commissions require IBKR Flex Query setup - Currently only 2 trades have commission recorded ($1 each) - Action Required: Set up IBKR Flex Query for historical execution data

Scripts Created: - scripts/full_audit.py - Comprehensive database audit - scripts/fix_database.py - P&L correction script - scripts/fill_earnings_data.py - Finnhub company data fetcher - scripts/fill_earnings_time.py - Earnings timing fetcher

Principle Established: IBKR is the single source of truth for trade data, P&L, and commissions. Database must always reflect actual IBKR data.


7.16 Earnings Detection Persistence Fix (Jan 29, 2026 - Finding #80)

Problem: Earnings release status for MA and IBM was not showing on dashboard despite logs showing "Polymarket: YES=99.95% → BEAT signal".

Root Cause Analysis:

  1. _on_earnings_detected() callback only persists data if position is in self.active_positions
  2. active_positions is an in-memory dictionary cleared on service restart
  3. After restart, positions entered before restart are NOT in active_positions
  4. if position: check fails → _persist_earnings_detection() never called
  5. Additionally: _persist_earnings_detection() only updated trading_positions, NOT earnings_markets.actual_beat

Code Path (Before Fix):

Polymarket 99.95% → _check_polymarket_price() → is_released=True
→ _check_all_positions() → position.callback()
→ _on_earnings_detected()
→ position = self.active_positions.get(symbol) → None after restart!
→ if position: → False → DB update SKIPPED

Fix Applied (trading_orchestrator.py):

  1. Query DB for entry_price if not in memory: python elif price_at_announcement and self._db_engine: # If position not in memory (service restarted), query DB with self._db_engine.connect() as conn: db_result = conn.execute(text(""" SELECT entry_price FROM trading_positions WHERE symbol = :symbol AND status = 'open' LIMIT 1 """), {'symbol': symbol})

  2. Always call _persist_earnings_detection (not just when position in memory)

  3. Update earnings_markets.actual_beat: python if condition_id and result.outcome: actual_beat = True if result.outcome.value == 'beat' else ( False if result.outcome.value == 'miss' else None) conn.execute(text(""" UPDATE earnings_markets SET actual_beat = :actual_beat WHERE condition_id = :condition_id """), {'condition_id': condition_id, 'actual_beat': actual_beat})

Verification: - MA and IBM now show earnings_outcome='beat' in trading_positions - earnings_markets.actual_beat=True for both condition_ids - Dashboard displays earnings status correctly

Principle: Service restarts must not break earnings detection persistence. Always query DB as fallback when in-memory state is unavailable.


7.17 NYSE Stop-Loss Order Type Fix (Jan 29, 2026 - Finding #81)

Problem: V (NYSE stock) had a pure STOP order which would fail in extended hours because NYSE rejects MARKET orders outside RTH.

Background: - Jan 27, 2026 (Finding #59): Implemented NYSE STOP-LIMIT logic - Jan 28, 2026: Changed to pure STOP for all exchanges per user request - Jan 29, 2026: Discovered the trade-off wasn't clearly explained - pure STOP fails for NYSE in extended hours

Root Cause: - Pure STOP → triggers → becomes MARKET order - NYSE rejects MARKET orders in extended hours (pre-market/after-hours) - Result: Stop order held until 9:30 AM or rejected entirely

Fix Applied (order_executor.py - _place_stop_order): - ~~NYSE only~~ → ALL exchanges: Use STOP-LIMIT with 2% buffer

UPDATED Feb 10, 2026 (Finding #179): Expanded to ALL exchanges including NASDAQ. Pure STP orders get IBKR Warning 2109 on NASDAQ (outsideRth IGNORED). HOOD lost 11% instead of 3% because STP stop didn't trigger after hours. STP LMT works.

# FIX (Feb 10, 2026 - Finding #179): ALL exchanges use STP LMT
limit_price = round(stop_price * (1 - NYSE_STOP_LIMIT_BUFFER), 2)
order.orderType = 'STP LMT'
order.auxPrice = stop_price   # Stop trigger
order.lmtPrice = limit_price  # Limit after trigger
order.outsideRth = True       # Respected for STP LMT (not for pure STP!)

Order Type Summary (UPDATED Feb 10, 2026):

Exchange Order Type Trigger Behavior Extended Hours
ALL STOP-LIMIT → LIMIT (2% buffer) ✅ outsideRth respected
~~NASDAQ~~ ~~STOP~~ ~~→ MARKET~~ ❌ Warning 2109 (outsideRth IGNORED)

Trade-off: STOP-LIMIT may not fill if price gaps beyond the 2% limit buffer. Software backup layer provides additional safety. In practice, 0% of our STP LMT fills had gap > 2% (ARES was exactly at the 2% limit).


7.18 Table Synchronization Share Count Fix (Jan 29, 2026 - Finding #82)

Problem: Dashboard showed V with 1 share while IBKR had 2 shares. trade_audit_log was out of sync with trading_positions.

Root Cause: 1. Manual script (buy_v.py) ran twice, creating 2 × 1-share orders 2. _reconcile_positions_with_ibkr() fixed trading_positions shares (1 → 2) 3. But did NOT update trade_audit_log (still had 1 share) 4. _validate_table_synchronization() only checked symbol presence, not share counts

Fix Applied (trading_orchestrator.py):

  1. In _reconcile_positions_with_ibkr(): When fixing share mismatch, also update trade_audit_log: python # Also update trade_audit_log to maintain data integrity conn.execute(text(""" UPDATE trade_audit_log SET shares = :shares, entry_price = :avg_cost, ... WHERE symbol = :symbol AND status = 'open' """), {...})

  2. In _validate_table_synchronization(): Added share count mismatch detection and auto-fix: python for symbol in open_positions: if symbol in open_audit: if pos_shares != audit_shares: share_mismatches.append(...) # Auto-fix mismatches

Verification: - V now shows 2 shares in both trading_positions and trade_audit_log - Dashboard displays correct share count

Principle: Both trading_positions and trade_audit_log must always be synchronized. When fixing one, fix both.

7.19 Duplicate Order Prevention Fix - Finding #58 Regression (Jan 29, 2026 - Finding #83)

Problem: AAPL and V both received 2x intended position size due to duplicate orders on service restart.

Timeline: 1. 9:34 AM - AAPL entered correctly (1 share @ $258.04) 2. 9:37 AM - Dashboard deployment restarted earnings-trader service 3. 9:38 AM - System ran AMC cycle again, placed duplicate AAPL order (1 share @ $256.84) 4. Result: 2 shares instead of 1 (2x intended position)

Root Cause - Finding #58 "Fix" Was Broken From Day 1:

The Jan 27 fix (Finding #58) added a pre-order IBKR position check, but used incorrect attribute access:

# WRONG - PositionInfo is a dataclass, not a dict!
for pos in ibkr_positions:
    if pos.get('position', 0) != 0:       # FAILS: 'PositionInfo' has no attribute 'get'
        existing_positions.add(pos.get('symbol'))

The exception was caught and logged as a warning:

Could not verify IBKR positions for duplicate check: 'PositionInfo' object has no attribute 'get'

But the code then continued to place orders instead of aborting.

Fix Applied:

  1. Correct attribute access (batch_order_manager.py and trading_orchestrator.py): python # CORRECT - Use dataclass attributes for pos in ibkr_positions: if pos.shares != 0: # .shares not .get('position') existing_positions.add(pos.symbol) # .symbol not .get('symbol')

  2. Fail-safe: Abort on exception: python except Exception as e: # CRITICAL: If we can't verify positions, we MUST NOT proceed logger.error(f"❌ CRITICAL: Could not verify IBKR positions: {e}") logger.error("❌ ABORTING entry to prevent duplicate orders!") return result # or return None

Principles Applied: Test changes in production context; Safety-critical code must fail safely (see memory.md Core Principles #3)

7.20 Earnings Timing Data Source Priority Fix (Jan 29, 2026 - Finding #84)

Problem: DIS showed as BMO on dashboard when it's actually AMC. Multiple symbols had wrong or missing timing/market cap.

Root Cause: 1. EarningsCalendar (external source) had Priority 1, database had Priority 3 2. External source returned 'time-pre-market' for DIS (wrong!) 3. Sync jobs overwrote correct database values with wrong external data

Investigation:

EarningsCalendar (External):  DIS → 'time-pre-market' → BMO (WRONG)
Database (earnings_markets):  DIS → 'AMC' (CORRECT)
Dashboard displayed:          DIS → BMO (from external, overriding database)

Fix Applied:

  1. Database is source of truth for earnings timing data (Priority 1):
  2. Dashboard and signal_generator read from database first
  3. External sources only used as fallback when database has NULL

  4. Sync jobs only fill NULL values:

  5. earnings_sync.py: COALESCE(earnings_time, :new_value) instead of COALESCE(:new_value, earnings_time)
  6. earnings_market_fetcher.py: Same fix
  7. Principle: "Once verified in database, don't override"

  8. Fixed wrong data:

  9. DIS: Set to AMC (verified - Disney reports after market)
  10. APTV: Set to BMO (verified - 8:00 AM ET press release)
  11. TSN: Set to BMO (verified - 9:00 AM ET press release)
  12. Filled market cap for APTV, TSN, NXPI

  13. Weekend visualization:

  14. Monday BMO trades now show bar spanning Fri (entry) → Sat → Sun → Mon (exit)

Files Modified: - earnings_dashboard/app.py (priority order) - src/earnings/signal_generator.py (priority order) - src/db/earnings_sync.py (only fill NULLs) - src/data_tracker/collectors/earnings_market_fetcher.py (only fill NULLs) - earnings_dashboard/templates/dashboard.html (weekend visualization, Finding #84 card)

Principle: For earnings timing data, database is the source of truth. External sources can be wrong. Once a value is verified and stored, don't overwrite it. Note: For live position state, IBKR is the source of truth (Phase 4B+4C, Feb 7, 2026).

7.21 Market Health Indicators Fix (Jan 29, 2026 - Finding #85)

Problem: VIX showed "--" and SPX showed "0" on dashboard most of the time.

Root Cause: 1. yfinance API is rate-limited and unreliable 2. No caching - every page load made fresh API calls 3. Single fallback method - if it failed, returned 0 4. No distinction between market hours, pre/post, and closed

Fix Applied:

  1. Cached with intelligent TTL:
  2. 5 minutes during market hours (regular or extended)
  3. 30 minutes when markets closed
  4. Background refresh every 30 seconds

  5. Multiple fallback methods: ```python # VIX fallbacks: fast_info.lastPrice → history().Close → download().Close → cached → default (18.0)

# SPX fallbacks: fast_info.lastPrice → info.postMarketPrice → info.preMarketPrice → info.regularMarketPrice → history().Close → cached → default (605.0) ```

  1. Source indicators:
  2. "live" - real-time data during regular hours
  3. "pre-mkt" - pre-market price (4:00-9:30 AM)
  4. "after-hrs" - after-hours price (4:00-8:00 PM)
  5. "(close)" - last closing price (markets closed/weekends)
  6. "cached" - stale data when API fails

  7. Never shows empty data:

  8. Always returns valid number, never "--" or "0"
  9. Default values: VIX=18.0, SPX=$605.0

Files Modified: - earnings_dashboard/app.py (get_cached_market_health, _fetch_market_health_data) - earnings_dashboard/templates/dashboard.html (source indicators display)

Principle: External APIs are unreliable - cache aggressively and always have fallbacks.

7.22 Exposure Limit Not Enforced (Jan 30, 2026 - Finding #86)

Problem: Exposure reached 78.1% when limit should be 70%.

Evidence from logs (20:50:22):

Current exposure: $0          ← WRONG! Should be ~$1,170 (V + AAPL)
Effective exposure: $0
Available budget: $1,919

Root Cause: RiskManager.open_positions was never populated because register_position() was not called: 1. When positions loaded from database on startup (_load_persisted_positions) 2. After batch entries (_execute_batch_entry) 3. During reconciliation reloads (_reconcile_positions_with_ibkr)

Execution Path (before fix):

Service restart → _load_persisted_positions() 
               → positions loaded to self.active_positions 
               → risk_manager.open_positions = [] (EMPTY!)

Batch entry calculation → get_risk_state() 
                       → current_exposure = sum([]) = $0
                       → allows 70% new budget when 38% already used!

Fix Applied (4 locations in trading_orchestrator.py):

  1. _load_persisted_positions() (after line 542):
  2. Added risk_manager.register_position() for each loaded position

  3. _reconcile_positions_with_ibkr() (after line 1203):

  4. Added risk_manager.open_positions.clear() when clearing active_positions

  5. _reconcile_positions_with_ibkr() (after line 1240):

  6. Added risk_manager.register_position() for each reloaded position

  7. _execute_batch_entry() (after line 3112):

  8. Added risk_manager.register_position() for each batch entry
  9. Note: Single entries via execute_entry() already call this through order_executor.place_entry_with_stop()

Verification: - After restart, logs should show: Registered position: SYMBOL DIRECTION X shares @ $XXX - Exposure calculation should show actual values, not $0

Principle: When the same data exists in multiple places (active_positions and risk_manager.open_positions), they MUST stay in sync. Update all or none.

7.23 Earnings Detection Not Persisting (Jan 30, 2026 - Finding #87)

Problem: AAPL and V earnings were detected (Polymarket 99.95% beat signal) but dashboard showed no outcome. Detection happened repeatedly without persisting.

Evidence from logs:

21:30:07 - ⚡ POLYMARKET SIGNAL: V detected as beat (first to detect)
21:30:07 - 🎯 Earnings detected for V: beat (callback fired)
21:30:09 - ⚡ POLYMARKET SIGNAL: V detected as beat (first to detect)  ← Detected AGAIN!
21:30:16 - ⚡ POLYMARKET SIGNAL: V detected as beat (first to detect)  ← And again!

No "Stopped monitoring V" log → position never removed from monitoring. No "Earnings detection persisted" log → database never updated.

Root Cause: _on_earnings_detected callback runs from EarningsDetector's background thread. It called _get_current_price() → ibkr_client.get_quote() → self.ib.sleep(1).

ib_insync is NOT thread-safe! Calling ib.sleep() from a background thread causes: - Deadlock (waiting forever on wrong event loop) - Silent failure (callback never completes) - No exception logged (no traceback)

Execution Path (before fix):

EarningsDetector thread → callback → _get_current_price() 
                       → ibkr_client.get_quote() 
                       → self.ib.sleep(1) ← DEADLOCK on wrong thread!
                       → callback never returns
                       → _persist_earnings_detection never called
                       → remove_monitored_position never called
                       → V stays in monitoring, detected again and again
                       → AAPL never gets checked (loop stuck on V)

Fix Applied: Added skip_ibkr=True parameter to _get_current_price() when called from callback:

# In _on_earnings_detected:
price_at_announcement = self._get_current_price(symbol, skip_ibkr=True)

# In _get_current_price:
def _get_current_price(self, symbol: str, skip_ibkr: bool = False) -> Optional[float]:
    if not skip_ibkr and self.ibkr_client and self.ibkr_client.connected:
        # IBKR path (only safe from main thread)
    # Fallback to yfinance (always safe)
    # If yfinance fails (rate limited), use entry_price as last resort
    if position and position.entry_price > 0:
        return position.entry_price

Addendum - B4 Failure Mode Addressed: Initial fix lost price_at_announcement because yfinance was rate limited. Added fallback to use entry_price from position when yfinance fails.

Files Modified: - src/earnings/trading_orchestrator.py (2 changes + fallback logic)

Principle: ib_insync is NOT thread-safe. Never call IB methods from background threads. Always have multiple fallbacks for external data sources.

7.24 Stop-Loss Tracking and Slippage Analysis (Jan 30, 2026 - Finding #88)

Trigger: XOM stop-loss exit showed -2.46% loss when stop was set at -2.00%. Needed to track and analyze the 0.46% slippage.

XOM Analysis: | Metric | Value | Notes | |--------|-------|-------| | Entry Price | $140.47 | - | | Stop Trigger | $137.66 | -2.00% (exactly per spec) | | Stop Limit | $134.91 | -4.00% (2% buffer below trigger) | | Exit Price | $137.01 | -2.46% | | Slippage | $0.65 (0.46%) | Trigger to fill |

What Happened: 1. NYSE STOP-LIMIT order placed (orderId 7615) with outsideRth=True 2. At 10:44 PM ET (after NYSE extended hours close), price dropped to $137.50 3. Hardware stop couldn't trigger - NYSE was closed 4. Software backup detected price breach and executed market sell 5. Filled at $137.01 - slightly below the trigger price

Root Cause: Hardware STOP-LIMIT cannot execute when exchange is fully closed. Software backup layer correctly caught the breach but executed as a market order, incurring slippage.

Tracking Implemented (4 areas):

  1. Stop Order Details on Entry:
  2. stop_order_type: 'STP' or 'STP LMT'
  3. stop_trigger_price: auxPrice (when stop triggers)
  4. stop_limit_price: lmtPrice (for STP LMT only)

  5. Exit Slippage Tracking:

  6. exit_actual_slippage: Difference between stop_trigger_price and exit_price
  7. Formula (LONG): stop_price - exit_price (positive = slippage, bad)
  8. Formula (SHORT): exit_price - stop_price (positive = slippage, bad)

  9. Market Session at Exit:

  10. exit_market_session: One of 'regular', 'pre_market', 'after_hours', 'closed'
  11. Critical for understanding why hardware stops failed (XOM was 'closed')
  12. Helper function _get_market_session() determines session based on ET time

  13. Persistence Flow:

  14. order_executor._place_stop_order() → extracts order type and prices
  15. batch_order_manager._place_stop_loss() → also extracts (same pattern fix)
  16. ExecutionResult → carries stop_order_type, stop_trigger_price, stop_limit_price
  17. BatchOrder → carries same fields (converted to ExecutionResult)
  18. _persist_position_open() → saves to trading_positions
  19. _persist_position_close() + _persist_position_close_with_exit_details() → saves exit_actual_slippage and exit_market_session

Database Columns:

-- trading_positions
stop_order_type VARCHAR       -- 'STP' or 'STP LMT'
stop_trigger_price NUMERIC    -- auxPrice
stop_limit_price NUMERIC      -- lmtPrice (STP LMT only)
exit_actual_slippage NUMERIC  -- $ slippage on exit
exit_market_session VARCHAR   -- 'regular', 'pre_market', 'after_hours', 'closed'

Market Session Logic:

regular:     9:30 AM - 4:00 PM ET (weekdays)
pre_market:  4:00 AM - 9:30 AM ET (weekdays)
after_hours: 4:00 PM - 8:00 PM ET (weekdays)
closed:      8:00 PM - 4:00 AM ET, and all weekends

Files Modified: - src/earnings/order_executor.py (ExecutionResult + _place_stop_order) - src/earnings/trading_orchestrator.py (_get_market_session, _persist_position_open, _persist_position_close, _persist_position_close_with_exit_details) - src/earnings/batch_order_manager.py (BatchOrder + _place_stop_loss + conversion to ExecutionResult)

Same Pattern Fix Applied: Both order_executor (single entries) and batch_order_manager (batch entries) now capture stop order details.

Lessons: 1. Software backup is critical for NYSE stocks after hours 2. Track all stop order parameters for future analysis 3. Market session tracking explains hardware stop failures 4. Slippage tracking enables strategy optimization 5. Same pattern check - when fixing entry flow, also check batch entry flow


7.25 Exit Verification Ignored - Position Closed Despite Failed Exit (Jan 30, 2026 - Finding #89)

Trigger: XOM showed two entries in dashboard - one closed at stop loss, one added via reconciliation. Investigation revealed the exit order never actually filled.

Timeline (XOM): | Time (UTC) | Event | Issue | |------------|-------|-------| | Jan 29, 20:58 | BUY 2 shares @ $140.47 | ✅ Filled | | Jan 30, 03:44:18 | MARKET SELL placed | ⚠️ PreSubmitted only | | Jan 30, 03:44:20 | EXIT VERIFICATION FAILED: still has 2 shares | ⚠️ DETECTED! | | Jan 30, 03:44:20 | Position marked as closed anyway | 🔴 BUG | | Jan 30, 03:44:21 | Stop order cancelled | 🔴 Position unprotected | | Jan 30, 06:29:00 | Reconciliation adds position as "external" | Cleanup |

Root Cause: The exit verification mechanism (result.exit_verified = False) was correctly detecting that the position still existed in IBKR, but the calling code was ignoring it:

# BEFORE (Bug):
if result.success:  # Only checks if order was SUBMITTED
    # Proceeds to close position even if exit_verified=False!
    del self.active_positions[symbol]
    self._cancel_stop_orders_for_symbol(symbol)

# AFTER (Fix):
if result.success:
    if not result.exit_verified and not self.paper_trading:
        # DO NOT close - position still exists in IBKR!
        logger.critical(f"EXIT VERIFICATION FAILED for {symbol}")
        return result  # Keep position open, stop order active
    # Only proceed if verified

Why MARKET Order Didn't Fill: - Time: 03:44:18 UTC = 10:44 PM ET (NYSE fully closed) - Order: MARKET with tif='DAY' and outsideRth=True - Problem: No liquidity at that hour, order stayed in PreSubmitted

Fix Applied (4 areas):

  1. _handle_instant_exit - Check exit_verified before closing
  2. execute_scheduled_exit - Check exit_verified before closing
  3. _execute_batch_exit - Check exit_verified before closing
  4. place_scheduled_exit - Add exit verification (was missing)

Stop Order Cancellation Fix: Previous code cancelled stop orders BEFORE exit was verified. Now: - Stop orders are only cancelled AFTER exit is verified - If exit fails, stop order remains active as protection

Files Modified: - src/earnings/trading_orchestrator.py (3 exit handlers) - src/earnings/order_executor.py (place_scheduled_exit + place_instant_exit)

Safety Principle: NEVER mark a position as closed until IBKR confirms it's flat. result.success means order submitted, NOT position closed.


7.26 Stop Order & Market Hours Root Cause Analysis (Jan 30, 2026 - Finding #90)

Trigger: Full audit revealed orphaned stop orders (IBM), duplicate stop orders (V, AAPL), and stuck market order (XOM).

Three Root Causes Identified:

90A: Orphaned Stop Orders (IBM id=4)

Problem: When IBM closed via scheduled_exit, stop order id=4 was NOT cancelled.

Root Cause: _cancel_stop_orders_for_symbol() relied solely on ib.openTrades() to find orders, but: - openTrades() can miss PreSubmitted orders due to timing - The tracked stop_orders[symbol] dictionary was ignored - No fallback when openTrades() returned nothing

Fix:

# Now uses tracked order ID as PRIMARY method
if symbol in self.order_executor.stop_orders:
    tracked_stop_id = self.order_executor.stop_orders[symbol]
    # Cancel by order ID (most reliable)
    for trade in ib.openTrades():
        if trade.order.orderId == tracked_stop_id:
            ib.cancelOrder(trade.order)
    # Clear dictionary
    del self.order_executor.stop_orders[symbol]

# FALLBACK: Also search by symbol (catches duplicates)
# Retry 3 times with increasing delays

90B: Duplicate Stop Orders (V, AAPL)

Problem: V had 2 stop orders (1529 + 2296), AAPL had 2 (2271 + 2299).

Root Cause: _place_stop_order() never checked for existing stops: - Both batch and single entry paths could place stops - stop_orders[symbol] just overwrote previous order ID - Old stop order left orphaned in IBKR

Fix:

# Before placing new stop, check and cancel existing
if symbol in self.stop_orders:
    existing_stop_id = self.stop_orders[symbol]
    # Cancel existing before placing new
    for trade in ib.openTrades():
        if trade.order.orderId == existing_stop_id:
            ib.cancelOrder(trade.order)
    del self.stop_orders[symbol]

# Also check IBKR for untracked stops
for trade in ib.openTrades():
    if trade.contract.symbol == symbol and "STP" in trade.order.orderType:
        ib.cancelOrder(trade.order)  # Cancel untracked stop

90C: Stuck Market Order (XOM id=36979)

Problem: MARKET SELL order placed at 10:44 PM ET stuck in PreSubmitted forever.

Root Cause: _is_extended_hours() returned False when market was CLOSED (8PM-4AM): - Code treated "not extended hours" as "regular hours" - Attempted MARKET fallback which can't fill when market is closed - No detection of fully closed market

Fix:

def _is_market_closed(self) -> bool:
    """Check if market is fully closed (8PM-4AM ET, weekends)."""
    ET = ZoneInfo("America/New_York")
    now = datetime.now(ET)

    if now.weekday() >= 5:  # Weekend
        return True

    hour = now.hour
    if hour >= 20 or hour < 4:  # 8PM-4AM
        return True

    return False

# In place_instant_exit():
if is_closed:
    # DO NOT use MARKET fallback - it will get stuck!
    # Keep GTC LIMIT active, will fill at next open
    return ExecutionResult(
        success=True,
        exit_verified=False,  # Position NOT closed yet!
        error="Market closed - GTC LIMIT pending"
    )

Market Status Logic:

regular:     9:30 AM - 4:00 PM ET (MARKET OK)
pre_market:  4:00 AM - 9:30 AM ET (LIMIT preferred)
after_hours: 4:00 PM - 8:00 PM ET (LIMIT preferred)
closed:      8:00 PM - 4:00 AM ET, weekends (NO MARKET!)

Files Modified: - src/earnings/trading_orchestrator.py (_cancel_stop_orders_for_symbol) - src/earnings/order_executor.py (_place_stop_order, _is_market_closed, place_instant_exit)

Key Principles: 1. Use tracked order IDs for cancellation, openTrades() as fallback 2. Cancel existing stops before placing new ones (no duplicates) 3. Detect fully closed market - NEVER use MARKET orders when closed 4. Always set exit_verified=False if order can't fill immediately 5. Skip software stop backup during closed market hours - GTC hardware stops will handle it

Additional Fix: check_stop_loss_backup() now skips execution when _is_market_closed() returns true. This prevents the loop from repeatedly creating exit orders that can never fill.


7.27 Stop Orders Dictionary Not Populated on Startup (Jan 30, 2026 - Finding #91)

Trigger: XOM LIMIT orders kept appearing on service restart despite stop order existing in IBKR.

Root Cause Analysis: After service restart, order_executor.stop_orders dictionary was empty:

Service starts
→ Positions loaded from DB (stop_order_id may be NULL)
→ stop_orders dictionary is EMPTY
→ XOM price below stop → software backup triggers
→ _check_hardware_stop_status() checks stop_orders dict → NOT FOUND
→ Returns False (even though stop 36981 exists in IBKR!)
→ Executes exit → Creates LIMIT order
→ Market closed → LIMIT stuck in PreSubmitted
→ Loop repeats → More spurious orders

Two Gaps Found:

Gap Description
Gap 1 _load_persisted_positions() did NOT repopulate stop_orders from DB
Gap 2 _check_hardware_stop_status() only checked in-memory dictionary, not IBKR

Fixes Applied:

Fix A: _repopulate_stop_orders_on_startup() (NEW FUNCTION)

def _repopulate_stop_orders_on_startup(self):
    # Step 1: Load from database stop_order_id column
    for row in db.execute("SELECT symbol, stop_order_id WHERE stop_order_id IS NOT NULL"):
        self.order_executor.stop_orders[symbol] = stop_order_id

    # Step 2: For positions without DB stop_order_id, query IBKR
    for trade in ib.openTrades():
        if symbol in positions_without_stops and "STP" in order_type:
            self.order_executor.stop_orders[symbol] = trade.order.orderId
            # Also update DB with discovered ID
            db.update("SET stop_order_id = ? WHERE symbol = ?")

Fix B: Enhanced _check_hardware_stop_status()

def _check_hardware_stop_status(self, symbol):
    # Method 1: Check by tracked order ID (fastest)
    if stop_orders.get(symbol):
        # Check IBKR by order ID

    # Method 2: Fallback - search IBKR by symbol
    # This catches stops that exist in IBKR but aren't in dictionary
    for trade in ib.openTrades():
        if trade.contract.symbol == symbol and "STP" in order_type:
            stop_orders[symbol] = trade.order.orderId  # Auto-populate!
            return True

Verification: - Before fix: All positions had stop_order_id = NULL in DB - After fix: All positions have correct stop_order_id values - No more spurious XOM LIMIT orders on restart

Files Modified: - src/earnings/trading_orchestrator.py: - _load_persisted_positions() - calls new repopulation function - _repopulate_stop_orders_on_startup() - NEW - _check_hardware_stop_status() - IBKR fallback search

Key Principle: State that persists in IBKR must be discoverable on startup. Don't rely on in-memory dictionaries surviving restarts.


7.28 Dashboard Reality Gap - IBKR Overlay (Jan 30, 2026 - Finding #92)

Trigger: Dashboard showed a position labeled as reconciliation, stop-loss values did not match IBKR, and IBKR-only positions were invisible.

Root Cause Analysis: 1. Dashboard rendered positions from DB even when IBKR was missing them. 2. Stop-loss values were DB-only, with no verification of IBKR stop orders. 3. Reconciliation trades were filtered out of the UI entirely.

Fixes Applied: - Added IBKR Reality Overlay (DB vs IBKR positions + stop orders + reconciliation list) - Stop order verification now pulls IBKR open stop orders (STP / STP LMT) - Added cache age/staleness indicator for IBKR snapshot

Files Modified: - earnings_dashboard/app.py (IBKR snapshot + overlay) - earnings_dashboard/templates/dashboard.html (UI + testing findings)

Key Principle: For live trading, the dashboard must surface IBKR reality, not just database state.


7.29 Findings Log Completeness & Dashboard Doc Sync (Jan 30, 2026 - Finding #93)

Trigger: The dashboard findings tab was hard to verify quickly and the order-type summary no longer matched live NYSE stop-loss behavior.

Root Cause Analysis: 1. Findings were static and not searchable, making post-audit verification slow. 2. The order-type summary still described NYSE stops as STOP→MARKET in regular hours, which is no longer true after the NYSE STOP-LIMIT fix. 3. Dashboard deployment fixes (context guard + service binding) were not captured in the findings log.

Fixes Applied: - Added a findings filter + live count for rapid verification. - Updated the order-type summary to reflect NYSE STOP-LIMIT across sessions. - Documented dashboard deployment fixes (context guard + service binding). - Findings log updated to include all post-audit changes.

Files Modified: - earnings_dashboard/templates/dashboard.html - docs/STRATEGY.md

Key Principle: Documentation and dashboards must always reflect live trading logic and the full audit trail.


7.30 XOM Reconciliation Drift - History vs IBKR Reality (Jan 30, 2026 - Finding #94)

Trigger: The dashboard showed two XOM rows in trade history and an open XOM position that did not exist in IBKR.

Root Cause Analysis: 1. Reconciliation-added positions were inserted into trade_audit_log without a reconciliation entry tag. 2. The trade history filter only excluded reconciliation on exit_reason, so open reconciliation rows appeared as real trades. 3. Reconciliation only ran on startup, so DB could remain open after IBKR positions disappeared.

Fixes Applied: - Tag reconciliation-added positions with entry_reason=reconciliation:external_position. - Trade history now excludes reconciliation-tagged entries. - Added periodic reconciliation (every 5 minutes) to close orphaned DB positions.

Files Modified: - src/earnings/trading_orchestrator.py - earnings_dashboard/app.py - earnings_dashboard/templates/dashboard.html

Key Principle: IBKR is the source of truth; DB state must converge automatically and history must exclude reconciliation artifacts.


7.31 Trade Log Accuracy - Slippage, Fees, Order Types, Times (Jan 30, 2026 - Finding #95)

Trigger: Trade log showed zero slippage and inconsistent order types (e.g., AXP) and lacked ET entry/exit times and position sizing.

Root Cause Analysis: 1. Entry/exit slippage existed in execution results but was never persisted to trade_audit_log. 2. Batch order execution did not track commissions or slippage. 3. Trade log rendered slippage percent using dollar fields, causing 0% display. 4. Entry/exit times were displayed without ET conversion. 5. position_size_pct and capital_at_entry were never stored.

Fixes Applied: - Persist entry/exit slippage, limit price, order IDs, and commissions to trade_audit_log. - Batch orders now compute slippage and fetch commission details by order ID. - Trade log uses percent slippage fields and shows entry/exit times in ET. - Position column now uses position_value and position_size_pct.

Files Modified: - src/ibkr/client.py - src/earnings/batch_order_manager.py - src/earnings/order_executor.py - src/earnings/trading_orchestrator.py - earnings_dashboard/app.py - earnings_dashboard/templates/dashboard.html

Key Principle: Trade history must reflect execution reality (order types, fees, slippage) with consistent ET timestamps.


7.32 Historical Trade Backfill - IBKR Executions (Jan 30, 2026 - Finding #96)

Trigger: Historical trades lacked commissions, order types, and slippage because those fields were never persisted at the time of execution.

Root Cause Analysis: 1. Execution details were not stored for early trades. 2. Batch orders did not capture slippage/commission per order. 3. IBKR execution data is only retrievable via API, not DB.

Fix Applied: - Added scripts/backfill_trade_audit_from_ibkr.py to backfill historical trade_audit_log entries using IBKR executions (read-only). - Fills missing order IDs, order types, commissions, limit prices, and slippage when possible.

Operator Notes: - Run with --commit to write; otherwise dry-run. - Use a tight time window and confirm IBKR gateway is available.

Files Added: - scripts/backfill_trade_audit_from_ibkr.py

Key Principle: Historical correctness matters; if execution data was missed, backfill from IBKR as the authoritative source.


7.33 Historical Trade Backfill - Flex Query (Jan 30, 2026 - Finding #97)

Trigger: Live IBKR API only returns same-day executions, preventing full historical backfill.

Root Cause Analysis: 1. reqExecutions is session-limited and does not expose older fills. 2. Historical fills require IBKR Flex Query export.

Fix Applied: - Added scripts/backfill_trade_audit_from_flex.py to backfill all historical trades using Flex Query (token + query ID). - Matches executions by order ID first, then symbol/side/time window. - Populates missing commissions, order types, order IDs, and slippage where limit price is available. - Normalizes Flex commission sign (IBKR reports negative commissions) to ensure fees populate correctly.

Operator Notes: - Requires IBKR_FLEX_TOKEN + IBKR_FLEX_QUERY_ID env vars. - Run with --commit to write; otherwise dry-run. - Flex API errors now surface immediately (no silent timeouts). - Optional: --xml-file /path/to/statement.xml to backfill from a downloaded Flex file.

Files Added: - scripts/backfill_trade_audit_from_flex.py

Key Principle: Use IBKR Flex Query for historical execution truth when live API is insufficient.


7.34 Upcoming Earnings Table Regression - Gamma Batch Fetch (Jan 30, 2026 - Finding #98)

Trigger: Dashboard "Upcoming Earnings with Polymarket Data" table showed missing market caps, mostly SKIP priority, and unreliable conviction (0%/100%).

Root Cause Analysis: 1. Gamma API batch requests used comma-separated condition_ids, which now return empty results. 2. With empty Gamma data, the UI fell back to DB snapshots and marked entries as unreliable. 3. Market cap tier from DB included mega, which was treated as unknown, forcing priority to 0 (SKIP). 4. MCap column displayed -- because it required a numeric market cap value, not a tier.

Fixes Applied: - Use repeated condition_ids params for Gamma batch fetch (API-compatible). - Map mega → Large before priority scoring. - Render MCap column using tier when numeric cap is missing.

Files Modified: - earnings_dashboard/app.py - earnings_dashboard/templates/dashboard.html

Key Principle: External API behavior can change; use robust parameterization and normalize DB tiers before scoring.


7.35 Live Data Freshness & IBKR Position Truth (Jan 30, 2026 - Finding #99)

Trigger: The system treated DB state as truth for live positions and silently used stale snapshot data for Polymarket prices.

Root Cause Analysis: 1. trading_positions was treated as the primary live state for dashboard positions, even when IBKR diverged. 2. Gamma API failures fell back to DB snapshots without staleness validation.

Fixes Applied: - IBKR snapshot now acts as the primary live position source when fresh; DB-only positions are marked as orphans and excluded from totals. - Added staleness detection for Polymarket price snapshot fallback and exposed data_stale/data_age_min. - Dashboard conviction cell displays STALE when fallback data is too old.

Files Modified: - earnings_dashboard/app.py - earnings_dashboard/templates/dashboard.html

Key Principle: Live state must be broker‑truth and price‑truth; DB is cache with explicit freshness checks.


7.36 AXP Earnings Timing Correction (Jan 30, 2026 - Finding #100)

Trigger: Dashboard showed AXP as AMC, but NASDAQ earnings calendar showed BMO.

Root Cause Analysis: 1. EarningsTimingManager.DEFAULT_TIMINGS listed AXP as AMC. 2. Sync jobs only filled NULL timing values, so the incorrect timing persisted once written.

Fixes Applied: - Added an explicit override for AXP to BMO in src/earnings/timing_data/overrides.json. - Sync paths now allow override timing to update existing earnings_markets.earnings_time. - Backfilled current AXP record in earnings_markets to BMO.

Files Modified: - src/earnings/timing_data/overrides.json - src/db/earnings_sync.py - src/data_tracker/collectors/earnings_market_fetcher.py - earnings_dashboard/templates/dashboard.html

Key Principle: Overrides are authoritative corrections and must be allowed to update existing DB timing.


7.37 IBKR Execution Source-of-Truth Pipeline (Jan 30, 2026 - Finding #101)

Trigger: Trade log data drifted from IBKR reality (missing commissions, unclear order types, stop-loss slippage not visible).

Root Cause Analysis: 1. trade_audit_log was populated directly from execution results without a canonical execution ledger. 2. Flex reports provide commissions/order IDs but do not include limit/aux prices for slippage validation. 3. Stop-loss slippage was calculated but stored only in trading_positions, not displayed in the trade log.

Fixes Applied: - Added ibkr_executions table as the raw execution ledger (source-of-truth). - Live entry/exit executions now persist to ibkr_executions and populate exec IDs + exchange in trade_audit_log. - Flex backfill now stores executions into ibkr_executions for historical reconciliation. - Stop-loss slippage (exit_actual_slippage) is persisted in trade_audit_log and shown in the main slippage column when applicable.

Files Modified: - src/data_tracker/migrations/015_ibkr_executions.sql - src/data_tracker/migrations/016_trade_audit_exit_actual_slippage.sql - src/earnings/trading_orchestrator.py - scripts/backfill_trade_audit_from_flex.py - earnings_dashboard/app.py - earnings_dashboard/templates/dashboard.html

Key Principle: IBKR executions are canonical; trade log must reconcile to broker‑truth and explicitly surface stop-loss slippage.


7.38 Daily Trade Reconciliation Job (Jan 30, 2026 - Finding #102)

Trigger: Need continuous verification that trade_audit_log remains aligned to the canonical ibkr_executions ledger.

Root Cause Analysis: 1. No automated daily check to flag missing exec IDs or mismatched links after persistence. 2. Drift could go unnoticed between manual audits.

Fixes Applied: - Added a lightweight trade reconciler that compares recent trade_audit_log entries to ibkr_executions. - Alerts on missing/mismatched exec IDs, symbol/side, order ID, and exchange. - Persists reconciliation results to collection_jobs and surfaces status in the System dashboard. - Added a daily systemd timer and dedicated log file for reconciliation runs.

Files Modified: - src/data_tracker/collectors/trade_reconciler.py - deploy/systemd/polymarket-trade-reconcile.service - deploy/systemd/polymarket-trade-reconcile.timer - deploy/install_services.sh - earnings_dashboard/app.py - earnings_dashboard/templates/dashboard.html

Key Principle: Broker-truth must be continuously verified, not only at trade time.


7.39 IBKR Reconciliation Auto-Fix (Jan 30, 2026 - Finding #103)

Trigger: Trade reconciliation alerts and IBKR overlay showed mismatches but did not auto-fix or report combined status.

Root Cause Analysis: 1. Trade reconciliation only detected issues; it did not attempt safe fixes from the execution ledger. 2. IBKR position overlay surfaced mismatches but relied on the trading service for fixes. 3. Dashboard lacked a unified reconciliation status for trades + positions.

Fixes Applied: - Auto-fix missing exec ID links when a safe, unambiguous match exists in ibkr_executions. - Added automated IBKR position reconciliation that applies fixes and reports before/after status. - Unified status reporting for trade log + positions in the System dashboard. - Switched reconciliation timer to frequent cadence for continuous broker-truth alignment. - Fixed reconciliation logging format error so auto-fix runs to completion.

Files Modified: - src/data_tracker/collectors/trade_reconciler.py - src/earnings/trading_orchestrator.py - deploy/systemd/polymarket-trade-reconcile.timer - earnings_dashboard/app.py - earnings_dashboard/templates/dashboard.html

Key Principle: Reconciliation must be corrective, continuous, and visible.


7.40 PDT Reserve Capital Exclusion (Feb 2, 2026 - Finding #104)

Trigger: User deposited $25,000 to IBKR to avoid Pattern Day Trader restrictions but wanted to continue testing with only the original ~$2,700 capital. The system was using full IBKR net_liquidation for position sizing.

Root Cause Analysis: 1. run_earnings_trader.py fetched IBKR net_liquidation and passed it directly as trading capital. 2. RiskManager.sync_from_ibkr() synced IBKR balance without excluding any reserve. 3. Multiple entry points (__init__.py docs, trade_reconciler.py) also used net_liquidation directly.

Fixes Applied: 1. Added PDT_RESERVE_CAPITAL environment variable (default: $25,000). 2. Modified connect_ibkr() to calculate: trading_capital = max(0, ibkr_balance - PDT_RESERVE_CAPITAL). 3. Modified RiskManager.sync_from_ibkr() to apply same formula during live syncs. 4. Updated src/earnings/__init__.py documentation example. 5. Updated src/data_tracker/collectors/trade_reconciler.py to use adjusted capital.

Code Changes:

# run_earnings_trader.py
PDT_RESERVE_CAPITAL = float(os.environ.get("PDT_RESERVE_CAPITAL", "25000"))

# In connect_ibkr():
ibkr_balance = account_info.net_liquidation
self.capital = max(0, ibkr_balance - PDT_RESERVE_CAPITAL)

# risk_manager.py - sync_from_ibkr():
trading_capital = max(0, account_info.net_liquidation - PDT_RESERVE_CAPITAL)
self.update_capital(trading_capital)

Verification: - Logs show: "PDT Reserve: $25,000 (excluded from trading)" - Logs show: "RiskManager initialized: $X trading capital (PDT reserve: $25,000)" - Position sizing uses reduced trading capital, not full IBKR balance.

Configuration:

# Set PDT reserve (in .env or environment)
PDT_RESERVE_CAPITAL=17450   # FUND-1 (Aug 20, 2026, #366); was 25000 Feb 2 – Aug 20, 2026

# To use full capital (disable reserve):
PDT_RESERVE_CAPITAL=0       # DANGER: GUARD_REGISTRY flags any value < 17000

UPDATE (Aug 20, 2026 — FUND-1 capital step, Finding #366): the reserve is a pure code-side sizing subtraction — no money moves. The FINRA PDT floor is total account equity ≥ $25,000 (= reserve + trading capital), not the reserve value itself. The reserve was lowered 25,000 → 17,450, raising trading capital to ~$10,015 at the verified $27,465.52 equity (PDT margin ~$2,465). Code defaults now read "17450"; PDT_RESERVE_CAPITAL is floored at ≥ 17,000 in GUARD_REGISTRY. Do NOT withdraw the reserve (§14.6 of the revalidation: a sub-$25k account breaches the day-trade limit in the first stop-cluster week). The narrative above is the historical Feb 2 record.

Dashboard Updates (Feb 2, 2026): 1. Added trading_capital and pdt_reserve to dashboard state. 2. Dashboard header now shows both "IBKR Balance" and "Trading Capital". 3. Trading Capital has hover tooltip explaining PDT reserve. 4. Exposure % now calculated using trading capital, not total IBKR balance. 5. Exposure % tooltip updated to clarify it excludes PDT reserve.

Files Modified: - run_earnings_trader.py - src/earnings/risk_manager.py - src/earnings/__init__.py - src/data_tracker/collectors/trade_reconciler.py - earnings_dashboard/app.py - earnings_dashboard/templates/dashboard.html - [hidden]/.env (server)

Key Principle: Capital configuration must be explicit and consistently applied across all entry points (startup, sync, reconciliation, dashboard display).

7.41 IBKR Reconciliation Connection Retry (Feb 2, 2026 - Finding #105)

Trigger: Dashboard showed "Positions Status: Error" and "Stop-Loss Status: IBKR client not available" after the midnight reconciliation job failed to connect to IBKR Gateway.

Root Cause Analysis: 1. The trade_reconciler.py script ran at midnight UTC (7:00 PM ET). 2. At that time, IBKR Gateway had a transient connection issue (possibly post-restart). 3. The script attempted a single connection, failed, and proceeded without IBKR data. 4. The failed reconciliation job metadata was stored with error messages. 5. Dashboard reads the most recent job metadata, showing stale error state.

Evidence: - Job 260 (Feb 2, 00:00 UTC): positions.error = "Failed to connect to IBKR Gateway" - Job 227 (Feb 1, 00:36 UTC): Both positions and stop_loss status = "ok" (IBKR connected) - Manual test at 10:08 AM ET: IBKR connection with client_id=99 succeeded immediately.

Fixes Applied:

  1. Added IBKR connection retry with escalating backoff:
  2. Attempts 1-4: 0s, 2s, 4s, 8s delays
  3. After 4th failure: 5 minute wait
  4. Attempts 5-6: 2s, 4s delays
  5. After 6th failure: 10 minute wait
  6. Attempt 8: final try
  7. Total: up to 8 attempts over ~16 minutes max

  8. Changed reconciliation timer to safer hours:

  9. Old: OnCalendar=daily (midnight UTC / 7 PM ET)
  10. New: OnCalendar=*-*-* 08:00:00 (8 AM UTC / 3 AM ET)
  11. Rationale: 3 AM ET is during market-closed hours when IBKR Gateway is stable.

Code Changes:

# src/data_tracker/collectors/trade_reconciler.py - main()
max_attempts = 8
backoff_schedule = {
    1: 0,      # First attempt, no delay
    2: 2,      # 2 seconds
    3: 4,      # 4 seconds
    4: 8,      # 8 seconds
    5: 300,    # 5 minutes (4th retry failed)
    6: 2,      # 2 seconds
    7: 4,      # 4 seconds
    8: 600,    # 10 minutes (6th retry failed)
}

for attempt in range(1, max_attempts + 1):
    delay = backoff_schedule.get(attempt, 0)
    if delay > 0:
        time.sleep(delay)
    # ... connection attempt ...

Timer Update:

# /etc/systemd/system/polymarket-trade-reconcile.timer
OnCalendar=*-*-* 08:00:00

Verification: - Next reconciliation scheduled for Feb 3, 08:00 UTC (3:00 AM ET). - Manual reconciliation run at 10:08 AM ET succeeded with IBKR connected. - Dashboard now shows "Positions Status: OK".

Files Modified: - src/data_tracker/collectors/trade_reconciler.py - /etc/systemd/system/polymarket-trade-reconcile.timer (server)

Key Principle: For scheduled background jobs that depend on external services, implement retry with escalating backoff and schedule during low-activity periods to maximize reliability.


7.42 Reconciliation Disconnection Guard (Feb 2, 2026 - Finding #106)

Trigger: AAPL trade disappeared from trade history log, stop-loss missing, but position still existed in IBKR (should have been closed Friday).

Root Cause Analysis: 1. At 06:00:01 UTC (Feb 2), IBKR connection dropped ("Peer closed connection"). 2. At 06:00:08 UTC, _reconcile_positions_with_ibkr() ran while IBKR was disconnected. 3. The function called ib.positions() which returned empty list (due to disconnection). 4. Reconciliation incorrectly marked AAPL position (ID 48) as "orphaned" and closed it in DB. 5. At 06:01:12 UTC, service reconnected to IBKR. The actual AAPL position was still held in IBKR. 6. At 06:01:13 UTC, another reconciliation ran. Now seeing AAPL in IBKR but not in DB. 7. It added AAPL as a new "external position" (ID 54) with stop_price=NULL. 8. Result: Original trade history lost, stop price missing, data integrity compromised.

Evidence from logs:

06:00:01 - Peer closed connection
06:00:08 - ⚠️ ORPHANED DB POSITION: AAPL - closing with P&L calculation
06:00:08 - Closed orphaned AAPL (P&L: $0.00)
06:01:12 - IB connected
06:01:13 - ⚠️ ADDING EXTERNAL POSITION: AAPL LONG 1 shares

Fixes Applied:

  1. Guard reconciliation against disconnected IBKR: Added connection check at start of _reconcile_positions_with_ibkr(). If IBKR is disconnected, skip reconciliation and send alert.

  2. Sanity check for zero IBKR positions: If IBKR returns 0 positions but DB has open positions, abort reconciliation immediately. This catches degraded connections or silent API failures.

  3. Repair current AAPL position:

  4. Deleted fake external position (ID 54) and its trade_audit_log entry (ID 20)
  5. Restored original position (ID 48) by setting status='open', clearing exit fields
  6. Updated stop_order_id from 2271 to 2299 (current IBKR order)
  7. Restored original trade_audit_log (ID 14)

  8. Populate stop_price for external positions: When adding external positions, now queries IBKR for existing stop orders and populates stop_price, stop_order_id, and stop_order_type in both trading_positions and trade_audit_log.

Code Changes:

# src/earnings/trading_orchestrator.py - _reconcile_positions_with_ibkr()

# Fix 1: Connection guard at function start
if not ibkr_client.ib.isConnected():
    logger.warning("🚫 IBKR not connected - SKIPPING reconciliation")
    return

# Fix 2: Sanity check after getting IBKR positions
if len(ibkr_positions) == 0 and len(db_positions) > 0:
    logger.critical(f"🚨 RECONCILIATION ABORTED: IBKR returned 0 positions but DB has {len(db_positions)}")
    return

# Fix 4: Query stop orders when adding external positions
ibkr_stop_orders = {}
for trade in ibkr_client.ib.openTrades():
    if "STP" in trade.order.orderType:
        ibkr_stop_orders[trade.contract.symbol] = {
            'order_id': trade.order.orderId,
            'stop_price': trade.order.auxPrice
        }
# Then populate stop_price when inserting external position

DB Repair Commands:

-- Delete fake entries
DELETE FROM trade_audit_log WHERE id = 20;
DELETE FROM trading_positions WHERE id = 54;

-- Restore original position
UPDATE trading_positions 
SET status = 'open', exit_time = NULL, exit_price = NULL, 
    exit_reason = NULL, stop_order_id = 2299
WHERE id = 48;

UPDATE trade_audit_log SET status = 'open', exit_time = NULL, exit_price = NULL
WHERE id = 14;

Verification: - AAPL position ID 48 now shows status='open', stop_order_id=2299 - AAPL trade_audit_log ID 14 now shows status='open' - Dashboard correctly displays AAPL as open position with stop loss

Files Modified: - src/earnings/trading_orchestrator.py

Key Principle: Never modify database state based on external API data without first verifying the API connection is healthy and data is complete. Empty results from external APIs should trigger alerts, not deletions.

7.43 Stale Conviction Data - Gamma API Batch Reliability (Feb 2, 2026 - Finding #107)

Trigger: Dashboard showing "STALE" conviction for markets like NXPI, TSN, APTV while PLTR and DIS showed fresh data.

Root Cause Analysis: 1. Dashboard fetches conviction data from Gamma API using batch endpoint: /markets?condition_ids=A&condition_ids=B&... 2. Gamma API batch endpoint silently drops markets from responses - requesting 25 condition_ids returns only ~20. 3. Markets not returned in batch fall back to price_snapshots table. 4. If price_snapshots data is >5 minutes old, dashboard shows "STALE".

Evidence:

Batch request: 25 condition_ids
Gamma returned: 20 markets
Missing: APTV, NXPI, TSN, MRK, PYPL (5 markets)

Individual fetch for same markets: ALL SUCCEED

Fix Applied: Two-Phase Fetch Strategy

Phase 1: Batch fetch (unchanged, fast) - Request up to 25 condition_ids at once - Most markets returned successfully

Phase 2: Individual fallback for missing markets - After batch, identify which condition_ids are missing - Fetch each missing market individually - Individual fetches are reliable (never seen to fail for valid markets)

Code Changes:

# earnings_dashboard/app.py - fetch_gamma_markets_batch()

# Phase 1: Batch fetch (unchanged)
for i in range(0, len(ids), chunk_size):
    chunk = ids[i:i + chunk_size]
    resp = requests.get(
        'https://gamma-api.polymarket.com/markets',
        params=[('condition_ids', cid) for cid in chunk],
        timeout=10
    )
    # ... parse and add to markets_by_condition

# Phase 2: Individual fetch for missing markets
missing_ids = [cid for cid in ids if cid not in markets_by_condition]
if missing_ids:
    logger.debug(f"Gamma batch missed {len(missing_ids)}/{len(ids)} markets")
    for cid in missing_ids:
        resp = requests.get(
            'https://gamma-api.polymarket.com/markets',
            params=[('condition_ids', cid)],
            timeout=5
        )
        if resp.status_code == 200:
            markets = resp.json()
            if isinstance(markets, list) and markets:
                markets_by_condition[markets[0]['conditionId']] = markets[0]

Additional Bug Found During Fix:

After implementing the two-phase fetch, APTV showed "50%" instead of the correct "0%". Root cause: Template condition e.conviction and e.has_market treats 0.0 as falsy, falling through to display "50%".

Template Fix:

# Before (buggy - 0.0 is falsy)
{% elif e.conviction and e.has_market %}

# After (correct - explicitly checks for None)
{% elif e.conviction is not none and e.has_market %}

Fixed 4 instances of this pattern in dashboard.html.

Verification: - Dashboard should now show fresh conviction for ALL markets (no STALE badges) - 0% conviction markets (like APTV) should display "0.0%" not "50%" - Same Pattern Check: Only earnings_dashboard/app.py uses condition_ids batch pattern

Files Modified: - earnings_dashboard/app.py - earnings_dashboard/templates/dashboard.html

Key Principle: External API batch endpoints can be unreliable. Always have a fallback strategy (individual fetches) for critical data that must be complete. Also, in templates, use is not none instead of truthy checks for numeric values that can legitimately be 0.


7.44 IBKR Stop Order Blocks Exit Order (Feb 5, 2026 - Finding #132)

Trigger: ARES position failed to instant exit despite missing earnings. The exit order was rejected by IBKR.

Root Cause Analysis:

  1. ARES had a LONG position with 1 share
  2. A SELL stop order was active in IBKR for protection
  3. When instant exit triggered, it tried to place a SELL exit order
  4. IBKR rejected: total sell quantity (stop + exit) exceeded position size
  5. Exit order was cancelled by IBKR, position remained open

Timeline:

07:02:33 - Instant exit triggered for ARES (missed earnings)
07:02:33 - SELL stop order active (orderId in self.stop_orders)
07:02:34 - Exit order placed: SELL 1 share
07:02:34 - IBKR rejects: conflicting orders for same position
07:02:34 - Exit order status: Cancelled
07:02:34 - Position remains open (unprotected after finding)

Previous Logic (Finding #89): - "Do NOT cancel stop order before exit is verified" - Rationale: Keep protection until exit confirmed - Problem: IBKR never accepts the exit order in the first place

New Logic (Finding #132): 1. Cancel stop order BEFORE placing exit 2. Store stop order details for restoration 3. Place exit order (IBKR now accepts it) 4. If exit fails or unverified → restore stop order 5. If exit verified → stop stays cancelled (position closed)

Code Changes:

src/earnings/order_executor.py:

# In place_instant_exit() and place_scheduled_exit():

# Before exit order:
cancelled_stop_info = None
if not self.paper_trading:
    cancelled_stop_info = self._cancel_stop_for_exit(symbol, direction)
    if cancelled_stop_info:
        logger.info(f"🛡️ Temporarily cancelled stop for {symbol}")

# Place exit order...

# After exit verification:
if cancelled_stop_info and not result.exit_verified and not self.paper_trading:
    logger.warning(f"⚠️ Exit not verified for {symbol}, restoring stop order")
    self._restore_stop_order(symbol, cancelled_stop_info, direction)

New Helper Methods: - _cancel_stop_for_exit(symbol, direction) - Cancels stop, returns details for restoration - _restore_stop_order(symbol, stop_info, direction) - Restores stop using saved details

Safety Analysis: - If exit succeeds: Stop is cancelled, position closed - no orphan orders - If exit fails: Stop is restored, position protected - same as before - If restoration fails: Alert sent, manual intervention required

Files Modified: - src/earnings/order_executor.py

Key Principle: When IBKR rejects orders due to conflicting existing orders, the blocking order must be temporarily removed before the new order can be placed. Always have a restoration path for safety-critical orders like stops.


Fix Batch (Feb 15, 2026) - Stop-Fill Execution ID Hardening

Problem

Hardened Solution

  1. Stop-fill cleanup now captures real execution details from IBKR in-memory trades (exec_id, order_id, price, exchange, commission, execution_time) before DB close.
  2. Hardware stop close now persists execution identity into both trade_audit_log and ibkr_executions at close time.
  3. EarningsDetector.stop_monitoring(symbol) is now supported (symbol-scoped path), preventing cleanup exceptions caused by signature mismatch.
  4. Reconciliation auto-fix now includes log-based fallback:
  5. Parses earnings_trader.log fill lines when no DB execution match exists.
  6. Inserts deterministic synthetic execution IDs (logfill.<symbol>.<order_id>.<epoch>) into ibkr_executions.
  7. Backfills ibkr_entry_exec_id / ibkr_exit_exec_id idempotently.
  8. One-time repair utility added: scripts/repair_trade_exec_mismatches.py.

Why This Is Permanent

Files


7.32 Dashboard History Tab Empty (Feb 17, 2026 - Finding #188)

Trigger: History tab showed no trade log rows and zeroed Win/Loss + Direction metrics.

Root Cause Analysis: 1. get_trade_history() query used text("""... {TRADE_METRICS_FILTER_SQL_T} ...""") without f-string interpolation. 2. Postgres received literal {TRADE_METRICS_FILTER_SQL_T} and failed with syntax error at or near "{". 3. Query exception path returned empty trades, cascading to empty/zero history analytics in UI.

Fixes Applied: - Corrected trade history query to text(f"""... {TRADE_METRICS_FILTER_SQL_T} ..."""). - Verified same filter pattern callers still use interpolated SQL in summary/slippage queries. - Confirmed server logs no longer emit syntax error at or near "{" for history requests.

Files Modified: - earnings_dashboard/app.py

Key Principle: Shared SQL filter fragments must always be interpolated before execution; unresolved placeholders are a hard failure and should be treated as production blockers.


7.33 Trader Session vs Gateway Status Separation + Reconnect Hardening (Feb 18, 2026 - Finding #190)

7.34 Reconnect Guard Alert Taxonomy + Retry-on-Ready (Feb 2026 - Finding #196)

Issue: - Reconciliation fail-closed skips during reconnect churn were being surfaced as critical position sync incidents even when no mismatch existed.

What changed: - Reconnect state contract is now explicit: reconnect flags are reset before callbacks run. - Fail-closed reconciliation skips are warning-level operational events (reconciliation_guard), not critical sync failures. - Reconciliation now retries automatically after short delay when skipped by guard, with bounded attempts. - Stall watchdog diagnostics now include stage and thread-dump evidence to accelerate root-cause for restart loops.

Impact: - Reduces false critical pages during expected reconnect transitions. - Preserves fail-closed safety and manual-required semantics for true reconciliation mismatches.

7.35 Robust Conviction Sampling + Reliability Gate (Feb 2026 - Finding #197)

Issue: - Single-point price_snapshots fallback in volatile windows could select a transient outlier and invert trade direction.

What changed: - Conviction fallback now uses windowed robust sampling (median across bounded nearby snapshots) instead of first-point selection. - Reliability metadata is attached to conviction results: - sample_count - window min/max/range - reliability_reason (insufficient_samples, unstable_window, etc.) - Signal generation fail-closes on unreliable conviction windows.

Impact: - Prevents outlier-driven SHORT/LONG flips in noisy windows. - Improves post-trade explainability with explicit reliability context in logs.

Refinement — Jun 11, 2026 (Finding #325): the robust sampler was under-counting genuinely high-conviction markets and let an excluded ADBE LONG (true ~0.97, computed 0.79) through #202. Three changes: (1) the dense sample cap was raised from 9 → 10000 so the median spans the full ±window (9 of ~1,700/±5min let noise dominate; the high-frequency Gamma stream needs the wide cap — a §G prod probe caught an intermediate 600 still collapsing to ~±2min on ADBE); (2) the 0.02 < yes_price < 0.99 filter was removed (it deleted the true high-conviction prints — ADBE's most-common reading was 0.999) — now only resolved 0/1 are excluded; (3) the reliability gate switched from raw min-max range > 0.35 to robust IQR (middle-50% spread) > 0.25 so a flickering-but-stable market keeps its correct median instead of being skipped. Net (378 resolved markets): FEWER unreliable-skips (24→19), zero new tradeable-band skips, conviction-band corrections recovered. Gamma yes_price validated as the best source vs actual_beat (78% directional accuracy) — the bug was the estimator, not the data.

7.36 Unified Close Persistence + High-Confidence Execution-Ledger Recovery (Feb 2026 - Finding #197)

Issue: - Multiple close-persistence implementations increased recurrence risk when one path diverged. - EXEC_LEDGER_ONLY recurrences persisted for broker-flat rows with strong non-stop execution evidence when auto-close was globally disabled.

What changed: - All orchestrator close persistence now routes through PositionManager.persist_position_close_with_exit_details(...). - Reconciliation high-confidence override now supports broker-confirmed non-stop exits under strict guardrails (side/shares/time consistency + execution ID evidence). - Reconciliation execution-ledger close uses the same hardened close path and only then applies reconciliation metadata.

Impact: - Reduces close-path drift and recurrence risk. - Resolves recoverable execution-ledger cases without masking true unresolved mismatches.

Trigger: Dashboard could show gateway as connected while the trading service session (clientId=1) was disconnected/retrying, creating operator confusion and stale confidence.

Root Cause Analysis: 1. Header status semantics mixed two different signals: gateway reachability vs trader session readiness. 2. Reconnect loop reused the same in-process IB object across repeated failures, allowing stale internal state to persist. 3. Snapshot freshness used dashboard cache timing in fast-path, not fetched_at, so stale snapshot data could appear fresh. 4. Alert timestamps were rendered with ET label but left in UTC.

Fixes Applied: - src/ibkr/client.py - Reconnect now resets to a fresh IB() object before retry attempts. - Event handler binding/unbinding is explicit and idempotent (no duplicate callback accumulation). - Reconnect telemetry exposed (reconnect_in_progress, mode, attempt counters, last reconnect error/timestamps). - src/earnings/trading_orchestrator.py - Snapshot extended with trader_status and account summary fields. - earnings_dashboard/app.py - Snapshot freshness now derives from snapshot fetched_at. - Added unified trading_connectivity model with separate gateway_state, trader_session_state, and effective_trading_state. - Removed dashboard startup behavior that spun up its own gateway monitor thread (consume monitor status instead of competing). - /api/trader-status now returns trader session state and reconnect telemetry from trader-owned snapshot. - earnings_dashboard/templates/dashboard.html - Split top status into two explicit indicators: Trader Session and Gateway. - src/earnings/alerts.py - Telegram timestamps now converted to ET before rendering ET.

Files Modified: - src/ibkr/client.py - src/earnings/trading_orchestrator.py - earnings_dashboard/app.py - earnings_dashboard/templates/dashboard.html - src/earnings/alerts.py - tests/unit/test_ibkr_error_block_policy.py - tests/unit/test_dashboard_connectivity_state.py - tests/unit/test_alerts_timezone.py

Key Principle: Treat gateway availability and trader-session readiness as separate operational truths; execution safety depends on trader session state, not socket reachability alone.


MAIN DIRECTIVE

NEVER remove content from documentation unless it is clearly obsolete. Always preserve strategy details, analysis findings, and implementation specifications.