Single source of truth for all findings, fixes, pending validations, and production evidence. The dashboard Testing Findings tab renders from this file via
/api/architecture-findings. Do NOT create finding entries in other files. Seememory.mdfor the documentation rule.
done: implemented and verifiedin_progress: being implementeddeferred: known improvement, not urgent, planned for futureproposed: ready for reviewapproved: approved to implement## Legacy Findings (#1-#105). #106–#221 are numbered list items whose list index differs from the finding number (list 14) = Finding #106). Later entries use ### Finding N — headers; newest sit near the top.1)–13) are not legacy #1–#13. They are a Feb 2 2026 architecture list. The dashboard parser keys Format A by (Finding #N) in the title, not by list index.1) Entry + stop atomicity
- Severity: Critical
- Status: done
- Implementation: Complete in code; verified in live trading Feb 2, 2026.
- Proposed change: Use atomic IBKR order placement for entry+stop (preserve NYSE stop-limit behavior), verify stop placement before confirming entry.
- Files: src/ibkr/client.py
- Live Test Result (Feb 2, 2026 09:34 AM ET):
- PLTR entry filled @ $148.70, stop placed @ $144.23 within 1 second
- Stop Order ID 84904 confirmed in IBKR
- Batch completed: Total: 1, Success: 1, Failed: 0
2) Batch stop placement safety
- Severity: High
- Status: done
- Implementation: Complete in code; verified in live trading Feb 2, 2026.
- Proposed change: Treat stop placement failures as critical (retry, alert, and/or fail batch) so no filled entry is left unprotected.
- Files: src/earnings/batch_order_manager.py
- Live Test Result (Feb 2, 2026 09:34 AM ET):
- Batch ID 319b8adb executed successfully
- Stop placement confirmed for PLTR immediately after entry fill
- Total batch time: 4467ms (submit: 1778ms)
3) Duplicate order prevention / idempotency
- Severity: High
- Status: done
- Implementation: Complete in code; verified in live trading Feb 2, 2026.
- Proposed change: Add per-symbol lock or DB idempotency key before order placement to prevent race duplicates.
- Files: src/earnings/trading_orchestrator.py
- Live Test Result (Feb 2, 2026 09:34 AM ET):
- AAPL correctly skipped: "Existing positions (will skip): {'AAPL'}"
- Also verified via test_duplicate_prevention.py at 04:40 AM ET
4) Market hours gating / holiday handling
- Severity: High
- Status: done
- Implementation: Complete (IBKR liquidHours gating + calendar fallback).
- Proposed change: Gate entry/exit orders to regular market hours using IBKR market hours; allow stop-loss/emergency exits during extended hours.
- Files: src/earnings/order_executor.py and scheduling layer
- Live Test Result (Feb 2, 2026 04:40 AM ET):
- Pre-market test: Entry correctly blocked (market_status: pre_market)
- Instant exit confirmed always allowed
- Tested via test_market_hours_gating.py
5) IBKR error blocking
- Severity: Medium
- Status: done
- Implementation: Complete. trading_block.txt persistence, 1100 transient (cleared on reconnect), 1300 critical (blocks trading). Unit tests in test_ibkr_error_block_policy.py.
- Files: src/ibkr/client.py
6) Collector retries/backoff
- Severity: Medium
- Status: done
- Implementation: Complete. Exponential backoff implemented in all 9 collector files. Running stable for 2+ months.
- Files: src/data_tracker/collectors/continuous_collector.py, src/data_tracker/collectors/live_trades_collector.py, src/data_tracker/collectors/earnings_market_fetcher.py, src/data_tracker/collectors/gamma_collector.py, src/data_tracker/collectors/api_health_checker.py, src/data_tracker/collectors/prices_backfill_service.py, src/data_tracker/collectors/sync_earnings_markets.py, src/data_tracker/collectors/dome_client.py, src/data_tracker/collectors/polymarket_client.py
7) DB-level trade dedupe
- Severity: Medium
- Status: done
- Implementation: Added trade_dedupe table with global unique trade_id; collectors insert into dedupe first, then trades.
- Files: src/data_tracker/collectors/live_trades_collector.py, src/data_tracker/collectors/backfill_trades.py, src/data_tracker/migrations/017_trade_dedupe.sql
8) Backfill state atomic writes
- Severity: Medium
- Status: done
- Implementation: Atomic state writes via temp + fsync + rename.
- Files: src/data_tracker/collectors/prices_backfill_service.py, src/data_tracker/collectors/backfill_trades.py, src/data_tracker/collectors/orderbook_backfill_service.py, src/data_tracker/watchdog_v3.py, src/ibkr/client.py
9) Reconciliation window hybrid schedule
- Severity: Medium
- Status: done
- Implementation: Daily reconciliation timer + monthly full-history timer enabled.
- Files: deploy/systemd/polymarket-trade-reconcile.timer, deploy/systemd/polymarket-trade-reconcile-full.*, deploy/install_services.sh
10) Dashboard slippage weighting + staleness warnings
- Severity: Low
- Status: done
- Implementation: Weighted slippage from trade notional + stale warnings in UI.
- Files: earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html
11) Fractional-share display consistency
- Severity: Low
- Status: done
- Implementation: Preserve fractional shares display with consistent formatting.
- Files: earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html
12) Closed-market instant exit safety
- Severity: Critical
- Status: done
- Implementation: When market is fully closed, instant exit uses GTC LIMIT only (no MARKET fallback).
- Files: src/earnings/order_executor.py, docs/STRATEGY.md, docs/EDGE_CASES.md
13) Orderbook collector partial commit safety
- Severity: High
- Status: done
- Implementation: Commit per-market snapshots to avoid full-batch rollback.
- Files: src/data_tracker/collectors/main_collector.py
Testing Gaps (recommended additions) Unit tests for entry+stop atomicity (including failure cases). Integration tests for batch orders to ensure stop placement success. Reconciliation tests covering old trades and partial fills. Dashboard P&L tests covering fractional shares and stale pricing.
14) Reconciliation Disconnection Guard (Finding #106)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 2, 2026). Guards, sanity checks, and external position stop_price population.
- Root Cause: Reconciliation ran while IBKR was disconnected, incorrectly closing AAPL as "orphaned", then re-adding it as external position without stop_price.
- Fixes:
1. Added IBKR connection guard at start of _reconcile_positions_with_ibkr()
2. Added sanity check: abort if IBKR returns 0 positions but DB has open positions
3. DB repair: restored original AAPL position (ID 48), deleted fake external (ID 54)
4. External position creation now populates stop_price from IBKR stop orders
- Files: src/earnings/trading_orchestrator.py
15) Stale Conviction Data - Gamma API Batch Reliability (Finding #107)
- Severity: High
- Status: done
- Implementation: Complete (Feb 2, 2026). Two-phase fetch: batch + individual fallback + 0% display fix.
- Root Cause: Gamma API batch endpoint (/markets?condition_ids=...) silently drops markets from responses. Requesting 25 condition_ids returns only ~20. Affected markets show "STALE" in dashboard.
- Additional Bug Found: Template used e.conviction and ... which is falsy for 0.0, causing 0% convictions to show as "50%".
- Fixes:
1. Phase 1: Batch fetch (unchanged, fast)
2. Phase 2: Identify missing markets, fetch each individually (reliable)
3. Log warnings when markets are still missing after both phases
4. Fixed template to use e.conviction is not none instead of e.conviction and
- Files: earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html
Tests left to be done post implementation - Finding 12) Trigger instant exit when market is fully closed (8 PM - 4 AM ET): confirm GTC LIMIT only, no MARKET fallback. (Pending - will test if instant exit needed during 8 PM - 4 AM window)
Tests completed
- Finding 1) Live entry+stop test - PASSED Feb 2, 2026 09:34 AM ET: PLTR entry filled, stop placed within 1 second (order ID 84904).
- Finding 1) Second live test - PASSED Feb 2, 2026 03:58 PM ET: PEP @ $155.12 + stop @ $152.01, PFE @ $26.64 + stop @ $26.11 (Batch 34c25f75).
- Finding 2) Live batch entry test - PASSED Feb 2, 2026 09:34 AM ET: Batch 319b8adb completed with stop placement confirmed.
- Finding 2) Second batch test - PASSED Feb 2, 2026 03:58 PM ET: Batch 34c25f75 - 2/2 success, 0 failed, stops placed for both.
- Finding 3) Duplicate prevention test - PASSED Feb 2, 2026 04:40 AM ET: AAPL correctly skipped due to existing position.
- Finding 3) Second duplicate test - PASSED Feb 2, 2026 03:58 PM ET: PLTR correctly skipped (existing position) in batch.
- Finding 4) Market hours gating test - PASSED Feb 2, 2026 04:40 AM ET: Pre-market entry blocked, instant exit allowed.
- Finding 5) Simulated critical IBKR error + block file removal using scripts/verify_retry_backoff_and_block.py.
- Finding 6) Simulated 429/5xx retries across collectors/backfills using scripts/verify_retry_backoff_and_block.py.
- Finding 14) Code review VERIFIED Feb 2, 2026: IBKR connection guard (line 935) + zero position sanity check (line 1001) confirmed in _reconcile_positions_with_ibkr().
- Finding 15) Stale conviction fix VERIFIED Feb 2, 2026: All markets now show fresh data from gamma_api, 0% convictions display correctly.
- Finding 16) IBKR Connection Resilience VERIFIED Feb 3, 2026: IBC auto-login fixed, alert rate limiting added.
16) IBKR Connection Resilience - IBC Auto-Login & Alert Fatigue (Finding #108)
- Severity: High
- Status: done
- Implementation: Complete (Feb 3, 2026). Fixed IBC Java path for auto-login, added alert rate limiting.
- Root Cause: Two issues identified:
1. IBC gatewaystart.sh used system OpenJDK (/usr/lib/jvm/java-17-openjdk-amd64/bin) which lacks JavaFX, causing NoClassDefFoundError: javafx/scene/Parent. IB Gateway restarts without auto-login.
2. Reconciliation skip alert (Finding #106 guard) fired every 5 minutes when IBKR disconnected, causing 50+ alerts overnight.
- Fixes:
1. Updated /opt/ibc/gatewaystart.sh line 33: JAVA_PATH=/opt/i4j_jres/Oda-jK0QgTEmVssfllLP/17.0.10.0.101-zulu/bin (bundled Zulu Java with JavaFX)
2. Switched from ibgateway.service to ibgateway-ibc.service (enabled, tested auto-login + 2FA)
3. Added _last_disconnect_alert_time tracking in trading_orchestrator.py
4. Alert rate limited: only first alert per disconnect episode, then every 30 min
5. Log at DEBUG level for suppressed alerts (issue still visible in logs)
- Files:
- /opt/ibc/gatewaystart.sh (server)
- /etc/systemd/system/ibgateway-ibc.service (enabled)
- src/earnings/trading_orchestrator.py (rate limiting)
17) Hardware/Software Stop Race Condition & Exit Retry Loop (Finding #109)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 3, 2026). IBKR position check before software exit + exit-in-progress tracking.
- Root Cause: Two bugs caused PFE LONG 12 shares to spiral into SHORT 108 shares:
1. Race Condition: Hardware stop #93950 filled at 11:42:49 (sold 12, position flat). At 11:42:51, software stop backup checked _check_hardware_stop_status() which only looks for active stop orders (PreSubmitted/Submitted), not whether position is still open. Since stop had filled (status=Filled), it returned False. Software executed another SELL, creating SHORT position.
2. Exit Retry Loop: Exit verification failed (saw -12 shares instead of 0). Position stayed in active_positions. Main loop (1s interval) kept triggering software stop backup. Each iteration placed another SELL, growing SHORT from -12 to -108 shares.
- Fixes:
1. Added _exit_in_progress set to track symbols with pending exits - prevents retry loops
2. Added _get_ibkr_position_shares() helper to check current IBKR position before software exit
3. Modified check_stop_loss_backup():
- Skip if symbol already in _exit_in_progress
- Before executing: verify IBKR position is not flat (prevents race with hardware stop)
- Verify position direction matches expected (prevents selling into SHORT)
- Add to _exit_in_progress BEFORE executing to prevent loops
4. Added _handle_hardware_stop_fill() to clean up when hardware stop already closed position
5. Clear _exit_in_progress only on successful verified exit
6. Corrected PFE database record: direction=LONG, shares=12, entry=$26.73, exit=$26.13, pnl=-$7.20
- Files:
- src/earnings/trading_orchestrator.py (lines 258-266, 3130-3185, 3396-3445, 3203, 3213)
- Database Fix:
- PFE position ID 57: Corrected from SHORT/108/$26.00 to LONG/12/$26.73
- P&L corrected from -$31.32 to -$7.20
- Prevention:
- Software backup now checks IBKR position before acting
- Exit attempts are tracked to prevent infinite loops
- Direction mismatch triggers critical alert, not automatic action
18) Scheduled Exit DB Update Failure & Early Exit Trigger (Finding #110)
- Severity: High
- Status: done
- Implementation: Complete (Feb 3, 2026). Fixed pg8000 type casting + exit timing threshold.
- Root Cause: Two bugs caused PEP scheduled exit data to be corrupted:
1. DB Update Failed: _persist_position_close_with_exit_details() failed with "invalid input syntax for type integer" when passing Python float slippage values to PostgreSQL numeric columns via pg8000. Position remained "open" in DB.
2. Early Exit Trigger: check_scheduled_exits() used <= 120 threshold (2 minutes). At 9:30:00 AM, time_to_exit=120 and 120<=120 was TRUE, triggering BMO exits 2 minutes early (9:30 instead of 9:32).
3. Reconciliation Overwrite: Because position was still "open" after failed DB update, reconciliation closed it as "orphaned_position" with wrong data ($158.98 exit instead of actual $155.96).
- Fixes:
1. Added to_decimal() helper to cast float values to Decimal before DB insert/update
2. Applied to all slippage parameters: exit_slippage, exit_slippage_pct, exit_limit_price, exit_actual_slippage, entry_slippage, entry_slippage_pct, entry_limit_price
3. Changed check_scheduled_exits() threshold from <= 120 to <= 10 (10-second grace window)
4. Changed _execute_batch_exit() threshold from <= 120 to <= 10 for consistency
5. Added critical alert when DB update fails (instead of silent error log)
6. Corrected PEP database records: exit_price=$155.96, pnl=$1.68, reason=scheduled_exit
- Files:
- src/earnings/trading_orchestrator.py (lines 1997-2010, 2129-2134, 3332-3339, 3924-3928, 1908-1922)
- Database Fix:
- PEP position ID 56 (trading_positions): exit_price=155.96, pnl=1.68, reason=scheduled_exit
- PEP position ID 22 (trade_audit_log): exit_price=155.96, pnl=1.68, reason=scheduled_exit
- Prevention:
- Float-to-Decimal conversion prevents pg8000 type mismatches
- 10-second threshold ensures exits happen at scheduled time, not 2 minutes early
- Critical alerts on DB failures prevent silent data corruption
19) Auto-Fix Data Corruption & Duplicate Shutdown Alerts (Finding #111)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 3, 2026). Converted auto-fix to detection-only + idempotent shutdown + hardware stop DB fix.
- Root Cause: Three issues identified during Feb 3, 2026 incident analysis:
1. Auto-Fix Data Corruption: _reconcile_positions_with_ibkr() automatically "fixed" discrepancies between DB and IBKR by:
- Closing "orphaned" positions (position in DB but not IBKR) with calculated P&L using current price
- Syncing share counts to match IBKR
This caused data corruption in two incidents:
- PFE: Synced 12 shares to 108 (from erroneous exit loop SHORT)
- PEP: Closed as "orphaned" with wrong $7.72 P&L (actual was $1.68)
The auto-fix was dangerous because:
- It didn't know if DB update was just pending (position recently closed correctly)
- It trusted IBKR even when IBKR had erroneous positions (from bugs)
- Calculated P&L used current price, not actual exit price
2. Duplicate Shutdown Alerts: Signal handler called shutdown() then sys.exit(0), but sys.exit() triggers the finally block in run() which also called shutdown(). Result: 2-3 shutdown alerts per shutdown.
- Fixes:
1. Detection-Only Reconciliation:
- Duplicates: Log CRITICAL + alert, no auto-merge
- Orphaned positions: Log CRITICAL + alert, no auto-close
- Share mismatches: Log CRITICAL + alert, no auto-sync
- External positions: STILL auto-added (safe operation - doesn't overwrite data)
2. Alert for Manual Fix: Issues detected send Telegram alert with list of discrepancies requiring manual review
3. Idempotent Shutdown: Added _shutdown_called flag to EarningsTrader.__init__(). shutdown() now checks flag and returns early if already called. Single alert per shutdown guaranteed.
- Files:
- src/earnings/trading_orchestrator.py (lines 916-1325: reconciliation function rewritten)
- run_earnings_trader.py (lines 146, 361-376: idempotent shutdown)
- Additional Bug (AMD):
- _handle_hardware_stop_fill() called non-existent method _close_position_in_db()
- Caused AttributeError, DB not updated, then reconciliation "fixed" with wrong P&L
- Fix: Changed to call _persist_position_close_with_exit_details() instead
- AMD record corrected: exit_price=$244.84, P&L=-$5.00 (not -$6.66)
- Database Fixes:
- PFE position ID 57: direction=LONG, shares=12, exit=$26.13, pnl=-$7.20
- PEP position ID 56/22: exit=$155.96, pnl=$1.68, reason=scheduled_exit
- AMD position ID 58/24: exit=$244.84, pnl=-$5.00, reason=stop_loss:hardware
- Prevention:
- Auto-fix can no longer overwrite correct data with wrong calculated values
- Discrepancies require human review before fixing (check IBKR activity log)
- Shutdown sends exactly one alert regardless of how shutdown is triggered
- Hardware stop cleanup now uses correct DB method
20) IBKR Connection Reliability Overhaul (Finding #112)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 4, 2026). Removed dual state tracking, fixed race conditions, added health verification.
- Root Cause: IBKR Gateway disconnected at 11:25 PM ET on Feb 3 and never reconnected. 7 AMC signals (QCOM, GOOGL, F, etc.) missed execution. Three issues identified:
1. Dual State Tracking: _connected flag and _connection_state enum could diverge. Comment at line 172 said _connected was removed, but it was still being used at lines 279, 302-303, 313, 1595.
2. Race Condition: Heartbeat loop checked _reconnect_in_progress without holding _reconnect_lock, allowing competing reconnect attempts.
3. No Health Verification: After reconnect, connection was considered good if ib.connect() succeeded, but connection could be degraded or non-functional.
- Fixes:
1. Removed all _connected flag references - now using only _connection_state enum
2. Added lock acquisition before checking _reconnect_in_progress in heartbeat loop
3. Added _verify_connection_health() method that performs functional checks after reconnect:
- Verifies ib.isConnected() returns True
- Verifies accountValues() returns data (functional check)
- Verifies managedAccounts() returns account ID
4. Reconnect loop now retries if health check fails after successful connect
- Files:
- src/ibkr/client.py (lines 279, 302-303, 313, 455, 1595, new _verify_connection_health method)
21) Ticker Extraction Reliability (Finding #113)
- Severity: Medium
- Status: done
- Implementation: Complete (Feb 4, 2026). Added fallback patterns and logging for skipped markets.
- Root Cause: QRVO missing from earnings_markets because single regex pattern \(([A-Z]{1,5})\) failed when ticker wasn't in parentheses. Markets silently skipped with no logging.
- Fixes:
1. Added fallback ticker extraction patterns:
- Pattern 1: Standard parentheses (TICKER)
- Pattern 2: "ticker SYMBOL" or "ticker: SYMBOL"
- Pattern 3: All-caps word at start of question
- Pattern 4: Quoted ticker 'TICKER' or "TICKER"
- Pattern 5: Last resort - any standalone 1-5 letter uppercase word (filtered)
2. Added warning logs when markets are skipped due to missing ticker or condition_id
- Files:
- src/data_tracker/collectors/earnings_market_fetcher.py (_extract_ticker_from_question method, extract_markets_from_events method)
22) Exit Reason Display Normalization (Finding #114)
- Severity: Low
- Status: done
- Implementation: Complete (Feb 4, 2026). Standardized exit reason display in dashboard.
- Root Cause: Exit reasons displayed inconsistently:
- Raw values like instant_exit:wrong_prediction, stop_loss:hardware, execution_ledger_reconciliation
- Template had incomplete switch logic that didn't cover all variations
- Fixes:
1. Added normalize_exit_reason() function to dashboard app.py
2. Converts internal exit reasons to user-friendly format:
- scheduled_exit, scheduled_batch → "Scheduled Exit"
- instant_exit:wrong_prediction → "Instant Exit (Wrong Prediction)"
- stop_loss:hardware → "Stop Loss (Hardware)"
- execution_ledger_reconciliation → "Ledger Reconciliation"
3. Updated dashboard template to use normalized display with appropriate icons/colors
- Files:
- earnings_dashboard/app.py (normalize_exit_reason function, trade history formatting)
- earnings_dashboard/templates/dashboard.html (exit reason display)
23) Exit Retry Loop Prevention - Complete Overhaul (Finding #115)
- Severity: CRITICAL
- Status: done
- Implementation: Complete (Feb 4, 2026). Comprehensive fix for exit retry loop that caused CME SHORT -38 shares.
- Root Cause: Finding #109's fix was incomplete. Multiple compounding issues:
1. _exit_in_progress only applied to stop_loss_backup - NOT to scheduled exits, instant exits, or batch exits
2. No IBKR position verification before exit - Orders placed without checking position still exists in expected direction
3. DB persist failure didn't block cleanup - active_positions cleared even when DB update failed
4. Exit verification used abs() - Couldn't distinguish "position still open" from "position flipped to opposite direction"
5. pg8000 type casting issue - COALESCE with integer literal 0 caused slippage values to fail
- Incident: CME position Feb 4, 2026:
- Original: LONG 1 share @ $293.46
- First scheduled exit executed successfully, DB update failed
- Service restart reloaded CME as "open" from DB
- Main loop kept triggering scheduled exits
- Each iteration placed another SELL order
- Result: CME SHORT -38 shares (CATASTROPHIC)
- Fixes:
1. Added _exit_in_progress to ALL exit paths:
- check_scheduled_exits() - Skip if symbol in _exit_in_progress
- execute_scheduled_exit() - Add symbol BEFORE order, clear only on DB success
- execute_instant_exit() - Add symbol BEFORE order, clear only on DB success
- _execute_batch_exit() - Add all symbols BEFORE batch, clear only on DB success
- check_positions_for_exit() - Skip if symbol in _exit_in_progress
- _process_instant_exit_queue() - Skip queued items if already in progress
2. Added IBKR position verification before exit:
- New _verify_ibkr_position_for_exit() method
- Checks position exists in expected direction (LONG vs SHORT)
- Blocks exit if position is flat, opposite direction, or can't verify
- Sends critical alert on direction mismatch
3. DB persist now returns bool:
- _persist_position_close_with_exit_details() returns True/False
- Callers check return value before cleanup
- If False: keep in active_positions, keep in _exit_in_progress
- This prevents service restart from re-triggering failed exits
4. Exit verification enhanced to detect direction flip:
- Uses signed position value (not abs())
- Detects if position flipped to opposite direction
- Logs CRITICAL with specific direction flip warning
- Sends separate alert for direction flip vs incomplete exit
5. Fixed pg8000 type casting:
- Changed COALESCE defaults from 0 to 0::numeric
- Prevents integer/numeric type inference issues
- Files:
- src/earnings/trading_orchestrator.py:
- Line 265: _exit_in_progress set initialized
- Lines 3350-3360: check_scheduled_exits() - added skip check
- Lines 3130-3145: execute_scheduled_exit() - added marking and verification
- Lines 3197-3213: execute_instant_exit() - added marking and verification
- Lines 3337-3362: execute_instant_exit() - DB persist check before cleanup
- Lines 4073-4098: _execute_batch_exit() - added marking
- Lines 4114-4156: _execute_batch_exit() - persist before cleanup, check return
- Lines 4193-4208: _process_instant_exit_queue() - skip if in progress
- Lines 3413-3421: check_positions_for_exit() - skip check
- Lines 3680-3714: _handle_stop_order_fill() - check persist return
- Lines 2260-2320: New _verify_ibkr_position_for_exit() method
- Lines 1986-2002: _finalize_position_exit() - check DB persist return
- Lines 2205-2225: _persist_position_close_with_exit_details() - return bool
- Lines 2170: Fixed COALESCE with 0::numeric
- src/earnings/order_executor.py:
- Lines 1148-1195: place_instant_exit() - enhanced verification with direction check
- Lines 1375-1420: place_scheduled_exit() - enhanced verification with direction check
- Acceptance Test: Position exit must be blocked if:
- IBKR shows position in opposite direction
- DB persist fails (position stays in active_positions)
- Same symbol already has exit in progress
24) Trade Data Integrity and Exit Reason Standardization (Finding #116)
- Severity: Medium
- Status: done
- Implementation: Complete (Feb 4, 2026). Fixed three data integrity issues.
- Root Cause: Multiple issues:
1. CME showed wrong P&L: Dashboard displayed bug artifact (ID 62, -$19.60) instead of real trade (ID 60, -$3.85)
2. Exit reasons not standardized: EFX/NYT showed "Ledger Reconciliation" when they were actually stop losses (had stop_order_id)
3. Unverified DB writes: External position sync auto-added IBKR positions without verification, causing bug artifacts to pollute strategy data
- Fixes:
1. Exclude recovery records from trade log:
- Added filter exit_reason NOT LIKE 'manual_recovery:%' to 3 queries
- Bug artifacts no longer skew strategy P&L calculations
2. Detect stop_loss in reconciliation:
- Added stop_order_id to DB position query
- If position had stop_order_id, set exit_reason to 'stop_loss:hardware_reconciled'
- Future reconciled stops correctly identified
3. External positions: detection-only:
- Removed auto-INSERT for external IBKR positions
- Now logs warning and sends alert for manual review
- Prevents unverified data from entering database
4. Updated normalize_exit_reason():
- Added 'hardware_stop' pattern matching
- 'stop_loss:hardware_reconciled' displays as "Stop Loss (Hardware)"
5. Corrected existing records:
- EFX (ID 61) and NYT (ID 59) exit_reason updated to 'stop_loss:hardware_reconciled'
- Files:
- earnings_dashboard/app.py: Lines 1041, 2927, 3445 (filters), 3353-3358 (normalize)
- src/earnings/trading_orchestrator.py: Lines 1007-1027 (query), 1118-1124 (exit reason logic), 1238-1268 (detection-only)
- Acceptance Test:
- CME shows LONG 1 share, -$3.85, "Scheduled Exit"
- EFX/NYT show "Stop Loss (Hardware)"
- External positions no longer auto-added to database
25) Add Missing Thread Locks (Finding #117)
- Severity: High
- Status: done
- Implementation: Complete (Feb 5, 2026). Added thread locks for shared state.
- Root Cause: active_positions, _pending_batch_entry, and _reserved_capital_for_bmo
were accessed without locks, creating potential race conditions when background threads
(EarningsDetector callback) accessed shared state simultaneously with main thread.
- Fixes:
1. Added three new locks in __init__:
- _positions_lock: Guards active_positions dict
- _batch_lock: Guards _pending_batch_entry
- _capital_lock: Guards capital reservation values
2. Created thread-safe accessor methods:
- _get_position(), _set_position(), _remove_position(), _has_position()
- _get_all_position_symbols()
- _get_reserved_capital(), _set_reserved_capital(), _add_to_reserved_capital()
- Files:
- src/earnings/trading_orchestrator.py: Lines 251-255 (locks), 321-366 (accessor methods)
- Acceptance Test:
- No race conditions under concurrent access
- Thread-safe methods available for future refactoring
26) Fix Division by Zero Guards (Finding #118)
- Severity: High
- Status: done
- Implementation: Complete (Feb 5, 2026). Added guards to prevent ZeroDivisionError.
- Root Cause: Several division operations in risk_manager.py could throw ZeroDivisionError
if total_capital, daily_starting_capital, or target values were 0 (e.g., on startup).
- Fixes:
1. Line 684: new_exposure_pct = new_exposure / self.total_capital if self.total_capital > 0 else 1.0
2. Line 877: Added elif total_target > 0 before scale-down calculation
3. Line 1052-1053: Added early return if daily_starting_capital <= 0
4. Line 897: Added guard for position percentage display logging
- Files:
- src/earnings/risk_manager.py: Lines 684, 877, 897, 1052-1053
- Acceptance Test:
- No ZeroDivisionError with zero capital
- Graceful handling on startup/edge cases
27) Add Missing _send_alert Method (Finding #119)
- Severity: Medium
- Status: done
- Implementation: Complete (Feb 5, 2026). Added _send_alert method to OrderExecutor.
- Root Cause: OrderExecutor called self._send_alert() at lines 1058, 1177, 1182, 1187, 1401,
1406, 1411 for critical errors (direction flips, position sync issues) but the method
did not exist, causing AttributeError.
- Fixes:
1. Added _send_alert(self, message: str) method to OrderExecutor class
2. Method uses fire-and-forget pattern to send CRITICAL level alerts
3. Wraps async AlertManager calls in try/except for safe execution
- Files:
- src/earnings/order_executor.py: Lines 210-238 (new method)
- Acceptance Test:
- Critical alerts sent for direction flips
- No AttributeError when calling _send_alert
28) Add Entry Verification (Finding #120)
- Severity: High
- Status: done
- Implementation: Complete (Feb 5, 2026). Added IBKR position verification after entry.
- Root Cause: After entry orders filled, we tracked position in memory but never verified
IBKR actually had the position. If order silently failed, DB/IBKR desync would occur.
- Fixes:
1. Added entry_verified and ibkr_position_at_entry fields to ExecutionResult
2. After entry+stop completes, verify IBKR has the position
3. Check direction matches expected (BUY->LONG, SELL->SHORT)
4. Allow 1 share tolerance for fractional share rounding
5. Send alert on direction mismatch
- Files:
- src/earnings/order_executor.py:
- Lines 117-118 (new fields in ExecutionResult)
- Lines 139-140 (to_dict updated)
- Lines 834-889 (entry verification logic in place_entry_with_stop)
- Acceptance Test:
- Entry success logs "(verified)" when IBKR position confirmed
- Entry logs "(UNVERIFIED)" if position not found in IBKR
- Alert sent on direction mismatch
29) Bound Instant Exit Queue (Finding #121)
- Severity: Medium
- Status: done
- Implementation: Complete (Feb 5, 2026). Queue bounded to prevent memory leak.
- Root Cause: _instant_exit_queue was unbounded queue.Queue(). If exits were
queued faster than processed, queue could grow unbounded causing memory issues.
- Fixes:
1. Changed queue.Queue() to queue.Queue(maxsize=100)
2. Changed put() to put_nowait() at both call sites
3. Added try/except queue.Full handling with CRITICAL log
4. Full queue logs message with current queue size
- Files:
- src/earnings/trading_orchestrator.py:
- Line 230 (maxsize=100)
- Lines 604-610 (crash recovery put with Full handling)
- Lines 3481-3487 (earnings detection put with Full handling)
- Acceptance Test:
- Queue does not grow beyond 100 items
- CRITICAL log when queue full (operational alert)
30) Standardize Error Handling with Result Pattern (Finding #122)
- Severity: Medium
- Status: done
- Implementation: Complete (Feb 5, 2026). Created Result pattern type.
- Root Cause: Methods used mixed patterns (None, exceptions, bool) for error handling,
making error propagation inconsistent and hard to debug.
- Fixes:
1. Created src/earnings/types.py with generic Result[T, E] type
2. Added Ok() and Err() convenience constructors
3. Added is_ok(), is_err(), unwrap(), unwrap_or() methods
4. Documented usage pattern for gradual migration
- Files:
- src/earnings/types.py (new file)
- Acceptance Test:
- Result type importable: from src.earnings.types import Result, Ok, Err
- New code can use Result instead of None/exceptions
- Note: Existing methods not refactored to minimize risk; gradual migration recommended
31) Fix IBKR Connection State Management (Finding #123)
- Severity: High
- Status: done
- Implementation: Complete (Feb 5, 2026). Atomic state transitions.
- Root Cause: _connection_state was modified without locks, causing race conditions
between heartbeat thread, error handler, and connect/disconnect methods.
- Fixes:
1. Added _state_lock for atomic state transitions
2. Created _set_state(), _get_state(), _transition_if() helper methods
3. Updated all state transition sites to use atomic helpers
4. Connected property now uses atomic state access
- Files:
- src/ibkr/client.py:
- Line 212 (_state_lock initialization)
- Lines 224-246 (atomic helper methods)
- All state transitions updated to use helpers
- Acceptance Test:
- No race conditions during connection state changes
- State machine transitions are atomic
32) Standardize Database Transactions (Finding #124)
- Severity: High
- Status: done
- Implementation: Complete (Feb 5, 2026). Thread-safe engine and transaction helper.
- Root Cause: Engine initialization had race condition (check-then-act without lock).
Transaction patterns were inconsistent across codebase.
- Fixes:
1. Added threading.Lock() for _engine_lock (was None before)
2. get_engine() now uses double-checked locking pattern
3. Added get_transaction() context manager for atomic write operations
4. Documented pattern: use get_transaction() for writes, get_connection() for reads
- Files:
- src/db/connection.py:
- Line 44 (threading import and lock initialization)
- Lines 80-138 (thread-safe get_engine with double-checked locking)
- Lines 182-200 (new get_transaction helper)
- Acceptance Test:
- Concurrent engine access is thread-safe
- Transactions auto-commit on success, auto-rollback on exception
33) Add Stop Order Verification (Finding #125)
- Severity: High
- Status: done
- Implementation: Complete (Feb 5, 2026). Verify stop in open orders after placement.
- Root Cause: After placing stop order, we logged success but didn't verify the order
actually appeared in IBKR's open orders list. Rejected orders could go unnoticed.
- Fixes:
1. After placing stop, call reqAllOpenOrders() to get current orders
2. Search for our order ID in open trades list
3. Log verification status (verified or warning if not found)
4. Return trade object regardless (caller can check orderId tracking)
- Files:
- src/earnings/order_executor.py:
- Lines 1647-1662 (stop order verification after placement)
- Acceptance Test:
- Stop order placement logs "✅ Stop order verified in IBKR"
- Warning logged if order placed but not found in open orders
34) Fix Earnings Detector Thread Safety (Finding #126)
- Severity: High
- Status: done
- Implementation: Complete (Feb 5, 2026). Lock around monitored positions dict.
- Root Cause: _monitored_positions dict was accessed from main thread (add/remove)
and monitor thread (_check_all_positions) without synchronization.
- Fixes:
1. Added _positions_lock in init
2. add_monitored_position() uses lock when adding
3. remove_monitored_position() uses lock when removing
4. _has_active_positions() takes snapshot under lock
5. _check_all_positions() takes snapshot under lock before iteration
- Files:
- src/earnings/earnings_detector.py:
- Line 150 (_positions_lock initialization)
- Lines 757-759 (add with lock)
- Lines 766-769 (remove with lock)
- Lines 807-809 (snapshot for _has_active_positions)
- Lines 821-824 (snapshot for _check_all_positions)
- Acceptance Test:
- No race conditions when adding/removing positions during monitoring
- Iteration uses snapshot to avoid concurrent modification
35) Alert System Retry and Circuit Breaker (Finding #127)
- Severity: Medium
- Status: done
- Implementation: Complete (Feb 5, 2026). Added retry with backoff and circuit breaker.
- Root Cause: Alert sending did single attempt, could fail silently during network issues.
No protection against alert channel outages flooding with retries.
- Fixes:
1. Added CircuitBreaker class with CLOSED/OPEN/HALF_OPEN states
2. Added _retry_with_backoff() helper (3 retries, exponential delay)
3. Per-channel circuit breakers (_telegram_circuit, _webhook_circuit)
4. CRITICAL message deduplication (rate limit same message per 30s)
5. Send method now uses circuit breaker before retry
- Files:
- src/earnings/alerts.py:
- Lines 49-118 (CircuitBreaker class and retry helper)
- Lines 377-380 (circuit breaker initialization)
- Lines 468-520 (send with circuit breaker and retry)
- Acceptance Test:
- Failed sends retry with exponential backoff
- Circuit opens after 5 consecutive failures
- Same CRITICAL message not sent within 30s
36) Stop Order Race Condition Fix (Finding #129)
- Severity: High
- Status: done
- Implementation: Complete (Feb 5, 2026). Lock around stop order placement.
- Root Cause: Race between checking self.stop_orders and placing new order.
Concurrent threads could both pass check and place duplicate stops.
- Fixes:
1. Added _stop_order_lock in init
2. _place_stop_order() acquires lock and delegates to _place_stop_order_locked()
3. All stop order logic runs under lock
- Files:
- src/earnings/order_executor.py:
- Line 209 (_stop_order_lock initialization)
- Lines 1557-1562 (lock acquisition and delegation)
- Acceptance Test:
- Concurrent stop placements for same symbol serialized
- No duplicate stop orders
37) Configuration-Driven Thresholds (Finding #130)
- Severity: Medium
- Status: done
- Implementation: Complete (Feb 5, 2026). Centralized config with env overrides.
- Root Cause: Magic numbers scattered across codebase without documentation.
Changing thresholds required code changes and deployment.
- Fixes:
1. Created src/earnings/config.py with all thresholds
2. Each value documented with rationale
3. Environment variable override support
4. Categories: Order execution, limits, risk, IBKR, database, alerts
- Files:
- src/earnings/config.py (new file)
- Acceptance Test:
- Config values importable: from src.earnings.config import Config
- Override via env: ENTRY_FALLBACK_TIMEOUT=60
- Note: Existing code not refactored to use Config class (gradual migration)
38) Exit Verification Retry with Backoff (Finding #131)
- Severity: High
- Status: done
- Implementation: Complete (Feb 5, 2026). Exit verification retries 3x with backoff.
- Root Cause: Single exit verification attempt could fail if IBKR slow to update.
Position reported as not flat even when exit actually succeeded.
- Fixes:
1. Retry loop: 3 attempts with exponential backoff (3s, 6s, 12s)
2. Breaks early on success (position flat)
3. Logs retry attempts for debugging
4. Final state analysis after all retries
5. Applied to both instant exit and scheduled exit
- Files:
- src/earnings/order_executor.py:
- Lines 1238-1305 (instant exit verification with retry)
- Lines 1483-1543 (scheduled exit verification with retry)
- Acceptance Test:
- Exit verification retries before reporting failure
- Logs show retry attempts with delays
- Direction flip detection still works after retries
Note: Finding #128 (Parallel Earnings Detection) deferred - requires async refactor
39) Cancel Stop Order Before Exit (Finding #132)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 5, 2026). Cancel active stop before exit to prevent IBKR rejection.
- Root Cause: Instant exit for ARES failed with IBKR rejection. IBKR rejected the SELL order
because an active stop-limit order (stop_order_id 103984) was already in place for the same
symbol, and IBKR prevents conflicting orders.
- Fixes:
1. Added cancel_order() method to IBKRClient for canceling active orders
2. Modified process_instant_exits() to cancel stop order BEFORE placing exit order:
- Check if position has stop_order_id
- If yes, cancel stop via IBKRClient.cancel_order()
- Wait for cancel confirmation (up to 5 seconds)
- Then place exit order
- If exit fails, restore stop order for protection
3. Added restore_stop_order() helper for re-placing stop if exit fails
- Files:
- src/ibkr/client.py: Lines 1545-1600 (cancel_order method)
- src/earnings/trading_orchestrator.py: Lines 2675-2750 (cancel-before-exit logic)
- Acceptance Test:
- Instant exit succeeds when stop order is active
- Stop order restored if exit order fails
- No IBKR rejection for conflicting orders
40) Enhanced Execution Data Capture (Finding #133)
- Severity: High
- Status: done
- Implementation: Complete (Feb 5, 2026). Enhanced get_recent_executions() + order type helper.
- Root Cause: Stop-order fills were missing critical data:
- Commission not captured from fill.commissionReport
- realizedPNL not captured from fill.commissionReport
- No way to retrieve order type (STP LMT vs STP) for filled orders
- Fixes:
1. Modified get_recent_executions() to include:
- commission: From fill.commissionReport.commission
- realized_pnl: From fill.commissionReport.realizedPNL
2. Added get_order_type_for_order(order_id) method:
- Searches ib.trades() for matching order ID
- Returns order.orderType (e.g., 'STP LMT', 'STP', 'LMT')
- Used by reconciliation to capture exit order type
- Files:
- src/ibkr/client.py:
- Lines 1450-1468 (get_recent_executions with commission/realizedPNL)
- Lines 1515-1545 (get_order_type_for_order helper)
- Acceptance Test:
- get_recent_executions() returns commission and realized_pnl
- get_order_type_for_order() returns correct order type for filled orders
41) Reconciliation Full Data Capture (Finding #134)
- Severity: High
- Status: done
- Implementation: Complete (Feb 5, 2026). Reconciliation populates all exit fields + ibkr_executions.
- Root Cause: Stop-order fills discovered via reconciliation were missing data:
- exit_order_type NULL in trading_positions
- exit_commission NULL in trade_audit_log
- total_commission not calculated
- Execution not recorded in ibkr_executions table
- Fixes:
1. Enhanced reconciliation exit processing to extract all data:
- exit_commission, exit_order_id, exit_exec_id, exit_exchange from exit_exec
- exit_order_type via get_order_type_for_order()
- Calculate total_commission (entry + exit)
2. Update trading_positions with exit_order_type
3. Update trade_audit_log with full exit data:
- exit_order_type, exit_order_id, exit_commission
- total_commission, gross_pnl, ibkr_exit_exec_id, exit_execution_exchange
4. INSERT execution into ibkr_executions table:
- Records all stop-order fills in execution table
- ON CONFLICT DO NOTHING prevents duplicates
- Source marked as 'reconciliation'
- Files:
- src/earnings/trading_orchestrator.py:
- Lines 1163-1280 (enhanced reconciliation exit processing)
- Acceptance Test:
- Reconciled exits have exit_order_type populated
- Commission captured from IBKR and stored in DB
- Stop-order fills appear in ibkr_executions table
42) Correct Slippage for Stop-Limit Orders (Finding #135)
- Severity: Medium
- Status: done
- Implementation: Complete (Feb 5, 2026). Dashboard uses limit_price vs fill_price for STP LMT orders.
- Root Cause: Dashboard showed misleading slippage for stop-limit orders:
- ARES showed "2.00% slippage" which was actually the NYSE_STOP_LIMIT_BUFFER
- Calculation used stop_price (trigger) vs fill_price
- For STP LMT, should use exit_limit_price (limit) vs fill_price
- Actual slippage was 0% (filled at limit price)
- Fixes:
1. Added exit_limit_price to dashboard trade query
2. Enhanced slippage calculation logic:
- If exit_order_type contains 'LMT' AND exit_limit_price exists:
Use limit_price as reference (our worst acceptable price)
- Else if stop_price exists (pure STP order):
Use stop_price as reference (trigger price)
3. Slippage = reference_price - fill_price (for LONG)
4. This shows TRUE slippage (worse than our limit)
- Files:
- earnings_dashboard/app.py:
- Line 2923 (added exit_limit_price to query)
- Lines 2976-3010 (enhanced slippage calculation)
- Acceptance Test:
- STP LMT orders show slippage relative to limit price
- Fill at limit = 0% slippage (correct)
- Fill below limit = negative slippage (filled better than limit)
43) Exit Reason Mislabeling in Reconciliation (Finding #136)
- Severity: High
- Status: done
- Implementation: Complete (Feb 5, 2026). Reconciliation now compares order IDs to determine exit type.
- Root Cause: Reconciliation assumed any position with stop_order_id was closed via stop loss.
This was wrong when:
1. The stop order was CANCELLED (not filled)
2. The position was closed by a DIFFERENT order (scheduled/instant exit)
TW, ICE, CI were scheduled limit exits at profit but labeled as "stop_loss:hardware_reconciled"
because they had stop_order_ids. The stop orders were cancelled after the limit exits filled.
exit_exec['order_id'] with db_pos['stop_order_id']src/earnings/trading_orchestrator.py: Lines 1215-1240 (exit reason detection)earnings_dashboard/app.py: Lines 3367-3372 (normalize_exit_reason)44) ibkr_executions Missing Unique Constraint (Finding #137)
- Severity: Medium
- Status: done
- Implementation: Complete (Feb 5, 2026). Added UNIQUE constraint on exec_id.
- Root Cause: Reconciliation INSERT used ON CONFLICT (exec_id) DO NOTHING but
the ibkr_executions table only had an INDEX on exec_id, not a UNIQUE constraint.
PostgreSQL requires a UNIQUE constraint for ON CONFLICT specification.
This caused CI reconciliation to fail repeatedly with:
"there is no unique or exclusion constraint matching the ON CONFLICT specification"
ALTER TABLE ibkr_executions ADD CONSTRAINT ibkr_executions_exec_id_unique UNIQUE (exec_id)Numbering trap (docs-sweep, Sep 26 2026): list items 45–50 below reused Finding #132–#137, which were already assigned on Feb 5 (list items 39–44). Those Feb 5 entries remain PRIMARY. These Feb 7 bugs are aliases of the reused numbers, not new IDs — cite the line (list 45–50) when referring to them. The dashboard parser first-write-wins on
(Finding #N), so the Testing Findings tab shows the Feb 5 titles for #132–#137.
45) fixes_applied NameError in reconciliation (Finding #132 reused Feb 7 — PRIMARY is list 39 / Feb 5 Cancel Stop Order Before Exit)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 7, 2026). Initialized fixes_applied = [] at line 1133 alongside issues_detected.
- Root Cause: Variable referenced at line 1340 (execution ledger auto-close) before initialization at line 1377.
- Files: src/earnings/trading_orchestrator.py
46) cancelled_stop_info undefined in scheduled exit (Finding #133 reused Feb 7 — PRIMARY is list 40 / Feb 5 Enhanced Execution Data Capture)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 7, 2026). Added cancelled_stop_info = None initialization in place_scheduled_exit().
- Root Cause: Variable only defined in place_instant_exit() but referenced in place_scheduled_exit() stop restore logic.
- Files: src/earnings/order_executor.py
47) Direct active_positions access bypasses thread locks (Finding #134 reused Feb 7 — PRIMARY is list 41 / Feb 5 Reconciliation Full Data Capture)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 7, 2026). Replaced all 10+ direct self.active_positions[symbol] accesses with thread-safe wrappers (_get_position, _set_position, _remove_position). All iteration uses with self._positions_lock: snapshots.
- Root Cause: Finding #117 added locks and wrappers but many call sites still used direct dict access.
- Files: src/earnings/trading_orchestrator.py (12 locations fixed)
48) Division by zero in batch sizing (Finding #135 reused Feb 7 — PRIMARY is list 42 / Feb 5 Correct Slippage for Stop-Limit Orders)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 7, 2026). Added if total_weight <= 0: return {} guard.
- Root Cause: proportion = weights[symbol] / total_weight crashes if all signal weights are 0.
- Files: src/earnings/batch_order_manager.py
49) State desync - position removed before DB commit verified (Finding #136 reused Feb 7 — PRIMARY is list 43 / Feb 5 Exit Reason Mislabeling in Reconciliation)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 7, 2026). Wrapped cleanup steps (remove position, cancel stops, close in risk manager) in try/except. Sends CRITICAL alert if cleanup fails after DB persist.
- Root Cause: If _persist_ibkr_execution() threw exception, position was removed from memory but execution not recorded.
- Current state (#408): live persist is PositionManager.persist_ibkr_execution (fail-open). Orchestrator _persist_ibkr_execution deleted. Historical root-cause name above is the then-live twin.
- Files: src/earnings/trading_orchestrator.py
50) Queue full drops instant exits silently (Finding #137 reused Feb 7 — PRIMARY is list 44 / Feb 5 ibkr_executions Missing Unique Constraint)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 7, 2026). Added CRITICAL alert via _send_alert_async() when queue is full.
- Root Cause: Queue was bounded (#121) but only logged when full, no alert sent. Positions left unprotected.
- Files: src/earnings/trading_orchestrator.py
51) Execution ledger 48h lookback too short (Finding #138)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 7, 2026). Extended find_exit_execution() lookback from 48h to 168h (7 days).
- Root Cause: CBOE/MCHP incident (Feb 6, 2026) - stop fills unrecoverable if bot was down >48h.
- Files: src/ibkr/client.py
52) Positions unmonitored during IBKR disconnection (Finding #139)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 7, 2026). Added reconnection callback system to IBKRClient. TradingOrchestrator registers _on_ibkr_reconnect() which triggers immediate reconciliation after IBKR reconnects.
- Root Cause: After IBKR reconnection, no reconciliation was triggered. Positions closed by stops during downtime went undetected until next scheduled reconciliation (5 min).
- Files: src/ibkr/client.py, src/earnings/trading_orchestrator.py
53) Missed scheduled exits on restart (Finding #140)
- Severity: High
- Status: done
- Implementation: Added _check_missed_scheduled_exits() on startup. Checks all open positions for scheduled_exit_time < now and executes immediately with alert.
- Current state (#408): live startup callee is ExitManager.check_missed_scheduled_exits (from orchestrator __init__ after _load_persisted_positions). Orchestrator _check_missed_scheduled_exits deleted. Historical implementation name above is the then-live twin.
- Files: src/earnings/trading_orchestrator.py
54) _exit_in_progress never cleared on failure (Finding #141)
- Severity: High
- Status: done
- Implementation: Added _exit_in_progress_times dict tracking timestamps. Auto-clears stale entries after 5 minutes in check_scheduled_exits(). Prevents permanent blocking from transient failures.
- Files: src/earnings/trading_orchestrator.py
55) Stop order tracking outside lock (Finding #142)
- Severity: High
- Status: done
- Implementation: Moved self.stop_orders[symbol] = stop_order_id inside _stop_order_lock.
- Files: src/earnings/order_executor.py
56) Hardcoded market holidays expire 2027 (Finding #144)
- Severity: High
- Status: done
- Implementation: Added 2027-2028 US market holidays and early close days.
- Files: src/earnings/market_calendar.py
57) Audit log files can fill disk (Finding #145)
- Severity: High
- Status: done
- Implementation: Uses single rotating file audit_log.jsonl. Rotates at 50MB with 1 backup.
- Files: src/earnings/audit_logger.py
58) Config values lack bounds validation (Finding #146)
- Severity: High
- Status: done
- Implementation: Added Config.validate() classmethod. Checks percentage fields (0-1 range) and integer fields (reasonable bounds). Call on startup.
- Files: src/earnings/config.py
59) DB connection pool timeout already configured (Finding #149)
- Severity: High
- Status: done (already implemented)
- POOL_CONFIG['pool_timeout'] = 30 already exists in src/db/connection.py.
60) Signal generator DB queries unhandled (Finding #150)
- Severity: High
- Status: done
- Implementation: Wrapped _get_earnings_markets_from_db() query in try/except, returns empty list on failure.
- Files: src/earnings/signal_generator.py
61) get_previous/next_trading_day() returns wrong date (Finding #151)
- Severity: High
- Status: done
- Implementation: Extended search window from 10 to 30 days. Handles holiday clusters.
- Files: src/earnings/market_calendar.py
62) Earnings timing fallback hardcoded path (Finding #152)
- Severity: High
- Status: done
- Implementation: Uses os.path.dirname(__file__) relative path with env var override EARNINGS_TIMING_CACHE_PATH.
- Files: src/earnings/earnings_timing_fallback.py
63) Missing alert when price sources fail (Finding #154)
- Severity: High
- Status: done
- Implementation: Sends warning alert when both IBKR and yfinance fail for a symbol.
- Files: src/earnings/trading_orchestrator.py
64) No startup safe mode (Finding #155)
- Severity: High
- Status: done
- Implementation: Added _reconciliation_completed flag. execute_entry() blocks until first reconciliation succeeds. Prevents new entries during inconsistent state.
- Files: src/earnings/trading_orchestrator.py
65) Deferred to Phase 4A: P&L calculation dedup (Finding #143) - Severity: High - Status: deferred - Reason: Requires refactoring trading_orchestrator.py (Phase 4A scope)
66) Deferred to Phase 4A: N+1 query in conviction history (Finding #147) - Severity: High - Status: deferred - Reason: Performance optimization, not a crash risk
67) Deferred to Phase 4A: Blocking IBKR market data calls (Finding #148) - Severity: High - Status: deferred - Reason: Requires async refactor
68) Deferred to Phase 4A: Split place_instant_exit() (Finding #153)
- Severity: High
- Status: deferred
- Reason: 436-line function needs careful extraction during orchestrator split
69) Split TradingOrchestrator into 6 focused modules
- Severity: Architecture
- Status: done
- Implementation: Complete (Feb 7, 2026). Split 4878-line monolith into 6 modules using
composition with full dependency injection. Each manager receives only its specific
dependencies via init.
- New modules created (3,326 total lines):
- trading_utils.py (464 lines): 18 utility methods (market time, pricing, scheduling)
- position_manager.py (815 lines): 14 methods (position CRUD, DB persistence, stop orders)
- exit_manager.py (792 lines): 13 methods (scheduled/instant/batch exits, stop backup)
- capital_manager.py (671 lines): 14 methods (capital reservation, signals, entries)
- reconciliation_manager.py (586 lines): 3 methods (IBKR/DB reconciliation, table sync)
- TradingOrchestrator updated: Creates all managers in init, delegates to them.
Original methods remain for backward compatibility. External callers unchanged.
- Files: src/earnings/trading_utils.py, src/earnings/position_manager.py,
src/earnings/exit_manager.py, src/earnings/capital_manager.py,
src/earnings/reconciliation_manager.py, src/earnings/trading_orchestrator.py,
src/earnings/__init__.py
- Note: Method location references in earlier findings (e.g., #14, #17, #18, #19, #23, #25,
#39, #41, #43) still reference trading_orchestrator.py. The methods now exist in both
the original file AND the new manager modules (identical code). The original methods will
be removed once gradual migration to managers is complete.
70) IBKR as source of truth for live position state
- Severity: Architecture
- Status: done
- Implementation: Complete (Feb 7, 2026). IBKR is now the definitive source for
live position state. Database remains source of truth for historical data.
- New components:
- position_state.py: PositionState enum (PENDING/OPEN/CLOSING/CLOSED/FAILED)
with state machine validation and transition rules
- ibkr_position_cache.py: Thread-safe TTL cache (5s) wrapping ib.positions()
with disconnection detection
- Changes:
- position_manager.py: Added IBKR cache integration methods
- trading_orchestrator.py: Main loop blocks ALL trading when IBKR disconnected
- memory.md: Principle #2 updated to reflect IBKR as source of truth
- DB migration 018: Added position_state column to trading_positions
- Key behavior: When IBKR disconnected, ALL trading operations blocked.
Hardware stops remain active in IBKR regardless.
71) Event-Driven Architecture with real-time stop fill detection
- Severity: Architecture
- Status: done
- Implementation: Complete (Feb 7, 2026). Created EventBus for decoupled communication
and IBKR fill callbacks for real-time stop order fill detection.
- New components:
- event_bus.py: Thread-safe publish/subscribe with 20 event types, JSONL persistence
- ibkr/client.py: Added on_fill() callback + _on_order_status() handler
- trading_orchestrator.py: Added _on_ibkr_fill() handler for stop fill detection
- Key achievement: Solves CBOE/MCHP problem. Stop fills now detected in real-time
via ib_insync orderStatusEvent, rather than relying on periodic reconciliation.
- Events published: ORDER_FILLED, STOP_FILLED (triggers position cleanup),
IBKR_DISCONNECTED, IBKR_RECONNECTED, etc.
72) 20 medium priority fixes (#156-#175) - Severity: Medium - Status: done - Implementation: Complete (Feb 7, 2026). All 20 findings from the holistic audit addressed. - Concurrency (#156-#159): - #156: Lock ordering documented (positions -> capital -> batch) - #157: Capital lock already consistent in capital_manager.py (verified) - #158: Added _positions_lock to risk_manager.open_positions - #159: Circuit breaker state reset on restart (documented as intentional) - Error Handling (#160-#164): - #160: Added debug logging to bare except clauses - #161: cancel_order() now polls for cancellation verification (3s) - #162: Already guarded (total_depth > 0 check exists) - #163: Override JSON validated on load (structure + timing values) - #164: dispose_engine() wrapped in _engine_lock - Config (#165-#168): - #165: earnings_datetime timezone validated (auto-converts naive to ET) - #166: Polling intervals configurable via env vars - #167: Already done in Phase 3E (2027-2028 holidays) - #168: Trading block file uses atomic remove (no TOCTOU race) - Monitoring (#169-#172): - #169: Alert delivery confirmation logged (Telegram success/failure) - #170: Daily position summary at 8 PM ET - #171: Already exists (/api/health endpoint in dashboard) - #172: Reconciliation abort alert failure now logged - Code Quality (#173-#175): - #173: No obsolete commented-out code found - #174: Dead method _is_earnings_announcement() removed - #175: Strategy doc already documents 10x vs 2x/6x leverage correctly
76) IBKR connectivity loss auto-recovery (Finding #176)
- Severity: Critical
- Status: done
- Root Cause: Error 1100 (connectivity lost) was in CRITICAL_BLOCK_ERRORS, persisting
trading_block.txt that survived restarts. Errors 1101/1102 (connectivity RESTORED)
were treated as disconnections instead of recovery events.
- Impact: Bot blocked for 33+ hours (Feb 8-9) until manual file deletion.
- Fixes:
1. Removed 1100 from CRITICAL_BLOCK_ERRORS (transient, not critical)
2. Errors 1101/1102 now clear trading block, verify health, restart heartbeat
3. Health check auto-clears connection-related blocks on success
- Files: src/ibkr/client.py
77) Stale earnings dates in DB (Finding #177)
- Severity: Critical
- Status: done
- Root Cause: earnings_markets table populated by sync job every ~6h. Companies shift
earnings dates but DB doesn't re-verify. ON had Feb 10 in DB, actual was Feb 9.
- Impact: ON trade would have been missed (signal generator uses DB date).
- Fixes:
1. Added verify_earnings_dates() to signal_generator.py - cross-checks DB vs
EarningsCalendar before each signal check (AMC 10:30 AM, BMO 3:50 PM)
2. Auto-corrects mismatches and sends Telegram alert
3. Fixed 5 stale dates: ON, CRSP, NET, GRAB, PANW
- Files: src/earnings/signal_generator.py, src/earnings/trading_orchestrator.py
78) Duplicate order from market fallback (Finding #178)
- Severity: Critical
- Status: done
- Root Cause: batch_order_manager._apply_market_fallback accessed result.filled_price
but OrderResult has avg_fill_price. AttributeError caused batch to think order
failed, triggering market fallback = duplicate (ON: 6 shares instead of 3).
- Same pattern as PFE (#17) and CME (#23): retry/fallback without checking IBKR position.
- Fixes:
1. Fixed filled_price -> avg_fill_price attribute bug
2. Added IBKR position guard (_check_ibkr_position) before EVERY market fallback:
- batch_order_manager._apply_market_fallback
- order_executor.place_guaranteed_fill
If position already exists, ABORT the fallback. Defense-in-depth.
3. Manual recovery: sold 3 excess ON shares, placed stop loss.
(Corrected #353, Jul 31 2026: the original entry claimed the recovery
legs were "recorded in DB" — they never were. The excess-share legs
exist only in IBKR; the DB holds the corrected strategy trade. Now
represented as an UNQUANTIFIED placeholder row in the
trading_errors ledger — finding '#178', realized_loss 0.00,
quantifiable from IBKR Flex on demand.)
- Files: src/earnings/batch_order_manager.py, src/earnings/order_executor.py
79) Earnings detector false exit from PRNewswire wrong EPS (Finding #179A)
- Severity: Critical
- Status: done
- Root Cause: Two bugs in earnings_detector.py:
1. Polymarket confirmation state was popped after confirmation (line 322),
creating 30-second vulnerability windows where secondary sources could override.
2. No contradiction guard: PRNewswire parsed wrong EPS ($0.06 vs actual $0.64),
concluded MISS, overrode Polymarket's 99.95% BEAT signal.
- Impact: ON position exited prematurely (still profitable, but missed further gains).
- Fixes:
1. Keep Polymarket confirmation state (don't pop after confirmation)
2. PRNewswire/BusinessWire that contradict confirmed Polymarket outcome are logged but ignored
3. Yahoo (authoritative) can still override if needed
- Files: src/earnings/earnings_detector.py
80) NASDAQ stop orders don't trigger in extended hours (Finding #179B)
- Severity: Critical
- Status: done
- Root Cause: IBKR ignores outsideRth on pure STP orders for NASDAQ stocks
routed via SMART (Warning 2109). Every NASDAQ STP in our history got 2109.
Stops only trigger during RTH (9:30 AM - 4:00 PM ET).
- Impact: HOOD dropped from $87.83 to $78.00 after hours (-11.2%), but stop at
$85.20 (STP) never triggered. (Clarified #353, Jul 31 2026: the -11.2% was
the UNREALIZED trough while the stop was blocked; the REALIZED loss on the
closed trade was -$5.26 = -3.0%, i.e. the design stop distance — price
recovered before the exit filled. The original "Actual loss: -11.2%"
wording conflated the two.)
- Evidence: CVS, KO, NET, RDDT, RL stops (all STP LMT) triggered correctly
in pre/post market. HOOD, DDOG, MAR (all STP) got Warning 2109.
- Fix: ALL exchanges now use STP LMT with 2% buffer (not just NYSE).
Removes the is_nyse conditional entirely. Same 2% buffer approach that
was already proven to work for NYSE.
- Files: src/earnings/order_executor.py, src/ibkr/client.py
- Docs updated: STRATEGY.md (sections 5.5, 7.3, 7.17), EDGE_CASES.md,
OPERATIONS.md, TESTING_FINDINGS_PENDING.md
81) IBKR reconnection failures + zero alerts + stale dashboard (Finding #180)
- Severity: Critical
- Status: done
- Root Cause: Three failures from the same pattern (3 incidents in 5 days, Feb 8-12):
1. Gateway daily restart drops bot connection. Reconnection loop retries but
gateway keeps rejecting clientId=1 (stale session state, "remove Client 1").
2. No Telegram alerts during 37h outage: alerts only sent from orchestrator
main loop, which never starts when initial connection fails.
3. Dashboard shows "IBKR Connected" from stale snapshot file (no staleness check).
- Impact: 37 hours downtime, missed trading signals for Feb 11-12 earnings.
- Fixes:
1. Send Telegram alert immediately when reconnection starts + every 30 min
(from IBKRClient._schedule_reconnect, not dependent on main loop)
2. Clean disconnect() before each reconnect attempt (clears stale clientId)
3. Dashboard checks snapshot age > 2 min → show "DISCONNECTED"
4. ibgateway-ibc.service: Restart=on-failure → Restart=always (gateway
exits with code 0 on daily restart, on-failure didn't restart it)
- Files: src/ibkr/client.py, earnings_dashboard/app.py,
deploy/systemd/ibgateway-ibc.service (server updated directly)
82) IBKR weekly 2FA schedule drift hardening (Finding #181)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 15, 2026). Added single source-of-truth schedule + drift audit and aligned all operational scripts.
- Root Cause: Timing/service drift across scripts/docs caused missed 2FA expectation window and inconsistent operational behavior.
- Fixes:
1. Added canonical schedule file scripts/ibkr_schedule.env (restart/reminder/manager times)
2. Updated gateway_restart_manager.sh and ibkr_restart_warning.sh to read canonical schedule values
3. Added missing sunday_2fa_reminder.sh with Sunday guardrails
4. Added ibkr_schedule_audit.sh to detect cron/service drift daily
5. Updated diagnostics and deploy flow to verify ibgateway-ibc.service and canonical cron entries
6. Updated continuity docs with canonical schedule source and audit cron
- Files:
- scripts/ibkr_schedule.env
- scripts/gateway_restart_manager.sh
- scripts/ibkr_restart_warning.sh
- scripts/sunday_2fa_reminder.sh
- scripts/ibkr_schedule_audit.sh
- scripts/ibkr_diagnostics.py
- deploy_ibkr_monitoring.sh
- docs/IBKR_CONTINUITY.md
83) Holistic hardening batch: trading/reconciliation/ops/dashboard consistency (Finding #182)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 15, 2026). Added guardrails against race conditions, strengthened IBKR ops readiness checks, and standardized dashboard trade-quality filtering.
- Root Cause: Multiple cross-component assumptions allowed partial false-positive health states, fragile restart timing, and inconsistent reporting filters.
- Fixes:
1. Exit race hardening in order_executor.py:
- Skip stop restore when IBKR position is already flat
- Re-check live position immediately before market fallback order
- Handle disconnect during exit verification retries
2. Reconciliation guardrails in reconciliation_manager.py:
- Auto-close only when execution side/shares/time invariants pass
- Closed-table consistency checks (counts + NULL exit price detection)
3. Capital estimate safety in capital_manager.py:
- Use current-price estimate (fallback to entry notional)
4. Ops hardening:
- ibkr_health_monitor.sh now includes login-pending detection, command fallbacks, durable state file
- gateway_restart_manager.sh uses bounded polling window with PID kill verification
- ibkr_schedule_audit.sh validates cron with normalized matching and checks IBC AutoRestartTime
5. Dashboard consistency:
- Unified filter for reconciliation/manual recovery/external trades
- Explicit COALESCE totals and exit_price IS NOT NULL guard in closed-trade summary
- Files:
- src/earnings/order_executor.py
- src/earnings/exit_manager.py
- src/earnings/reconciliation_manager.py
- src/earnings/capital_manager.py
- src/ibkr/client.py
- scripts/ibkr_health_monitor.sh
- scripts/gateway_restart_manager.sh
- scripts/ibkr_schedule_audit.sh
- scripts/ibkr_diagnostics.py
- deploy_ibkr_monitoring.sh
- earnings_dashboard/app.py
- docs/OPERATIONS.md
- docs/IBKR_CONTINUITY.md
- docs/STRATEGY.md
- docs/TESTING_FINDINGS_PENDING.md
84) Health monitor false-unhealthy correction after deploy (Finding #183)
- Severity: High
- Status: done
- Implementation: Complete (Feb 15, 2026). Fixed gateway-running detection to use ibgateway-ibc.service status as primary signal.
- Root Cause: Process regex was too strict and marked Gateway=false while API was live, causing false unhealthy state.
- Fix:
1. ibkr_health_monitor.sh now checks systemctl is-active ibgateway-ibc.service first
2. Falls back to broader process patterns only if service check unavailable
- Files:
- scripts/ibkr_health_monitor.sh
85) Reconciliation missing exec_id hardening + one-time repair (Finding #184)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 15, 2026). Hardened stop-fill persistence and added deterministic log-based reconciliation repair path.
- Root Cause:
1. Hardware stop-fill cleanup path could close trades without persisting ibkr_exit_exec_id / exit_order_id
2. Reconciliation auto-fix only used ibkr_executions, so historical gaps remained unresolved
3. Delayed IBKR execution history retrieval was not reliable for historical repair in this environment
- Fixes:
1. exit_manager.py now captures recent stop-fill execution payload from IBKR in-memory trades and persists:
- exit_exec_id, exit_order_id, exit_execution_exchange, exit_commission
- matching ibkr_executions row (source=live_stop_fill)
2. earnings_detector.py now supports stop_monitoring(symbol) (symbol-scoped remove), preventing runtime cleanup exceptions
3. trade_reconciler.py adds log fallback parser for fill events in earnings_trader.log
- inserts deterministic synthetic IDs (logfill.<symbol>.<order_id>.<epoch>) into ibkr_executions
- backfills ibkr_entry_exec_id / ibkr_exit_exec_id when DB-only matching fails
4. Added one-time repair utility: scripts/repair_trade_exec_mismatches.py
- Files:
- src/earnings/exit_manager.py
- src/earnings/earnings_detector.py
- src/data_tracker/collectors/trade_reconciler.py
- scripts/repair_trade_exec_mismatches.py
- tests/test_reconciliation_and_orders.py
- docs/STRATEGY.md
- docs/TESTING_FINDINGS_PENDING.md
85b) Reconciliation missing_exec_id hardening + deterministic backfill (Finding #184; duplicate list index of 85 — same finding, not a new number)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 15, 2026). Hardened stop-fill persistence path and added deterministic reconciliation backfill for legacy gaps.
- Root Cause:
1. Hardware stop cleanup could close DB rows without durable ibkr_exit_exec_id / exit_order_id
2. Cleanup path called stop_monitoring(symbol) against a method that accepted no symbol, throwing post-fill errors
3. Reconciliation auto-fix searched only ibkr_executions, so historical gaps remained unresolved when execution rows were never persisted
- Fixes:
1. exit_manager.py now captures recent stop-fill details from IBKR in-memory trades and persists:
- exit_exec_id, exit_order_id, exit_execution_exchange, exit_commission
- matching ibkr_executions row (source=live_stop_fill)
2. earnings_detector.py now supports stop_monitoring(symbol) as symbol-scoped removal (backward-compatible with full stop)
3. trade_reconciler.py now has log-based deterministic fallback:
- Parses fill lines from /var/log/polymarket/earnings_trader.log
- Generates stable synthetic IDs (logfill.<symbol>.<order_id>.<epoch>)
- Inserts missing ibkr_executions rows and links trade exec IDs idempotently
4. Added one-time repair utility scripts/repair_trade_exec_mismatches.py
- Files:
- src/earnings/exit_manager.py
- src/earnings/earnings_detector.py
- src/data_tracker/collectors/trade_reconciler.py
- scripts/repair_trade_exec_mismatches.py
- tests/test_reconciliation_and_orders.py
- docs/STRATEGY.md
- docs/TESTING_FINDINGS_PENDING.md
86) Unified NYSE/NASDAQ execution policy + stop trigger timeout escalation (Finding #185)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 15, 2026). Removed exchange-specific execution branching, normalized priority scoring to cap+confidence, and added stop-trigger timeout escalation.
- Root Cause:
1. Legacy NYSE/NASDAQ branching made behavior and docs diverge over time.
2. Hardware stop could remain active after breach without quick fill, leaving unresolved downside window.
3. Priority scoring encoded exchange bias that no longer matched hardened execution policy.
- Fixes:
1. Unified instant-exit logic in order_executor.py:
- all exchanges use LIMIT (GTC, 1%) -> MARKET fallback when market is open
- fully closed market remains GTC LIMIT only
2. Added stop-trigger timeout escalation in exit_manager.py:
- tracks first breach timestamp
- escalates to instant_exit:stop_timeout_escalation after configurable timeout
3. Normalized priority.py scoring to market cap + confidence only (exchange-independent)
4. Updated dashboard priority legends/order matrices and strategy/operations/edge-case docs for consistency
- Files:
- src/earnings/order_executor.py
- src/earnings/exit_manager.py
- src/earnings/config.py
- src/earnings/priority.py
- src/earnings/signal_generator.py
- earnings_dashboard/templates/dashboard.html
- tests/test_reconciliation_and_orders.py
- tests/unit/test_order_executor_stops.py
- tests/comprehensive_system_test.py
- docs/STRATEGY.md
- docs/OPERATIONS.md
- docs/EDGE_CASES.md
- docs/TESTING_FINDINGS_PENDING.md
87) 2x sizing intent alignment: full testing profile + 140% exposure cap (Finding #191)
- Severity: High
- Status: done
- Implementation: Complete (Feb 23, 2026). Updated sizing logic to match requested behavior: double current testing profile without ambiguity, while retaining leverage guardrails.
- Root Cause:
1. User intent ("2x sizing vs current testing behavior") differed from prior implementation semantics.
2. Existing testing_prod sizing multiplier remained at 50% of base Option-B sizing.
3. Exposure budget defaults and opportunity tiers were still tuned around a 70% ceiling.
- Fixes:
1. Config.TESTING_PROD_SIZE_MULTIPLIER introduced and set to default 1.0 (from prior hardcoded 0.50).
2. Exposure defaults updated to 1.40:
- MAX_DAILY_EXPOSURE=1.40
- MAX_TOTAL_EXPOSURE_PCT=1.40
3. Opportunity exposure tiers doubled proportionally in RiskManager:
- rare 0.60, normal 1.40, busy 1.40
4. Kept leverage cap unchanged (LEVERAGE_MAX_EFFECTIVE_LEVERAGE=2.0).
5. Dashboard now reflects configured exposure cap dynamically (max_exposure_pct=140.0).
6. Added unit coverage for new sizing/exposure behavior.
- Verification Evidence (production):
1. Runtime check after deploy:
- TESTING_PROD_SIZE_MULTIPLIER 1.0
- MAX_DAILY_EXPOSURE 1.4
- LEVERAGE_MAX_EFFECTIVE_LEVERAGE 2.0
- large-cap size on $10k in testing_prod => $2,500
- exposure tiers => rare=0.6, normal=1.4, busy=1.4
2. /api/state shows max_exposure_pct=140.0
3. Live HIMS row confirms leverage snapshot persistence remains intact (trade_audit_log.id=65)
- Files:
- src/earnings/config.py
- src/earnings/risk_manager.py
- earnings_dashboard/app.py
- tests/unit/test_config.py
- tests/unit/test_position_sizing_2x_rollout.py
- docs/TESTING_FINDINGS_PENDING.md
88) Close persistence permanence hardening after AS recurrence (Finding #193)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 24, 2026). Eliminated nullable-parameter type ambiguity and added fallback close persistence path.
- Root Cause:
1. pg8000 raised 42P18 ("could not determine data type of parameter") during rich close update when trade_audit_log.notes append path received nullable values.
2. The failed DB transaction left broker-closed positions marked open in DB, creating repeated orphan reconciliation alerts.
- Fixes:
1. Reworked close-note update to use type-safe, non-null append semantics in persist_position_close_with_exit_details().
2. Added fallback close persistence path that writes essential close fields to both trading_positions and trade_audit_log if the rich update fails.
3. Added high-confidence reconciliation auto-close override to safely resolve strict stop-fill matches even when global auto-close is disabled.
4. Added closed-trade metadata completeness check for exit_order_type, exit_order_id, and ibkr_exit_exec_id.
- Files:
- src/earnings/position_manager.py
- src/earnings/reconciliation_manager.py
- src/earnings/trading_orchestrator.py
- src/earnings/config.py
- earnings_dashboard/app.py
- docs/OPERATIONS.md
- docs/TESTING_FINDINGS_PENDING.md
89) Upcoming earnings dashboard live-visibility hardening (Finding #195)
90) IBKR reconnect/reconciliation holistic hardening (Finding #196)
- Root cause: Reconnect callback race: reconciliation observed reconnect_in_progress=true during expected transition and raised critical position-sync noise for guard skips.
- Fixes:
- Clear reconnect flags before firing callbacks in IBKRClient.
- Reclassify fail-closed guard skips to warning reconciliation_guard category.
- Add bounded retry-on-ready after guard skip.
- Add guard alert cooldown and watchdog thread-dump/stage forensics.
- Verification:
- Unit test: reconnect flags are idle before callback executes.
- Unit test: guard retry scheduler is bounded + deduped.
- Unit test: disconnected-guard path emits warning guard alert.
- Impacted files:
- src/ibkr/client.py
- src/earnings/reconciliation_manager.py
- src/earnings/alerts.py
- src/earnings/trading_orchestrator.py
- src/earnings/config.py
- tests/unit/test_ibkr_error_block_policy.py
- tests/unit/test_reconciliation_guard_retry.py
- tests/unit/test_config.py
earnings_markets data./api/earnings-calendar?days=14./api/earnings-calendar (db_upcoming_14d, db_upcoming_30d, db_next_upcoming_date) for explicit health context.earnings_dashboard/templates/dashboard.htmlearnings_dashboard/app.pydocs/OPERATIONS.mddocs/TESTING_FINDINGS_PENDING.md91) Conviction robustness + unified close/reconciliation recovery (Finding #197)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 15, 2026). Added robust conviction sampling with reliability gates, unified close persistence routing, and broadened high-confidence reconciliation auto-close for broker-confirmed exits.
- Root Cause:
1. Conviction fallback could rely on a single outlier price snapshot in volatile windows, producing wrong direction.
2. Reconciliation high-confidence override was stop-order-centric, leaving recoverable non-stop exits stuck in EXEC_LEDGER_ONLY loops.
3. Trading orchestrator retained a legacy close persistence implementation separate from hardened PositionManager path.
- Fixes:
1. conviction_calculator.py: robust price-window median estimator with sample/range reliability checks and reason codes. (Refined in Finding #325, Jun 11 2026: the reliability gate changed from min-max range>0.35 to robust IQR>0.25, the dense-sample cap raised from 9→10000 snapshots, and the 0.02<yes_price<0.99 filter removed — the old range+filter under-counted high-conviction markets and let an excluded ADBE LONG through.)
2. signal_generator.py: fail-closed skip for unreliable conviction windows with reason logging.
3. trading_orchestrator.py: close persistence routed to position_manager.persist_position_close_with_exit_details(...).
4. reconciliation_manager.py: high-confidence execution-ledger override generalized for non-stop exits under strict guardrails; execution-ledger close now uses hardened position-manager close path.
5. Added targeted tests for conviction robustness, reliability gating, reconciliation high-confidence criteria, and close-path routing.
- Verification:
- pytest -q tests/unit/test_conviction_robustness.py tests/unit/test_reconciliation_guard_retry.py tests/unit/test_close_path_routing.py
- pytest -q tests/unit/test_signal_reliability_gate.py
- All tests passed; targeted lints clean.
- Production evidence (Feb 26, 2026):
- EXEC_LEDGER_ONLY for SNOW eliminated (was recurring every 5 min pre-deploy)
- Reconciliation metadata complete: SNOW#75 + ICUI#62 repaired with full exit linkage
- Reconciliation all-clear: "DB and IBKR are in sync" on every post-deploy cycle
- Conviction robustness log evidence: blocked (no entry candidates since deploy)
- Follow-up fix (commit 6cb8830):
- Alert deduplication for CLOSED TRADE EXEC METADATA INCOMPLETE check (1h cooldown, row-ID tracking)
- SNOW data repair (Feb 26, 2026):
- Corrected exit from stop_loss@$170.85 to instant_exit@$166.45 (42P18 caused original persist failure)
- PnL corrected from -$9.96 to -$1.16
- position_state fixed from 'open' to 'closed'
- Files:
- src/earnings/conviction_calculator.py
- src/earnings/signal_generator.py
- src/earnings/reconciliation_manager.py
- src/earnings/trading_orchestrator.py
- tests/unit/test_conviction_robustness.py
- tests/unit/test_signal_reliability_gate.py
- tests/unit/test_reconciliation_guard_retry.py
- tests/unit/test_close_path_routing.py
- docs/OPERATIONS.md
- docs/STRATEGY.md
- docs/TESTING_FINDINGS_PENDING.md
92) NaT EPS crash loop in Yahoo earnings check (Finding #198)
- Severity: Critical
- Status: done
- Implementation: Complete (Feb 27, 2026). Fixed crash loop from pandas NaT value in Yahoo earnings data.
- Root Cause:
1. Yahoo Finance returned pandas NaT for EB (Eventbrite) reported_eps field.
2. _check_yahoo_earnings() set result.is_released = True BEFORE float(reported_eps).
3. float(NaT) threw TypeError, caught by except, but is_released was already True with actual_eps=None.
4. Caller check_earnings() crashed on f"${yahoo_result.actual_eps:.2f}" — None has no __format__.
5. Unhandled in main loop → exit → systemd restart → 69+ restarts over 20 hours.
- Fixes:
1. Wrapped float(reported_eps) in try/except; set is_released AFTER successful conversion.
2. Same guard on eps_estimate float conversion.
3. Guarded ALL f-string EPS formatting across file (8 instances, memory.md Section E same-pattern check).
- Impact: 3 open positions (DELL, REAL, EB) unmonitored for ~20 hours. Hardware stops remained active in IBKR.
- Files: src/earnings/earnings_detector.py
93) Direction-inverted duplicate prevention blocks scheduled exit fallback (Finding #199)
- Severity: Critical
- Status: done
- Implementation: Complete (Mar 6, 2026). Made market-fallback duplicate check direction-aware and fixed IBKR market hours parser.
- Root Cause:
1. place_guaranteed_fill() duplicate check at line 680 used abs(p.shares) which ignores position direction.
2. For EXIT orders (BUY to cover SHORT), finding shares means the exit DIDN'T work — but the check concluded "likely filled" and skipped the market fallback.
3. GPRO SHORT -397 shares: limit BUY at $0.77 didn't fill in 45s. Market fallback was blocked by inverted duplicate check. Position left open +$111 unprotected over weekend.
4. Secondary: IBKR market hours parser crashed on YYYYMMDD:HHMM format (int("0306:1600")), blocking all post-failure retry attempts with "Market not in regular hours."
- Fixes:
1. order_executor.py: Duplicate check now compares p.shares sign against side. BUY+LONG=duplicate, BUY+SHORT=exit-not-done (proceed). SELL+SHORT=duplicate, SELL+LONG=exit-not-done (proceed).
2. client.py: _parse_ibkr_hours strips YYYYMMDD: date prefix from time components before parsing.
3. batch_order_manager.py: Same direction-aware fix in _apply_market_fallback (memory.md Section E same-pattern check).
- Impact: GPRO closed manually after hours at $0.7752, P&L +$111.08. No capital loss, but position was unprotected for ~15 minutes after scheduled exit failure.
- Files:
- src/earnings/order_executor.py
- src/ibkr/client.py
- src/earnings/batch_order_manager.py
Migrated from TESTING_FINDINGS_PENDING.md (consolidated Mar 7, 2026).
memory.md.4001, Paper 4002. Avoid clientId 1 (use 99).1) Finding 1 — Entry + stop atomicity PASSED - Result (Feb 2, 2026): PLTR entry @ $148.70, stop @ $144.23 within 1 second. PEP/PFE batch also passed.
2) Finding 2 — Batch stop placement safety PASSED - Result (Feb 2, 2026): Batch 319b8adb - 1/1 success. Batch 34c25f75 - 2/2 success.
3) Finding 3 — Duplicate order prevention PASSED - Result (Feb 2, 2026): AAPL correctly skipped (existing position). PLTR skipped in batch.
4) Finding 4 — Market hours gating PASSED - Result (Feb 2, 2026): Pre-market entry blocked, instant exit allowed.
5) Finding 12 — Closed-market instant exit PENDING - Will test if instant exit needed during 8 PM - 4 AM window.
6) Finding 14 — Reconciliation disconnection guard VERIFIED (Code Review) - Guards confirmed in reconciliation_manager.py. Phase 4D event-driven architecture provides real-time backup.
7) Finding 15 — Stale conviction data fix VERIFIED - Two-phase Gamma fetch + 0% display fix deployed and verified.
1) Entry + stop atomicity (unit tests) — include success and failure cases
2) Batch order stop placement (integration) — validate retry logic and fallback
3) Reconciliation tests — cover old trades and partial fills
4) Dashboard P&L tests — fractional share calculations and stale price warnings
5) Heartbeat degradation (unit) — ADDED (tests/unit/test_ibkr_state.py)
6) IBKRPositionCache invalidation (unit) — ADDED (tests/unit/test_ibkr_position_cache.py)
7) EarningsDetector precedence + confirmation (unit) — ADDED (tests/unit/test_earnings_detector.py)
8) Stop order type by exchange (unit) — ADDED, UPDATED Feb 10 (Finding #179): ALL exchanges use STP-LMT
trading_mode_persistence.py. Default testing_prod, self-healing file write.trading_mode_persistence.py in production use. Dashboard/trader/reconciliation all agree on mode.run_gateway_monitor.py, reconciliation scoped to recent window, excludes reconciliation/external rows.ibkr-monitor service running. No legacy false-positive alerts since deploy.get_trade_history(). Uses parameterized queries.send_alert.94) Dashboard event loop blocking from sync calls in async handlers (Finding #200)
- Severity: High
- Status: done
- Implementation: Complete (Mar 7, 2026). Converted all route handlers from async def to def so FastAPI runs them in a thread pool. Removed inline blocking fallbacks for earnings calendar and trade history.
- Root Cause:
1. All FastAPI route handlers were declared async def but called only synchronous blocking functions (DB queries, yfinance HTTP, NASDAQ calendar API).
2. In FastAPI, async def handlers run on the main event loop. Any blocking call freezes the entire loop, making ALL endpoints unresponsive.
3. The main dashboard route / had an inline fallback: when the cache was empty, it called get_earnings_with_polymarket(14) which makes 14+ HTTP requests to NASDAQ, plus Gamma API and yfinance calls. This single call could block the event loop for 30+ seconds.
4. Same pattern for get_trade_history("all") fallback — heavy DB query on cache miss.
- Fix:
1. Changed all route handlers from async def to def. FastAPI automatically runs def handlers in a thread pool via anyio.to_thread.run_sync, preventing event loop blocking.
2. Removed get_earnings_with_polymarket(14) inline fallback from dashboard route — now always serves from cache (background thread populates within 30s).
3. Removed get_trade_history("all") inline fallback — same pattern, serves from cache or empty list.
4. Kept async def only for: lifecycle hooks (startup_event, shutdown_event), pure cache reads (api_earnings_calendar), and minimal-computation endpoints (api_health).
- Same Pattern Check: All 25 route handlers audited. Every handler that calls blocking sync functions converted to def.
- Files: earnings_dashboard/app.py
95) Strategy parameter changes: conviction cap at 90% and stop widening to 3%/4%/5% (Finding #202)
- Severity: Critical
- Status: done
- Implementation: Complete (Mar 9, 2026). Two strategy changes based on analysis of 87 live trades.
- Change 1 — LONG conviction cap at 90%:
- Root Cause: 90%+ conviction LONG trades have 14.3% WR, -$116 P&L, 78% stop rate across 29 trades. The stock has already priced in the expected outcome at these conviction levels.
- Fix: Added Config.SIGNAL_LONG_MAX_THRESHOLD = 0.90. Signal generator rejects LONG signals with conviction >= 90%. SHORT signals remain uncapped.
- Same-pattern check: Updated signal_generator.py (generate_signal + is_valid), priority.py, conviction_calculator.py, dashboard app.py.
- Change 2 — Stop loss widening to 3%/4%/5%:
- Root Cause: What-If analysis using Polygon actual exit prices shows moderate stops (3%/4%/5%) improve total P&L from +$51 to +$166 (+$115 improvement).
- Fix: Changed Config defaults: Large 2%->3%, Mid 3%->4%, Small 4%->5%. TradingSignal.post_init now reads from Config instead of hardcoded values.
- Files: src/earnings/config.py, src/earnings/signal_generator.py, src/earnings/priority.py, src/earnings/conviction_calculator.py, earnings_dashboard/app.py
- Documentation: Created docs/FINANCIAL_PROJECTIONS_REVISED_87_TRADES.md as comprehensive reference.
97) Double VIX reduction in position sizing (Finding #203)
- Severity: Critical
- Status: done
- Implementation: Complete (Mar 9, 2026). Removed duplicate VIX reduction.
- Root Cause: When VIX >= 30, _calculate_risk_level() returns ELEVATED (50% cut at line 929), THEN a separate VIX check at line 933 applies ANOTHER 50% cut. Result: 25% * 0.5 * 0.5 = 6.25% instead of intended 12.5%. ORCL got $153 (1 share) instead of ~$306 (2 shares).
- Fix: Removed the duplicate VIX check at line 933. Risk level ELEVATED already handles VIX >= 30.
- Files: src/earnings/risk_manager.py
98) ORCL limit+market double fill race condition (Finding #204) - Severity: High - Status: done (Finding #205 fixes the root cause) - Root Cause: Limit order filled at 14:34:05 but order status remained "PreSubmitted". At 14:34:17 (12s batch timeout), bot saw order as unfilled and placed market fallback. Market order filled at 14:34:20. IBKR=2 shares, DB=1. The duplicate prevention check reads IBKR positions, but the position cache hadn't updated with the limit fill in time. - Fix: See Finding #205.
99) Race condition root cause: time.sleep prevents IBKR event processing (Finding #205)
- Severity: Critical
- Status: done
- Implementation: Complete (Mar 9, 2026). Replaced all time.sleep() with ib.sleep() in order monitoring and fallback paths.
- Root Cause: time.sleep() blocks the Python thread but does NOT process the ib_insync event loop. Fill events arrive from IBKR gateway but sit in the event queue unprocessed. Order status, positions, and fills remain stale. Previous fixes (#3, #178, #199) addressed symptoms (wrong field names, direction checks) but never fixed the root cause: the event loop was never given a chance to process fills.
- Fix: Replaced time.sleep() with self.order_executor.ibkr.ib.sleep() in:
1. batch_order_manager.py _monitor_orders() polling loop (line ~753)
2. batch_order_manager.py _apply_market_fallback() pre-position-check (new ib.sleep(1))
3. batch_order_manager.py _apply_market_fallback() post-cancel delay (line ~834)
4. order_executor.py place_guaranteed_fill() monitoring loop (line ~651)
5. order_executor.py place_guaranteed_fill() post-cancel delay (line ~712)
6. order_executor.py instant exit monitoring loop (line ~1124)
7. order_executor.py instant exit fallback delays (lines ~1113, ~1188)
- Why this is definitive: ib.sleep() processes all pending IBKR events. After each call, order status, positions, and fills are fully current. The duplicate prevention check now sees actual data, not stale cache.
- Same-pattern check: All time.sleep() in order monitoring/fallback paths across both files replaced.
- Files: src/earnings/batch_order_manager.py, src/earnings/order_executor.py
- Mitigation: DB manually reconciled to 2 shares. Stop order covers only 1 share — needs manual fix.
- Files: src/earnings/batch_order_manager.py, src/earnings/order_executor.py
100) verify_earnings_dates() silently failed due to self._get_engine() bug (Finding #206)
- Severity: Critical
- Status: done
- Implementation: Complete (Mar 9, 2026). Fixed broken method call + added startup log.
- Root Cause: verify_earnings_dates() called self._get_engine() which does not exist on SignalGenerator. The AttributeError was caught by a broad try/except and silently returned empty. The function should use self.engine (which exists and works). This meant earnings date cross-validation against NASDAQ calendar NEVER ran, despite the EarningsCalendar and finance_calendars being properly installed and available.
- Impact: ORCL entered a day early (March 9 instead of March 10) because the DB date from Polymarket slug was wrong and verify_earnings_dates() would have caught and corrected it.
- Fix: Changed self._get_engine() to self.engine. Added startup log confirming "NASDAQ calendar available for date cross-validation" so silent failures are immediately visible.
- Files: src/earnings/signal_generator.py
101) Enable small cap longs at 70-80% conviction (Finding #207)
- Severity: Medium
- Status: done
- Implementation: Complete (Mar 9, 2026). Changed small cap LONG rule from "require 90%+" to "allow 70-80% only".
- Root Cause: Previous rule required 90%+ conviction for small cap longs, but Finding #202 blocked 90%+ entirely. This made small cap longs impossible. Backtest analysis (Q3 2025, proper MCAP classification) showed:
- 70-80% conviction: 6 trades, 66.7% WR, EV +$30.68 (strong positive)
- 80-90% conviction: 10 trades, 30.0% WR, EV +$0.40 (near-zero, not worth the risk)
- Fix: Allow small cap longs at 70-80% only (block 80%+). Full position sizing (10% of capital). Priority score 2/6.
- Files: src/earnings/signal_generator.py, src/earnings/priority.py, earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html
102) Dual MCAP classification systems caused KSS to bypass small cap rules (Finding #208)
- Severity: Critical
- Status: done
- Implementation: Complete (Mar 9, 2026). Centralized market cap data into single source of truth.
- Root Cause: Signal generator had its own MARKET_CAPS dict (~100 symbols) separate from the dashboard's _KNOWN_MCAPS (265 symbols). KSS ($3B) was not in the signal generator's dict. yfinance lookup failed (rate-limited), so classify_market_cap(None) defaulted to MID. As Mid cap, KSS at 86.5% conviction LONG passed all filters. As Small cap (correct), it should have been blocked (80%+ LONG not allowed for small caps).
- Fix:
1. Created src/earnings/market_cap_data.py — single source of truth for all market cap classifications (265+ symbols). Used by signal generator, dashboard, and backtest scripts.
2. Signal generator's get_market_cap_for_symbol() now reads from KNOWN_MCAPS as primary source, yfinance as fallback.
3. Changed classify_market_cap(None) default from MID to SMALL (most restrictive — unknown stocks get small cap safety rules).
4. Added discover_unknown_mcaps() auto-enrichment: queries upcoming earnings symbols, looks up unknowns via yfinance, persists tier to DB, adds to runtime dict. Runs during dashboard deep refresh (every 2h).
5. Dashboard _KNOWN_MCAPS now imports from the shared module (was inline 285-line dict).
- Same Pattern Check: Searched for MarketCap.LARGE defaults in DB loading code — found 4 instances in reconciliation/position/orchestrator, but these are for existing positions where the column is already populated at entry. Not the same pattern.
- Impact: KSS entered as LONG at 86.5% with Mid cap rules ($430 position, 4% stop). Should have been blocked entirely.
- Files: src/earnings/market_cap_data.py (new), src/earnings/signal_generator.py, earnings_dashboard/app.py
103) Dashboard earnings calendar always shows "Refresh error" (Finding #209)
- Severity: Medium
- Status: done
- Implementation: Complete (Mar 10, 2026). Increased JS abort timeout from 15s to 30s and added resp.ok check.
- Root Cause: The JS AbortController timeout was 15s. On cold start, the server thread pool is saturated by the slow / page render (~55s), causing the /api/earnings-calendar fetch to timeout. Additionally, the fetch did not check resp.ok before calling resp.json(), so 500 errors with HTML bodies caused parse failures shown as generic "Refresh error".
- Fix: Increased timeout to 30s. Added if (!resp.ok) throw new Error('Server returned ' + resp.status) before resp.json() for clearer error messages.
- Files: earnings_dashboard/templates/dashboard.html
104) Main loop stall during BMO/AMC scheduled cycles causes forced restarts (Finding #210)
- Severity: High
- Status: done
- Implementation: Complete (Mar 10, 2026). Added intermediate progress marks and NASDAQ timeout.
- Root Cause: The main loop watchdog (120s timeout) attributed ALL work inside run_bmo_cycle() and run_amc_cycle() to a single progress stage run_scheduled_cycles. These cycles legitimately take >120s due to: (1) verify_earnings_dates() making 5 NASDAQ API calls with no timeout, (2) signal generation doing yfinance lookups per symbol, (3) multi-day BMO reservation calculation. The watchdog saw no progress for >120s and killed the process.
- Fix: (A) Added self._mark_main_loop_progress() calls inside run_bmo_cycle() and run_amc_cycle() after verify_dates and after signal generation, so watchdog sees forward progress. (B) Wrapped EarningsCalendar.get_upcoming_earnings() in verify_earnings_dates() with a 30s ThreadPoolExecutor timeout to prevent hung NASDAQ API from blocking indefinitely.
- Same Pattern Check: Other get_earnings_by_date() calls in earnings_timing_fallback.py are per-symbol (not bulk) and have individual try/except — lower risk. The bulk call in verify_earnings_dates() was the only unbounded one.
- Files: src/earnings/trading_orchestrator.py, src/earnings/signal_generator.py
105) Position sizing increase: 25/18/10 to 30/22/15 (Finding #211)
- Severity: Medium
- Status: done
- Implementation: Complete (Mar 10, 2026). Increased position sizes based on 95-trade analysis.
- Root Cause: The 90%+ conviction filter (Finding #202) removed ~36% of trades (26 out of 81 LONG trades). Fewer concurrent positions means more capital available per trade. Analysis of position size quintiles showed Q5 (largest positions, avg $490) generated best absolute returns (+$139, $7.70/trade avg) while Q1-Q2 (smallest, $157-$226) were net losers (-$40 combined). The "kept" trades (70-89% conviction) have 40% WR vs 15% for filtered 90%+.
- Fix: Changed Config defaults — Large 25%→30%, Mid 18%→22%, Small 10%→15%. On $2,450 capital: Large $735 (+$123/trade), Mid $539 (+$98/trade), Small $367 (+$122/trade). Max concurrent exposure ~65-90% of capital with fewer positions.
- Same Pattern Check: Updated all references in risk_manager.py (4 locations), STRATEGY.md (3 tables), FINANCIAL_PROJECTIONS_REVISED_87_TRADES.md, dashboard.html (System tab).
- Files: src/earnings/config.py, src/earnings/risk_manager.py, docs/STRATEGY.md, docs/FINANCIAL_PROJECTIONS_REVISED_87_TRADES.md, earnings_dashboard/templates/dashboard.html
96) Volume_24h_at_entry never populated in trade_audit_log (Finding #201)
- Severity: Medium
- Status: done
- Implementation: Complete (Mar 8, 2026). Added volume_24h_at_entry to all 4 INSERT INTO trade_audit_log statements. Backfilled 41/94 trades.
- Root Cause: All INSERT statements omitted the column despite signal.volume_24h being available in scope.
- Files: src/earnings/trading_orchestrator.py, src/earnings/position_manager.py, src/earnings/reconciliation_manager.py
107) Chinese ADRs and other stocks missing from COMPANY_TIMING_LOOKUP — missed trades (Finding #213)
- Severity: High
- Status: done
- Implementation: Complete (Mar 18, 2026). Added 27 tickers to DEFAULT_TIMINGS in earnings_timing.py.
- Root Cause: PDD, BABA, NIO, XPEV, LI, WB, JD and other stocks were not in the COMPANY_TIMING_LOOKUP table. When the Gamma API endDate fell in the ambiguous 13-19 UTC window or midnight (00:00), the sync service stored earnings_time = NULL. The signal generator's determine_earnings_timing() then checked: (1) DB=NULL, (2) COMPANY_TIMING_LOOKUP=not found, (3) EarningsCalendar=time-not-supplied for some, (4) yfinance=rate limited. Result: timing returns None, trade is SKIPPED.
- Impact: PDD (6/6 priority, Large cap, 77% conviction LONG) would have been skipped entirely. BABA was correctly resolved by EarningsCalendar but others like CTAS and DBI were not.
- Fix: Added 27 tickers to DEFAULT_TIMINGS covering Chinese ADRs (BMO), payroll/business services (BMO), retail (BMO), and others. All classifications verified against historical DB data.
- Added: BABA, PDD, JD, NIO, XPEV, LI, WB, BIDU, BEKE, TME, BILI, IQ (Chinese ADRs BMO), PAYX, ADP, ACN (payroll BMO), DBI, CHWY, KSS, BBW, ANF, AEO, URBN, ASO (retail BMO), WSM, CTAS (BMO), FIVE, GME, NMAX (AMC).
- Files: src/earnings/earnings_timing.py
106) yfinance rate-limited on server — eliminate dependency for mcap auto-discovery (Finding #212)
- Severity: High
- Status: done
- Implementation: Complete (Mar 16, 2026). Routed auto-discovery through yfinance_limiter with Finnhub fallback, added IBKR-first VIX/SPY, reduced dashboard yfinance calls.
- Root Cause: Production server IP permanently rate-limited by Yahoo Finance (429 on every call). discover_unknown_mcaps() called yf.Ticker() directly (no fallback). Dashboard made uncoordinated direct yfinance calls for VIX/SPY, company names, dependency checks, and batch mcap. All returned zero data, leaving symbols as "Unknown" in the dashboard.
- Fix:
1. Added 6 missing symbols to KNOWN_MCAPS: BABA ($200B Large), CHWY ($12B Mid), CTAS ($80B Mid), NMAX ($2B Small), PAYX ($50B Mid), DLTR ($15B Mid).
2. Routed discover_unknown_mcaps() through yfinance_limiter.get_market_cap() which has automatic Finnhub fallback when yfinance returns 429.
3. Dashboard _fetch_market_health_data() now checks IBKR snapshot for VIX/SPY before falling back to yfinance.
4. Dashboard get_company_name() checks KNOWN_MCAPS tuple[2] before calling yfinance.
5. Dashboard check_dependencies() no longer makes a yfinance API call (just checks module import).
6. Dashboard populate_mcap_cache() skips symbols already in KNOWN_MCAPS.
- Same Pattern Check: Remaining direct yf.Ticker() calls in dashboard were routed through yfinance_limiter (Mar 22, 2026 — Finding #217). Finnhub fallback behavior for market cap was tightened in Finding #219 (empty Yahoo marketCap + MEDIUM priority during Yahoo backoff).
- Files: src/earnings/market_cap_data.py, earnings_dashboard/app.py
108) Production timing verification — PDD/BABA BMO (Finding #214)
- Severity: Low (ops / evidence)
- Status: done
- Evidence: docs/OPS_TIMING_VERIFICATION.md. Production logs show PDD: Timing from database: BMO and BABA: Timing from database: BMO (Mar 18–19, 2026). Trades executed and exited per plan (stops filled Mar 19).
- Files: docs/OPS_TIMING_VERIFICATION.md
109) Dashboard cold start — heavy _fetch_db_stats_from_db on first paint (Finding #215)
- Severity: Medium
- Status: done
- Root Cause: First HTTP request to / ran the full DB stats query (category coverage joins, per-market availability, data_gaps) before background cache warmed.
- Fix: _fetch_db_stats_from_db(full=False) on cache miss + background thread upgrades to full=True; reuse state.positions for timeline to avoid duplicate positions query.
- Files: earnings_dashboard/app.py
110) PostgreSQL “out of shared memory” / lock pressure on managed DO (Finding #216)
- Severity: Medium
- Status: deferred (monitor)
- Root Cause: Transient lock spikes during heavy dashboard queries; max_locks_per_transaction not adjustable on DigitalOcean managed Postgres.
- Mitigation: Avoid many simultaneous dashboard hard-refreshes; rely on dashboard caching; see docs/POSTGRES_OPERATIONS.md.
- Files: docs/POSTGRES_OPERATIONS.md
111) Dashboard yfinance — route fallbacks through limiter (Finding #217)
- Severity: Medium
- Status: done
- Fix: Replaced remaining direct yf.Ticker() usage in dashboard with yfinance_limiter (get_market_cap, get_price, get_ticker_info, get_exchange, VIX/SPY already via limiter after IBKR).
- Files: earnings_dashboard/app.py
112) Strategy Analysis — position sizing quintiles in report + tab (Finding #218 / memory.md §K)
- Severity: Low
- Status: done
- Fix: compute_position_sizing_quintiles() in scripts/strategy_performance_analysis.py; table + renderPositionQuintiles() in Strategy Analysis tab; print_summary() prints quintiles when present. Re-run analysis script on prod (with DATABASE_URL) after deploy to refresh strategy_analysis_report.json.
- Files: scripts/strategy_performance_analysis.py, earnings_dashboard/templates/dashboard.html
113) Dashboard upcoming table — mass “Unknown” MCAP despite #212/#217 (Finding #219)
- Severity: High
- Status: done
- Root Cause: (1) YFinanceRateLimiter.get_market_cap() only called Finnhub on specific Yahoo exception text; Yahoo often returns empty info/no marketCap without raising, so Finnhub never ran. (2) For Priority.MEDIUM (dashboard batch), when Yahoo was in backoff, the method returned None immediately — Finnhub was skipped entirely. (3) populate_mcap_cache could treat mixed-case tickers as uncached vs get_cached_mcap lookups. (4) Deep-refresh discover_unknown_mcaps used a 60s thread timeout vs dozens/hundreds of sequential lookups. (5) DB persist used exact ticker = :symbol match; casing mismatches skipped updates. (6) Hourly earnings_market_fetcher inserted earnings_markets rows without market_cap_category, so the dashboard showed NULL/Unknown until a rare manual sync.
- Fix: Always attempt Finnhub when Yahoo yields no cap (and when Yahoo is rate-limited, before giving up on non-CRITICAL); Finnhub calls throttled ~1.05s apart for free-tier quota; uppercase-normalized dashboard mcap cache + UPPER(TRIM(ticker)) in persist; discovery timeout 300s. EarningsMarketSync.get_market_cap_category() checks KNOWN_MCAPS then limiter; backfill_upcoming_null_mcaps() runs from run_full_sync() and after each EarningsMarketFetcher.run_full_sync(); API gap-fill inserts include company + mcap when possible. YFinanceRateLimiter no longer passes requests.Session into yf.Ticker (Yahoo requires curl_cffi session).
- Future-proof: Keep FINNHUB_API_KEY on prod; optional IBKR fundamentals if Yahoo+Finnhub fail; monitor limiter + Finnhub 429s in logs.
- Files: src/utils/caching.py, earnings_dashboard/app.py, src/earnings/market_cap_data.py, src/db/earnings_sync.py, src/data_tracker/collectors/earnings_market_fetcher.py, scripts/sync_earnings_markets.py
- Documentation (cross-refs only — canonical narrative stays here per memory.md §J): docs/CONTEXT.md, docs/STRATEGY.md §7.8, docs/DATABASE_DIRECTIVES.md, docs/OPERATIONS.md, .cursor/memory.md §L. Finding #220 extends the same docs for BMO/AMC DB sync + dashboard gating.
114) Trusted earnings_time DB enrichment, upcoming TRADE gating, sync transaction fix, UNTY default (Finding #220)
- Severity: High (data integrity + operator clarity)
- Status: done
- Implementation: Complete (Apr 2026).
- Root Cause:
1. TBD / NULL earnings_time: NASDAQ calendar often returns non-parseable strings; sync_from_markets_table only stored calendar output. Tickers not in DEFAULT_TIMINGS/overrides.json stayed NULL/TBD. Dashboard showed ✓ TRADE on conviction/volume/depth alone while BMO/AMC was unknown — misleading vs batch logic (SignalGenerator skips when determine_earnings_timing() returns None unless runtime fallback resolves).
2. sync_from_polymarket_api INSERT omitted earnings_time, leaving gaps until a later path filled rows.
3. SQLAlchemy 2: with engine.connect() plus nested with conn.begin() on the same connection per row caused “Transaction already initialized” — hundreds of sync errors, zero effective updates per run on affected deployments.
- Fix:
1. resolve_earnings_time_for_sync() — Order: NASDAQ calendar only if explicit BMO/AMC; else get_timing_manager().get_timing() (same trusted tier as signal generator priority 2). Does not persist yfinance/web fallback into DB (reliability: no speculative timing stored as calendar truth).
2. backfill_upcoming_null_earnings_time() — Fills upcoming rows where earnings_time is NULL, empty, or TBD. Wired into run_full_sync() after mcap backfill. Logs as earnings_time_backfill in scripts/sync_earnings_markets.py.
3. API gap-fill INSERT now includes earnings_time when resolvable.
4. Per-row with self.engine.begin() for market-table sync writes; initial SELECT uses a short-lived connect() only.
5. Dashboard upcoming table: ✓ TRADE requires known BMO or AMC; if edge/liquidity OK but timing unknown → NEEDS TIMING badge (tooltip notes bot may still use runtime fallback).
6. UNTY (Unity Bancorp): Added UNTY → BMO to DEFAULT_TIMINGS (regional bank, pre-market). One-off DB fixes: scripts/backfill_ticker_earnings_timing.py (--ticker / --timing) for NULL/TBD/empty only.
- Same Pattern Check: Signal generator unchanged for fallback order; DB layer aligned to trusted sources only.
- Files: src/db/earnings_sync.py, scripts/sync_earnings_markets.py, earnings_dashboard/templates/dashboard.html, src/earnings/earnings_timing.py, scripts/backfill_ticker_earnings_timing.py
- Documentation: docs/DATABASE_DIRECTIVES.md, docs/OPERATIONS.md, docs/CONTEXT.md, docs/STRATEGY.md §7.8, .cursor/memory.md (cross-refs).
115) Polymarket opportunity log + counterfactual queue for all reject stages (Finding #221)
- Severity: Low (research / analytics)
- Status: done
- Root Cause: Post-hoc analysis needed a single append-only log for every Polymarket earnings candidate on the signal path (not only executed trades or skipped_signals volume skips), with live-rule entry/exit/stop timestamps for offline P&L simulation.
- Fix:
1. Migration 021_polymarket_opportunity_log.sql: Table polymarket_opportunity_log with skip_stage / skip_reason, conviction/volume/depth/mcap/priority, scheduled entry/exit + stop_loss_pct, JSON snapshot, nullable counterfactual columns.
2. SignalGenerator: _emit_pm_opportunity_log + _hypothetical_schedule_fields populate BMO/AMC schedules and stop % (from Config by cap tier) for all stages where timing + earnings datetime are known (including depth/timing/generate_signal rejects and calendar paths).
3. src/earnings/counterfactual_pricing.py + scripts/compute_counterfactual_pnl.py: With POLYGON_KEY, loads Polygon 1-minute bars between scheduled entry/exit, fills at bar close near schedule (research proxy for limit fills), applies stop on bar low (LONG) / high (SHORT) vs logged stop_loss_pct. Without Polygon, yfinance daily close-to-close on entry/exit calendar days (yfinance_daily_approx_v1, no intraday stop). hypothetical_pnl uses fixed USD 10,000 notional (COUNTERFACTUAL_NOTIONAL_USD env override). Script marks non-simulatable rows no_price_simulation_v1 and UPDATEs price columns for simulatable rows.
- Files: src/data_tracker/migrations/021_polymarket_opportunity_log.sql, src/earnings/polymarket_opportunity_log.py, src/earnings/signal_generator.py, src/earnings/counterfactual_pricing.py, scripts/compute_counterfactual_pnl.py, tests/unit/test_counterfactual_pricing.py
- Documentation (cross-refs per memory.md §J): docs/DATABASE_DIRECTIVES.md, docs/DATA_ARCHITECTURE.md, docs/OPERATIONS.md, docs/CONTEXT.md, docs/STRATEGY.md
DEFAULT_TIMINGS/overrides only; upcoming timing backfill; API inserts set earnings_time when known; sync uses per-row engine.begin(); dashboard NEEDS TIMING when BMO/AMC unknown; UNTY default BMO + optional backfill_ticker_earnings_timing.py.master (commits 58e50b5, 652bf92, b1d8ecf area). Run run_full_sync / sync_earnings_markets.py with PYTHONPATH set to project root on servers.volume_total (DONE).cursor/memory.md §B (full execution path + runtime simulation), §E (all consumers of “volume for gate vs display”), or §G (NULL vs 0 vs Gamma, Jinja vs JS) in one pass. That incomplete rollout directly caused the user-visible regressions repaired in #223 ($0 display) and #224 (volume_ok vs displayed $ / trader DB-only). Canonical process lesson: Finding #225.SIGNAL_MIN_VOLUME_TOTAL defaulted to 0.0, so SignalGenerator._build_trading_signal skipped the low-volume branch whenever volume_total was NULL (condition volume_total is not None was false). The earnings dashboard used a hardcoded $2,000 floor and Gamma volume, not markets.volume_total, so the UI could show LOW VOL / below $2K semantics while the bot still emitted signals. trading_positions.min_volume_threshold_used and position_manager inserts hardcoded 2000.0 instead of config.SIGNAL_MIN_VOLUME_TOTAL default 2000.0 (USD cumulative); set 0 in .env to disable the gate (e.g. research).volume_total is missing, fail-closed — log skipped_signals with skip_reason=volume_total_missing, return volume_total_missing. Low cumulative volume still uses low_volume with volume_24h column storing the total USD value compared (unchanged analytics column semantics).get_polymarket_earnings_from_db: read m.volume_total from SQL; volume_ok / volume_threshold use Config.SIGNAL_MIN_VOLUME_TOTAL; same fail-closed rule when DB total is NULL and threshold > 0.Config.SIGNAL_MIN_VOLUME_TOTAL as min_volume_threshold_used.src/earnings/config.py, src/earnings/signal_generator.py, earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html, src/earnings/trading_orchestrator.py, src/earnings/position_manager.py, tests/unit/test_signal_volume_threshold.py, .env.exampledocs/STRATEGY.md §2.4, docs/CONTEXT.md, docs/OPERATIONS.md, docs/EDGE_CASES.md, docs/DATA_ARCHITECTURE.md, .cursor/memory.mdvolume_total = 0 vs Gamma) (DONE)markets.volume_total when not SQL-NULL, else Gamma. SignalGenerator.get_market_volume uses float(row.volume_total) if row.volume_total else None, so numeric 0 in Postgres is treated as no total (same as NULL). The dashboard used float(row[8]) if row[8] is not None else None, so 0 stayed 0 and never fell back to Gamma’s cumulative volume. Many markets rows carry 0 until collectors backfill, while Gamma and orderbook_snapshots already show activity — hence $0 volume beside healthy OB quality (different pipelines, not swapped fields).get_polymarket_earnings_from_db, normalize volume_total the same way as get_market_volume (NULL or 0 → db_volume_total is None); keep volume_ok fail-closed when there is no positive DB total and threshold > 0; display uses Gamma when DB has no positive total.earnings_dashboard/app.pymarkets.volume_total from the API so DB and Gamma converge; optional dashboard hint when volume is Gamma-only vs DB-backed.volume_total was NULL/0, but volume_ok still required db_volume_total is not None and db_volume_total >= threshold. Display showed e.g. $3,282 while volume_ok stayed false → every row showed the yellow ⚠ in MIN VOLUME and LOW VOL in TRADE? even when cumulative liquidity exceeded $2K. Trading used get_market_volume (DB-only), so the bot would also skip those names as volume_total_missing — dashboard and bot were consistent but wrong vs Polymarket liquidity.src/earnings/volume_policy.py: effective_cumulative_volume_usd (positive DB else Gamma) and volume_threshold_passes — single rule for gate + display dollar amount.get_polymarket_earnings_from_db: volume / volume_ok use that module (same as trader semantics).SignalGenerator.get_market_volume: When DB has no positive volume_total (or no row), Gamma API cumulative volume with a short TTL cache; when DB is positive, use DB only (no extra HTTP).src/earnings/volume_policy.py, src/earnings/signal_generator.py, earnings_dashboard/app.py, tests/unit/test_volume_policy.pydocs/STRATEGY.md §2.4 (gate uses effective cumulative = DB if positive else Gamma).get_market_volume truthiness on 0; then volume_ok still DB-only while display used Gamma).src/earnings/volume_policy.py + get_market_volume Gamma fallback (#224). (2) .cursor/memory.md §M — mandatory grep + edge matrix before any future cross-cutting gate change. (3) This finding as canonical “start investigation from first rollout” anchor.docs/FINDINGS.md (this entry + #222 retrospective line), .cursor/memory.md §M + §L updatemarkets.volume_total is written by continuous_collector.sync_markets_from_gamma every 15 minutes (MARKET_SYNC_INTERVAL = 900). The upcoming table already fetches Gamma per refresh (dashboard cache ~30s). Using effective_cumulative_volume_usd for display preferred positive DB, so the VOLUME cell could lag polymarket.com by up to ~15 min while conviction/price in the same row was live. Production check: MAX(markets.last_synced_at) was current; polymarket-collector active.volume in get_polymarket_earnings_from_db = Gamma cumulative (volume / volumeNum) from the same response as prices; volume_ok still volume_threshold_passes (DB + Gamma per #224).MARKET_SYNC_INTERVAL if DB analytics need fresher markets.volume_total.earnings_dashboard/app.pypolymarket_opportunity_log had the counterfactual schema (added in #221) but 0 of 268 historical rows had hypothetical_pnl computed — scripts/compute_counterfactual_pnl.py was operator-only with no timer, so cost-of-skipped-signal could never be measured.skipped_signals had actual_outcome / would_have_won columns since migration 007 but 303 of 303 rows were NULL — no writer was ever built. The volume-gate (#222–#226) could never be validated.earnings_markets.actual_beat was only set on the live trading path (orchestrator on instant-exit detection), so only 68 of 800 (8.5%) were resolved; 194 high-conviction past-date markets were unresolved. The post-earnings eps_actual / revenue_actual / stock_price_* / stock_return_* columns had no automated writer at all.trade_audit_log.vix_at_entry / sp500_change_at_entry were NULL on 107 of 107 trades — the columns existed but the writer in position_manager.persist_position_open never set them. VIX / SPY / regime circuit breakers and bucketed studies could never run on real data.022 (regime_at_entry, regime_details on trade_audit_log), 023 (intended_position_value, intended_shares, intended_stop_price, intended_flex_applied, regime_at_entry, vix_at_entry, sp500_change_at_entry on polymarket_opportunity_log), 024 (same set + hypothetical_pnl, hypothetical_pnl_pct, hypothetical_stop_hit, counterfactual_computed_at, counterfactual_version on skipped_signals).src/earnings/market_regime_provider.py — single shared MarketRegimeProvider (IBKR primary for VIX+SPY, yfinance fallback, 60-second snapshot cache, SPY MA20/50/200 classifier matching scripts/strategy_performance_analysis.get_spy_regime_at_date). SignalGenerator.__init__ now accepts regime_provider + risk_manager; _emit_pm_opportunity_log and log_skipped_signal stamp every row with regime/VIX/SPY change and intended sizing. position_manager._persist_position_open writes regime_at_entry, regime_details, vix_at_entry, sp500_change_at_entry to trade_audit_log on entry. TradingOrchestrator constructs the singleton provider once and threads it into both. Per memory.md §M, this is the only definition of regime classification — any future gate must import from here.src/data_tracker/collectors/outcome_resolver.py with run_once() performing four ordered passes:earnings_markets: Yahoo earnings_history → Finnhub /stock/earnings → late-stage Polymarket probability proxy for actual_beat / eps_actual; Polygon 1m bars for stock_price_day_before_close / _day_of_open / _1h_after / _4h_after / _day_of_close / _next_day_close; stock_return_* derived arithmetically. Idempotent via COALESCE(... IS NULL).skipped_signals: reuses counterfactual_pricing.simulate_minute_path with scheduled BMO/AMC entry/exit and a 3% default stop; sets actual_outcome (win / loss / stop), would_have_won, and the hypothetical_* columns.polymarket_opportunity_log: invokes existing run_counterfactual_for_row with fixed $10K notional (replaces the manual compute_counterfactual_pnl.py usage but preserves it for ad-hoc runs).trade_audit_log.prediction_correct: single SQL UPDATE joining to earnings_markets.actual_beat (now populated by pass 1). Replaces the hardcoded beat_map in scripts/backfill_prediction_correct.py.
Wired as polymarket-resolver.service + .timer at 06:00 ET daily, installed by deploy/deploy_from_github.sh.scripts/backfill_learning_loop.py (looped resolver, no date floor) + scripts/backfill_regime_for_audit_log.py (one-shot historical regime/VIX fill for the 107 closed trades using classify_regime_for_date + fetch_historical_vix). Polygon plan guidance + POLYGON_KEY added to .env.example. Polygon Stocks Starter ($29/mo, 100 req/min) recommended; default --polygon-sleep 0.6.scripts/strategy_performance_analysis.py adds three new sections — skipped_signal_cost, volume_gate_counterfactual, regime_vix_performance — persisted in scripts/strategy_analysis_report.json and rendered in the dashboard "Strategy Analysis" tab via new renderLearningLoop() HTML + JS. Replaces the old all-NULL VIX bucket section.SELECT count(*) FROM polymarket_opportunity_log WHERE counterfactual_computed_at IS NULL → 0 after backfillSELECT count(*) FROM skipped_signals WHERE earnings_date < CURRENT_DATE AND counterfactual_computed_at IS NULL → 0 after backfillSELECT count(*) FROM earnings_markets WHERE earnings_date < CURRENT_DATE - INTERVAL '2 days' AND actual_beat IS NULL → < 5% (Yahoo + Finnhub + PM resolution lag)SELECT count(*) FROM trade_audit_log WHERE entry_time > '2026-04-22' AND vix_at_entry IS NULL → 0journalctl -u polymarket-resolver.timer shows daily 06:00 ET run.actual_beat / actual_outcome / prediction_correct. Findings: orchestrator's instant-exit path still pre-fills actual_beat (kept — strictly faster than the 06:00 timer); scripts/backfill_prediction_correct.py superseded by resolver Pass 4 but left in repo for one-off symbol-list use. No duplicate regime classifier — scripts/strategy_performance_analysis.get_spy_regime_at_date is now mirrored by MarketRegimeProvider; future work can collapse the script into a thin wrapper.src/data_tracker/migrations/022_regime_vix_capture.sql, 023_opportunity_log_intended_size.sql, 024_skipped_signals_intended_size.sqlsrc/earnings/market_regime_provider.py (new), src/earnings/signal_generator.py, src/earnings/polymarket_opportunity_log.py, src/earnings/position_manager.py, src/earnings/trading_orchestrator.pysrc/data_tracker/collectors/outcome_resolver.py (new), deploy/systemd/polymarket-resolver.service (new), deploy/systemd/polymarket-resolver.timer (new), deploy/deploy_from_github.shscripts/backfill_learning_loop.py (new), scripts/backfill_regime_for_audit_log.py (new)scripts/strategy_performance_analysis.py, earnings_dashboard/templates/dashboard.html.env.example (POLYGON_KEY + plan guidance)docs/DATA_ARCHITECTURE.md (resolver + new columns), docs/DATABASE_DIRECTIVES.md (writer ownership of actual_beat / vix_at_entry), docs/STRATEGY.md §6 / §10 (regime-aware future gates pointer), docs/OPERATIONS.md (new service runbook + manual backfill commands), docs/AGENT_HANDOFF.md (services list + finding pointer), .cursor/memory.md §M (regime provider as single source).After the initial Track A backfill, actual_beat coverage was only 25% (179/715) — Yahoo's earnings_history and Finnhub's free-tier /stock/earnings both have severe coverage gaps for mid/small-caps. Two additive fixes raised this to 73% (524/715):
Tier 3 — Polygon /vX/reference/financials (commit 926f967):
- Probed Polygon Stocks Starter ($29/mo): the financials endpoint is unlocked (the screenshot's "Financials & Ratios ✗" gate refers to the realtime ratios endpoint, not historical SEC filings).
- New _polygon_earnings_actual helper queries with a ±120 day window, picks the filing whose end_date is closest to target within 30 days, prefers diluted EPS over basic.
- Also captures revenue_actual from Polygon's revenues field (was 0 rows before).
- Coverage lift: actual_beat 25% → 51%, eps_actual 15 → 210 (14×), revenue_actual 0 → 197.
Tier 4 — Polymarket cascade (commit a86058f):
- The remaining 49% gap was almost entirely "we have eps_actual from Polygon but no eps_estimate from anywhere → can't compute BEAT." The Polymarket data was always there; we weren't using it properly.
- New cascade in _pm_resolution_actual_beat:
1. Post-event yes_price (latest snapshot from price_snapshots >12h after earnings_date) — markets converge to 0.000 / 1.000 within hours of resolution; this is essentially ground truth. Sample of 15 unresolved tickers showed every one converged cleanly (TFC 1.000, STT 0.001, JNJ 0.820 → 0.999, etc).
2. Pre-event prob cascade (prob_30min_before → prob_1h → prob_6h → prob_12h → prob_24h) — falls through nulls to find the most recent conviction read.
- Threshold relaxed from 0.99/0.01 to 0.95/0.05, calibrated against our own resolved-EPS rows: yes_price ≥0.95 → BEAT with 93.75% accuracy (n=112); yes_price ≤0.05 → MISS with 93.6% accuracy (n=47). The old strict thresholds remain available via strict=True for callers that want maximum precision.
- Coverage lift: actual_beat 51% → 73% (366 → 524). The 158-row gain came almost entirely from price_snapshots post-event yes_price.
Final actual_beat source mix on the 524 resolved rows:
- EPS surprise (eps_actual ≥ eps_estimate): primary for ~30% (where Yahoo or Polygon supplied both numbers)
- Post-event Polymarket yes_price: primary for ~50%
- Pre-event prob cascade: primary for ~20%
Remaining 191 unresolved rows (~27%): these are markets where neither Yahoo / Finnhub / Polygon EPS exists AND no Polymarket conviction snapshot was ever taken (the conviction_tracker service didn't have those condition_ids in scope when their earnings ran). Most are pre-Jan-2026 markets predating reliable conviction collection. The daily polymarket-resolver.timer will keep retrying; new tickers as they're added to the conviction collector will be picked up automatically.
Final test count: 51 unit tests in test_outcome_resolver.py + test_market_regime_provider.py, including the 9 new tests for the Polymarket cascade (post-event vs pre-event, default vs strict thresholds, NULL cascade, missing-key tolerance for older schema rows).
Polygon plan note: The user upgraded to Stocks Starter ($29/mo). Verified via burst test (50 req in 5s with zero 429s — Starter is unlimited API calls, only restriction is 15-min-delayed real-time). The default --polygon-sleep 0.05 in scripts/backfill_learning_loop.py and the polymarket-resolver.service ExecStart was tuned accordingly. Earlier docs that cited "100 req/min" for Starter were corrected in commit 9324af5.
outsideRth=True per Finding #179, so a software-only gate is functionally inert.scripts/analyze_finding_a_full.py (570 lines, read-only). The simulator:outsideRth=True; price evolution identical to A_base — INERT for P&L) from FA_hard (hardware also flipped to outsideRth=False; stop is silent in pre/post; on first RTH bar after a non-RTH segment, gap-throughs the stop are exited at the open price, otherwise stop re-arms intraday).exit_time is treated as a hard ceiling so the simulator can't fake-credit the rule for trades the live instant_exit:earnings_miss path catches).A_base baseline; cancels Polygon-bar-vs-IBKR-fill reconstruction noise; n=51 stopped trades):| Rule | Δ vs A_base | per-100 | better | worse | Bias-gauntlet verdict |
|---|---|---|---|---|---|
| FA_soft (rule as written) | $0.00 | $0.00 | 0 | 0 | INERT — software gate cannot change behavior while hardware fills outsideRth |
| FA_hard (hardware also gated) | −$213.23 | −$418.10 | 9 | 23 | FAIL — net negative, opposite sign from audit's claim |
| RuleB (±90 min window only) | +$62.97 | +$123.47 | 4 | 1 | PASS — only stop-loss rule that survives |
| RuleD (ATR k=2.0) | +$99.23 | +$194.57 | 22 | 27 | FAIL — ex-top-2 collapses to −$109/100 |
| RuleG (window + ATR) | +$102.32 | +$200.63 | 22 | 27 | FAIL — ex-top-2 collapses to −$103/100 |
| RuleF (no stop ever) | −$12.77 | −$25.04 | 24 | 25 | net-zero, very high variance |
rearm_gap_open — n=22 — −$416.72 (avg −$18.94 / trade): stop blocked in extended hours, RTH opened past stop → forced fill at the worse opening price. Worst trades: MDB (−$82), TTD (−$58), NKE (−$54), BABA (−$38), WDAY (−$26), NYT (−$23), RL (−$18), GTLB (−$15), HOOD (−$15), LOW (−$13), CSCO (−$12), VSCO (−$12).blocked_to_scheduled — n=9 — +$203.61 (avg +$22.62 / trade): stop blocked, stock recovered fully, exited at scheduled = profit. Best trades: KSS (+$66), GME (+$33), BBY (+$31), KR (+$23), JPM (+$15).STRATEGY_AUDIT_v3.md §6 as lesson #6): Every counterfactual rule needs a TRUE simulator that replays the proposed rule end-to-end on the actual data. Proxies ("Rule B is close enough to Finding A") and observational tables ("stops fire in pre-market AND pre-market trades have negative P&L → therefore blocking pre-market stops would have helped") are NOT counterfactual evidence — they conflate selection with causation. The +$300–$400 audit estimate compounded both errors plus +$117 of Polygon-bar-vs-IBKR-fill simulator noise that was attributed to rule "lift."scripts/analyze_finding_a_full.py (new, 570 lines)scripts/finding_a_full.csv (output, 51 rows × 41 cols)docs/STRATEGY_AUDIT_v3.md — §10 retraction added, §4 ranked recommendations reviseddocs/AGENT_HANDOFF.md §11 — outstanding-work table re-rankeddocs/STRATEGY_PLAYBOOK.md (new) — forward-looking consolidated source of truthscripts/strategy_performance_analysis.py — new finding_a_simulator + audit_v3_status sectionsearnings_dashboard/templates/dashboard.html — new dashboard tab sections rendering the simulator resultsscripts/analyze_finding_a_full.py on prod; FA_soft Δ vs A_base should be $0.00 (sanity check that the rule-as-written is inert), FA_hard Δ vs A_base should be −$213.23 (within Polygon-fetch variance). Re-run after each new ~10 closed trades to track whether the conclusion holds; the conclusion will be re-validated as part of the trade-#150+ audit-gauntlet pass (memory §K)._empty_summary missing 13 fields added by Finding #284 → / returned HTTP 503 with UndefinedError: 'roi_tc_total_pct' for ~2min after every restart; deploy_from_github.sh smoke test exited 1 (RESOLVED May 9, 2026)exit 1 blocking final daemon-reload step on every routine deploy). User impact mostly invisible because dashboard self-recovered as soon as _bootstrap_cache_from_db() thread finished, but every subsequent housekeeping deploy (#274/#273/#269) would have tripped the same 503 and confused the operator.deploy/deploy_from_github.sh's built-in dashboard health probe (sleep 3 && curl / after restart) returned HTTP 503 and exited 1. journalctl trace showed:
jinja2.exceptions.UndefinedError: 'dict object' has no attribute 'roi_tc_total_pct'
File "earnings_dashboard/templates/dashboard.html", line 2391, in top-level template code
<div class="value {% if trade_history.summary.roi_tc_total_pct >= 0 %}positive{% else %}negative{% endif %}" ...
Dashboard returned to HTTP 200 within ~2 minutes (cache bootstrap thread completed), but the deploy was already marked failed by then.earnings_dashboard/app.py:4359-4371 defines _empty_summary (used as the cold-start fallback at line 4372 when _dashboard_cache.get('trade_history') returns None). Pre-fix, this dict had 24 fields — the original pre-#284 set. Finding #284 (May 6, 2026) added 13 new TC-ROI fields to the populated summary.update({...}) block at app.py:3584-3600 (trading_capital_usd, trading_capital_source, trading_capital_net_liq_observed, period_days, alltime_days, alltime_total_pnl, alltime_n_closed, roi_tc_total_pct, roi_tc_monthly_avg_pct, roi_tc_annualized_pct, roi_tc_alltime_total_pct, roi_tc_alltime_monthly_avg_pct, roi_tc_alltime_annualized_pct) but did not mirror them into _empty_summary. Result: in the ~2-minute window after every dashboard restart (cache cold, bootstrap thread still running its DB queries), the / endpoint hit the empty fallback path and Jinja's {% if trade_history.summary.roi_tc_total_pct >= 0 %} (line 2391) blew up — the displayed value at 2392 had |default(0), but the conditional check did NOT. Same lesson as Finding #265.v2 + Finding #284 v1→v2 (the missing t. alias caught only by §G probe): every fix MUST end with a probe of the changed output — not just code-level review of the populated-path. §E same-pattern check on data-shape contracts was missed during the #284 ship./ → templates.TemplateResponse('dashboard.html', context) → Jinja env renders → trade_history resolves to {"trades": [], "summary": _empty_summary} (cache miss) → template at line 2391 reads trade_history.summary.roi_tc_total_pct → KeyError (Jinja raises UndefinedError for missing dict keys with StrictUndefined semantics) → Starlette returns 503.systemctl restart polymarket-dashboard → FastAPI starts at T+0 → @app.on_event("startup") schedules init_cache thread (daemon=True) → bootstrap thread runs get_trade_history(period="all") (DB queries take ~1-3s) → meanwhile deploy script's smoke test fires curl / at T+3s → endpoint hits empty fallback → 503.app.py:4359-4400 (extended _empty_summary to mirror all 13 new fields with safe defaults — keeps the dict shape contract synchronized between cold-start and populated paths). dashboard.html:2391, 2397, 2403, 2417, 2423, 2429 (added (... | default(0)) wrapper to the 6 numeric {% if %} conditionals in the new TC ROI panel for defense-in-depth — pattern enforcement so future field additions don't break this exact way)._empty_summary field name typo — verified each new key against app.py:3585-3599 letter-for-letter. (b) trading_capital_source set to string "fallback" — matches the == comparison at dashboard.html:2378 (renders fallback branch correctly). (c) trading_capital_net_liq_observed=None (sentinel "no IBKR snapshot yet") — Jinja renders as None/empty in |default(0) displays; consistent with populated-path behavior when net_liq is 0. (d) Jinja (expr | default(0)) >= 0 — verified default() filter applies to the value before the >= comparison; works because default(0) returns 0 for undefined, and 0 >= 0 is True (positive class) which matches the original behavior on the empty fallback path (was previously crashing instead of rendering positive — net behavior change: 0% in green, not 503). (e) Pre-#284 fields like total_pnl / win_rate / long_win_rate etc. — already in _empty_summary since Day 0; {% if %} checks on them never broke; intentionally not adding |default(0) to those conditionals per §C surgical-changes (don't refactor things that aren't broken).systemctl restart polymarket-dashboard && curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/ (no sleep) returns 200, not 503. journalctl -u polymarket-dashboard --since "1 minute ago" | grep -c UndefinedError returns 0. deploy_from_github.sh final smoke test exits 0 cleanly._dashboard_cache.get('X') is called for: trade_history, earnings_calendar, db_stats, high_conviction, recent_trades, dependencies, positions — only trade_history has the empty-fallback dict shape problem because only it has a multi-key summary sub-dict consumed by Jinja {% if %}. The others are lists (empty list → no key access) or simpler shapes; no analogous bug.{% if trade_history.summary.X %} peers? Grep returned 14 references (lines 2331, 2339, 2346, 2356, 2378, 2391, 2397, 2403, 2417, 2423, 2429, 2494, 2502, 2518, 2526). 6 are the new TC ROI panel ones (FIXED). The remaining 8 reference pre-#284 fields (total_pnl, total_pnl_pct, win_rate, trading_capital_source — wait, this last one IS new! See note below; long_win_rate, short_win_rate, bmo_win_rate, amc_win_rate) — all of which ARE in _empty_summary post-fix, so they cannot trigger the same UndefinedError. Intentionally not adding |default(0) to them per §C.trading_capital_source (string == comparison at line 2378): populated value is "ibkr_net_liq_minus_pdt" or "fallback". Empty-fallback now sets "fallback", matching the existing else-branch. No defensive filter needed (Jinja handles missing keys in == differently than in >= — would still UndefinedError; but with the dict fix it's no longer missing).bash deploy/deploy_from_github.sh. Dashboard restart triggers smoke test, which now succeeds because _empty_summary has all expected keys.trade_audit_log for the get_trade_history(period="all") summary). The deploy script's sleep 3 && curl / smoke test threads the needle — sometimes bootstrap finishes before the curl, sometimes after. Pre-#286, the latter case 503'd; post-#286, both cases return 200. Could optionally extend the smoke test's sleep to 10-15s so it only ever sees the warm path, but that's belt-and-suspenders given the dict fix is the structurally correct one. Not done in this commit per §C.@dataclass or a _make_empty_summary() helper that's called from both paths so the shape contract is enforced by the typechecker, not by hand. Not refactoring to that today (§C surgical), but flagged for a future cleanup pass.t. alias caught by re-probe), Finding #286 (today — caught by deploy-script smoke test, which IS a §G probe baked into infrastructure). The deploy-script smoke test introduced as part of Finding #262 is doing exactly what it was designed to do; the dict-shape bug would otherwise have been silent for who-knows-how-long.earnings_dashboard/app.py (extend _empty_summary with 13 new fields)earnings_dashboard/templates/dashboard.html (add |default(0) to 6 TC ROI {% if %} conditionals — defense-in-depth)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 (May 9 LATE evening housekeeping block extended)docs/CONTEXT.md (What changed this week — May 9 LATE evening line)❌ TTD: Would exceed 60% exposure limit (63.0%) + ❌ MCHP: Would exceed 60% exposure limit (63.0%). Operator: "this is wrong, we are missing opportunities for no reason! the logic seems to be flawed when we have less opportunities we don't take trades!"polymarket_opportunity_log for any skip with exposure / limit / capital in skip_reason — 0 rows ever recorded. The rule has been firing silently for the lifetime of the bot. Phase A fix wires can_open_position rejections through _emit_pm_opportunity_log with skip_stage='exposure_cap'.src/earnings/risk_manager.py:can_open_position — new optional kwarg extra_pending_exposure: float = 0.0. Default preserves all existing call-site semantics (capital_manager, paper_trade_test, fixture validation, etc — n=8 callers, all backward-compatible). When >0, the exposure check uses current_exposure + extra_pending_exposure + new_position so batch loops can run greedy-fill.src/earnings/trading_orchestrator.py:check_bmo_signals + check_amc_signals — both loops now maintain a pending_exposure = 0.0 running total. Each iteration calls can_open_position(signal, extra_pending_exposure=pending_exposure); on accept, pending_exposure += position_size so the NEXT iteration sees the running total. Top-priority signal goes first → next signals see growing pending exposure.src/earnings/trading_orchestrator.py:_log_exposure_skip — new helper that calls the signal_generator's existing _emit_pm_opportunity_log with skip_stage='exposure_cap'. Reused for BMO loop, AMC loop, AND BMO-reservation rejections (capital constraint = same class of skip). Fail-soft (try/except → debug log, never raises) so exposure gating works even if log path is broken.sizing-priority-v2 (Finding #279) since the exposure-cap rule and the priority-tier rule are interconnected (both gate which valid-signal trades actually enter).skip_stage='exposure_cap' rows in polymarket_opportunity_log — providing the n ≥ 12 evidence base memory.md §K's bias gauntlet requires for any rule change.check_amc_signals() → for each signal in priority order → can_open_position(signal, extra_pending_exposure=pending_exposure) → if accept, pending_exposure += position_size; if reject, _log_exposure_skip(signal, reason) → signal_generator._emit_pm_opportunity_log(...) writes to DB. Same flow for BMO loop.risk_manager.py lines 749-815 (signature + check); trading_orchestrator.py lines 3045-3097 (_log_exposure_skip), 3257-3286 (BMO loop), 3360-3445 (AMC loop, all 3 accept paths + reject paths).extra_pending_exposure < 0 (defensive: clamped to 0 via max(..., 0.0)); (b) _emit_pm_opportunity_log not present on a mock signal_generator (helper checks hasattr first); (c) DB write fails (try/except → debug log, gating continues); (d) signal missing condition_id (defaults to empty string — opportunity_log accepts NULL); (e) signal.market_cap is enum None (defensive getattr + mcap_enum is not None check). All handled.polymarket_opportunity_log row with skip_stage='exposure_cap' and skip_reason=.... Cannot test on TTD/MCHP today (window passed); next test fires on next BMO/AMC tick with rare-day classification.rg "can_open_position\(" returned 8 callers across 4 files. Compliance audit broke them down by semantic:trading_orchestrator.py check_bmo_signals (line ~3309) ✓ patchedtrading_orchestrator.py check_amc_signals (line ~3433) ✓ patchedcapital_manager.py check_bmo_signals (line ~289) ✓ patched (same pattern; module is currently dead-code per Phase 4A extraction but kept in lockstep so it can never be a re-activation footgun)capital_manager.py check_amc_signals (line ~360) ✓ patchedtrading_orchestrator.py _calculate_multi_day_bmo_reservation (line ~3147) — by design, evaluates each signal independently against current state to compute MAX possible future-BMO capacity; greedy-fill would systematically under-estimate. Default extra_pending_exposure=0.0 is the correct semantic. ✓ unchangedtrading_orchestrator.py execute_entry final risk check (line ~3677) — single-signal, last-look gate at order time. ✓ unchangedcapital_manager.py two equivalents (lines ~198, 462) — same single-signal semantic. ✓ unchangedtests/unit/test_risk_manager.py:TestPhase285GreedyFillExposureMath — 9 new tests covering: top-priority sees only registered exposure ✓; second signal sees first's pending ✓; today's TTD/MCHP scenario recovery ✓; defensive None / negative pending → clamped to 0 ✓; zero capital returns max-pct ✓; normal-day 140% cap allows 4 signals ✓; rare-day 60% cap blocks 3rd ✓; today's exact APP-carryover scenario enters 1 of 2 ✓. All 41 unit tests passing.trading_positions / trade_audit_log writes. Does NOT change order-execution path. Does NOT change the rare-day cap rule itself (Phase B). Pure correctness + visibility shipment._emit_pm_opportunity_log helper is used by the trader's signal generation path (signal_generator) AND the orchestrator's exposure path (new _log_exposure_skip). One serialization, one schema, one DB destination. The new skip_stage='exposure_cap' value is documented in docs/DATA_ARCHITECTURE.md under polymarket_opportunity_log.src/earnings/risk_manager.py (can_open_position signature + computation).src/earnings/trading_orchestrator.py (_log_exposure_skip helper + BMO loop + AMC loop greedy-fill + 4 reject-paths now log to opportunity_log).src/earnings/capital_manager.py (Phase 4A extracted module — same greedy-fill patches in check_bmo_signals + check_amc_signals to keep it in lockstep with the orchestrator's canonical implementation per memory §E).tests/unit/test_risk_manager.py (9 new tests covering greedy-fill math across the failure-mode matrix; replays today's TTD/MCHP exact scenario).docs/FINDINGS.md (this entry — canonical per memory §J).docs/STRATEGY_PLAYBOOK.md §7 (Phase B deferred-post-Phase-3 row, bundled with #279).docs/AGENT_HANDOFF.md §11 (DEFER queue bullet referencing #285 Phase B).scripts/strategy_performance_analysis.py:build_action_queue() (sizing-priority-v2 row updated to call out exposure-cap re-evaluation).psql ... -c "SELECT symbol, evaluated_at::date, skip_reason FROM polymarket_opportunity_log WHERE skip_stage = 'exposure_cap' ORDER BY evaluated_at DESC LIMIT 10" should return rows on rare-day cap fires. journalctl -u earnings-trader | grep "Would exceed" should show greedy-fill behavior — at most one ❌ per overflow batch, with the top-priority signal showing ✅.net_liquidation − $25,000 PDT reserve, falling back to $2,500 when IBKR snapshot is unavailable. Auto-tracks account growth.(1 + return)^(365/days) − 1, not simple linear monthly × 12.earnings_dashboard/app.py:get_trade_history): added trading-capital read from get_cached_ibkr_snapshot() with PDT_RESERVE_USD=25_000, TRADING_CAPITAL_FALLBACK=2_500, TRADING_CAPITAL_FLOOR=100. New helper _compute_roi_triplet(pnl, tc, days) -> (total_pct, monthly_avg_pct, annualized_pct) reused for both period + all-time. All-time computed via a separate small MIN(entry_time) + COUNT + SUM query (respects TRADE_METRICS_FILTER_SQL_T so corrupt rows are excluded the same way the rest of the metrics do). 12 new fields surfaced on summary: trading_capital_usd, trading_capital_source ("ibkr_net_liq_minus_pdt" or "fallback"), trading_capital_net_liq_observed, period_days, alltime_days, alltime_total_pnl, alltime_n_closed, roi_tc_total_pct, roi_tc_monthly_avg_pct, roi_tc_annualized_pct, roi_tc_alltime_total_pct, roi_tc_alltime_monthly_avg_pct, roi_tc_alltime_annualized_pct. Defensive: annualization base floored at 1e-9 to avoid negative-base fractional-exponent errors on extreme drawdowns.earnings_dashboard/templates/dashboard.html): tooltips added to ALL six existing P&L Summary tiles (Total P&L, Total ROI, Total Trades, Win Rate, Profit Factor, Avg Position) explaining formula + caveat (Win Rate tooltip cross-refs memory.md §K 5-outlier rule; Profit Factor tooltip has the industry-standard buckets 1.0/1.2/1.5/2.0/3.0). New "🎯 Trading Capital ROI" panel below the 6 tiles with two side-by-side columns (PERIOD + ALL-TIME), each showing 3 numbers (Total / /mo avg / annualized) on bg-secondary cards. Capital source badge displayed inline ("dynamic" green or "fallback" yellow). Footer line explains the compound annualization formula, caveats past-performance-doesn't-guarantee-future-results, references the 5-outlier dependency from STRATEGY_PLAYBOOK.md §1, and links to /docs/STRATEGY_PLAYBOOK#1-current-verdict.filterTradeHistory): new helper _setPct(id, pct) and _setText(id, text) consolidate the 6+3 ID writes; the time-filter buttons (All Time / Year / Month / Week / Today) now also refresh the period-column TC ROI numbers. All-time numbers recompute server-side and re-flow through the same JSON.trade_history.summary. Time-filter click → filterTradeHistory(period) → fetch /api/trade-history?period=X → api_trade_history → get_trade_history(period) → builds summary dict (now with 12 extra TC fields) → JS read + _setPct/_setText rewrite both columns + capital-source badge.app.py:get_trade_history ~lines 3492-3585 (new _compute_roi_triplet + 12 summary fields); dashboard.html Jinja ~lines 2336-2440 (tooltips + new panel); dashboard.html JS ~lines 4395-4435 (_setPct/_setText + 9 ID writes).tc_source="fallback" — UI shows yellow "fallback" badge; (b) period_days = 0 (e.g., today before any trade) → guarded max(... , 1); (c) total_pnl/tc <= -1 (account wipeout, near-impossible) → annualization base floored at 1e-9 so we render a finite (very negative) number instead of crashing; (d) all-time query fails → keeps alltime_* at zeros + logs warning, doesn't crash the response; (e) operator picks "Today" filter → period_days=1 → compound annualized = (1+r)^365 — extremely volatile on tiny windows. Mitigated by tooltip text explicitly warning "very volatile on short windows — read it as 'noisy' under those filters."curl /api/trade-history?period=year | jq '.data.summary | {tc: .trading_capital_usd, src: .trading_capital_source, net_liq: .trading_capital_net_liq_observed, period: .roi_tc_total_pct, period_mo: .roi_tc_monthly_avg_pct, period_ann: .roi_tc_annualized_pct, all_total: .roi_tc_alltime_total_pct, all_mo: .roi_tc_alltime_monthly_avg_pct, all_ann: .roi_tc_alltime_annualized_pct}' returns all 9 numbers populated and consistent (e.g., period_ann ≈ period_total when period_days = 365); HTML render shows the new panel with both columns and the dynamic capital badge.summary dict — only api_trade_history returns it as {"data": history, ...}, only the trade-history Jinja block + filterTradeHistory JS render it. No other surface needs updating.trading_positions / trade_audit_log writes. Does NOT change order-execution path or any gate. Pure dashboard-side enrichment + UX._setPct/_setText helpers and field IDs. Single source of truth (get_trade_history); single render mapping.curl /api/trade-history returns all 12 new TC fields; visual on dashboard shows the new panel rendered with PERIOD + ALL-TIME columns + dynamic badge.earnings_dashboard/app.py (helper + 12 summary fields + all-time query).earnings_dashboard/templates/dashboard.html (tooltips on 6 existing tiles + new TC ROI panel + JS refresh helper).docs/FINDINGS.md (this entry).curl -s 'http://127.0.0.1:8080/api/trade-history?period=year' | jq '.data.summary | with_entries(select(.key | test("trading_capital|roi_tc|period_days|alltime")))' returns 12 fields, all numeric (with trading_capital_source as a string).LOW OB badge despite the dashboard's effective volume showing $4,210 (post Finding #282 fix). Operator: "now MRNA is showing as low OB... are we trading that based on current rules or not? what is low OB? is it important?"best_bid=$0.0100 / best_ask=$0.9900 (98¢ wide spread!), bid_depth=$1,163, ask_depth=$3,759 (depth is fine — 47× the $100 floor). The _calculate_depth_quality score collapses to 0.30 because of the spread penalty (0.20/0.98 = 0.30), NOT because of any actual depth issue. Trader's is_reliable flag is False → hard skip at signal_generator.py:1568. Conviction (18.5% SHORT) comes from last_trade_price / price_snapshots — independent of the bid/ask, so the signal IS real even though the visible orderbook is hollow.conviction = 0.500 (the calculator's default when no real signal exists — these would have been skipped at the conviction gate anyway, so the depth gate is redundant for 92% of its kills); only 3 had a tradeable conviction (0.70-0.90 LONG): ICE 2026-04-28 conv=89.9% (counterfactual P&L = +$158.22 — real missed winner), MPC 2026-04-29 conv=74.5% twice (counterfactual $0.00 = wash).earnings_dashboard/templates/dashboard.html Jinja badge (line ~1284) + JS render (line ~3855): LOW OB → WIDE SPREAD. Both surfaces now also have a title tooltip explaining the gate logic + cross-reference to this finding.src/earnings/signal_generator.py log messages (lines ~1570 + ~2008, BMO + AMC paths): "Skipping - poor orderbook quality" → "Skipping - PM orderbook unreliable" with reason field defaulting to "wide_spread_or_low_depth" (was "unspecified"). Comment block above each cite Finding #283.is_reliable = depth_quality >= 0.5 still skips the same 38 historical and any future markets with the same profile. No trader behavior change.min_depth_gate (real depth check, n=1 historical kill)spread_gate (the actual functional gate, n=37 historical kills)last_trade_price or price_snapshots (signals from real trades — bid/ask spread quality is irrelevant to signal quality). Would unblock MRNA-style markets with active trade history but hollow inside quotes.templates/dashboard.html line ~1284 emits badge in the upcoming-earnings table; JS path → renderUpcomingEarningsRows at line ~3855 emits the same badge during the 30s polling refresh. Both produce identical text per memory §M.(conv_short_ok && is_reliable && !depth_ok) → True → renders WIDE SPREAD badge with tooltip. NO change to tradeBadge selection logic — same conditions, same skip.dashboard.html ~1284 (Jinja), ~3855 (JS); signal_generator.py ~1570 (BMO log), ~2008 (AMC log).WIDE SPREAD — Phase-3 decomposition addresses; (b) tooltip text was added — no breakage if title is unrendered (older browsers) — operator just doesn't see the explanation; (c) both Jinja + JS render the same text per memory §M cross-interface.curl /api/earnings-calendar | jq '.upcoming[] | select(.symbol=="MRNA")' row should still surface skip indicators; visual on dashboard shows WIDE SPREAD not LOW OB. Hovering shows the explanation tooltip. journalctl -u earnings-trader | grep "PM orderbook unreliable" shows the new log text on the next signal evaluation pass. Action Queue includes new depth-gate-v2 row with DEFERRED-POST-PHASE-3 status.rg "LOW OB|poor orderbook" returns ONLY the 4 lines fixed (2 dashboard + 2 trader). No other surface uses these strings.trading_positions / trade_audit_log writes. Does NOT change order-execution path. Does NOT change the is_reliable semantics or the 0.50 threshold — pure UX rename + log-text consistency.earnings_dashboard/templates/dashboard.html (Jinja badge + JS badge — both LOW OB → WIDE SPREAD with explanatory title tooltip).src/earnings/signal_generator.py (BMO + AMC log messages updated for accuracy — was "poor orderbook quality", now "PM orderbook unreliable" with wide_spread_or_low_depth default reason).docs/FINDINGS.md (this entry — canonical per memory §J).docs/STRATEGY_PLAYBOOK.md §7 (deferred-post-Phase-3 entry mirroring Findings #278 + #279 pattern).docs/AGENT_HANDOFF.md §11 (DEFER queue bullet).scripts/strategy_performance_analysis.py:build_action_queue() (new depth-gate-v2 row with status=DEFERRED-POST-PHASE-3).curl -s 'http://127.0.0.1:8080/' | grep -c "LOW OB" should return 0; curl -s 'http://127.0.0.1:8080/' | grep -c "WIDE SPREAD" should return ≥2 (Jinja initial + JS renderer fallback markup). Strategy Action Queue API: curl -s http://127.0.0.1:8080/api/strategy-analysis | jq '.analysis.action_queue.summary.deferred_post_phase_3' should return ≥3 (was 2 — adds depth-gate-v2 alongside regime-detection-v2 + sizing-priority-v2).cached_volume_24h (rolling, not cumulative); now both surfaces share the SAME single-shot Gamma fetcher (RESOLVED Apr 30, 2026)get_market_volume('0xc0a568...') returned 4210.34. All 8 May 1 BMO markets misaligned by 7%-50%. The MRNA $2k gate WAS now passing on the trader (✓ — primary #282 fix worked) but dashboard still showed LOW VOL because the dashboard's number was wrong.WARNING:earnings_dashboard.app:Gamma API: 8/111 markets still missing after individual fetch. My direct probe to Gamma succeeded for all 8 in a single batch call, but the dashboard's fetch_gamma_markets_batch (Phase 1 chunk-of-25 + Phase 2 individual retry) consistently fails for them. Likely a transient Polymarket Gamma-side issue (Finding #15 batch-drop pattern). Result: all 8 fall into the except Exception fallback path.cached_volume_24h as the gamma arg, which is the rolling 24h field from price_snapshots, NOT cumulative. So effective_cumulative_volume_usd(db_total, cached_volume_24h) returned a max between two-different-semantic numbers — even with the #282 max() fix, the dashboard's number was a 24h figure while the trader's was true cumulative.volume_policy.fetch_gamma_cumulative_single(condition_id, *, timeout_sec=5.0) -> Optional[float] performs a single-shot GET /markets?condition_ids=X and extracts the volume (cumulative USD) field. Returns None on any error / missing field / zero volume so callers can fall back. ONE implementation, identical results for both surfaces.signal_generator._fetch_gamma_cumulative_volume now delegates to the new shared helper (was inline duplicate of the same logic). Trader's existing _gamma_cumulative_cached TTL wrapper is unchanged.fetch_gamma_cumulative_single. If it returns a positive value, uses it as the "gamma" arg to effective_cumulative_volume_usd AND flips is_reliable=True + data_source="gamma_singleshot" so the operator can see this row was rescued from the cached fallback. Only when the single-shot ALSO fails does it fall through to the historical cached-volume_24h behavior (preserved as a defensive lower bound, never inflates above true cumulative since 24h ≤ cumulative).get_polymarket_earnings_from_db → batch Gamma fetch fails for cid → except runs → NEW: fetch_gamma_cumulative_single(cid) → if returns gamma_volume, use that with effective_cumulative_volume_usd(db_total, gamma_volume) (max). Trader path unchanged externally — get_market_volume calls _gamma_cumulative_cached which calls _fetch_gamma_cumulative_volume which now delegates to the SAME fetch_gamma_cumulative_single. Same call, same result.fetch_gamma_cumulative_single → returns 4210.34 (current live cumulative). Dashboard except-fallback: effective_cumulative_volume_usd(6.28, 4210.34) = 4210.34 → volume=4210.34, volume_ok=True. Trader: same helper, same args, same result. Aligned to the cent.volume_policy.py lines 95-135 (new fetch_gamma_cumulative_single); signal_generator.py lines 346-353 (delegate); app.py lines 35-40 (import) + 2188-2245 (rewritten except-fallback to call helper before falling to cached_volume_24h).fetch_gamma_cumulative_single raises any exception → returns None (never propagates); (b) Gamma returns non-200 → None; (c) Gamma returns empty list → None; (d) Gamma returns market without volume field → None; (e) Gamma returns zero volume → None (treated as "no live data"); (f) all of the above → except-fallback uses cached_volume_24h (preserved historical behavior, is_reliable=False, data_source="price_snapshot").dash.volume == trader.vt to the cent; MRNA volume_ok=True._fetch_gamma_cumulative_volume (now delegated) and the dashboard's fetch_gamma_markets_batch (kept — different semantic: bulk fetch of many at once, fast happy path). No other consumers.tests/unit/test_volume_policy.py covering the single-shot helper: network-failure, non-200, empty response, success, zero-volume → None, missing field. All 20 tests passing.src/earnings/volume_policy.py (new fetch_gamma_cumulative_single helper).src/earnings/signal_generator.py (_fetch_gamma_cumulative_volume now delegates).earnings_dashboard/app.py (import + except-fallback now tries single-shot before cached snapshots).tests/unit/test_volume_policy.py (6 new tests for the helper).effective_cumulative_volume_usd returned stale DB value when live Gamma cumulative was much higher; trader silently rejected MRNA #2026-05-01 at $6.28 < $2k while live Gamma was $3,734 (RESOLVED Apr 30, 2026)condition_id and found a 10×-500× gap between displayed and live cumulative volume:
Ticker Dashboard DB.volume_total Gamma.volume Gamma.volume24hr
ARES $207 $0 $207.42 $206.08 ← only one matching (DB=0 → Gamma fallback fired)
CBOE $18 $18.12 $123.77 $16.22
CL $25 $24.73 $78.15 $6.02
CVX $8 $7.63 $244.49 $188.21
EL $6 $6.32 $153.27 $146.96
MRNA $6 $6.28 $3,734.76 $2,234.94 ← gated out at 6.28 < $2k despite 3.7k live cumulative
PIPR $10 $10.00 $261.62 $168.88
XOM $8 $7.53 $186.53 $18.60effective_cumulative_volume_usd(db, gamma) (Finding #224) was "if DB has any positive value, use it; otherwise fall back to Gamma" — based on the assumption that "DB is authoritative when synced". In practice, continuous_collector snaps markets.volume_total only every 15 minutes while Polymarket trades land continuously, so as soon as ANY positive value made it into the DB, every subsequent trade was invisible to both the dashboard display and the trader's gate until the next sync. Cumulative volume is monotonically non-decreasing so max(db, gamma) is the strictly correct semantic. Compounding: the trader had its OWN short-circuit at signal_generator.get_market_volume:441 (if vt_db > 0: return vt_db) that bypassed the helper entirely — so even fixing the helper alone wouldn't have helped the trader path. Both paths arrived at the same wrong number for different code reasons.volume_threshold_passes(2000, 6.28, ...) returned False. Post-fix it returns True (max(6.28, 3734.76) = 3734.76 ≥ 2000) — the SHORT signal becomes tradeable assuming its other gates (depth quality, conviction, regime) pass. Same dynamic for any earnings market whose DB row was synced before recent Polymarket activity.src/earnings/volume_policy.py:effective_cumulative_volume_usd — change semantics from "DB if positive else Gamma" to max(db, gamma) with defensive _safe_nonneg_float coercion (handles None, NaN, Inf, negative, string garbage — all → 0). volume_threshold_passes is unchanged (calls the fixed helper).src/earnings/signal_generator.py:get_market_volume — REMOVED the short-circuit if vt_db > 0: return vt_db. Now ALWAYS goes through the shared effective_cumulative_volume_usd helper. This is the §M consolidation: trader and dashboard share one definition with zero wrapper logic divergence.tests/unit/test_volume_policy.py — flipped the broken test_effective_prefers_positive_db_over_gamma assertion (which encoded the bug) to the new max() semantic, plus added 8 new defensive tests covering the regression case (stale-DB-low + Gamma-high → max), both-zero, both-None, negative inputs, NaN/Inf inputs, string garbage. All 14 tests passing locally pre-deploy.signal_generator._build_trading_signal → get_market_volume(condition_id) → effective_cumulative_volume_usd(vt_db_raw, gamma_v) → returns max. Dashboard path → _refresh_cache() → get_polymarket_earnings_from_db() → effective_cumulative_volume_usd(db_volume_total, gamma_volume) → returns max. Same call, same args, same result. Single shared module.get_market_volume. Pre-fix: row.volume_total=6.28 → if vt_db > 0: return 6.28 → trader sees vt=6.28 → signal.volume_total=6.28 → _build_trading_signal rejects below 2000 threshold. Post-fix: vt_db_raw=6.28, gamma_v=3734.76, effective_cumulative_volume_usd(6.28, 3734.76) = 3734.76 → trader sees vt=3734.76 → 3734.76 ≥ 2000 → signal qualifies (subject to other gates).volume_policy.py lines 30-83 (rewritten); signal_generator.py lines 441-455 (rewritten — short-circuit removed, now always calls helper); test_volume_policy.py rewritten with 14 tests._safe_nonneg_float coerces to 0 → never poisons gate; (b) DB pg8000 returns negative → coerced to 0; (c) both sources return 0 → returns 0 → fails any positive threshold (correct); (d) _gamma_cumulative_cached returns None (Gamma API down) → coerced to 0 → falls back to DB value via max; (e) DB connection fails inside get_market_volume → existing except Exception returns (None, None) (unchanged); (f) test that encoded broken behavior was identified + flipped + replaced with regression test.curl /api/earnings-calendar?days=14 for May 1 markets — MRNA volume should be ≈ $3,735 (was $6); the same call to trader's get_market_volume('0xc0a568...mrna...') from a Python REPL on prod must return the same (v24, vt) tuple with vt ≈ 3735. volume_threshold_passes(2000, db, gamma) returns True for MRNA. Verified live in §G probe below.earnings_dashboard/app.py:2087 (Gamma success branch) — now returns max ✓earnings_dashboard/app.py:2219 (except-fallback branch) — now returns max ✓ (with the existing semantic that the "gamma" arg is cached_volume_24h — see Finding #265.v2 design note)src/earnings/signal_generator.py:444 (trader path, no-DB-row branch) — was already calling helper with (None, gamma_v) ✓src/earnings/signal_generator.py:441-450 (trader path, with-DB-row branch) — previously short-circuited; now consolidated to call helper ✓trading_positions / trade_audit_log writes. Does NOT change order-execution path (only the pre-trade gate). Does NOT change earnings_markets.earnings_time semantics (Finding #220 unaffected). Cross-interface check (memory §M, mandatory for volume changes): grep ✓; edge matrix covered by tests; one definition ✓; canonical narrative this entry./api/earnings-calendar shows $3,735; trader-side probe via SignalGenerator.get_market_volume from a prod REPL returns (volume_24h, 3734.76). Trader and dashboard now agree to the cent.src/earnings/volume_policy.py (helper rewritten + new _safe_nonneg_float defensive helper).src/earnings/signal_generator.py (trader path consolidated to use the shared helper — removed the bypass short-circuit).tests/unit/test_volume_policy.py (existing test flipped + 8 new defensive tests).docs/FINDINGS.md (this entry)..cursor/memory.md §L (line that referenced "positive DB else Gamma" updated to reflect max() semantic).docs/STRATEGY.md, docs/STRATEGY_PLAYBOOK.md, docs/STRATEGY_ANALYSIS_ARCHITECTURE.md, docs/EDGE_CASES.md, docs/API_REFERENCE.md, docs/AGENT_HANDOFF.md, docs/CONTEXT.md, README.md, scripts/strategy_performance_analysis.py Action Queue note.for cid in <list>; do compare = python3 -c "from src.earnings.volume_policy import effective_cumulative_volume_usd; ..."; done should report dashboard_volume == trader_get_market_volume() for every May 1 BMO market. Visual: dashboard now shows MRNA at ~$3,735 instead of $6, with ✓ TRADE badge instead of LOW VOL (assuming other gates pass).polygon_daily with 16 1d bars. Quote: "some of the historical charts have only hourly candles, while other only 1 minute candles, what is the logic here? the daily candles dont help at all, as a minimum we should have the posibility to switch all candles to 1h (option to do 1h, 15 min, 1 min ) should be there". Documented routing logic for context: live OR closed-within-24h → fresh Polygon 1m; older closed (post-Apr-26) → equity_bars_1m cache; older closed (pre-Apr-26 cache start) → polygon_daily fallback. TXN entered the third bucket because the cache started Apr 26.resolution query param: auto (default, current routing) or 1m / 15m / 1h / 1d. Explicit resolutions bypass the auto routing and fetch directly from Polygon at the requested interval, with a resolution-appropriate window pad (1m: ±12h, 15m: ±5d, 1h: ±14d, 1d: ±30d) so wider intervals show more context bars._fetch_polygon_aggs_for_chart(symbol, multiplier, timespan, start, end) wraps /v2/aggs/ticker/{S}/range/{N}/{minute|hour|day}/{from}/{to} with proper pagination via next_url. Returns the chart shape directly. The legacy _fetch_polygon_minute_bars_for_chart and _fetch_polygon_daily_bars are unchanged (memory §C surgical — auto-mode is verified working, do not touch).Auto / 1m / 15m / 1h / 1d) above the chart canvas. Click handler setTradeChartResolution(res) is no-op if same as current; otherwise re-fetches with the new resolution and re-renders. State variable _tradeChartResolution persists within a single modal session, resets to auto on each open. The backend echoes meta.resolution_requested so the UI button stays in sync with what was actually returned.accent-blue background + white text + bold; others are transparent border-only. Helper _setTradeChartResolutionUI(res) is the single source of truth for button state._TRADE_CHART_RESOLUTION_MAP in app.py):
1m → ±12h (~ 1k bars max for 2 RTH days)
15m → ±5d (~ 350 bars across 12d window)
1h → ±14d (~ 230 bars across 30d window)
1d → ±30d (~ 60 bars across 60d window)
Pads scale with the resolution so wider intervals show enough context for trend analysis. Polygon Stocks Starter ($29/mo) supports all four resolutions with 5y of 1m history — well within our use case.15m button → setTradeChartResolution('15m') updates state + UI → calls _fetchAndRenderTradeChart(...) with new resolution → fetch URL has &resolution=15m → backend dispatches to _fetch_polygon_aggs_for_chart(SYMBOL, 15, "minute", entry-5d, exit+5d) → returns 15m bars → JS renders candles + markers + anchor + price lines.resolution=15m: window = Apr 17 to Apr 28 (10 trading days × 26 RTH bars/day ≈ 260 bars). With resolution=1h: window = Apr 8 to May 7 (~160 bars). With resolution=1m: tight window Apr 22 02:34 to Apr 24 07:56 UTC (~780 bars). With resolution=1d: ±30d window (~40 bars).app.py constants ~lines 4634-4650 (_TRADE_CHART_RESOLUTION_MAP); _fetch_polygon_aggs_for_chart ~4740-4800; endpoint signature with resolution param ~4870; bar-selection branch on resolution != "auto" ~4920-4940; meta.resolution_requested echo ~4990. dashboard.html resolution-selector buttons ~6320; _tradeChartResolution state + setTradeChartResolution + _setTradeChartResolutionUI ~6370-6420; URL threading ~6480; UI sync from echoed m.resolution_requested ~6510.resolution value → endpoint returns {ok: false, error: "resolution must be one of [...]"}; (b) Polygon returns 0 bars at requested resolution → modal shows "No bar data available"; (c) operator clicks resolution mid-fetch → setTradeChartResolution no-ops if same value; otherwise the next render is the latest click (last-write-wins on the chart instance); (d) auto-refresh interval re-fires with old resolution → uses _tradeChartResolution (current value) so the auto-refresh respects the latest selection./api/trade-chart-data?source=audit&id=115&resolution=1h should return interval=1h, data_source=polygon_1h, ~230 bars. Same for 15m / 1m / 1d. Modal visual: clicking each button changes the chart and bar count._fetch_polygon_aggs_for_chart is generic enough to replace the existing minute + daily helpers. NOT done in this commit (memory §C surgical) — auto-mode is verified working, refactor would be cosmetic. Future work: collapse the three helpers into one when the next chart-related change lands.earnings_dashboard/app.py — new _TRADE_CHART_RESOLUTION_MAP + _fetch_polygon_aggs_for_chart helper + resolution query param + bar-selection branch + meta.resolution_requested echo.earnings_dashboard/templates/dashboard.html — five resolution-selector buttons in modal header; _tradeChartResolution state + setTradeChartResolution + _setTradeChartResolutionUI JS; URL threading + UI echo sync.docs/FINDINGS.md — this entry.for r in 1m 15m 1h 1d; do curl -s "http://127.0.0.1:8080/api/trade-chart-data?source=audit&id=115&resolution=$r" | jq "{r: .meta.resolution_requested, src: .meta.data_source, n: .meta.bar_count}"; done should print four objects, one per resolution, with data_source=polygon_<r> and non-zero bar_count for each.equity_bars_1m collector (Finding #240) runs once daily at 10:00 UTC. Any bar after that fire is missing from the cache. Per-trade fallout:time=11:01:16 had no corresponding bar; Lightweight Charts anchored the marker to the nearest in-range bar (~$18.67) and the operator saw a phantom exit at $18.67. Compounding visual bug: the $17.56 price line we add via createPriceLine does NOT trigger price-scale auto-fit in Lightweight Charts, so the chart auto-scaled to the in-range bars (18.30-19.10) and the actual exit price line fell off-screen._fetch_polygon_minute_bars_for_chart reuses src.earnings.counterfactual_pricing.fetch_polygon_minute_bars (single shared Polygon-paginate impl per memory §M) and converts to chart shape. Bar-selection logic in api_trade_chart_data now: live → fresh fetch; closed within last 24h → fresh fetch; older closed → cache (always sufficient, bars don't change post-exit); fall back to daily Polygon for pre-Apr-26-cache trades.color: rgba(0,0,0,0)) line series with two points {time: bars[0].time, value: min(allPrices)} and {time: bars[-1].time, value: max(allPrices)} where allPrices includes bar highs/lows + entry/exit/stop prices. Price-scale auto-fit picks up the line series so the chart's Y range now ALWAYS includes the markers. SOFI exit at $17.56 now visible as a horizontal red dotted line.meta.live_refresh_seconds=600 (only for live trades). JS sets a setInterval(..., live_refresh_seconds*1000) that re-fetches and re-renders when the modal is open on a live trade. Cleared on modal close + on opening a different trade. Defensive guard verifies modal still open AND same trade before firing.entry-12h / exit+12h to entry-2d / exit+2d for closed trades; daily-fallback pad bumped from 7d to 14d. Matches operator's "graph should move further and should not be limited to the days when the trade was open" request from the original Finding #281 spec._fetchAndRenderTradeChart(source, id, false) → /api/trade-chart-data → backend selects bars (fresh Polygon for live/recent, cache for older, daily fallback for very old) → JSON has bars + markers + meta.live_refresh_seconds → JS renders candles + anchor + markers + price lines + arms setInterval if live.app.py constants ~4602-4631; _fetch_polygon_minute_bars_for_chart ~4720; selection logic ~4840-4880; meta live_refresh_seconds ~4925. dashboard.html closeTradeChartModal updated ~6363; openTradeChartModal split into open + _fetchAndRenderTradeChart ~6395; anchor line series ~6470-6500; setInterval wiring ~6610-6630.POLYGON_KEY missing → cache + daily fallbacks still work; (c) auto-refresh fires after modal closed → defensive modal.style.display !== 'flex' check clears the timer; (d) auto-refresh fires for a different trade than originally clicked → _tradeChartCurrent guard skips; (e) allPrices.length < 2 → anchor not added (degenerate case); (f) anchor times within bar range → never extends time-scale, only price-scale; (g) auto-refresh fetch errors mid-modal → isAutoRefresh=true flag suppresses error overlay so a transient hiccup doesn't blank the working chart./api/trade-chart-data?source=position&id=157 → bars[-1].time should be > markers.entry.time AND data_source="polygon_realtime"; probe SOFI #123 → bars should include the 11:01 UTC range; probe NKE #106 → unchanged daily fallback. Visual: open MSFT live chart → bars extend past entry; meta line shows "auto-refresh every 10 min · last update HH:MM:SS"; reopen 10 min later → new last-update timestamp + newer last bar. Open SOFI history chart → exit price line visible at $17.56 with the chart Y range extending down to include it.equity_bars_1m for live use cases — none exist on the dashboard side. The collector consumers (analyze_finding_a_full.py, compute_counterfactual_pnl.py) only run on closed/historical trades where the once-daily cadence is fine. No further surfaces to update.earnings_dashboard/app.py — new constants + new _fetch_polygon_minute_bars_for_chart helper + reworked selection logic + live_refresh_seconds in meta.earnings_dashboard/templates/dashboard.html — split openTradeChartModal into open + reusable _fetchAndRenderTradeChart; added hidden anchor line series; setInterval for live auto-refresh; defensive close + cross-trade guards; meta line shows "last update HH:MM:SS" on live charts.docs/FINDINGS.md — this entry.equity_bars_1m cache (Finding #240) covers Apr 26+ trades at full 1-minute resolution; older trades fall back to Polygon /v2/aggs/.../range/1/day.GET /api/trade-chart-data?source={position|audit}&id=N returning {ok, meta, bars[], markers}. Source modes: position reads trading_positions (Active Trades), audit reads trade_audit_log (History). Single endpoint, single response shape — frontend modal is symmetric.equity_bars_1m first for [entry-12h, exit+12h] (or [entry-12h, now+12h] for live). Fall-back to Polygon daily for [entry-7d, exit+7d] only when equity_bars_1m has < 10 bars in window (older trades pre-Apr-26 cache start). Heuristic threshold = _TRADE_CHART_MIN_1M_BARS = 10.setMarkers), exit (red/green inverse), horizontal stop-price line (orange dashed), vertical earnings-release marker (purple 📢 circle) when actual_earnings_time is known via the LEFT JOIN trading_positions lookup.unpkg.com/lightweight-charts@4.1.7. Single shared modal HTML + openTradeChartModal(source, id, title) JS function used by both tables (memory §M cross-interface — one shared definition, not parallel renderers). Chart icon 📈 button added as a new column on both Active Trades (JS-rendered) and History trade log (Jinja-rendered).typeof LightweightCharts === 'undefined' check → friendly message but bars still in API JSON for inspection. (b) Polygon key missing → daily fallback returns [] → modal shows "No bar data available". (c) Position/audit row not found → {ok: false, error: "..."} → modal shows error. (d) Orphan IBKR-only position with no DB id → button replaced with "-" placeholder.openTradeChartModal(source, id) → fetch('/api/trade-chart-data?source=...&id=...') → api_trade_chart_data() → _load_position_for_chart or _load_audit_for_chart → _fetch_chart_bars_1m (or _fetch_polygon_daily_bars fallback) → JSON response → JS renders candlestick + markers + price-lines.audit_id=121 (Apr 27-28 closed) → ~1232 bars in cache → 1m chart with entry $437.24 / exit $449.46 / stop $424.12 markers. NKE audit_id=106 (March, no cache) → daily Polygon fallback → ~14 daily bars + entry/exit markers. MSFT position_id=157 (live, just opened today) → may be 1m or daily depending on cache state at click time.app.py endpoint + 4 helpers ~lines 4602-4842; app.py trading_positions id field at SELECT line ~2473 + dict line ~2575; dashboard.html Active Trades <th>Chart</th> at line ~537; JS chart-cell render at line ~5180 + colspan bumps 13→14; History <th>Chart</th> at line ~2470 + Jinja per-row chart-cell at line ~2557 + colspan 17→18; modal HTML + Lightweight Charts script + openTradeChartModal/closeTradeChartModal at dashboard.html ~lines 6310-6510.curl /api/trade-chart-data?source=audit&id=121 returns ok=true, interval=1m, ≥1000 bars, markers with entry+exit+stop. curl /api/trade-chart-data?source=position&id=<live> returns ok=true. curl /api/trade-chart-data?source=audit&id=999999 returns ok=false, error="audit #999999 not found". Dashboard visual: chart icon on Active Trades + History rows opens modal with rendered chart and markers.openTradeChartModal() function with different source arg. No duplicate render logic.trade_audit_log writes; does NOT change trader behavior; does NOT modify any gate/sizing; pure read-only enrichment. Adding id to the trading_positions SELECT + dict is the minimum surface needed for the chart link.earnings_dashboard/app.py — new helpers (_load_position_for_chart, _load_audit_for_chart, _fetch_chart_bars_1m, _fetch_polygon_daily_bars); new endpoint /api/trade-chart-data; id added to active-positions SQL + dict.earnings_dashboard/templates/dashboard.html — chart-modal HTML + Lightweight Charts CDN script + openTradeChartModal/closeTradeChartModal JS; Active Trades + History trade-log gain a Chart column with the 📈 button; empty-state colspan bumps (13→14 Active, 17→18 History).docs/FINDINGS.md — this entry.curl -s 'http://127.0.0.1:8080/api/trade-chart-data?source=audit&id=121' | jq '.ok, .meta.bar_count, .markers' should return true, >= 100, and a markers dict with entry/exit/stop populated. Visual: open /, click 📈 on any History row → candlestick chart with markers renders within ~500ms.SKIP / -- / 0% ✗ / LONG / NO EDGE even though they had released hours earlier. Screenshot also showed today's AMC markets (CMG 4/6 / 72.0% ✓ / LOW VOL, EBAY SKIP / 91.5% / 100% / NO EDGE) which are still pre-event and correctly rendered.polymarket-resolver.timer runs daily at 06:00 ET and processes YESTERDAY's earnings (data needs to settle) — so actual_beat is NULL for today's earnings until tomorrow morning.continuous_collector stops sampling resolved markets (Polymarket drops them from ?active=true); recent price_snapshots rows stop arriving for them. Live PM yes_price evidence exists for some markets (e.g. HUM @ 0.9995, CAT @ 0.815) but not uniformly.--, Min Required 0% ✗, Direction LONG, Trade? NO EDGE) are exactly what they should be for a settled market — there's no Trade? action available because the trade window has closed. But the row visually looks identical to a low-quality opportunity that we're skipping.OUTCOME column between Trade? and Hold with five states computed from data we already collect:released_beat_eps / released_miss_eps — from earnings_markets.actual_beat. Gold-standard, overrides PM. Badge: ✅ BEAT · EPS / ❌ MISS · EPS.released_beat_pm / released_miss_pm — latest price_snapshots.yes_price >= 0.99 or <= 0.01 within last 12h. Same threshold as Finding #264's _live_polymarket_extreme_outcome so trader and dashboard agree on "PM at extreme = released". Badge: ✅ BEAT · PM / ❌ MISS · PM.released_pending — past typical BMO/AMC release window in ET (BMO ≥ 9:30 AM, AMC ≥ 4:30 PM), no clear outcome signal yet. Badge: ⏳ PENDING. Resolver will pick this up on the next cycle.pre_event — default; no badge; current behaviour.released_*) get opacity:0.55 so the operator's eye skips past them._compute_outcome_status(actual_beat, latest_yes_price, latest_yes_price_age_sec, earnings_date_str, earnings_time, now_et) in earnings_dashboard/app.py. Called once per row in get_earnings_with_polymarket. JS render and Jinja initial render BOTH read the same 5-state outcome_status field; no duplicate logic in HTML/JS._refresh_cache() (initial Jinja render) AND /api/earnings-calendar (30s JS poll) → get_earnings_with_polymarket() → _compute_outcome_status() → row dict gets outcome_status etc. → renderer paints badge + dim.actual_beat=NULL, latest yes_price=0.9995 (~3h old) → helper returns released_beat_pm. CAT today BMO, latest yes=0.815 → no PM extreme → past 9:30 AM ET → released_pending. GRMN today BMO, no recent PM snapshot → past 9:30 AM ET → released_pending. AMZN today AMC, no PM, before 4:30 PM ET → pre_event.app.py lines ~1740-1875; SQL extended at app.py line ~1791; row-loop fields at app.py line ~1908; both polymarket_data[ticker] dicts (success + except-fallback) at app.py lines ~2030 and ~2114; entry dict at app.py line ~2330; Jinja header at dashboard.html line ~1062; Jinja per-row cell at dashboard.html line ~1293; Jinja TR dim at dashboard.html line ~1071; JS render outcome cell at dashboard.html line ~3920; JS render TR dim at dashboard.html line ~3865.pm_data → helper called with all None → returns pre_event. (b) timezone mismatch → helper uses dt.now(ET) (ZoneInfo("America/New_York")); intra-day comparison in ET. (c) cache stale → entry dict has fields; backend serves cache → JS still renders. (d) actual_earnings_time is a datetime kept INTERNAL to polymarket_data; entry dict only carries scalars (no JSON serialization risk).curl /api/earnings-calendar returns rows with outcome_status / outcome_label / outcome_source / outcome_settled; today's HUM = released_beat_pm, CAT = released_pending, GRMN/YUM/TW/SOFI/PAG/VIRT/WING = released_pending, all today's AMC + future-dated rows = pre_event. Visual: settled rows dimmed with badges; pre-event rows unchanged.earnings_with_polymarket rows. Only _refresh_cache() populates the cache, only /api/earnings-calendar + the Jinja initial render consume it, and both use the new field. No other dashboard surface needs the field.trading_positions or trade_audit_log; does NOT change trader behavior or earnings_markets.earnings_time semantics (Finding #220 unaffected); does NOT change any gate (memory §M cross-interface for gates not triggered, but the cross-interface principle of "one shared definition, both surfaces" was followed anyway via the helper).earnings_dashboard/app.py (helpers + SQL + 2 dict additions + entry-dict outcome plumbing).earnings_dashboard/templates/dashboard.html (header + Jinja cell + Jinja TR dim + JS render outcome cell + JS TR dim + 2 colspan bumps 14→15).docs/FINDINGS.md (this entry).curl -s http://127.0.0.1:8080/api/earnings-calendar | jq '.[] | select(.date == "<today>") | {symbol, time, outcome_status, outcome_label, outcome_source, outcome_yes_price}'. Today's BMO rows post-release should be released_beat_pm / released_miss_pm / released_pending / released_beat_eps / released_miss_eps; today's pre-AMC + future rows should be pre_event.compound_v1 (see Finding #318). PR 4 of the Phase 3 simulator built the 6-classifier sweep (scripts/phase3_simulator/regime_classifiers.py + replay_regime_v2.py) and ran it on prod: faster detectors add NO robust ROI. The fastest (below_ema20) gains +$70 on the live-traded set but the gain evaporates to −$17 (outlier-driven) on the capacity-aware universe at the live 1.40 cap, and it whipsaws ~40% of LONGs (vs 17% for compound). Operator-confirmed KEEP. The faster detectors' only durable benefit is a better worst-week tail at ~breakeven P&L — a downside trade-off, not a money-maker. Historical context below preserved verbatim. (Originally documented Apr 28, 2026 — deferred to post-Phase-3; the deferral conditions were met and the analysis is now done.)scripts/analyze_regime_detection_lag.py, committed Apr 28) replayed MarketRegimeProvider._classify over SPY 2018-2026 daily history and measured trading days from each significant peak (≥8% drop within 60 days, n=8 peaks) to first regime == "downtrend" flip, vs faster alternative detectors:
```
Peak date Peak $ Trough $ DD% compound below_50 below_200 below_ema20 dd_3pct dd_5pct dd_10pct
2018-08-27 257.23 210.18 -18.3% 37d 31d 40d 27d 31d 32d 76d
2019-12-12 288.40 204.94 -28.9% 54d 48d 51d 29d 33d 49d 51d
2020-02-11 306.38 204.94 -33.1% 14d 8d 11d 8d 8d 9d 11d
2020-09-01 325.55 299.08 -8.1% never 12d never 3d 2d 4d never
2021-12-10 442.51 392.67 -11.3% 30d 6d 28d 2d 6d 26d 49d
2024-07-16 553.22 506.72 -8.4% 18d 7d never 3d 6d 13d never
2025-01-22 597.77 490.85 -17.9% 31d 22d 32d 8d 23d 28d 35d
2026-01-06 689.93 631.97 -8.4% 37d 9d 51d 9d 41d 50d neverDetector medians:
compound (current) 31d [22 historical downtrend-LONG trades sized against this]
below_50 12d faster, classic SMA breach
below_ema20 8d fastest, EMA smoothing
dd_3pct 23d drawdown filter
dd_5pct 28d "correction" textbook def
below_200 40d slower
dd_10pct 49d slowest
``
Two takeaways: (1) our compound 4-AND uptrend / 3-AND downtrend rule is the **second-slowest** detector in the family — onlyclose < MA200is slower; (2) our rule **missed an entire drawdown** (Sep 2020 −8.1%) because the move wasn't deep enough to invert MA20<MA50.
- **Why this is the right design today (do NOT change before Phase 3):**
- Action Queue rule #1 (Skip LONGs in downtrend) was sized on the **22 historical downtrend-LONG trades** identified by exactly this classifier. Switching to a faster detector retroactively re-classifies those trades and may shrink the bucket below the n≥12 gauntlet floor (memory §K). The +$140-$200 / 100-trades estimate is *causally tied to the current classifier's lag*.
- Per memory §K + §N, every regime-rule change requires a true-simulator end-to-end re-run on the actual data (not just lag analysis). That re-run is a meaningful piece of work and should be done as a deliberate "regime v2" project after rule #1 has accumulated live evidence.
- The lag is a **selection problem (we miss some good downtrend-skips)**, not a **damage problem (the rule itself works on the trades it does catch)** — opportunity cost, not loss.
- **Industry-consensus options (ranked by cost-of-integration, surveyed Apr 28):**
1. **SMA → EMA on the same windows** — median 8d vs our 31d; drop-in replacement; no new data; trade-off is more sideways↔uptrend whipsaw.
2. **Drawdown circuit-breaker as OR clause** —downtrend = (compound) OR (close ≤ 95% of 60-day high); 28d median; catches the Sep 2020 case our rule missed; textbook "correction" definition.
3. **VIX term structure (VIX vs VIX3M)** — backwardation = stress; the single most-watched leading vol-regime indicator on institutional vol desks; typically flips 5-15 days BEFORE the price-MA breakdown. We already capture VIX — VIX3M is one extra yfinance call (^VIX3M).
4. **Breadth deterioration** — % of S&P 500 stocks below 50-DMA; leads index by ~10 days; needs universe data we don't currently have.
5. **Credit spreads** — HYG/LQD ratio or HY OAS; leads equity by 5-20 days; free from FRED.
6. **Composite financial-conditions index (FCI)** — Goldman / Bloomberg / Chicago Fed NFCI; full institutional standard. Heavier integration cost.
7. **Hidden Markov Models / Markov-switching** — academic-grade; probably overkill for our purpose.
- **Asymmetry to flag in any redesign:** the current downtrend rule does NOT requireclose < MA200. So a sustained bull-market dip that briefly breaks MA50 + inverts MA20<MA50 can flip us to "downtrend" while SPY is still above MA200 — a slight false-positive risk. Symmetric construction would also requireclose < MA200. Worth raising during the post-Phase-3 redesign even if all other choices are unchanged.
- **Re-test triggers (when to revisit):**
1. Phase 3 unified simulator has shipped (~May 26, 2026 — equity-bars 30-day capture window completes, see Finding #240).
2. Action Queue rule #1 has been live for ≥30 closed trades AND its measured lift is within ±$30 of the +$140-$200 / 100 estimate (i.e. the current-classifier baseline holds).
3. THEN: buildMarketRegimeProvider v2candidate (likely option B above — compound rule ORdd_5pctORVIX > VIX3M for ≥3 days), re-run the full Action Queue analysis with v2 labels, apply the 4-check gauntlet (memory §K) to every rule that uses regime, ship only if v2 shows ≥+30% improvement and survives the gauntlet.
- **Saved artifacts (per operator request "ensure everything is clearly saved"):**
-scripts/analyze_regime_detection_lag.py— read-only, re-runnable, committed Apr 28. Re-run with:[hidden]/venv/bin/python3 scripts/analyze_regime_detection_lag.py. Output is the table above + aggregate medians.
- This Finding (canonical narrative per memory §J).
-docs/STRATEGY_PLAYBOOK.md§7 (forward-looking deferral entry per memory §N).
-docs/AGENT_HANDOFF.md§11 DEFER queue.
- Action Queue entryregime-detection-v2with statusDEFERRED-POST-PHASE-3(mirrored to dashboard viabuild_action_queue()per memory §K).
- **Files (current implementation, no changes):**
-src/earnings/market_regime_provider.py— single source of truth for regime classification (memory §M).
-scripts/strategy_performance_analysis.py:get_spy_regime_at_date— historical analysis mirror (must stay in sync with_classifyif the rule ever changes).
- **Acceptance test (when this finding is eventually closed):** post-Phase-3, after building v2, re-runscripts/analyze_regime_detection_lag.pyANDscripts/strategy_performance_analysis.py` AND the 4-check gauntlet. Lag table should show v2 median ≤ 15d. Action Queue rule #1 evidence under v2 must still pass the gauntlet. Then update this finding to RESOLVED and the Playbook §7 entry.
SIGNAL_MIN_VOLUME_TOTAL gate retune after PM earnings-market volume collapse was quantified)priority.py MAX_PRIORITY_SCORE=8, base 0-6 + volume bump), sizing 30/22/15 EVALUATED + KEPT (#314.c), exposure cap KEEP 1.40 + daily tiering SIMPLIFIED to uniform 140% (#315/#316/#317), 4 SKIP cells evaluated (#314.e, analysis-only). Residual deferred to after PR 4 (now un-gated, #318 shipped): SHORT ≤10%-conviction bucket validation + regime-conditioned sizing + position×regime interaction. (Originally documented Apr 28, 2026 deferred-post-Phase-3 alongside #278; May-28 amendment folded the volume-collapse evidence into Phase B scope. The deferral conditions were met and the core re-evaluation is now done; historical body preserved below.)src/earnings/config.py lines 141-143):POSITION_SIZE_PCT_LARGE = 0.30POSITION_SIZE_PCT_MID = 0.22POSITION_SIZE_PCT_SMALL = 0.15TESTING_PROD_SIZE_MULTIPLIER = 1.00 (line 151) — currently 100% of standard sizing; will downshift to live sizing after the trade #150+ transition.POSITION_SIZE_FLEX_MAX (Finding #74) — fractional-share flex allowing up to +57% over base size when rounding to 1 share.src/earnings/priority.py, full file):docs/AGENT_HANDOFF.md §11 follow-on observation). The tier ordering predates Findings #228 (stops eat winners), #232 (5-outlier curse rename), #233 (corrected stop classification), and the pending Action Queue rules.scripts/strategy_performance_analysis.py) but has not been used to inform a sizing-policy update. The quintile-by-position_value data is rendered on the dashboard Strategy Analysis tab but consumed by no rule.priority.py PRIORITY_TABLE has no row for it); tier 2 covers Small 70-80% LONGs only (very rare in production, n<5 historical). The 0-6 range is not actually 7 distinct tiers in production.docs/AGENT_HANDOFF.md §11 follow-on observation.)("size", "90+") from PRIORITY_TABLE so confidence ≥ 90% returns SKIP) applies symmetrically to LONG-90% AND SHORT-10% paths, but the rule was only data-validated on LONGs. SHORT-side: total SHORT history n=18; zero historical SHORTs at conviction ≤ 10% to compare against. Operator caught this on COIN #2026-05-07 (yes_price=0.0755 → confidence 92.45% → mid 90+ bucket → SKIP). The skip might be over-strict for SHORTs because miss-direction has different EV characteristics than beat-direction at the conviction extreme. Phase-3 evaluation must explicitly bucket SHORTs at confidence 90-95% / 95-99% / 99%+ separately and decide per bucket. Sample candidate when COIN-style markets accumulate: ("mid", "90+SHORT") = 3 (same priority as ("mid", "80-90") = 3 if data supports it).POSITION_SIZE_FLEX_MAX (currently +57%, Finding #74) against the now-larger capital base — flex was sized when 1-share-rounding was a frequent constraint at $25k; at $50k it is less common.sized_by_regime hypothesis) — currently size is independent of regime, which the data now suggests is sub-optimal for downtrend LONGs.docs/STRATEGY_PLAYBOOK.md §7 (forward-looking deferral entry per memory §N).docs/AGENT_HANDOFF.md §11 DEFER queue.sizing-priority-v2 with status DEFERRED-POST-PHASE-3 (mirrored to dashboard via build_action_queue() per memory §K).src/earnings/config.py lines 141-151 (sizing percentages + TESTING_PROD multiplier).src/earnings/priority.py (entire file, 0-6 tier system).src/earnings/risk_manager.py:calculate_position_size (line 881 + flex logic ~953-974).scripts/strategy_performance_analysis.py (Finding #218 quintile analysis already plumbed; will be the foundation for the v2 evaluation).IMPLEMENTED — sizing-priority v2 (Finding #279) with concrete new percentages + tier definitions in the Playbook, OR (b) KEPT — sizing-priority v1 verified at trade #150+ if the gauntlet showed the current values are still optimal. Either outcome closes this finding.This subsection was added May 28, 2026 (was a chat-only observation from May 19, escalated to canonical narrative here per operator's
AskQuestion"option B — fold #304 evidence into #279 Phase B" decision — i.e. the PM volume-collapse evidence got folded INTO this #279 subsection rather than creating a separate Finding entry for the collapse itself). Note for chronology: the Finding #304 number itself WAS subsequently used the same afternoon — assigned to the Queue Rule #1 ship (Skip LONGs in downtrend regime) perdocs/FINDINGS.mdFinding #304. The two events are independent: the volume-collapse evidence remains folded in here under #279; the next-numbered ship (Rule #1) took #304 in the natural sequence. This subsection captures the volume-environment shift that any Phase B sizing/priority/gating decision must now account for.
earnings_markets LEFT JOIN markets USING (condition_id)`, May 28, 2026 ~12:30 UTC):| Month | Markets | Avg vol | Median vol | Max vol | $2k gate pass | % pass |
|---|---|---|---|---|---|---|
| Dec 2025 | 52 | $16,438.41 | $14,521.84 | $51,573.51 | 51 / 52 | 98.1 % |
| Jan 2026 | 54 | $33,475.47 | $24,729.72 | $145,386.59 | 54 / 54 | 100.0 % |
| Feb 2026 | 170 | $33,387.08 | $8,823.12 | $850,539.22 | 163 / 170 | 95.9 % |
| Mar 2026 | 72 | $3,958.39 | $0.00 | $58,561.02 | 20 / 72 | 27.8 % ← first cliff |
| Apr 2026 | 120 | $7,031.82 | $4,306.62 | $59,683.42 | 77 / 120 | 64.2 % ← partial recovery |
| May 2026 (May-1 → today, May-28) | 154 | $305.98 | $13.39 | $9,358.05 | 3 / 154 | 1.9 % ← catastrophic |
prob_24h_before = 0.1205 (12 %). Bot's SHORT path skipped because Finding #207 symmetric application (added May 6, 2026, see sub-finding in this entry above) treats SHORT conviction ≤ 10 % as SKIP. AMPL at 12 % was borderline; market resolved MISS (actual_beat = False) — would have been a winning SHORT had the rule allowed it. (Already on the Phase B re-evaluation scope as the SHORT-≤-10 % sub-rule.)prob_24h_before = 0.9720 (97 %). Bot's LONG path skipped via Finding #202 (Skip all LONGs at conviction ≥ 90 %). LONG 95 %+ universe-bucket = +$192/trade across 27 markets (currently skipped); Phase B's LONG ≥ 90 % re-evaluation already lists NVDA-class markets as the population this might re-admit.prob_24h_before = 0.8900 (89 %), Large LONG. Below the 90 % skip cliff. The Mid-LONG-≥80 % skip (queue rule #3 ON-HOLD pending RuleB) does NOT apply because LOW is Large, not Mid. The reason LOW produced no entry is not on the current investigation surface; flagged as a sub-question for Phase B planning (likely either a entry_volume_24h < 1k bot-side check, or a depth/spread gate kill via Finding #283 / WIDE SPREAD).trading_positions: last pre-drought entry was id = 162 MCD on 2026-05-06 19:58 UTC; next entry was id = 163 HPQ on 2026-05-27 14:34 UTC — 20 days, 18 hours, 36 min with zero new entries. HPQ itself only crossed the gate via the Finding #282 Gamma-cumulative fallback path — the DB-recorded markets.volume_total = $23.84 is far below the $2k threshold, but volume_threshold_met = True was recorded in trading_positions at entry time, so the effective max(DB, Gamma) cumulative WAS ≥ $2k at the gate-decision instant (the DB value subsequently drifted or was overwritten by a stale sync — separate data-capture observation; see "Data-capture gap" bullet below).SIGNAL_MIN_VOLUME_TOTAL gate must be re-evaluated against fresh data. The historical bias-gauntlet population that justified $2k was built on the Dec-2025 / Jan-2026 / Feb-2026 regime when 95-100 % of markets passed; in the May regime the gate eliminates 98.1 % of potential signal sources. A retune may be appropriate, BUT (per memory §K + §N) requires a TRUE simulator pass first (counterfactual P&L on a wider universe of currently-skipped markets across a range of candidate thresholds, e.g. $1k / $500 / $250 / $0). Lowering the gate without simulator evidence is exactly the shortcut NO SHORTCUTS rule #9 forbids.normal/busy brackets are predicated on. The rule's normal/rare distinction has effectively no current production signal to distinguish. (Specific trading-day count + entries-per-day distribution to be re-derived during Phase B simulator work; the qualitative claim is robust to the precise denominator.)docs/AGENT_HANDOFF.md §11 carry-over). With PM signal density at ~1.9 % of historical norm, the question of whether to supplement with Kalshi's ~216 earnings-mention markets (~$1.24M cumulative open; KXTSLA-style KPI markets only for TSLA + AAPL, no general "Will X beat?" markets) becomes structurally relevant.trading_positions.entry_volume_24h and final_volume_24h capture the 24h-window volume, but the cumulative-volume value that the gate actually decides on (max(markets.volume_total, Gamma cumulative), Finding #282) is NOT persisted as a column. For forensic-grade replay of "what cumulative did the gate see at this trade's entry instant?" we'd need a new field, e.g. effective_cumulative_volume_at_entry. Possible future surfacing as a Finding once Phase 3 simulator design is in flight (the simulator will need this field for realistic counterfactual replays); deferred per memory §C "mention, don't expand scope unilaterally."AskQuestion, and folded it here.docs/FINDINGS.md (this subsection + status-line update at the top of #279).docs/STRATEGY_PLAYBOOK.md §1 verdict-banner + §7 deferred-bundle row for sizing-priority-v2.docs/AGENT_HANDOFF.md §11 (May 28 afternoon block).docs/CONTEXT.md (Last-Updated header + "What changed this week" bullet).build_action_queue() changes (the existing sizing-priority-v2 row already says DEFERRED-POST-PHASE-3 and now references this subsection as its framing premise).volume_24h_at_entry NULL for 76 consecutive trades since Feb 16 — Polymarket Gamma API drops volume24hr field intermittently (RESOLVED)volume_total is the gate, not volume_24h)trade_audit_log aggregated by entry_time week):
Week 2026-04-20 → 2026-02-16: 76 trades, 0 with_volume, 0% coverage
Week 2026-02-09 → 2026-02-09: 11 trades, 8 with_volume, 73% coverage
Week 2026-02-02 and earlier: ~100% coverage
Hard cutover at week of 2026-02-16. 76 consecutive trades NULL.curl https://gamma-api.polymarket.com/events/pagination?tag_id=1013&active=true&archived=false
→ "volume": 4894.41
→ "volume24hr": None ← THIS IS WHY
→ "volume24Hr": None
→ "volume1mo": 4894.41
→ "volume1wk": 4894.41
→ "volume1yr": 4894.41
Polymarket's Gamma API stopped reliably returning volume24hr for markets in the events response (~50% null rate per current probe; was ~100% null for many weeks). All long-term volume fields (volume, volume1wk, volume1mo, volume1yr) are populated. The 24h field is the unreliable one.continuous_collector.sync_markets_from_gamma writes m.get('volume24hr') or 0 to markets.volume_24h. When Gamma returns None, we write 0. Live state: 750 / 848 recently-synced markets (88%) have volume_24h = 0.signal_generator.get_market_volume() reads markets.volume_24h. With v24 = float(row.volume_24h) if row.volume_24h else None, 0 is treated as None because Python truthiness.signal.volume_24h = None.trading_orchestrator._persist_position() and position_manager.persist_position() both have 'volume_24h': to_decimal(signal.volume_24h) if signal and signal.volume_24h else None — None propagates → INSERT writes NULL.price_snapshots.volume_24h IS frequently populated for the same markets:19:55:19 Apr 24 captured volume_24h = 550.95 — a positive value that the markets-table sync had missed.volume_total is unaffected: Gamma's volume (cumulative) field is reliable (~100% populated). volume_total capture and the trader's volume gate (SIGNAL_MIN_VOLUME_TOTAL = 2000) work correctly. Only the 24h companion metric was lost.src/earnings/signal_generator.py:get_market_volume() falls back to most-recent price_snapshots.volume_24h (within 6 hours, positive only) when markets.volume_24h is 0/null. The snapshot collector's higher cadence catches the field when Gamma briefly returns it; the resulting value is the closest "near-entry" 24h volume we have on hand.price_snapshots.volume_24h within ±6 hours of entry_time for every NULL-volume_24h_at_entry row. Result:
total_trades: 117
with_volume_24h: 114 (97%)
still_null: 3 (markets without nearby positive snapshots)
VZ #120 now has volume_24h_at_entry = 550.95 (matches the 19:55 snapshot one minute before entry).signal_generator._build_trading_signal() → get_market_volume(condition_id) → returns (v24, vt) → assigned to TradingSignal.volume_24h → position_manager._persist_position / trading_orchestrator._persist_position reads signal.volume_24h → INSERT.markets.volume_24h = 0. Helper now queries price_snapshots with positive value within last 6h. Returns 550.95 (VZ case). signal.volume_24h = 550.95. INSERT writes 550.95.src/earnings/signal_generator.py lines 411-431 (the v24 computation + new fallback).trade_audit_log.volume_24h_at_entry IS NOT NULL even when markets.volume_24h = 0. Verifiable via SELECT id, symbol, volume_24h_at_entry FROM trade_audit_log ORDER BY entry_time DESC LIMIT 5 after the next earnings entry.markets.volume_24h. Only signal_generator.get_market_volume() and dashboard app.py use it. Dashboard already reads db_volume_total for the gate decision (Finding #265.v2); 24h is only displayed as a transparency field.signal.volume_24h is consumed only by the two persist paths (position_manager.py:381, trading_orchestrator.py:2468) and a debug log line. No dashboard / Jinja / JS dependency.src/earnings/signal_generator.py (8-line fallback in get_market_volume)docs/FINDINGS.md (this entry)maxsize 500M, NOT size 500M, per pre-flight dry-run on prod)collector.log at 8.7 GB despite Phase 5 logrotate config installed earlier the same day (commit 1c11cb3, deploy 13:43 UTC). The 8.7 GB had accumulated because the daily timer hadn't fired since deploy. Forced logrotate -f truncated 8.7 GB → 6.6 KB, proving the rest of the config was sound./etc/logrotate.d/polymarket declared daily only — between calendar rotations the log can grow unbounded (collector.log routinely hits ~6 GB / day in steady state). A bug-induced runaway logging spike could balloon to dozens of GB between fires.size 500M (or maxsize 500M to keep the daily cadence as the upper bound)". May 9 pre-flight logrotate -d dry-run on prod logrotate 3.21.0 + man logrotate re-read confirmed the semantics had been described inversely:daily + size 500M → size REPLACES the time-based count. Dry-run output: "log does not need rotating (log size is below the 'size' threshold)" — no mention of daily cadence. Result: small logs (backup.log <1 KB/day, integrity_monitor.log ~5 KB/day) would NEVER rotate, growing forever. Wrong directive for our needs.daily + maxsize 500M → maxsize ADDS a size early-trigger ON TOP of the daily cadence. Dry-run output: "rotating pattern: ... after 1 days (14 rotations) ... log files >= 524288000 are rotated earlier". Correct belt-and-suspenders semantics.maxsize 500M after daily in deploy/logrotate/polymarket, plus a 12-line comment block citing this finding's pre-flight verification so a future agent doesn't confuse size vs maxsize again./etc/cron.daily/logrotate (or logrotate.timer) → reads /etc/logrotate.d/polymarket → for each *.log file, check (age >= 1 day OR size >= 500M) → if either, rotate via copytruncate → keep last 14 archives compressed.logrotate.timer, that's the same midnight fire either way; the maxsize value is mostly insurance against a future change to hourly cadence + ensures behavior is documented.deploy/logrotate/polymarket:33-34 (added maxsize 500M between daily and rotate 14).maxsize not supported on logrotate <3.8.0 — verified prod is 3.21.0, well past that. (b) Threshold too low (e.g., 50M) → constant rotation churn → decided against, 500M is original analysis target. (c) Threshold too high (e.g., 5G) → mostly equivalent to no directive → decided against. 500M sized for ~6 GB/day collector → ~12 daily rotations on heavy days IF the timer were hourly; on prod's daily timer it stays as a single daily rotation but caps the maximum file size BETWEEN rotations to 500M (would prevent the 8.7 GB Apr 27 incident). (d) Multiple rotations on same day → rotate 14 keeps last 14 archives → on heavy day with hourly timer, 14 archives could cover only ~28 hours instead of 14 days. ON PROD'S DAILY TIMER this isn't a concern (1 rotation per day → 14 days retention preserved). Documented in the file's comment block for future-self.cat /etc/logrotate.d/polymarket shows the maxsize 500M line. logrotate -d /etc/logrotate.d/polymarket dry-run shows "log files >= 524288000 are rotated earlier" (vs. previous "rotated, old logs are removed" without the early-trigger phrase). logrotate -f /etc/logrotate.d/polymarket (force run) succeeds without errors.deploy/logrotate/polymarket). The *.log glob covers every service's log under /var/log/polymarket/. No other rotation surface in repo or prod.deploy_from_github.sh line 124-127 already auto-installs logrotate config (install -m 0644 deploy/logrotate/polymarket /etc/logrotate.d/polymarket). No service restart required (logrotate reads the file on each invocation, not at boot).logrotate.timer is daily-only (next fire Sun 2026-05-10 00:00:00 UTC). On distros that run logrotate hourly via logrotate.timer, maxsize 500M would trigger early between calendar days; on this prod the early-trigger and the daily cadence fire at the same moment (midnight UTC). If we ever want true intra-day capping, override logrotate.timer to hourly via /etc/systemd/system/logrotate.timer.d/. Not done in this commit because (i) it's a separate concern, (ii) current collector.log at 73 MB is well below 500M with normal daily growth, and (iii) the maxsize directive is the right structural fix regardless of timer cadence.deploy/logrotate/polymarket (1-line addition + 12 comment lines documenting the size-vs-maxsize gotcha)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 (May 9 LATE evening housekeeping block extended)docs/CONTEXT.md (What changed this week — May 9 LATE evening line)signal_emitted rows (DOCUMENTED — deferred to post-Phase-3 — medium)polymarket_opportunity_log since deploy of 578dee5 (Apr 26 22:00) shows:
skip_stage | schema | n | with_pm_best_bid
----------------+--------+----+-----------------
generate_signal | pm_v2 | 89 | 0
timing | | 76 | 0
signal_emitted | pm_v2 | 8 | 0
depth | pm_v2 | 1 | 1
Of 8 signal_emitted rows (where a real signal was generated, hence ConvictionResult MUST have been computed), 8/8 have pm_best_bid NULL. The schema label is bumped, but the value-add fields are missing. The single depth-skip row DOES have pm_best_bid populated because that branch fetches the orderbook unconditionally as part of the depth check._emit_pm_opportunity_log() call sites in signal_generator.py, conviction_result is passed but ConvictionResult.best_bid / best_ask may be None when the orderbook fetch path was bypassed (e.g., conviction computed from price_snapshots.yes_price only via the snapshot-based fallback per Finding #246's post-event backfill). The depth-skip rows DO have these fields populated because that branch fetches the orderbook unconditionally as part of the depth check.conviction_calculator.ConvictionResult + _emit_pm_opportunity_log). A fix-now would likely be re-touched at Phase 3 anyway, doubling change cost and risking churn.mid_price field or a slippage_bps derived field, a fix-now that only adds best_bid / best_ask would be insufficient and reworked._emit_pm_opportunity_log() call sites in src/earnings/signal_generator.py. Identify which branches (a) already have a fresh orderbook fetch in scope vs (b) fall back to price_snapshots.yes_price-only conviction.markets.token_id_yes/no orderbook lookup just before the _emit_pm_opportunity_log() call so ConvictionResult.best_bid / best_ask / spread are populated. Cache the lookup if multiple consumers in the same call frame.snapshot_schema='pm_v2' and accept partial NULLs (with a documented is_pm_v2_complete flag), or downgrade those rows to snapshot_schema='pm_v1.5'. Phase 3 simulator's tolerance for partial data drives this choice.sql
SELECT skip_stage,
COUNT(*) AS n,
COUNT(pm_best_bid) AS with_bid,
COUNT(pm_best_ask) AS with_ask,
COUNT(pm_spread) AS with_spread,
ROUND(100.0 * COUNT(pm_best_bid) / COUNT(*), 1) AS bid_pct
FROM polymarket_opportunity_log
WHERE snapshot_schema = 'pm_v2'
GROUP BY skip_stage
ORDER BY n DESC;
Pass criteria: bid_pct = 100.0 on all rows for skip_stage IN ('signal_emitted', 'generate_signal'). Tracking this across the next 30 days post-Phase-3 should show monotonic 100% on new rows; legacy rows (pre-fix) keep their NULLs as a "before" baseline.src/earnings/conviction_calculator.py (where ConvictionResult is built) + src/earnings/signal_generator.py:_emit_pm_opportunity_log (the merge step). Possibly src/data_tracker/migrations/ if schema_version or completeness flag is added.docs/AGENT_HANDOFF.md §11 May 26 deferral list. Per §K, no Action Queue row added in scripts/strategy_performance_analysis.py:build_action_queue() because #273 is data-capture infrastructure, not a strategy rule (the Action Queue tracks strategy-level decisions; the Testing Findings tab tracks engineering-level findings — both render from docs/FINDINGS.md via /api/architecture-findings, but the Action Queue is a curated subset).StartLimitIntervalSec/Burst in [Service] instead of [Unit] across 5 live systemd unit files (RESOLVED May 9, 2026)polymarket-trades-backfill.service; memory.md §E same-pattern check on May 9 surfaced 4 additional live instances + 1 install-helper template + 2 decommissioned-watchdog occurrences (left untouched per §C).Unknown key name 'StartLimitIntervalSec' in section 'Service', ignoring. Live 24h-window grep on May 9 (before fix) revealed the warning fires on every daemon-reload for 4 distinct active services (trades-backfill, integrity-monitor, collector, conviction-tracker) plus earnings-trader (less frequent because it hadn't been reloaded recently — bug still in file).StartLimitIntervalSec=N and StartLimitBurst=N are properties of the unit's restart-rate-limiting policy and belong in the [Unit] section per man systemd.unit. systemd silently ignores them in [Service]. Result: Restart=on-failure/Restart=always falls back to the systemd default (5 starts in 10s) instead of the per-unit intended values (e.g., 10 starts in 3600s for trades-backfill, 5 starts in 300s for the long-running daemons).StartLimit across deploy/ + systemd/ returned 7 hits across 7 files. Cross-checked against live systemctl is-active on prod to filter dead code:
| Repo file | Live state | Action |
|---|---|---|
| deploy/earnings-trader.service | enabled, active | FIXED |
| deploy/systemd/polymarket-integrity-monitor.service | enabled, active | FIXED |
| deploy/systemd/polymarket-trades-backfill.service | enabled, inactive (Finding #269) | FIXED (original scope) |
| deploy/systemd/polymarket-collector.service | enabled, active | FIXED |
| systemd/earnings-conviction-tracker.service | enabled, active | FIXED |
| deploy/install_trader_service.sh (heredoc) | install-helper template | FIXED (consistency w/ trader) |
| deploy/polymarket-watchdog.service | not-found on prod (decommissioned per #260c) | INTENTIONALLY NOT TOUCHED IN #270 (§C — dead code) — subsequently DELETED per Finding #290 (May 10, 2026) |
| deploy/polymarket-watchdog-v3.service | not-found on prod (decommissioned per #260c) | INTENTIONALLY NOT TOUCHED IN #270 (§C — dead code) — subsequently DELETED per Finding #290 (May 10, 2026) |daemon-reload → emits warning for unknown keys → Restart=on-failure falls back to systemd default rate-limit (5 starts in 10s) → if a service ever crash-loops, retry semantics differ from the per-unit intended StartLimitIntervalSec / StartLimitBurst values.[Service] to [Unit] (after the Wants=/After= lines, before the [Service] block), with an inline comment citing this finding.man systemd.unit confirms [Unit] is the correct section since systemd 230+; prod is well past that. (b) Restart attribute reads [Service] while StartLimit reads [Unit] — that IS the systemd convention; the two sections cooperate, not duplicate. (c) Comment placement could break parsing — verified no, comments are line-level and the directives are still on their own lines.journalctl --since "1 minute ago" | grep "Unknown key name" returns 0 lines after final daemon-reload.deploy_from_github.sh already auto-installs polymarket-trades-backfill.service (line 93) and polymarket-integrity-monitor.service (line 104). The other 3 affected files (earnings-trader, polymarket-collector, earnings-conviction-tracker) are NOT installed by the deploy script — only restarted (or not managed at all in conviction-tracker's case). Per §H fallback procedure, these 3 were install -m 0644'd manually from the just-pulled repo to /etc/systemd/system/ on prod, then systemctl daemon-reload. Deploy-script gap noted in docs/AGENT_HANDOFF.md §11 as a future cleanup candidate (intentionally not extending the deploy script in this commit per §C surgical-changes rule).daemon-reload is sufficient to pick up. The trader/collector/conviction-tracker services continue running uninterrupted.deploy/earnings-trader.service (2-line move)deploy/systemd/polymarket-integrity-monitor.service (2-line move)deploy/systemd/polymarket-trades-backfill.service (2-line move)deploy/systemd/polymarket-collector.service (2-line move)systemd/earnings-conviction-tracker.service (2-line move)deploy/install_trader_service.sh (2-line move in heredoc template)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 (May 9 evening housekeeping block)earnings_markets.stock_return_day_close; directional edge, NO stops); (B) the bot's stop-aware counterfactual (opp_log.hypothetical_pnl[_pct] — real sizing + 3/4/5% STOP-LIMIT + BMO/AMC timing) for the bands it actually logged as skipped. Full gauntlet: de-dupe by ticker (ex-repeat-tickers) + ex-top-2 + real per-tier $-scaling + split by mcap. Scripts: scripts/analyze_conviction_buckets.py, analyze_excluded_stopaware.py, analyze_raise_cap_gauntlet.py, scripts/phase3_simulator/replay_deadzone.py.replay_deadzone.py, de-duped 15 tickers): −$54.65, 40% win, 40% stop-rate, FAIL-EX-TOP-2. The 3-5% stops eat the edge exactly as #228 predicts. KEEP the dead-zone exclusion.docs/FINDINGS.md (this) + Action Queue (build_action_queue) + STRATEGY_PLAYBOOK.md §2 + CONTEXT + AGENT_HANDOFF. NO src/ or production change.hypothetical_pnl_pct already exists and is the scale-invariant truth, in PERCENT units (pricer counterfactual_pricing.py:226 pnl = notional × pnl_pct/100; verified stored avg_abs ~3.6%, range −5%..+38%); (2) the simulator already scales $10k → real correctly (algebraically pct × real_position), so no migration/rewrite needed; (3) the genuinely-misleading surfaces were display-only.avg_ret_pct * 100 while avg_ret_pct is already percent → showed e.g. 79% instead of 0.79%. Fixed in both renderers (skipped-signal-cost + volume-gate-counterfactual) → avg_ret_pct.toFixed(2).analyze_skipped_cost note now states pct is canonical PERCENT + total is $10k-notional + real $ = (pct/100)×per-tier.avg_ret_pct in the template found a 3rd usage (regime/VIX buckets, L7803) that uses a DIFFERENT field — realized_pnl_pct, which is a FRACTION — and renders WITHOUT ×100, so it appears to under-display (~0.00%). That is a separate suspected unit bug in a different metric; NOT flipped here (out of the hypothetical-P&L scope + needs its own verification). Flagged for a follow-up.earnings_dashboard/templates/dashboard.html (2 renderers + 2 headers), scripts/strategy_performance_analysis.py (units note); lockstep docs. NO trading change.analyze_finding_a_full simulators (simulate_baseline/simulate_window_only/simulate_no_stop). NEW scripts/phase3_simulator/replay_deadzone_ruleb.py (non-destructive, de-duped by ticker, real per-tier $-scaled).replay_high_conv_split.py, operator-requested): the pooled ≥0.90-positive was MASKING a bad extreme band. By sub-bucket (markets / baseline-ex2 / RuleB-ex2): 0.90-0.92 n=16 / −$3 / −$3 (weak — misses are real drops, baseline==RuleB); 0.92-0.95 n=35 / +$12 / +$134 (solid under RuleB); 0.95-0.99 n=16 / −$111 / +$148 (BAD under baseline, rescued by RuleB); 0.99-1.00 n=0. In-window-stop% rises with conviction (57%→53%→80%) — the extreme band is vol-spike-stopped most, so baseline kills it + RuleB rescues it. Implication: relaxing the cap WITHOUT RuleB would import the bad 0.95-0.99; the clean lever is 0.92-0.99 conditional on RuleB active (0.90-0.92 marginal).replay_202_ruleb.py): added a RuleB column to the EXACT original #202 dataset — the 26 real closed LONG trades with conviction_at_entry ≥0.90 (real fills). Result: ACTUAL −$112.25 / 15.4% WR / 73% stop → RuleB −$88.18 / 15.4% WR / 73% stop → NO-STOP −$153.67 / 42.3% WR. RuleB does NOT rescue the real ≥0.90 trades (WR unchanged 15.4%; still deeply negative; no-stop is worse). The +$853/ex2+$614 "flip" above was a DIFFERENT population (post-#202 skipped signals, Mar-May, counterfactually priced) and does NOT generalize to the real fills. Two divergence causes: (1) period/regime — later skipped signals had 44-52% WR vs the early real trades' 15%; (2) counterfactual bar-pricing is ~$33 optimistic vs real fills on the SAME 26 trades (A_base re-sim −$79 vs actual −$112). On the only ground-truth (real fills), #202 HOLDS even with RuleB — ≥0.90 LONGs are directionally wrong ~85% of the time, which RuleB cannot fix. The ≥0.90-relax lead is materially WEAKER than this finding first claimed.reproduce_207_backtest.py, archive/stocks_earnings/data/earnings_correlation_analysis.json): 80-90% = 49 names / 38.8% WR / EV +$1.70 / +0.5% avg raw return / 22.4% stop — the WEAKEST/flattest of the three bands (70-80% +2.7%, 90%+ +1.5%). Reproduces #207's "near-zero, not worth the risk" on more data.polymarket_opportunity_log.hypothetical_pnl, skip_reason small_cap_long_above_80, conv 0.80-0.90, $1k gate): raw n=8 / 86% win / +$1101 / +$137.66 avg / 0 stops — but de-dupes to 3 names: CBT +$235.79 (a +63.4% single-day move on a $372 position — extreme outlier), MBWM the SAME +$173.10 logged 5× (pos=None, scaling unverifiable), IART $0 (×2). De-duped: n=3 / +$408.89 / 2-win+1-wash. Textbook §K traps (repeat-ticker + outlier + suspect scaling).trade_audit_log n=0. The relax-case rests ENTIRELY on 3 counterfactual names. RuleB is irrelevant here (0 baseline stops → nothing to rescue; not a stop artifact).scripts/phase3_simulator/replay_deadzone_ruleb.py (offline); docs/FINDINGS.md (this) + Action Queue (conviction-band-revalidation row amended) + CONTEXT + AGENT_HANDOFF. NO production change.prob_24h_before), operator asked whether our universe backtests (#207/#323) — which key off prob_24h_before/pre_resolution_beat_prob_24h — could be biased by using a generic "24h before" conviction instead of conviction at the ACTUAL entry time. Asked to rebuild on the real entry/exit clock for both AMC and BMO.scripts/build_entry_aligned_dataset.py (+ analyze_entry_aligned.py, entry_aligned_dataset.json). For every resolved market it recomputes conviction at the real clock and the stock return + a per-tier stop model over that window. Verdict: every conviction-band exclusion HOLDS on the faithful data.trading_orchestrator.py): BMO enter 15:58 ET (prev trading day) → exit 10:32 ET (earnings day); AMC enter 10:34 ET (earnings day) → exit 15:57 ET (next trading day). Stops Large 3% / Mid 4% / Small 5% (#202), sizing 30/22/15% of ~$2450 (#211).price_snapshots (Oct 10 2025→now, 13M rows, sub-minute) gives conviction as a windowed-median of yes_price (robust to thin/crossed point-in-time orderbooks — a plain midpoint at a precise past minute degenerates to 0.001/0.5/0.999; the median fixed it, clean rows 10→361). equity_bars_1m (419 syms) gives entry/exit close + hold-window low/high for the stop model. Artifact filter (conv_entry_valid): ≥3 snapshots, not the ~0.5 no-data default, and within 0.35 of the 24h value (drops degenerate cases like AXP→0.001). 649 markets, 351 clean (2026+).scripts/build_entry_aligned_dataset.py, scripts/analyze_entry_aligned.py, scripts/entry_aligned_dataset.json. Offline; NO production change.systemd_last_run:polymarket-equity-bars.service FAIL (exit 2). First failure of this unit since ≥Jun 1 (18 clean daily runs prior); the exit-2 threshold (#251a) worked as designed — 30/35 fetch jobs failed = 85.7% > 10%.--polygon-sleep 0.05 (20 req/s each, 40 req/s combined); the Jul-29 heavy earnings slate (24 markets) made the resolver's load big enough to trip Polygon's soft throttle. The collision was latent all summer.fetch_polygon_minute_bars treated any error as terminal — no retry, no backoff.fetch_polygon_minute_bars retries 429/500/502/503/504 with 2s/8s/30s exponential backoff; non-transient errors never retry. NEW raise_on_transient=True mode raises PolygonTransientError after exhaustion so callers can classify honestly; default keeps legacy warn-and-return-partial (resolver/equity_bar_cache callers unchanged).transient= in the Done line); the >10% exit-2 threshold now counts hard+transient (post-backoff transients at that rate = genuine pacing/schedule problem → we WANT the flag) but the message distinguishes them.--polygon-sleep 0.05 → 0.25 in all three scheduled units (equity-bars, daily resolver, intraday resolver) — adds ~1-2 min to daily batch runs, irrelevant. The 0.05 default remains fine for one-shot manual backfills that run alone.counterfactual_pricing.py (backoff + PolygonTransientError), equity_bars_collector.py (classification + transient counter), polymarket-equity-bars.timer (11:35 UTC), 3 service units (pacing), NEW tests/unit/test_finding_348_polygon_backoff.py (5 tests), OPERATIONS.md ×2.docs/CODE_AUDIT_RECONCILIATION_2026_08.md). This commit does not start it.IMPLEMENTED-PENDING-DEPLOY. This commit does not deploy and does not restart any unit.resolution_sniper.py, polymarket_sniper/resolution_sniper.py) defaulted FUNDER_ADDRESS to the retired proxy 0x8cF01785CcDC3107e113aE7452D0381bAe0c2a6b. A start with no env var would use that wallet. No tracked file held the retired signer 0x5C215dD606A016a5892970800534621E8B144061.resolution_sniper. src/data_tracker/ (collector, conviction tracker, resolvers) does not reference it. deploy/cron/root.crontab does not. deploy/deploy_from_github.sh does not install or restart a sniper unit. The only unit whose ExecStart is the sniper is deploy/polymarket-sniper.service (polymarket_sniper/resolution_sniper.py, WorkingDirectory=/home/polybot/polymarket-bot), copied by the legacy bootstrap deploy/setup-digitalocean.sh, not by the [hidden] deploy. #457 records the sniper as ON-HOLD with no unit. #473 records polymarket_sniper/ as not run on prod. The live dashboard is run_dashboard.py → earnings_dashboard.app. dashboard/app.py only pgreps the filename.FUNDER_ADDRESS raises. The retired proxy raises. A PRIVATE_KEY that does not parse raises. A key whose address is the retired signer raises. Values are not logged. Both copies stay byte-identical (#457).load_dotenv() reads FUNDER_ADDRESS, then PRIVATE_KEY only when it is non-empty. Nothing in the collector, conviction tracker, data tracker, cron, or the deploy restart list loads this module.[hidden], the checkout stays at the previous SHA. A fast-forward updates the two files on disk. No unit in deploy/systemd/ executes them, so no restart is required for the running collector.FIX-003 block in both sniper copies.deploy/remote_deploy.sh or deploy/deploy_from_github.sh for this change; do not put a real private key in the test.tests/unit/test_finding_488_retired_funder.py — copies identical; empty default; synthetic funder accepted; missing, malformed, and retired funder refused; unparseable key refused; retired signer refused via a fake eth_account. The suite does not import the module, so no existing test needed a synthetic FUNDER_ADDRESS.resolution_sniper.py, polymarket_sniper/resolution_sniper.py, NEW tests/unit/test_finding_488_retired_funder.py, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.storage:orderbook_snapshots stays FAIL at 12 GB after the drain finishes; the page stops saying the drain failed (Oct 3, 2026)DEPLOYED Oct 4, 2026 12:05 UTC (unit polymarket-deploy-20261004T120553Z-812717079, SHA 7c9d6b0; trader restarted 12:06 UTC, integrity monitor 12:08 UTC; Gateway not restarted).orderbook_snapshots hypertable was 18.2 GB inside the 90-day window (warn 12 GB, FAIL at 18 GB). orderbook_archive_log had 264/264 ranges dropped, 0 proof failures, and 0 chunks with range_end older than 90 days. The archive timer's last run processed 0 eligible ranges. apply_drain_grace still returned FAIL with "the OPS-1 drain (#383) should have finished" because today > ORDERBOOK_DRAIN_GRACE_UNTIL (2026-08-29) was checked before the backlog rule. That sentence was false: the drain had finished. The size itself was still over the line (July ~83 MB/day, September ~333 MB/day on the kept window).STATUS_FAIL. If archive:orderbook_backlog is OK and eligible_live_chunks == 0, the message says the drain finished and the live 90-day working set is over the line. If the backlog is OK but chunks remain, the message says the drain has not failed. If the backlog is not OK, the message says the drain stalled. In-grace WARN is unchanged. HYPERTABLE_THRESHOLDS_GB for orderbook_snapshots stays 12.0.DEPLOYED Oct 4, 2026 12:05 UTC._check_orderbook_size_with_drain_grace → apply_drain_grace. today > grace_until still returns FAIL. Backlog OK + 0 eligible → expired_drain_finished. Backlog OK + eligible remaining → expired_drain_on_schedule. Backlog not OK → expired_drain_stalled.storage:orderbook_snapshots is FAIL at 18.4 GB and says the drain finished (0 live chunks older than 90 days). No chunk drop, no insert skip.apply_drain_grace in src/data_tracker/integrity_monitor.py.tests/unit/test_finding_390_storage_drain_grace.py — expired + backlog still draining stays FAIL and does not say the drain failed; expired + 0 eligible says the drain finished and stays FAIL; expired + stalled says stalled; in-grace WARN unchanged; wiring still cites 12 GB and #390.src/data_tracker/integrity_monitor.py, tests/unit/test_finding_390_storage_drain_grace.py, docs/OPERATIONS.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, src/earnings/strategy_changelog.py, this entry.NRestarts stays 5 until a later restart).DEPLOYED Oct 4, 2026 12:05 UTC (unit polymarket-deploy-20261004T120553Z-812717079, SHA 7c9d6b0; trader restarted 12:06 UTC; trader:restart_tripwire ok and NRestarts=0; Gateway not restarted).polymarket-deploy-20260930T201810Z the trader died five times on 2026-10-01, all os._exit(1) from MAIN LOOP STALL at the 120s watchdog. Stall dumps stall-20261001T195205Z, T195438Z, T195712Z, T195946Z, and T200231Z sit in urllib3 create_connection inside finance_calendars.__get_calendar_query requests.get with no timeout, called from fetch_nasdaq_calendar → verify_earnings_dates → run_bmo_cycle. The first death was during run_scheduled_cycles; the next four were _run_startup_signal_check on the way back up. OFFICIAL_CALENDAR_LOOKAHEAD_DAYS is 21, so one hung GET blocks the whole lookahead. The fifth start at 20:03:02 UTC was still the live PID the next time the tripwire was read. systemd NRestarts counts Restart=on-failure and does not clear until a later manual or deploy restart.finance_calendars issues requests.get with no timeout. fetch_nasdaq_calendar already turns any exception into {} (source silent, no earnings_time write — #220/#349). The exception never arrived, because the socket had no deadline. A process-global socket.setdefaulttimeout would also cover IBKR in this process, so the deadline is on our own GET.nasdaq_earnings_request calls requests.get(..., timeout=8). get_upcoming_earnings and get_earnings_between stop at a 45s loop budget and do not retry; later days are absent. Worst case is the budget plus one in-flight call (53s), under the 120s watchdog. 21 × 8s would be 168s, which is why the budget exists. A timeout or any other error still returns no rows. The package parser's row shape is kept (symbol index, API columns).get_earnings_for_ticker / fetch_yfinance_date is the same class of hangable HTTP and was not the Oct 1 stack. yfinance's get_earnings_dates has no timeout we can pass. Bounding it with a process-wide socket default would hit IBKR. Left unchanged on purpose.timeout= on hangable HTTP, treat failure as degrade, and do not retry in a tight loop. {} writes no date. The watchdog stays 120s. Deployed Oct 4, 2026 12:05 UTC. The marker is DEPLOYED Oct 4, 2026 12:05 UTC.verify_earnings_dates → fetch_nasdaq_calendar → EarningsCalendar.get_upcoming_earnings → get_earnings_for_date → nasdaq_earnings_request. Timeout or budget exhaustion returns partial or empty; the verifier's existing {} path does not write earnings_time.trader:restart_tripwire is ok (NRestarts=0). History: the Oct 1 deaths left NRestarts=5 until this restart.src/ibkr/earnings_calendar.py nasdaq_earnings_request, NASDAQ_REQUEST_TIMEOUT_SEC, NASDAQ_CALENDAR_BUDGET_SEC, _collect_nasdaq_days.socket.setdefaulttimeout; no tight retry; no watchdog increase; no prod restart; do not write a date from a single silent source.tests/unit/test_finding_486_nasdaq_calendar_timeout.py — timeout= is 8s and the helper source has no setdefaulttimeout; 21 × 8s exceeds 120s while 45 + 8 does not; a timeout returns []; the 21-day loop stops once the budget is spent and does not retry.src/ibkr/earnings_calendar.py, src/earnings/earnings_date_verifier.py (comment only), NEW tests/unit/test_finding_486_nasdaq_calendar_timeout.py, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, src/earnings/strategy_changelog.py, this entry.RESTART before last-resort 2FA (heal-1234 step 3) (Sep 27, 2026)#438/#439/#456. Does not change last-resort while the flag is off.IMPLEMENTED-PENDING-DEPLOY on branch cursor/launcher-pwd-rotation-62ee from master 2b38199. Flag default off. Monday AutoRestart rotation follow-up (Sunday PWD in launcher.YYYYMMDD.log). This commit does not enable the flag, does not deploy, and does not restart Gateway or trader.RESTART and got OK RESTART in progress, then Restart in progress + Login has completed on the token path (initialTokenType=TST, pwd=0 soft=256) with no IB Key 2FA. Mac nc to 7462/7463 timed out (loopback-only).#438/#439 last-resort is systemctl restart ibgateway-ibc, which kills the JVM and forces a phone 2FA. IBC 3.24.2's Command Server can recycle the Gateway inside the same session token. The trader had no loopback client and no blackout around Monday cash hours / entry-exit waves.src/ibkr/ibc_command.py — loopback only (127.0.0.1 / ::1 / localhost), 10 s timeout, allow-list RESTART/PAUSE/RESUME/RECONNECTDATA. STOP/EXIT/QUIT/HALT are refused (those kill the JVM). Reply must start with OK. Default port 7462 (live); paper is 7463 via IBKR_IBC_COMMAND_PORT.recycle_gateway_if_flat — when IBKR_GATEWAY_SOFT_RESTART is on, write the soft stamp (state=sending, count incremented) before send, capture the IBC log cursor, then RESTART inside the B1 recycle_lock (held through the token-reuse wait so a concurrent caller cannot systemctl mid-restart). CommandResult.sent is true after sendall; a transmitted-but-unacked or malformed reply takes the accepted path (soft_restarted + may_have_restarted) and never falls through to last-resort. Only a failure before/during sendall falls through. Token reuse does not consume the #439 weekly 2FA budget (own stamp logs/ibkr_last_soft_restart.json, ≤3/UTC-day, 20 min cooldown; leftover sending counts as spent). After 3/day or during that cooldown, last-resort may still run if its own budget allows.plan.md); daily IBC AutoRestart 05:45–06:30 UTC. Saturday is weekday()==5, so Sat 20:00 is not an exit wave. Blackout is folded into recycle_defer_reason so _last_resort_recycle_ready stays false and the client keeps IB() probing. Token alive (Sunday 01:00 ET until initialTokenType=PWD then IBC Login has completed, DST-aware) sits next to soft_restart_own_defer and gates only IBC RESTART. Last-resort systemctl restart ibgateway-ibc is a fresh PWD login (#430) and must still run when the weekly budget allows. initialTokenType= is launcher-only (heal-1234 L5 /opt/ibgateway/Jts/launcher.log via IBKR_LAUNCHER_LOG_PATH, plus this week's launcher.YYYYMMDD.log siblings after Monday 06:00 AutoRestart rotation — prod 28 Sep 2026 06:33 UTC: live file 25230 bytes of Monday TST, Sunday PWD only in launcher.20260927.log). TST does not count. Real IBC lines are IBC: Login attempt: 1 with no token type. Public recycle_defer_reason() does not skip the weekly/7d budget (that would suppress in-place IB() resets during the soft cooldown). Pass skip_weekly_budget=True only from recycle_gateway_if_flat's soft branch; _mark_recovery_exhausted still calls recycle when the flag is on and weekly-budget is the only defer.RecycleResult.soft_restarted — _maybe_standing_recycle consumes the attempt (with ok / pending_late_login / may_have_restarted) so an accepted or transmitted-unacked RESTART whose login times out does not retry into systemctl.IBKR_GATEWAY_SOFT_RESTART default false; true/1 needs # GUARD-OVERRIDE-UNTIL (inverted vs AUTO_RECYCLE). Daily cap 1–3; cooldown 600–3600 s.IB() reset fail → _last_resort_recycle_ready → recycle_defer_reason (age / Sunday quiet / freeze-when-flag-on / auth-in-flight; weekly budget not skipped on the public predicate; token-alive is not in this predicate) → in-place IB() resets continue when last-resort is not ready → _mark_recovery_exhausted still calls recycle when the flag is on and weekly-budget is the only defer → recycle_lock → token-alive / own-defer skip RESTART and fall through → else soft stamp sending → IBC RESTART on 127.0.0.1:7462 (sent after sendall) → wait Restart in progress then Login has completed + :4001 from the pre-send log cursor → systemd-run trader bounce. Transmitted-unacked never systemctl. Last-resort stamp untouched on token reuse. Flag off → identical #438 last-resort (including Monday freeze and Sat 20:00). Flag on + realistic IBC log + no launcher PWD + weekly budget available → no RESTART, systemctl still runs.launcher.YYYYMMDD.log shows this week's first PWD + IBC Login has completed: skip RESTART, last-resort still may run. Sunday 06:00 AutoRestart stays the planned weekly 2FA. A spent #439 budget still allows IBC RESTART without suppressing IB() resets. Dated enable: confirm week_token_alive_reason is None against live launcher.log and the newest launcher.YYYYMMDD.log sibling plus IBC after the weekly login.src/ibkr/ibc_command.py (NEW, CommandResult.sent); src/ibkr/gateway_recycle.py _soft_restart_enabled / soft_restart_blackout_reason / week_token_alive_reason / soft_restart_own_defer / classify_ibc_login_path / _ibc_log_cursor / wait_ibc_needle / recycle_defer_reason(skip_weekly_budget=) / recycle_gateway_if_flat / _complete_soft_restart; src/ibkr/client.py _last_resort_recycle_ready / _mark_recovery_exhausted / _maybe_standing_recycle soft_restarted; Config + GUARD.STOP; never non-loopback; never enable the flag in this commit; never fall through to systemctl after an accepted or transmitted-unacked RESTART; stamp before send; do not skip weekly budget on the public predicate; do not mute trader:ibkr_request_path; do not invent net-liq; do not consume #439 on token reuse; do not treat Saturday 20:00 UTC as a weekday wave; no RESTART from Sunday 01:00 ET until launcher.log or this week's launcher.YYYYMMDD.log shows this week's first PWD login; do not refuse last-resort because the token is dead; do not read initialTokenType= from IBC logs; do not treat initialTokenType=TST as the week's first full login.tests/unit/test_finding_484_gateway_soft_restart.py — STOP/non-loopback refused; sent on timeout vs connect-fail; P4 token vs PWD fixtures; Monday freeze / waves / AutoRestart; exit wave 19:45–20:05 including 19:47/19:50 send nothing; Sat 20:00 is not a wave; flag-off Sat 20:00 and Monday freeze still last-resort; flag-on freeze/waves refuse both; success without systemctl/budget; fail-send fallthrough; transmitted-unacked no systemctl; stamp written before send; public defer still weekly-used when budget spent (IB() resets continue); budget-spent still soft; 3/day then last-resort; 20-min cooldown; accepted-RESTART login timeout no systemctl; Sunday 01:00 ET token-alive (EDT/EST); #456 IBC shape alone does not open (Sun/Tue/Fri same defer); spliced PWD in IBC ignored; launcher PWD + IBC login opens; Monday AutoRestart rotated layout (launcher.log TST + launcher.20260927.log PWD) opens, live TST alone does not, TST in the rotated sibling does not count, last week's dated sibling does not open; flag-on Monday 06:33 with rotated PWD sends RESTART; flag-on midweek realistic IBC still systemctl; public defer is not token-alive; pre-send shared baseline sees a fast Login has completed.src/ibkr/ibc_command.py, src/ibkr/gateway_recycle.py, src/ibkr/client.py, src/earnings/config.py, src/data_tracker/integrity_monitor.py, NEW tests/unit/test_finding_484_gateway_soft_restart.py, tests/unit/test_config.py, tests/unit/test_finding_468_checkout_hygiene.py, .gitignore, .env.example, .cursor/memory.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, docs/OPERATIONS.md, docs/IBKR_CONTINUITY.md, src/earnings/strategy_changelog.py, this entry.WALLET_NAGI leaked on /api/btc5m/docs/doc_map; central identifier masking, comprehensive route sweep (Sep 27, 2026)3138aca, deploy #11).IMPLEMENTED-PENDING-DEPLOY on a new branch off master 3138aca. No deploy in this session; prod stayed read-only./api/btc5m/docs/doc_map still served the raw WALLET_NAGI value (strategies/btc5m/src/facts.py's benchmark-maker wallet prefix, [REDACTED]) in doc prose (strategies/btc5m/docs/DOC_MAP.md: "Nagi [REDACTED]…."), even though /api/btc5m/state already masks the SAME constant per-route (_mask_wallet(facts.WALLET_NAGI)).secret_redaction._collect_known_identifier_literals() (the central belt-and-suspenders backstop that redacts known identifier VALUES from every response, docs included) to catch this. That function collected btc5m's wallet/host constants from a HAND-MAINTAINED TUPLE OF ATTRIBUTE NAMES ("WALLET_MAIN", "WALLET_TITUS", "EXECUTION_HOST", "EXECUTION_HOST_IP", "EXECUTION_TREE_PATH") — a separate list from the per-route masking call sites. Whoever added WALLET_NAGI to facts.py had no reason to know this second list existed and needed updating too: a "forgot to add it to the list" gap, not a masking-logic bug.earnings_dashboard/secret_redaction.py: _collect_known_identifier_literals() now DISCOVERS every WALLET_* constant in facts.py by NAMING CONVENTION (name.startswith("WALLET_"), plus the three exact names EXECUTION_HOST/EXECUTION_HOST_IP/EXECUTION_TREE_PATH) instead of a hand-listed tuple. A future WALLET_<name> is covered with zero code changes elsewhere — this is what "mask identifiers centrally, for every public response, JSON APIs included, rather than per route" means in practice: the ONE place that has to stay correct is a naming convention, not a list a human has to remember to extend. Per-route structural masking (_mask_wallet, mask_identifier) is unchanged and still runs FIRST for the nicer partial-reveal UX on structured JSON fields (0x06…7c0, not a blunt [REDACTED]) — central redaction is the backstop that guarantees zero raw leaks even when a response surface (like a doc route) has no per-field masking of its own.facts.py — same bug class, one layer over): DU…91, etfstack's earlier paper-trading account id, referenced verbatim in FIVE main-dashboard doc files (docs/IBKR_SETUP.md ×2, docs/PAPER_TRADING_TEST_PLAN.md, docs/STRATEGY.md, docs/FINDINGS.md's own #467 entry) and never added to identifier_masking.KNOWN_ACCOUNT_IDS — fixed by adding it there (read by both the per-route doc masking mask_known_public_identifiers() AND the central secret_redaction backstop, so one addition covers both).tests/unit/test_finding_483_identifier_leak_gap.py, new): enumerates EVERY registered public GET route — parameter-free routes (existing helper), AND the routes that need a concrete parameter to reach the handler, which the FIRST version of the operator's 82-URL prod scan could not reach: /api/trade-chart-data (needs source+id query params — supplied via fixture values source=position&id=1 / source=audit&id=1), /api/etfstack/backtest/{run_id}/equity and /metrics (needs a run_id path param — fixture 1), and both doc-serving routes at every valid slug (/docs/{doc_name} via the existing 404-body slug discovery; /api/btc5m/docs/{slug} via DOC_SLUGS) — and asserts none of the known account ids, known server IP/path, or ANY WALLET_*/EXECUTION_HOST*/EXECUTION_TREE_PATH value in facts.py (discovered dynamically at test time, not copied into a list in the test either) appears in any response body. A new endpoint, or a new identifier constant added to facts.py, cannot leak silently past this test — the exact "that way a new endpoint can't leak silently" requirement.TestBtc5mIdentifiersMasked.test_known_identifier_literals_feed_the_secret_filter_too now checks every dynamically-discovered WALLET_*/EXECUTION_* value (was 3 hand-picked names, missing WALLET_NAGI); TestDocRoutesIdentifierMasking.test_every_btc5m_doc_slug_is_clean now also checks btc5m's OWN identifiers on btc5m's doc route (was checking only the MAIN dashboard's account id/server IP/path against btc5m's docs — the literal reason WALLET_NAGI shipped undetected).tests/unit/test_finding_467_dashboard_hardening.py::TestFullRouteSweepNoSecretLeak planted a fake DATABASE_URL (postgresql://...@10.20.30.40:5432/earnings) and hit every real dashboard route, including DB-touching ones. On this dev Mac that private-looking address fails instantly (unroutable / psycopg2 not installed); on a fresh cloud VM whose network silently drops rather than rejects traffic to it, the real psycopg2/pg8000 connect() call hung for the OS's full TCP timeout with no PGCONNECT_TIMEOUT set to bound it (confirmed: passes with PGCONNECT_TIMEOUT=1; hangs the same way on pure master, i.e. a pre-existing gap). Fixed hermetically, not by tuning a timeout: a new no_db_network fixture patches the ONE shared factory every dashboard/btc5m/etfstack engine is built through (src.db.connection.create_db_engine) to return a bare object() — any .begin()/.connect()/SQLAlchemy event.listen() call on it raises AttributeError immediately, no socket ever opens, and every DB call site already treats that as "DB unavailable" (its existing graceful fallback). Also drops any real engine a prior test already cached in one of the four module-level singletons, and replaces the fixture's literal 10.20.30.40 with the RFC 2606-reserved, guaranteed-never-resolvable db.invalid — no real host or IP stays in the fixture. Verified hermetic directly: instrumented socket.socket.connect during both affected tests and confirmed zero real socket calls./api/btc5m/docs/{slug} → get_doc() → _mask_known_public_identifiers(text) (btc5m's own local copy, unaffected by this fix — it never had wallets in its list) is NOT what redacts WALLET_NAGI; the doc response body then passes through the ASGI SecretRedactionMiddleware (installed globally on app), which calls redact_bytes() → _STORE.redact_bytes() → the literal pattern built from _collect_known_identifier_literals(). Before the fix, that pattern never contained WALLET_NAGI, so the middleware passed the raw value through untouched.GET /api/btc5m/docs/doc_map, WALLET_NAGI) before and after the fix; reverted secret_redaction.py to the pre-fix version via git show HEAD:... and confirmed 5 tests correctly fail (the new file's reviewer-reported-route test and one parametrized identifier case, plus the two widened #467 tests), then restored the file byte-identical (diff confirmed) — same for the DU…91 fix in identifier_masking.py (1 test fails on revert, confirmed, restored identical). For the DB-hang fix: instrumented socket.socket.connect around both affected tests and confirmed zero calls with the fixture applied.earnings_dashboard/secret_redaction.py (_collect_known_identifier_literals, ~30 lines changed to discovery-by-name) · earnings_dashboard/identifier_masking.py (KNOWN_ACCOUNT_IDS +1 value) · tests/unit/test_finding_483_identifier_leak_gap.py (NEW, 26 tests) · tests/unit/test_finding_467_dashboard_hardening.py (2 tests widened; no_db_network fixture + PLANTED_DATABASE_URL host change; both risky tests take the new fixture).TestCentralCollectionDiscoversFutureIdentifiers.test_an_unrelated_new_constant_is_not_swept_in). btc5m's own local doc-masking copy is deliberately NOT updated to also mask wallets — the "no cross-import" modularity rule stays intact, and the central backstop already covers this response surface without it.tests/unit/test_finding_483_identifier_leak_gap.py — 26 tests (4 classes): the comprehensive route+identifier sweep, the reviewer-reported route in isolation, the DU…91 second hit (5 specific docs + a dedicated mutation check), and the "future identifier discovered automatically / unrelated constant not swept in" unit tests on the collection function itself. Plus 2 widened tests in the #467 file. Full unit suite and drift audit rerun clean (counts below).facts.py for "any other identifier that leaks the same way" — exactly one identifier-shaped section (WALLET_MAIN, WALLET_TITUS, WALLET_NAGI, EXECUTION_HOST, EXECUTION_HOST_IP, EXECUTION_TREE_PATH); all six now discovered automatically. The comprehensive sweep itself then found the SEPARATE DU…91 hit (not from facts.py) by construction, rather than by a second manual grep — confirming the sweep does what it's meant to. A live operator scan of all 82 dashboard URLs (widened to the same parameterized routes this file covers) confirmed WALLET_NAGI on doc_map was the only current hit at scan time; DU…91 was found afterward by this file's own test, before deploy.earnings_dashboard/secret_redaction.py, earnings_dashboard/identifier_masking.py, tests/unit/test_finding_483_identifier_leak_gap.py (NEW, 26 tests), tests/unit/test_finding_467_dashboard_hardening.py (2 tests widened, no_db_network fixture added, PLANTED_DATABASE_URL host changed), docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.IMPLEMENTED-PENDING-DEPLOY (Batch B2, branch reliability/b2).sep21-22-stalls.txt is the one-sided-book path: One-sided orderbook, using price_snapshots fallback → Using robust latest price_snapshot 23–74 s later, for CTAS / PAYX / CBRL in turn, with heartbeat skipped — IB owner did not pump (#440) in between, then MAIN LOOP STALL … stage=amc_verify_dates (the stage mark is set after verify_earnings_dates and never updated inside check_*_signals).ConvictionCalculator._get_latest_from_price_snapshots ran one CTE: max(time) over all history, joined to the rows time >= mx − 60 min. The join predicate is not pushed into the chunk scans. Read-only EXPLAIN (ANALYZE, BUFFERS) on prod, Sep 26 16:48–16:50 UTC, active markets: 287k rows / 47,597 disk reads / 35.5 s cold (146k rows / 1.4 s warm) to return 792 rows across ~352 chunks. A 7-day bound on the CTE alone still scanned 108k rows (0.4–1.1 s). Two statements — bounded max(time) (0.3–1.8 ms), then the window with a constant lower bound (5.6–11.5 ms) — returned the same 792 / 628 rows.faulthandler.dump_traceback(file=io.StringIO()) raises UnsupportedOperation: fileno, swallowed at DEBUG (verified locally)._build_trading_signal's regime check calls the IBKR client (SPY / VIX quotes) and bounded_request / get_quote do not route to the owner — that would break the #440 same-thread contract._get_latest_from_price_snapshots: two bounded statements (anchor inside CONVICTION_LATEST_LOOKBACK_DAYS = 7, then time >= anchor − 60 min, LIMIT 10000) — same rows as the old CTE whenever data exists in the lookback; under SET LOCAL statement_timeout = CONVICTION_QUERY_TIMEOUT_MS (15000); failures still return None (existing degradation)._get_latest_conviction: the latest-book read is bounded to the same lookback, runs under the timeout, and a failed read rolls back and uses the snapshot / live-Gamma fallbacks. _get_conviction_from_price_snapshots (entry-time window, already bounded) gets the timeout backstop.SignalGenerator._get_signals_for_timing calls progress_hook per market (cid, then symbol, before the conviction read); the orchestrator wires _signal_progress: mark signals:<bmo|amc>:<SYMBOL> and pump_ib_owner() (heartbeat probes run between tickers)._write_watchdog_thread_dump writes stage= + an all-thread faulthandler dump to MAIN_LOOP_STALL_DUMP_DIR/stall-<UTC>-<pid>.txt (fsync), logs it, and names the file in the CRITICAL line and page; a dump failure logs ERROR and still exits.MAIN_LOOP_STALL_TIMEOUT_DATED_OVERRIDES and Config.main_loop_stall_timeout_sec() removed; the watchdog reads MAIN_LOOP_STALL_TIMEOUT_SEC (120, GUARD ≤ 600) every poll. The names join drift-audit C2 as stale literals; the #453 changelog entry is SUPERSEDED by #470; the #453 test file becomes the retirement tripwire.TradingOrchestrator._get_current_conviction_and_volume, TradingUtils.get_current_conviction_and_volume) get the same lower bound. .gitignore logs/stall_dumps/.run_bmo_cycle / run_amc_cycle → check_*_signals → get_*_signals_* → _get_signals_for_timing → per market _note_progress → _signal_progress (mark + pump_ib_owner) → get_conviction_at_entry_time → _get_latest_conviction (bounded book, timeout) → one-sided → _get_latest_from_price_snapshots (two bounded statements) → _build_trading_signal. Watchdog thread → MAIN_LOOP_STALL_TIMEOUT_SEC → stall → _write_watchdog_thread_dump → CRITICAL + page → os._exit(1).stage=signals:bmo:<SYMBOL> and a dump file.conviction_calculator.py _latest_since, _bound_statement, _get_latest_conviction book read, _get_latest_from_price_snapshots, _get_conviction_from_price_snapshots; signal_generator.py progress_hook (__init__), _note_progress, _get_signals_for_timing loop head; trading_orchestrator.py __init__ hook wiring, _main_loop_watchdog_loop, _write_watchdog_thread_dump, _signal_progress, _get_current_conviction_and_volume; trading_utils.py get_current_conviction_and_volume; config.py CONVICTION_LATEST_LOOKBACK_DAYS, CONVICTION_QUERY_TIMEOUT_MS, MAIN_LOOP_STALL_DUMP_DIR, removed override + accessor; scripts/doc_drift_audit.py C2.pump_ib_owner runs queued owner jobs only; it does not run the ib_insync loop between jobs; (d) other slow work inside one ticker (yfinance market-cap lookup, HTTP with its own timeouts) is not split further; (e) the detector-thread reads in earnings_detector.py (_live_polymarket_median, _check_polymarket_price, _live_polymarket_extreme_outcome) are the same unbounded shape but not on the owner thread and feed instant-exit inputs (eps_estimate from the latest row) — follow-up B2-F2, closed in review round 2 (below).tests/unit/test_finding_470_signal_stall_root_cause.py (17): old-CTE oracle on synthetic data (latest 1 min / 5 h / 6 d 23 h → same rows and median); two bounded statements under SET LOCAL statement_timeout = 15000, since ≈ now − 7 d, window bound, LIMIT 10000; > 7 d → None with no window read; timeout → None; book read bounded and a failed read rolls back and falls back to snapshots; per-ticker progress sequence; mark before the conviction read; hook failure harmless; orchestrator marks + pumps and wires the hook; 4 slow tickers (~160 s) → no exit; real hang → exit, one page, dump file with the real stage; dump failure still exits; dump dir ignored; bounded entry/close reads. pristine parent cefc00d with this commit's test file: 16 failed / 1 passed (review round 2 re-run; the earlier "14 of the first 16" was a partial-revert count). tests/unit/test_finding_453_stall_monday_stopgap.py rewritten to 4 retirement tripwires (override and accessor gone; watchdog reads the plain budget; budget guarded; a 200 s Monday stall exits).git grep of FROM price_snapshots|orderbook_snapshots|trades in src/earnings/, src/ibkr/: bounded here — the three conviction reads and the two entry/close reads; already bounded — get_conviction_at_time window, signal_generator.get_market_volume (6 h); offline — get_markets_with_conviction (only the module's main()); B2-F2 — the three detector-thread reads, closed in review round 2. Retired-mechanism grep (MAIN_LOOP_STALL_TIMEOUT_DATED_OVERRIDES, main_loop_stall_timeout_sec, budget 120s -> 300s): code/tests/OPERATIONS updated; FINDINGS #453 and the CONTEXT Sep 26 bullet are history; the R2 banners and the #453 SUPERSEDED marker flip at this finding's deploy sweep.bac9f25, blockers 8 and 10 + nits):TradingOrchestrator._get_current_conviction_and_volume and TradingUtils.get_current_conviction_and_volume run SET LOCAL statement_timeout = CONVICTION_QUERY_TIMEOUT_MS before the price read (they had only the date bound); a timeout degrades to (None, None) through the existing except.EarningsDetector._recent_prints_for_symbol(symbol, n) replaces the three ticker JOINs in _live_polymarket_median, _check_polymarket_price, _live_polymarket_extreme_outcome: on its own connection, under the timeout, it resolves the ticker's condition IDs (earnings_markets WHERE ticker), then reads price_snapshots WHERE condition_id IN (…) AND time >= now − CONVICTION_LATEST_LOOKBACK_DAYS ORDER BY time DESC LIMIT n and returns (yes_price, time, eps_estimate) — the old JOIN's rows whenever prints exist in the lookback. Any failure logs a warning and returns []: the crowd witness and the PM price check then take their existing live-Gamma fallback (before, a DB error in those two returned "no data" and skipped Gamma); the extreme-outcome shield returns None (unchanged). The two outer with engine.connect() blocks that no longer ran a query were removed (dedent only)._rollback_quietly); the watchdog keeps the newest 50 stall-*.txt dumps (STALL_DUMPS_KEEP)._get_current_conviction_and_volume → SET LOCAL → price read → except → (None, None). Detector thread → _check_release sources → _check_polymarket_price / _live_polymarket_median / _live_polymarket_extreme_outcome → _recent_prints_for_symbol → SET LOCAL → markets → prints → [] on failure → _live_gamma_yes_price (crowd witness, PM price check) or None (extreme-outcome shield). (2) Runtime: today's shapes run in ~5 ms (review probe); a lock wait now costs ≤ 15 s per read and yields the Gamma witness instead of a stalled check. (3) Lines: trading_orchestrator.py _get_current_conviction_and_volume, _write_watchdog_thread_dump, _prune_stall_dumps; trading_utils.py get_current_conviction_and_volume; earnings_detector.py _recent_prints_for_symbol + three callers; conviction_calculator.py _rollback_quietly + two handlers. (4) Limits: a ticker whose newest print is > 7 days old yields no rows (its db_eps_estimate falls back to the Gamma/consensus path) — the same lookback as the conviction reads; duplicate earnings_markets rows for one condition ID no longer duplicate prints. (5) Test tests/unit/test_finding_470_review_timeouts.py (13); on bac9f25 10 fail / 3 pass. test_earnings_detector.py / test_finding_358_print_density.py fakes now route the two-query contract and still pass on bac9f25 (same rows). Red-count method (review round 2): git worktree add --detach ~/.cursor/worktrees/polymarket-bot/b2-oldtest <parent>, git show <commit>:<test> > <test>, PYTHONDONTWRITEBYTECODE=1 python3.11 -m pytest <test> -q._live_gamma_yes_price — the fallback taken after a timed-out snapshot read — runs SET LOCAL statement_timeout = CONVICTION_QUERY_TIMEOUT_MS on both of its connections (the condition-ID lookup and the persist=True INSERT), so no untimed DB statement follows a timeout. Test test_gamma_fallback_db_work_is_timed_after_a_snapshot_timeout (per-connection recorder: every connection's first statement is the timeout). R2-6: AGENT_HANDOFF now says the crowd witness and the PM price check fall back to Gamma, while the extreme-outcome shield returns None.MAIN LOOP STALL, no heartbeat skipped during the scan, trader log stage=signals:… only if something hangs; Using robust latest price_snapshot within ~1 s of One-sided orderbook.execution entry; #453's entry SUPERSEDED (superseded_by #470).src/earnings/conviction_calculator.py, src/earnings/signal_generator.py, src/earnings/trading_orchestrator.py, src/earnings/trading_utils.py, src/earnings/config.py, scripts/doc_drift_audit.py, .gitignore, tests/unit/test_finding_470_signal_stall_root_cause.py (NEW), tests/unit/test_finding_453_stall_monday_stopgap.py (rewritten), src/earnings/strategy_changelog.py, docs/OPERATIONS.md §12.2, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.IMPLEMENTED-PENDING-DEPLOY (Batch B2, branch reliability/b2).TradingOrchestrator._check_leverage_alerts had no validity gate and rate-limited by clock-minute key (_last_margin_alert_key), so a persistent false condition paged once a minute:effective_leverage=2.50 > 2.00, source=internal_estimate, equity_base=1000, net_liquidation=18450 — get_leverage_state falls back to total_capital + PDT reserve, and total_capital is the run_earnings_trader.py startup placeholder (capital … else 1000) until the first IBKR sync;liquidity_buffer=0% < 25%, source=ibkr_live, net liq loaded, excess_liquidity=0, maint_margin_req=0, flat book — after the 06:00 reattach IBKR reports net liq before margin fields;margin_state_stale pages (Aug 25–26) while syncs were failing._leverage_state_validity: only source=ibkr_live, not stale, net_liquidation > 0 can page; excess liquidity and maintenance margin both 0 (or account_type None) = margin fields unpopulated → the effective-leverage check still runs, the margin-utilisation and liquidity-buffer checks do not. margin_state_stale no longer pages (unevaluable telemetry of an alert-only engine; the IBKR request-path latch covers a dead session). Latch = the persisted leverage incident from alert_policy (#451): one WARNING when a valid breach starts (text says alert-only), one leverage_resolved WARNING when a valid state shows no breach; unevaluable state neither pages nor resolves; a failed send is retried; a restart does not re-page. margin_events gets one row per incident (was one per minute). The per-minute key is removed.risk_manager.sync_from_ibkr() → _check_leverage_alerts → get_leverage_state → _leverage_state_validity → breach? → alert_policy.open_incident("leverage") → should_page → _record_margin_event + send_alert (→ mark_paged) / no breach + margin valid → _resolve_leverage_alert.internal_estimate → silent; after the 06:00 reattach, zeroed margin fields → only the leverage check runs (0 on a flat book); a genuine 2.5× breach pages once and resolves once.trading_orchestrator.py _leverage_state_validity, _check_leverage_alerts, _resolve_leverage_alert; __init__ (per-minute key removed).margin_events count semantics change (see §E).tests/unit/test_finding_452_leverage_alert_latch.py (9): Aug 26 fixture 46 min → 0 pages / 0 events; Sep 8 fixture 27 min → 0; stale → 0; genuine breach 30 min → 1 page + 1 event, then 1 resolve; restart (fresh orchestrator) → no re-page; unevaluable states mid-breach neither page nor resolve; margin breach with populated fields pages once; failed send retried once; no per-minute key. pristine parent fb237e4: 7 failed / 2 passed (the other two depend on the old code's real-clock minute key); re-run in review round 2.scripts/leverage_live_validation_autorun.py counts margin_events of the four Phase E types for its phase_e_alert_category_seen flag: after #452 it is set only by a genuine breach (one row per incident; margin_state_stale no longer written) — the intended meaning. risk_manager.py margin_state_stale is the Phase D admission uncertainty reason, not an alert; unchanged. No other leverage/margin alert sender exists (git grep leverage_cap_breach margin_headroom_low margin_utilization_high).claim_page("leverage") (atomic in-flight), send_alert(..., require_telegram=True) so only a Telegram delivery counts, mark_paged(..., generation=), and leverage_resolved only when page_delivered. §B: trace unchanged except should_page → claim_page; runtime identical when Telegram works; a webhook-only success is now retried and never resolved; NEW test_page_counts_only_telegram_delivery (the file has 10 tests; fakes accept require_telegram)._check_leverage_alerts opens the leverage incident with its category and metrics (margin event recorded once, when the incident is created) and _resolve_leverage_alert only closes it; the dispatcher sends the page and the resolve with require_telegram=True and retries both (the resolve now also Telegram-only, R2-2). Test fixture runs one dispatch pass per check (10 tests).alert_policy.EXPECTED_WINDOW_KEYS = {"ibkr", "recon_guard"}; every other key, including leverage, uses only its own grace (0 s), so a valid broker-reported breach at 04:14 UTC or during the Sunday 06:00 AutoRestart pages on the first dispatch (was: 30 / 5 min later). §B (1) _check_leverage_alerts → open_incident("leverage") → event=None → deadline = opened_at → dispatch. (3) alert_policy.py EXPECTED_WINDOW_KEYS, open_incident, _expected_name. (4) The IBKR / reconciliation incidents keep their window SLAs unchanged. (5) TestNoGatewayWindowDelay (4: nightly / Sunday / weekday-AutoRestart breaches page at once; broker incidents keep 1800 s); all 4 fail on 69d33e2.ibkr_live breach pages once.reporting entry (alert-only; no sizing or admission change).src/earnings/trading_orchestrator.py, tests/unit/test_finding_452_leverage_alert_latch.py (NEW), src/earnings/strategy_changelog.py, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.systemctl restart earnings-trader (client.py reconnect-FAILED and 30-min STILL DISCONNECTED pages). Reliability plan #451, Batch B2; OD6 option (c) approved Sat 26 Sep 07:11 UTC.IMPLEMENTED-PENDING-DEPLOY (Batch B2, branch reliability/b2).AlertManager is a per-process singleton with no persisted state (restart = filter reset); the IBKR client paged every 1100/1102 after a 20 s grace regardless of IBKR's schedule, paged "reconnect FAILED" as a second page, repeated STILL DISCONNECTED every 30 min, and paged RECONNECTED at CRITICAL; the #441 attach-bounce produced SHUTDOWN + STARTUP WARNINGs twice a day; the reconciliation guard paged every 300 s (in-memory cooldown). Integrity duplicated the trader's request-path page (RC6, 58 pages).src/earnings/alert_policy.py: expected IBKR windows — nightly reset 23:30–01:15 ET (SLA 30 min, M2), AutoRestart 05:58–06:20 UTC (SLA 10 min; Sunday weekly 2FA 5 min); persisted incidents (logs/alert_incidents.json, cross-process fcntl lock, atomic replace, fail-open) with a deadline fixed at first open (window SLA, else the caller's grace), page-in-flight / failed-send retry, 6 h stale reset; delivery record per key:polarity; ibkr_down_lever(tcp_up); planned-restart stamp (logs/planned_restart.json, 15 min) and deploy_in_progress() (active polymarket-deploy-* unit, F-8).src/ibkr/client.py (alert functions only, no session behaviour change): _queue_disconnect_alert arms the ibkr_disconnect incident page for its deadline with the :4001-aware lever; the reconnect-FAILED page and the 30-min STILL DISCONNECTED reminders are replaced by re-arming the same incident (a delivered page is never repeated; a failed send is retried); _maybe_send_reconnect_alert / _note_ibkr_path_healthy resolve incidents — RECONNECTED / RESTORED page at WARNING only if the incident paged; a fresh process's first connect with a live request path (no IB() reset in flight) and every durable-health recovery (_clear_inplace_recovery_state) count as healthy. _send_request_path_alert(message, kind): incident (DEAD/RESTORED pair through the policy), operator actions (last-resort recycle, waiting on 2FA, recycle did not run, open position still dead; see review round 2 for their dedupe), info (recycle deferred ×2, recycle held, keep probing — logged into the incident, never a page). _emit_ibkr_alert records delivery.run_earnings_trader.py: a fresh process that cannot connect joins ibkr_disconnect (grace 120 s outside windows) and pages once at the deadline; STARTUP silent when the planned stamp is fresh (consumed); SHUTDOWN silent when stamped or a deploy unit is active (stamps for the next STARTUP). gateway_recycle.bounce_trader stamps before scheduling and clears the stamp if systemd-run fails.reconciliation_manager.py: the fail-closed guard (disconnected and stale-session skips) is one recon_guard incident: pages once after RECON_GUARD_PAGE_SLA_SEC (1800) or RECON_GUARD_OPEN_POSITION_SLA_SEC (300) with an open position (inside an IBKR window the window SLA applies; the market is closed at both); resolve page when reconciliation runs again. RECONCILIATION_GUARD_ALERT_COOLDOWN_SEC retired (prod .env does not set it; read-only grep Sep 26, 2026). trading_orchestrator._reconcile_positions_with_ibkr (legacy copy used by the trade reconciler) routes its disconnected page through the same incident.integrity_monitor._trader_already_paged — the trader:ibkr_request_path rule keeps evaluating and showing; its Telegram page is skipped only when the trader's page for the same incident generation (review round 2) and polarity was delivered within 30 min (trader dead, page not yet due, or send failed → integrity pages). memory.md quick-ref and OPERATIONS §9 row reworded in this commit..gitignore: logs/alert_incidents.json*, logs/planned_restart.json* (keeps #468's clean prod checkout). OPERATIONS §8.A3 runbook._schedule_reconnect → _queue_disconnect_alert → open_incident → threading.Timer(deadline) → should_page → _send_disconnect_alert → _emit_ibkr_alert → send_alert → record_delivery / mark_paged; recovery → _attempt_reconnect_once → durable health → _clear_inplace_recovery_state → _note_ibkr_path_healthy → resolve_incident → WARNING resolve iff paged. Latch → _trip_request_path_dead → _send_request_path_alert (incident) → _arm_incident_page. Bounce → bounce_trader stamp → SIGTERM → _send_shutdown_alert (silent) → new process → connect() probe ok → incidents resolved → _send_startup_alert (silent, stamp consumed). Integrity cycle → transition → _send_integrity_alert → _trader_already_paged → skip or page.systemctl restart ibgateway-ibc + approve the push; no reminders; resolve when the trader reconnects. Deploy → 0 lifecycle pages. Unexpected 1100 at 15:00 UTC → page after 20 s (unchanged) + one resolve.alert_policy.py (whole); client.py _send_request_path_alert, _arm_incident_page, _resolve_ibkr_incident, _note_ibkr_path_healthy, _emit_ibkr_alert, _send_disconnect_alert, _queue_disconnect_alert, _maybe_send_reconnect_alert, _schedule_reconnect (two former pages), connect() probe branch, _clear_inplace_recovery_state, 8 kind= call sites; run_earnings_trader.py _page_startup_disconnect_if_due, _send_startup_alert, _send_shutdown_alert, connect loop; gateway_recycle.bounce_trader; reconciliation_manager.py _page_recon_guard_if_due, _resolve_recon_guard, two guard branches; trading_orchestrator._reconcile_positions_with_ibkr; integrity_monitor._trader_already_paged + _send_integrity_alert; config.py RECON_GUARD_*.ibkr_request_path page within 5 min (pre-existing); (d) ~~the recon-guard page is marked delivered optimistically~~ — fixed in review round 2; (e) a stamp older than 15 min is ignored, and an operator's manual restart still pages STARTUP/SHUTDOWN (intended); (f) state-file errors fail open to paging; (g) the IBKR windows are hard-coded to IBKR's documented NA reset and our IBC AutoRestartTime 06:00 UTC — a change to either needs this module updated.tests/unit/test_finding_451_trader_alert_policy.py (49): windows incl. late reset, DST (EST), Sunday, edges, naive rejected; incident deadline / reopen / in-flight / failed-send retry / stale reset / cross-process file / unreadable file; request-path leaf (in-window DEAD waits and RESTORED is silent; outside-window DEAD pages once with the lever and resolves at WARNING; deadline fire; info never, action always (superseded by review round 2); call-site kinds; no wrong-lever or STILL DISCONNECTED text left); disconnect leaf (window SLA; grace + one page + one resolve; healthy connect closes both); OD6 drill (delivered → integrity silent both polarities; trader dead / send failed / page not yet due → integrity pages; _send_integrity_alert honours the hook; other rules unaffected; rule still CRITICAL; memory wording); lever; lifecycle (planned silent + stamp consumed, deploy stamps, unplanned unchanged, stale stamp, bounce_trader stamps/clears, unit detection); startup-connect page once at the Sunday deadline; recon guard (open position 300 s outside windows, window SLA inside); replays (nightly reset + bounce → 0 pages; Sunday hang → exactly 1 page with the Gateway lever over 3 h). Mutation-checked: dropping either window, resolve-always-pages, info paging, removing the OD6 hook, ignoring delivery, ignoring the stamp, re-paging a delivered incident each fail. Existing tests moved to the new contract: test_finding_419_ibkr_inplace_recovery.py (source marker), test_ibkr_error_block_policy.py (resolve follows the incident), test_finding_420_ibkr_nightly_1100.py + test_reconciliation_guard_retry.py (guard pages once after its SLA), test_finding_404_telegram_schedule.py (guard excepts log in the helpers), test_config.py (knob swap). tests/conftest.py resets the in-memory incident store per test.client.py (all _send_disconnect_alert / _send_request_path_alert / _emit_ibkr_alert sites routed), run_earnings_trader.py lifecycle, reconciliation_manager.py guards, trading_orchestrator.py legacy reconcile. Left paging by design (genuine or actionable): stop-fill queue full, post-reconnection reconciliation failed, reconciliation aborted (IBKR=0 / DB open), reconciliation issues, external positions, price unavailable, instant exit dropped, #399 batch-entry block, daily summary, circuit-breaker pages (risk_manager.py), earnings-date correction; leverage → #452; stall → #470. gateway_monitor.py / cron senders / GRM / integrity policy are B1 (#448/#449). Docs mentioning STILL DISCONNECTED are dated #419/#421 history.bac9f25, blockers 1–6):AlertManager.send(..., require_telegram=True) / send_alert(..., require_telegram=True) return the Telegram result only (filtered or webhook-only = False); every incident sender uses it (_emit_ibkr_alert, alert_reconciliation_guard, leverage, startup page). send()'s default return is unchanged for every other caller. Same fact as B1 #449's last-send artifact (resp.status == 200).ibkr incident (blocker 3): 1100/1300/heartbeat (_queue_disconnect_alert), the startup connect failure, and the request-path latch all open ibkr; one page at its deadline, one resolve (_resolve_ibkr_incident, from RECONNECTED / RESTORED / verified-healthy). claim_page is atomic (read + mark in flight under the lock), so the disconnect timer and the request-path timer can never both page.generation; mark_paged ignores a result for an older generation; record_delivery(key, polarity, delivered, generation); trader_page_delivered matches down to the open incident's generation and up to the last resolved generation. A second outage inside 30 min is never suppressed by the first one's delivery.claim_page("recon_guard"), then a wrapper coroutine awaits alert_reconciliation_guard (now returns the Telegram result) and persists it with mark_paged(generation=); a failed send is due again next pass; the resolve pages only if page_delivered.kind="approve_2fa" (LAST-RESORT recycle, waiting on 2FA), "recycle_failed" (standing recycle did not run), "open_position" (#418) go through claim_action("ibkr", kind): the first action of an unpaged incident IS the incident page (immediate); after it, approve_2fa and recycle_failed page at most once per incident (persisted, restart-safe; a failed escalation is retried); open_position never escalates._state() keeps a process-local mirror; if the file cannot be locked, read, or written, this process works from the mirror (dedupe across restarts degrades, paging does not).open_incident("ibkr") → timer at deadline → claim_page → _emit_ibkr_alert(purpose="page") → send_alert(require_telegram=True) → mark_paged + record_delivery(down, generation); action → claim_action → page / escalate → mark_paged or mark_action; healthy → _resolve_ibkr_incident → resolve_incident (stores the resolved generation) → resolve iff page_delivered → record_delivery(up, generation). Integrity → _trader_already_paged → trader_page_delivered("ibkr", …) (generation match). Recon → claim_page → _page() awaits the send → mark_paged. (2) Runtime: nightly 1100 + bounce = 0 pages (unchanged); an out-of-window outage with a last-resort recycle = outage page + one "approve 2FA" page + one resolve; the same inside the nightly window = LAST-RESORT (as the incident page) + "recycle did not run" if it fails + one resolve. (3) Lines: alert_policy.py persisted state, incidents, claim_page, claim_action, mark_action, resolve_incident, delivery record; alerts.py AlertManager.send, send_alert, alert_reconciliation_guard; client.py IBKR_INCIDENT, _send_request_path_alert, _arm_incident_page, _resolve_ibkr_incident, _emit_ibkr_alert, _queue_disconnect_alert, _send_disconnect_alert, 4 action call sites; reconciliation_manager.py _page_recon_guard_if_due, _resolve_recon_guard; run_earnings_trader.py _page_startup_disconnect_if_due; trading_orchestrator.py _check_leverage_alerts, _resolve_leverage_alert; integrity_monitor.py TRADER_PAGED_RULES. (4) Limits: with require_telegram a send still waits for the webhook retries before returning (≤ ~3 s extra); a resolve is one attempt (its failure is recorded; OD6 then lets integrity page its resolved transition) — B1's IncidentLedger retries failed resolves; the merge adopts that (see Integration). (5) Test tests/unit/test_finding_451_trader_alert_policy.py (68, rewritten); on bac9f25 26 fail / 42 pass. On the pristine parent 0846f43 the original file fails at collection (imports alert_policy), so the round-1 red evidence was mutation runs. Red-count method (review round 2): git worktree add --detach ~/.cursor/worktrees/polymarket-bot/b2-oldtest <parent>, git show <commit>:<test> > <test>, PYTHONDONTWRITEBYTECODE=1 python3.11 -m pytest <test> -q.cursor/b1-review-fixes-aaad worktree, uncommitted src/earnings/incident_ledger.py + alerts.DeliveryResult) uses the same definition (delivered = telegram is True) and the same incident semantics (generation, deadline, 120 s in flight, failed page due again, older generation ignored, resolve owed only after a delivered page, one-shot escalation, process copy on persist failure). Merge plan: B1 lands first; on the rebase AlertManager.send takes B1's DeliveryResult body and keeps a require_telegram: bool = False keyword (redundant — truthiness already means Telegram), so B2's callers need no edit; then alert_policy's incident store becomes a thin layer over IncidentLedger (expected-window deadlines and the OD6 delivery record stay in alert_policy), adopting B1's resolve retry. tests/conftest.py: keep both autouse fixtures.eaa4dbe, R2-1 / R2-2 / R2-3): B1 c1fb6c7 merged into reliability/b2 (merge 54c6f69), and B2's incident store rebuilt as a thin layer over B1's IncidentLedger — one incident model in production.alert_policy keeps only trader domain rules (expected-window deadlines, operator actions, page texts, ownership, OD6 record); incidents, generations, page state, escalations and resolve debt live in IncidentLedger(ALERT_INCIDENT_STATE_PATH, name="trader-alerts"). The ledger's process copy covers a vanished or unwritable file (R2-2 missing-file case). Page texts, resolve texts and the OD6 record live in a small side store ALERT_DELIVERY_STATE_PATH (logs/alert_deliveries.json, same process-copy rule).alert_policy.dispatch() is the only sender — a daemon thread started by EarningsTrader.__init__, kicked on every open/action/resolve and run every 15 s. A failed page is due again; a failed action stays owed; a failed resolve stays owed (the ledger's resolves_due) — all retried without a second symptom (R2-2). Claims are page_due → mark_page(None) under _claim_lock (the ledger file has one writer, the trader), so two dispatch passes never double-send. The IBKR client, recon guard, leverage latch and startup path only open / act / resolve; nothing sends on the IB owner thread.resolve_incident defers the close until the send result lands, so a delivered page always gets its resolve; a new symptom cancels the deferred close.ibkr_operator_action (CRITICAL; not a #413 latch category), so the same-polarity cooldown after a delivered request-path page cannot swallow "approve 2FA"; the per-incident action dedupe is the ledger escalation (mark_escalation(False) = owed, retried until delivered).:4001 is closed and ibkr-monitor's heartbeat (IBKR_MONITOR_HEARTBEAT_PATH, ≤ 120 s old) says live_open: false, the monitor's gateway:live incident pages the outage (its 180 s SLA, its Sunday-2FA wait, its lever) and the trader's ibkr page stands down, recorded as handled_by: ibkr-monitor for that generation (down and, on close, up). Integrity OD6 honours it. A stale / missing heartbeat or an open port hands the page back to the trader; operator actions are never deferred._send_integrity_alert returns True ("handled") when OD6 applies, so the integrity ledger records it and never retries into a delayed duplicate.info messages only log (they no longer touch the incident, which would now be paged). The lever / ownership probe is the Gateway port (client socket, else a 1.5 s TCP connect; before a client exists, gateway_recycle._port_open)._queue_disconnect_alert / _send_request_path_alert / _page_recon_guard_if_due / _check_leverage_alerts / _page_startup_disconnect_if_due → open_incident (window SLA → IncidentLedger.open, spec → side store) → kick → dispatcher dispatch → _dispatch_page (page_due → ownership check → mark_page(None) → send_alert(require_telegram=True) → mark_page(ok) → record_delivery(down) → deferred close) → _dispatch_actions (escalation_due → send → mark_escalation) → _dispatch_resolves (resolves_due → send → mark_resolve → record_delivery(up)). Healthy → _resolve_ibkr_incident → resolve_incident (defer if in flight) → IncidentLedger.resolve. Integrity → _dispatch → _send_integrity_alert → _trader_already_paged → trader_page_delivered (ledger generation + side store). (2) Runtime: nightly 1100 + bounce = 0 pages; an out-of-window request-path death pages within ≤ 15 s; a Telegram outage retries every 15 s until delivered; a Gateway crash (:4001 closed) = one ibkr-monitor page + one RESTORED, 0 trader, 0 integrity. (3) Lines: alert_policy.py incidents section (whole), start_dispatcher; client.py _send_request_path_alert, _resolve_ibkr_incident, _queue_disconnect_alert, _cancel_pending_disconnect_alert, _maybe_send_reconnect_alert, _gateway_port_open, __init__ probe; removed _send_disconnect_alert, _arm_incident_page, _emit_ibkr_alert; reconciliation_manager.py guard pair; trading_orchestrator.py _check_leverage_alerts, _resolve_leverage_alert; run_earnings_trader.py __init__, _page_startup_disconnect_if_due; integrity_monitor.py _send_integrity_alert; .gitignore. (4) Limits: ~~a second trader outage page within 300 s of the previous one waits for the #413 same-polarity cooldown~~ — closed in round 4 (cooldowns scoped to the incident); a page in flight when the process dies is re-sent after 120 s (possible duplicate); if ibkr-monitor owns the outage but its own page fails, B1's ledger retries it — the trader does not step in unless the heartbeat goes stale; the claim is process-atomic, not cross-process (one writer per ledger file; interface question to B1 in internal/reliability/b2-ledger-adapter-handoff-to-b1.md). (5) Test tests/unit/test_finding_451_trader_alert_policy.py (78, rewritten): ledger type; failed page / action / resolve retried without a new symptom; recovery during an in-flight page; new symptom cancels a deferred close; stale-generation result ignored; two concurrent dispatchers → one send; restart keeps debt, never re-pages; replace / temp-write / lock / unreadable / deleted-file failures still page; operator action through the real AlertManager after a delivered page; one Gateway outage across GatewayPager + trader + integrity = 1 page + 1 resolve; stale monitor / open port / actions page from the trader; OD6 handled-return; replays. Older tests migrated to the dispatcher (test_finding_418/419/420/421, test_ibkr_error_block_policy, test_reconciliation_guard_retry, test_finding_404).69d33e2, R3-5 and the B2 half of R3-1):incident_generation and incident_purpose (page / resolve / action:<kind>) in its data; AlertManager._cooldown_scope appends both to every cooldown key — the per-category rate limit, the #413 same-polarity cooldown and the CRITICAL duplicate-message cache. A new incident (or this incident's resolve) goes out on its first dispatch; only a repeat of the same send in the same incident is held back; untagged sends keep the historical category-wide keys (#413 unchanged for them). Removes the round-3 limit "a second outage page within 300 s waits for the cooldown".dispatch → _dispatch_page / _dispatch_actions / _dispatch_resolves → _send(..., generation, purpose) → send_alert(data={incident_generation, incident_purpose}) → AlertManager._should_send / CRITICAL cache with _cooldown_scope. _store_mutate / _store_read → _store_persist_ok gate. (2) Runtime: A pages at 15:00:21, recovers, B pages at 15:01:21 on its first dispatch (was: ~15:05); a flap of three short outages = six messages; a quick recovery's resolve is not held 30 s behind its own page. (3) Lines: alerts.py _cooldown_scope, _should_send, send (CRITICAL cache + tracker keys); alert_policy.py _send + three call sites, _store_mutate, _store_read. (4) Limits: legacy untagged sends still share category-wide keys by design; while the side store cannot be written, the integrity monitor (another process) reads the older file and pages (fail-open). (5) Tests: TestCooldownScopedToIncident (6, real AlertManager: second outage inside 300 s, rapid flap, identical text of a new incident, same-incident repeat still cooled, untagged #413 kept, dispatcher tags), test_quick_recovery_resolve_is_not_held_behind_its_own_page, TestSideStoreFailedWrite; on 69d33e2 5 of these fail.12d0f93 R4-1 + the final-B1 checklist; B1 839f58e merged):AlertManager.send records the per-category rate limit, the #413 same-polarity timestamp and the CRITICAL duplicate cache only after a delivered alert (Telegram HTTP 200; any channel when Telegram is not configured). A failed page / action / resolve is retried on the next dispatcher pass (15 s) with no filter; same-send dedupe stays in the ledger claim. Test: real AlertManager, first Telegram result false, second true 15 s later → exactly two transport attempts, one delivered incident (request-path, disconnect, resolve, action, WARNING category)._dispatch_page claims with B1's atomic claim_page (the one-writer-per-file contract is in the ledger docstring); _pending_resolve removed — resolve() on an in-flight page persists a pending resolve that a later delivered mark_page (also from a new process) turns into an owed resolve, a failed one drops; PAGE_IN_FLIGHT_SEC comes from incident_ledger. Reopen semantics: a key that fails again before its resolve was delivered continues THAT incident (same generation, page state kept, stale resolve dropped); open_incident returns created=False for it (leverage records no second margin event) and clears the stale resolve text / handled-up record. Escalation and resolve sends remain claimed under the trader's process lock (no B1 claim API for them; one writer per ledger file).live_incident from the ibkr-monitor heartbeat (0644) and stands down only while that incident's page is delivered, or pending / failed-and-retrying within its attested deadline + 60 s. One Gateway-down policy: the monitor's 180 s any day, and its 20-min wait only for the latched scheduled Sunday 2FA — the trader's own Sunday 5-min SLA applies only when the monitor is absent or stale. Once the monitor delivered a generation's page (recorded handled_by: ibkr-monitor + monitor_generation), failover never re-pages that generation, across trader restarts; a stale heartbeat or an overdue monitor page before delivery hands the page back to the trader.ibkr_down_lever, follows the runtime IBKR_GATEWAY_AUTO_RECYCLE) and the client's action texts say what was detected, what the bot did automatically and how the outcome is reported; the manual lever appears only after "Fallback only". The #433 hold path (reachable only under a dated AUTO_RECYCLE=false hold) pages a recycle_needed notice once per incident. Tests: TestPagesAreInformation (4 cases × states), TestKeepProbingPagesTheLever.dispatch → _dispatch_page → handled-record check → gateway_monitor_owns_outage (monitor_live_incident) → claim_page → _send → send_alert → AlertManager.send (filters; channels; arm cooldowns only if delivered) → mark_page; resolve_incident → IncidentLedger.resolve (pending if in flight) → mark_page settles it; _mark_recovery_exhausted (recycle off) → _send_request_path_alert(kind="recycle_needed") → action. (2) Runtime: a Telegram blip delays a page by one dispatcher pass (was ≤ 5 min); a weekday Gateway crash = one monitor page at ~180 s + one RESTORED, trader and integrity silent; unapproved weekly 2FA = one "NOT APPROVED" page at ~20 min; an IB() reset that cannot recover pages the operator lever once. (3) Lines: alerts.py send; alert_policy.py _unresolved_generations, _note_open, open_incident, action, resolve_incident, _close_locked, monitor_live_incident, gateway_monitor_owns_outage, _dispatch_page, record_delivery, ACTION_KINDS; client.py REQUEST_PATH_ACTIONS, _mark_recovery_exhausted. (4) Limits: a page in flight when the process dies is re-sent after 120 s (possible duplicate, fail-open); escalation / resolve claims are process-local (one writer per file). (5) Tests: TestFailedSendNeverArmsCooldown (6), TestKeepProbingPagesTheLever (2), TestMergedLedgerContract (11: claim API, two-instance claim, mid-send recovery across restart, failed page owes nothing, reopen continues, leverage reopen, failed resolve owed across restart × 3 keys, no resolve without a delivered page), TestOwnershipOnFinalLedger (10: Sunday scheduled 2FA, Sunday afternoon crash, weekday crash, weekday AutoRestart blip, monitor Telegram failure, monitor crash before / after delivery, restart, attested-only, heartbeat mode). R4-1 tests: 4 of 6 fail on 12d0f93.abbbaf4 R5-1..R5-4; B1 2ae3f5f merged):created is simply "was not open", and a new outage always pages (no stale RESTORED). Resolves go through B1's atomic claim_resolves() (prune + in-flight claim; mark_resolve settles); a recovery during an in-flight page whose sender died becomes claimable once the claim is 120 s old (R5-1) and the dispatcher sends RESTORED; expired debt is deleted (R5-2).ibkr:up handled_by=ibkr-monitor. _sync_monitor_restored (every dispatcher pass) records it only when the heartbeat's last_resolved shows the SAME monitor generation (the monitor_generation stored with the handled DOWN) with resolve == "delivered"; monitor death, a failed / owed RESTORED, a dropped resolve or another generation leave integrity paging the recovery (fail-open duplicate at worst).ibkr_down_lever and the last-resort text state that every Gateway restart is a fresh login completing once the phone 2FA is approved (#430); the #433 hold notice appears only under a dated IBKR_GATEWAY_AUTO_RECYCLE=false hold.74f4938 recorded the unresolved memory.md and an empty AGENT_HANDOFF.md; 7f5c3a2 applies the intended resolution (content verified against both parents).dispatch → _dispatch_page (claim_page) → _dispatch_actions → _dispatch_resolves (claim_resolves → _send → mark_resolve → record_delivery(up)) → _sync_monitor_restored (store DOWN handled + closed generation → heartbeat last_resolved). (2) Runtime: a Gateway crash = one monitor DOWN + one monitor RESTORED; integrity's recovery page is suppressed only after that RESTORED got HTTP 200. (3) Lines: alert_policy.py open_incident, action, _close_locked, _dispatch_resolves, monitor_last_resolved, _sync_monitor_restored, dispatch, ibkr_down_lever; client.py last-resort text. (4) Limits (accepted in round 5): action claims process-local under the one-writer contract; a page in flight at process death may be re-sent. (5) Tests: test_recovery_mid_send_survives_restart_without_the_dead_senders_result (no replayed result; claimable after 120 s), test_new_outage_while_a_resolve_is_owed_pages_as_a_new_incident, test_new_leverage_breach_after_an_owed_resolve_is_a_new_incident, TestMonitorRestoredAttestation (5: delivered same generation, monitor death, RESTORED Telegram failure, other generation, no fabricated record), Sunday test on B1's evidence rule.5d53721, R6-3 — OD6 reverse handback) — SUPERSEDED by round 9 (the post-send handback is removed; the outage arbiter replaces it): when the trader's page is not yet delivered, integrity pages its own trader:ibkr_request_path transition (OD6 c); once that fallback is delivered, _send_integrity_alert calls alert_policy.record_integrity_handback, which writes ibkr:<down|up> with the trader's incident generation (open incident; for up, the last resolved one if already closed) to ALERT_HANDBACK_PATH (logs/alert_handback.json, 0644, integrity is its only writer). The trader dispatcher reads it: a handed-back DOWN skips the trader's own page for that generation (not marked delivered, so no trader RESTORED becomes owed — integrity resolves its own); a handed-back RESTORED settles the trader's owed resolve (mark_resolve(..., True)) without sending. A failed integrity send writes nothing and the trader pages as usual. One physical incident = one DOWN + one RESTORED whichever pager won. §B (1) integrity _dispatch → _send_integrity_alert → delivered → record_integrity_handback → file; trader dispatch → _dispatch_page / _dispatch_resolves → integrity_handled(key, polarity, generation). (4) A race where the trader's page is already in flight when integrity delivers can still duplicate (fail-open). (5) TestIntegrityHandback (6: integrity delivers DOWN → trader skips and integrity resolves; integrity DOWN fails → trader sends; integrity delivers RESTORED → trader settles silently; integrity RESTORED fails → trader sends; generation-scoped; file 0644 + survives a trader restart). The B1-owned R6-2 / R6-4 / R6-5 and R6-6 follow with B1's next head.c814593 merged — R6-2 counter generations, R6-4 page_unproven, R6-5 heartbeat parser): B2 now (a) reads the ibkr-monitor heartbeat only through B1's src.ibkr.monitor_heartbeat.parse_heartbeat (live ownership needs fresh valid proof; the delivered-RESTORED fact is accepted from a valid heartbeat up to 24 h old) — B2's own schema checks are gone and malformed proof is no proof; (b) passes B1's claim tokens to mark_page / mark_resolve; (c) sends ONE combined "was DOWN from … to … UTC, now RESTORED; the outage page may not have reached you (the trader restarted mid-send); no action needed" resolve when the ledger marks it page_unproven (IBKR, reconciliation guard, leverage), matching the monitor's and integrity's wording; (d) keeps the R6-3 handback keyed by B1's opaque counter generation (<opened_at>-<n>, equality only); (e) classifies a skipped RecycleResult (deferred / refused, retried by B1's backoff) structurally as information, and the "restart did not run" page says the trader retries automatically. No B1 change is needed for R6-3: the hook is already in the merged integrity_monitor._send_integrity_alert (a second call from _dispatch would be redundant). §B (1) dispatch → _dispatch_page (claim_page → mark_page(claim=)) / _dispatch_resolves (claim_resolves → handback check → _resolve_message → _unproven_resolve_message if page_unproven → mark_resolve(claim=)) → _sync_monitor_restored → monitor_last_resolved → _heartbeat_proof → parse_heartbeat. (5) Tests: TestUnprovenPageResolve (3 keys + plain resolve + claim tokens), TestHeartbeatParsedByB1 (6 malformed shapes → trader pages; valid pending proof defers; no B2 schema), test_handback_keys_use_the_counter_generation, TestRecycleResultIsStructured (2); R5-1 test now expects the combined message.9834776, R7-1 / R7-2 / R7-3; B1 d862942 merged): a record written after a send cannot stop a second pager, so every DOWN / RESTORED send for a physical IBKR outage now takes a cross-process claim first — NEW src/earnings/outage_arbiter.py (JSON under fcntl.flock, fsync + atomic rename, ALERT_ARBITER_PATH = logs/alert_arbiter.json), used by the trader dispatcher, integrity's _send_integrity_alert (trader-paged rules) and ibkr-monitor's _page_live / _flush_resolves. Verdicts send / delivered / busy / no_down; a failed send releases the claim, a claim older than 150 s is taken over, arbiter I/O errors fail open. Invariants per outage: I1 one DOWN, I2 one RESTORED, I3 RESTORED only after a delivered DOWN (a dead claimant's DOWN → one combined "may not have reached you" RESTORED), I4 never zero, I5 membership captured at claim time — a new generation of a pager never joins an outage holding a different generation of that pager (R7-1). The round-7 post-send handback (logs/alert_handback.json) is removed. may_have_restarted (B1): the "restart did not run" page says the outcome is unknown, the weekly budget is used and there is no automatic retry. Design note: store internal/reliability/b2-outage-arbiter-design.md. §B (1) trader _dispatch_page → (monitor deferral) → outage_arbiter.claim("down","trader",gen) → send → settle; _dispatch_resolves → claim("up"); integrity _dispatch → _INTEGRITY_GENERATION → _send_integrity_alert → OD6 fast path → claim(phase,"integrity",gen,correlate={trader: gen captured now}) → send → settle; GatewayPager._page_live / _flush_resolves → claim(...,"ibkr-monitor",gen). (4) Fail-open duplicates only: arbiter unwritable; a pager joining > 30 min after the DOWN; a trader dead for the whole outage restarting after it. (5) Tests: tests/unit/test_finding_451_outage_arbiter.py (7, real concurrent processes: 4-way DOWN race, failed send taken over, dead claimant taken over after TTL, RESTORED race, real trader vs real integrity DOWN, real GatewayPager vs integrity RESTORED, R7-1 across processes), TestOneDownOneRestoredSequential (6), test_ambiguous_restart_page_says_outcome_unknown_and_budget_used.d939bb3, R8-1..R8-4; B1 1f09008 merged, which brings master 860af84):action:approve_2fa / action:recycle_failed / action:recycle_needed (I6: at most one send each per outage, never a second DOWN); claim("down", ..., action=kind) claims the DOWN and the action in one critical section and settle_claims settles both. ibkr-monitor's 2FA-needed page = DOWN + approve_2fa: after another pager's delivered DOWN it goes out as the phone step only ("The outage alert for this was already sent"), not at all after a delivered approve_2fa, and it waits behind a busy DOWN (it no longer bypasses delivered / busy). The trader's action pages claim DOWN + action and its escalations claim the action phase; recon_guard joins the arbiter when its page falls due inside an open ibkr incident (decided once per generation; its RESTORED follows the same path) and pages itself otherwise. Integrity claims for systemd:ibgateway-ibc.service (integrity:ibgateway) next to trader:ibkr_request_path, and for trader:restart_tripwire only when it FAILs on the #454 attach-bounce marker (integrity:attach_bounce; its resolve only if it joined); all correlate the trader's and the monitor's generations (alert_policy.outage_correlation). Every outage page type is audited against the arbiter in the design note's table (store internal/reliability/b2-outage-arbiter-design.md).ALERT_ARBITER_CLAIM_TTL_SEC must be in [120, 180] s: above the 81 s longest bounded send (15 s screenshot + Telegram and webhook, 3 × 10 s + 3 s backoff each) and at most the monitor's 180 s SLA. Unparsable / NaN / out of range → 150 s, logged ERROR; GUARD_REGISTRY flags an out-of-range .env override; .env.example documents it._observe_live → claim_page → _page_live → claim("down","ibkr-monitor",gen,action="approve_2fa"?) → full / action-only / none → settle_claims; trader dispatch → _dispatch_page → _outage_claimant → claim("down",claimant,gen,correlate,action) → _send → settle_claims; _dispatch_actions → claim("action:<kind>"); _dispatch_resolves → claim("up") (→ _reconstruct when unrecorded); integrity _send_integrity_alert → _outage_claimant(rule, detail, gen) → claim(phase, claimant, gen, correlate=outage_correlation()). (2) Runtime: Gateway crash with a fresh 2FA login after the trader's DOWN = 1 DOWN (trader) + 1 phone-step page (monitor) + 1 RESTORED; the trader's later LAST-RESORT approve_2fa is not repeated. (3) Lines: outage_arbiter.py (_claim_ttl_from_env, _prune/_settled, _reconstruct, _claim_action, claim, settle_claims, member_of); alert_policy.py _outage_claimant, outage_correlation, _dispatch_page, _dispatch_actions, _dispatch_resolves; gateway_pager.py _page_live; integrity_monitor.py OUTAGE_RULE_CLAIMANTS, _outage_claimant, _send_integrity_alert, GUARD_REGISTRY. (4) Limits: a recon_guard RESTORED can be the outage's one RESTORED if it resolves first (its text); B3's #454 attach-bounce tripwire still pages inside the nightly-reset window when no DOWN was delivered (B3 semantics, now deduplicated, flagged for review); fail-open duplicates as before. (5) Tests: test_finding_451_outage_arbiter.py (TTL bounds + import fallback + send-budget / SLA bracket + guard; open outage 3 days; reconstructed RESTORED with 3 real processes and a torn file; DOWN + 2FA + escalation race with 4 real processes in 3 orders; leaf-lock import check), TestEveryOutagePageThroughTheArbiter (11).7131e35, R9-1..R9-6 + the Sunday-red suite; B1 deployed at 543fa5d, not rewritten):_send_integrity_alert claims the arbiter first, so even an outage another pager already paged gets integrity's generation as a member and integrity's RESTORED resolves that same outage (one RESTORED whichever pager resolves first). The OD6 delivery record is consulted after the claim: if the arbiter says send but the record proves the trader's delivered page (the arbiter lost it), the claim is settled delivered and nothing is sent; with the arbiter unusable OD6 still suppresses as before._locate never falls through to the newest live outage when a correlated generation was found in a non-joinable (closed, stale, other-generation) outage: the DOWN starts its own outage; an UP reconstructs.busy and claims nothing (no DOWN without the action it would carry); a page includes action text only when it owns the action claim (carries_action) — otherwise the trader and the monitor send the plain DOWN ("… the alert for its operator step was already sent"). settle_claims settles DOWN and action in one locked mutation (_settle_many); a crash inside it leaves both claims in flight together, taken over together.outage:integrity:attach_bounce in the integrity incident's ledger detail; the resolve record carries it (_INTEGRITY_OUTAGE_CLAIMANT), so the RESTORED always claims up and after arbiter state loss joins the reconstructed outage (member_of removed)._send_integrity_alert settles a held claim from a finally on every exit (import failure, transport exception, undelivered, an escaping exception), so another pager takes over at once instead of after the TTL.outage_arbiter.health() probes it as a pager uses it (directory / file writable, lock within 2 s, state parseable, TTL config) and reads alert_arbiter.json.health.json, where every claim / settle failure and every torn-file reset is recorded (best effort). NEW integrity rule alerting:outage_arbiter (WARNING, dashboard-only, systemd category so it runs with the DB down, outside the arbiter): FAIL when unusable now, WARN on a failure in the last hour. Registry pin 103 → 104 (b2_added).recycle_defer_reason() with its default wall clock and failed inside the #439 Sunday 06:00–18:00 UTC quiet window. tests/unit/conftest.py weekday_recycle_clock pins that default now to Tue 15:00 UTC for those six classes; the explicit Sunday-window tests pass their own now and are unchanged; a new test runs with the real clock at Sunday 07:00._dispatch → (attach-bounce: set_detail … outage:…) → _INTEGRITY_OUTAGE_CLAIMANT → _send_integrity_alert → _outage_claimant → claim → delivered/no_down handled · busy retry · OD6 → settle_claims(True) · transport in try → finally: settle_claims(delivered); arbiter claim("down", action=) → _locate (correlation authoritative) → busy action → busy; settle_claims → _settle_many (one _mutate). (4) Limits: the attach-bounce page stays (reviewer: acceptable while rare; a soak showing nightly recurrence reopens it); the health record is best effort (an unwritable directory is caught by the live probe instead). (5) Tests: TestRound9CallerRaces (11, both R9-1 orders, OD6 settle after state loss, plain-DOWN / busy-action for trader and monitor, R9-4 through the real IntegrityMonitor._dispatch with a state loss, R9-5 × 4 failures), TestRound9Arbiter (30: R9-2 × 2, every DOWN × action state combination, one-mutation settle, crash inside the settle, real-process crash inside the settle), TestArbiterHealth (5), test_quiet_window_reads_the_pinned_clock.e55bea0, R10-1 / R10-2; deployed master 543fa5d merged):claim fail open without an action claim, and ibkr-monitor read that as "another pager sent the 2FA step" — the one page Marius must act on said the step was already sent. A fail-open DOWN claimed with an action now carries a fail-open action claim (carries_action); the trader and the monitor leave the action text out only for an explicit busy / delivered action claim (action_is_anothers). Tests with a real unusable arbiter file (read-only directory): the monitor's latched and non-latched 2FA pages, and the trader's action page and escalation for every action kind._note_failure now holds its own leaf fcntl lock (alert_arbiter.json.health.json.lock, given up after 1 s so a pager never blocks), fsyncs and renames atomically, and keeps the latest failure per operation plus at most 50 events from the last hour (health() reports recent_failures). Test: 6 real processes × 5 failures keep all 30 events.543fa5d (B1 release, deploys 16–18: #483, #479 restart grace); conflicts only in docs/CONTEXT.md and this file, both sides kept.Login has completed and :4001 is up; trader log shows alert_policy: incident ibkr opened (expected=nightly_reset …) then resolved … no page. Drill (plan): a forced paper hang pages once with ibgateway-ibc in the text; D6 drill as tested.execution entry (alert semantics are live behaviour; no trading-rule change).src/earnings/alert_policy.py (NEW), src/ibkr/client.py, src/ibkr/gateway_recycle.py, run_earnings_trader.py, src/earnings/reconciliation_manager.py, src/earnings/trading_orchestrator.py, src/earnings/config.py, src/data_tracker/integrity_monitor.py, .gitignore, tests/unit/test_finding_451_trader_alert_policy.py (NEW), tests/conftest.py, tests/unit/test_finding_419_ibkr_inplace_recovery.py, tests/unit/test_ibkr_error_block_policy.py, tests/unit/test_finding_420_ibkr_nightly_1100.py, tests/unit/test_reconciliation_guard_retry.py, tests/unit/test_finding_404_telegram_schedule.py, tests/unit/test_config.py, src/earnings/strategy_changelog.py, .cursor/memory.md, docs/OPERATIONS.md (§8.A3, §9 row, reconciliation-guard note), docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.IMPLEMENTED-PENDING-DEPLOY (Batch B2, branch reliability/b2).EarningsDetector._check_prnewswire (src/earnings/earnings_detector.py) backed off with self._http_check_failures.get(cache_key, 0) > time.monotonic() - 300. time.monotonic() counts from host boot, so the default 0 is "failed at boot", not "never failed". For the first 300 s after any droplet boot the PR Newswire check returned "not released" without a log line or page. It dates from #395/#407 (per-feed key). The Batch A author found it when test_finding_395_prn_one_poller::test_two_symbols_one_urlopen failed on a low-uptime VM._check_prnewswire: an absent key means never failed (last_failure is not None and last_failure > time.monotonic() - 300). A real failure still stamps time.monotonic() and backs off 300 s; a stamp of exactly 0.0 still counts as a failure.IBKRPositionCache._refresh_if_stale treated _last_refresh == 0.0 (never refreshed, or invalidate()) as fresh while uptime < TTL (5 s), serving an empty position map (is_position_open → False). Now 0.0 always refreshes, matching get_cache_age_seconds, which already read 0 as "never".EARNINGS_POLL_ACTIVE_SEC) → check_earnings → _check_prnewswire → backoff check → _get_prnewswire_rss (30 s TTL cache, timeout=3) → parse → EarningsResult → instant-exit decision. Position cache: get_position / get_all_positions / is_position_open / get_shares → _refresh_if_stale → ib.positions().0 > 12 - 300 → True → return; the new code sees None → fetches. After a failure at t=40 s, calls up to t=340 s skip; t=340.1 s fetches.earnings_detector.py _check_prnewswire backoff block; ibkr_position_cache.py _refresh_if_stale freshness check._get_prnewswire_rss already tests if cached and …. The detector's other sources are unaffected (INFERRED unchanged, no shared backoff dict use).tests/unit/test_finding_469_monotonic_never_sentinel.py (11 tests; time.monotonic patched, independent of real uptime): uptime 0.5/12/299 s performs the HTTP call; #395's two-symbols-one-fetch at 12 s; a release detected at 12 s; failure → exactly 300 s backoff; a 0.0 stamp still backs off; position cache refreshes at 1–2 s uptime, after invalidate(), and still caches inside the TTL; §E tripwire (no .get(…, 0) > time.monotonic() in src/, earnings_dashboard/, scripts/, strategies/). pristine parent facb218: 9 failed / 2 passed (re-run in review round 2).time.monotonic() site in src/, earnings_dashboard/, scripts/, strategies/, run_*.py, deploy/ reviewed. Correct already: utils/caching.py Polygon/Finnhub throttles (<= 0 → no wait), dashboard coverage cache (at is not None), orchestrator watchdog (initialised to monotonic()), btc5m jsonl_io (initialised to monotonic()), deadline-style uses (+ timeout). No perf_counter() in live code. time.time() variants cannot misfire this way (0 is 1970), and no .get(…, 0) > time.time() exists.integrity entry (restores intended detection; no gate/sizing/order change). The plan listed §O as no; the operator's B2 instruction requires an entry for every live-behaviour change.src/earnings/earnings_detector.py, src/earnings/ibkr_position_cache.py, tests/unit/test_finding_469_monotonic_never_sentinel.py (NEW), src/earnings/strategy_changelog.py, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.trades.token_label to match outcome (Sep 27, 2026)DEPLOYED Sep 27, 2026 02:43 UTC (deploy #15 860af84, PRs #12 and #13, master fast-forward 28b572b..860af84; unit polymarket-deploy-20260927T024258Z-2459628687, DEPLOY_EXIT rc=0; migration 042 applied on attempt 1).token_label stayed varchar(20) when #294/028 widened outcome to varchar(64). Collectors bind outcome into both columns; the Sep-26 incident trade (22-char outcome) poison-quarantined under #481.042_widen_trades_token_label.sql — ALTER TABLE trades ALTER COLUMN token_label TYPE VARCHAR(64) (028 pattern). Integrity telegram_when nit: page on FAIL and OK (RESOLVED), not on live-trade poison WARN.db_write_spool_admin.py release then restart polymarket-live-trades.deploy/deploy_from_github.sh → deploy/migrate.py MANIFEST entry main/042_widen_trades_token_label.sql (column_probe_typmod=True, :189-193) → probe_sql / _column_probe_cond (:239-280) compares format_type(atttypid, atttypmod) to the reviewed baseline character varying(64); on live varchar(20) the probe is false → psql -1 applies the ALTER and records the ledger row before any restart → live_trades_collector._write_trade binds outcome into token_label varchar(64).lock_timeout=5s / statement_timeout=120s.042_widen_trades_token_label.sql; deploy/migrate.py:239-280 (probe_sql, _column_probe_cond — format_type comparison when column_probe_typmod and the baseline type are set); :87-89 (column_probe_typmod field) and :189-193 (042 MANIFEST registration); integrity_monitor.py artifact:db_write_spool telegram_when (page on FAIL and on OK/RESOLVED, not on live-trade poison WARN).trades (lock timeout → the migration fails and the deploy stops before restarts); a presence-only probe on live varchar(20) with baseline varchar(64) → probe_sql true → baseline_problems → MigrationError (refuses APPLY — no ledger row; column stays varchar(20); the 704d784 ship-stopper), not a silent ledger record; presence-only with a matching baseline would bootstrap-record without DDL (not this catalog); column_probe_typmod plus _column_probe_cond/probe_sql make the probe false on varchar(20) so APPLY runs; a future writer binding a longer outcome (still outcome varchar(64) = token_label varchar(64)).tests/unit/test_finding_482_token_label_widen.py (manifest registration, SQL, typmod-probe, 029 existence-only, applies when the ledger row is missing and the column is still varchar(20)) and tests/unit/test_finding_481_live_trades_poison.py::test_db_write_spool_telegram_pages_fail_and_ok_not_warn.internal/ops-log-2026-09-27-deploy-15.md in the Project store): after the ~02:09 DigitalOcean maintenance, a read-only Step A found the DB writable and all services healthy. migrate: main/042_widen_trades_token_label.sql: applied (attempt 1), done: current=8 bootstrap=0 applied=1; trades.token_label = character varying(64). Gateway PID 1215183 unchanged; trader restarted by the deploy (expected), flat and ready. Release per OPERATIONS: live-trades stopped, the 3 quarantined payload files (2 distinct trades — one trade had two spool files under one key; a third file had appeared at 02:42 UTC before the deploy) released with operator cursor-deploy-15 and RELEASE-AFTER-VERIFICATION, copies under released/; live-trades restarted ~02:46:59. Result: the Sacramento Republic FC trade stored (trades 1, trade_dedupe 1); the Igor Ribeiro Marcondes trade not stored (the API no longer returns it — accepted by the runbook). quarantine/ empty; artifact:db_write_spool ok ("DB-write spool empty"); one CRITICAL RESOLVED page for artifact:db_write_spool at 02:51:25; no live_trades_db_write CRITICAL and no 22001 after 02:47.src/data_tracker/migrations/042_widen_trades_token_label.sql, deploy/migrate.py, deploy/migration_baseline.json, src/data_tracker/integrity_monitor.py, docs/OPERATIONS.md, tests/unit/test_finding_481_live_trades_poison.py, tests/unit/test_finding_482_token_label_widen.py, this entry.DEPLOYED Sep 27, 2026 00:33 UTC (deploy #14 28b572b, PR #11, master fast-forward 00607c9..28b572b; unit polymarket-deploy-20260927T003319Z-514718516, DEPLOY_EXIT rc=0; the deploy restarted polymarket-live-trades, stopped since 23:39:08 Sep 26). The incident's quarantined trades were released after #482 in deploy #15 860af84.00607c9, B3 #454/#455) at 23:29–23:33 UTC Sep 26. One Polymarket trade fails INSERT INTO trades with SQLSTATE 22001 because outcome (22 chars) is copied into trades.token_label varchar(20). Pre-#455 the collector logged and marked the trade seen (37× that day). Post-#455 the trade spooled, replayed, quarantined, was not marked seen, the drained branch re-armed the once-only CRITICAL, and the API re-delivered the trade → ~26 CRITICAL + 8 WARNING Telegram pages in ~5 min. Operator stopped polymarket-live-trades at 23:39 UTC.public.trades.token_label is varchar(20); live_trades_collector._write_trade binds token_label from outcome (fits trades.outcome varchar(64)). Follow-up (history): done as #482 — migration 042 widened token_label to varchar(64) in deploy #15 860af84.save_trade → quarantine_once (one file per payload hash), mark seen, no spool, no CRITICAL.live_trade.recovered > 0).dedupe_quarantine("live_trade") archives duplicate quarantine files by payload hash and seeds _seen_trade_ids / _poison_payload_hashes.artifact:db_write_spool: trader quarantine/stale pending still STATUS_FAIL + CRITICAL Telegram via telegram_when; distinct live_trade poison → STATUS_WARN only (dashboard).position_open quarantine still FAIL. (5) tests/unit/test_finding_481_live_trades_poison.py.internal/ops-log-2026-09-27-deploy-14.md in the Project store): migrate: current=8 bootstrap=0 applied=0; six app services active, 0 journal err; trader ready, 0 positions, 0 breakers; ibgateway-ibc PID 1215183 unchanged, :4001 listening. No live_trades_db_write CRITICAL/WARNING after the 00:34:01 restart; one integrity RESOLVED for systemd:polymarket-live-trades.service (00:37:12). Startup dedupe: 2 poison payload files kept in quarantine/ (1 trade — same spool key; Marcondes), 10 duplicate files archived; artifact:db_write_spool WARN (integrity counter: "2 distinct live-trade poison payload(s) quarantined (#481)" — two files, not two trades). Dashboard-restart false page again: systemd:polymarket-dashboard.service read deactivating at 00:33:57 during the deploy's restart and RESOLVED at 00:37:09 — the OPEN deploy-order race (see #479); its fix, PR #9, is pending.src/data_tracker/collectors/live_trades_collector.py, src/utils/durable_write_spool.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_481_live_trades_poison.py, this entry.timing_channel_leak audit rows, but it runs only inside a timing-mismatch branch that always skips; the exception is caught. It cannot admit a duplicate, block a valid same-channel entry or crash the trader. The explicit integrity detector independently excludes covered rows, so the four false labels did not make coverage:channel_window_leak FAIL.DEPLOYED Sep 26, 2026 21:56 UTC (deploy #9 0e985dd, merge of PR #5 d61d8af after the cross-family review — code and migrations approved at b693dc6, docs-only re-check at d61d8af; migration 041 sha256 1f5a6e44…).CHANNEL_ALREADY_COVERED_SQL, $1 was the bare :cid IS NOT NULL; the typed condition_id = :cid occurrence became a different slot and could not type $1. The same applied to bare :symbol and :earnings_dt. A read-only production transaction (statement_timeout=5s, exact SQL, str/str/aware-datetime, SQLAlchemy 2.0.45, pg8000 1.31.5) reproduced 42P18 could not determine data type of parameter $1. Retained Sep 12–26 trader logs have four executions, all Sep 23: GIS 14:30:07, CTAS 14:30:11, PAYX 14:30:11 and GIS 19:50:09 UTC (4 warning events / 12 traceback matches in the canonical log; the error log mirrors it). All four had prior non-timing coverage: GIS depth skips, CTAS/PAYX conviction-deadband skips.CHANNEL_ALREADY_COVERED_SQL: every occurrence of cid / symbol / earnings_dt is explicitly CAST to VARCHAR / TIMESTAMPTZ; #414/#431's condition-id-first, symbol+ET-day fallback and any-non-timing semantics are unchanged.sync_from_markets_table() and run_full_sync() now contain only a docstring plus raise LEGACY_SYNC_RETIRED_MESSAGE; the unreachable writer bodies are deleted. python -m src.db.earnings_sync and scripts/sync_earnings_markets.py exit 2 with the replacement (src.data_tracker.collectors.earnings_market_fetcher). Production's hourly service already uses that fetcher; its only import from EarningsMarketSync is the supported NULL-mcap backfill. The typed update constant remains solely for real-driver regression proof and cannot write through either retired entry point. This avoids reactivating a January-broken writer that could change earnings_date without the mandatory earnings_date_audit row (#349 / memory §F). The dashboard's genuine-empty-table warning and its test point operators at the active fetcher module, not the retired command.tests/integration/test_batch_execution.py: allocation / shares casts both binds to NUMERIC, closing the test-only PostgreSQL 42725 unknown / unknown..sql file. It rejects bare bind null tests, bind-to-bind comparison/arithmetic, bare SELECT/RETURNING, bare grouping/sort binds, ambiguous date_trunc('…', :bind) / EXTRACT(… FROM :bind), and SQLAlchemy's :bind::type mis-parse. SQL comments and the non-SQL strategy-changelog data file are excluded; explicit CAST(:bind AS type) cases are negative controls.integration, db, dedicated finding480_test* URL): exact channel query with condition-id and NULL fallback shapes; exact earnings update with confirmed/unconfirmed rows; AST-extracted batch-allocation INSERT.041_channel_leak_false_rows.sql updates only opportunity-log ids 9381 GIS, 9382 CTAS, 9383 PAYX and 9387 GIS from timing_channel_leak to timing_channel_mismatch. Every UPDATE carries the exact prod before-values (id, microsecond timestamp, condition id, symbol, channel, resolved timing, earnings timestamp, stage and reason), then GET DIAGNOSTICS ROW_COUNT + <> 1 RAISE. It is a DATA_MANIFEST migration: never schema-probed/bootstrap-adopted, one psql -1 transaction with its ledger row, checksum-immutable, and later deploys no-op from the ledger. #477 is merged at 4d80e11; the manifest is numerically pinned as 040 then 041 and both ship in the same data-migration deploy. This is preferable to a note because the rows are proven false, are part of the research audit trail, and can be corrected without deleting history or risking a broad update._get_signals_for_timing → timing differs → classifier says deadline passed → _already_covered_on_resolved_channel → typed lookup → covered row returns true → resolve_timing_mismatch_reason writes timing_channel_mismatch → opportunity row → unconditional continue. Same-channel signal generation never enters this branch. Legacy module/script → explicit retired guard → exit before engine access. Live hourly sync → earnings_market_fetcher (unchanged) → supported backfill helper only. Deploy → Runner.run schema MANIFEST → DATA_MANIFEST 040 → 041 → ledgers.earnings_date_audit. A 040 or 041 mismatch rolls back that complete migration and stops before restarts; 041 mismatch rolls back all four relabels. At 4 AM, weekends and restart the query behavior is identical; no state is held in memory.signal_generator.py CHANNEL_ALREADY_COVERED_SQL / _already_covered_on_resolved_channel; earnings_sync.py retired guards and typed legacy constant; scripts/sync_earnings_markets.py guard; migrations 040/041; deploy/migrate.py DATA_MANIFEST / Runner.run; the #477/#480 migration tests and #480 bind tests.614a29d: full unit suite 2,541 passed (14 warnings); scripts/doc_drift_audit.py CLEAN (12 checks, 0 violations). Review VM on pinned pg8000/PostgreSQL 16: all three #480 real-driver tests passed and migration 041 passed exact/apply, ledger rerun, drift rollback, already-relabelled fail-closed and look-alike isolation cases through the real runner. Local #477/#480 integration files collect 8 tests and skip without their dedicated URLs. Post-deploy (not this branch): pre-check returns 4; 040 then 041 log their one-row notices; the four ids read timing_channel_mismatch; no new covered-channel lookup warning/42P18 appears; integrity remains 0 explicit + 0 swept.:bind IS NULL, ambiguous date_trunc/EXTRACT, pyformat or $n equivalent remains. earnings_market_fetcher.py's old pattern is a comment documenting its prior Python-side fix; ::date < :entry_d is a PostgreSQL cast plus a typed column comparison. Typed COALESCE(:bind, column) / column IS DISTINCT FROM :bind sites obtain type from the column and are outside this defect. The tripwire now scans the ~13 fragment literals too; explicit casts remain clean.schema_migrations had no 040/041 row.polymarket-deploy-20260926T215623Z-326053163 21:56:26 → 21:59:54 UTC, DEPLOY_EXIT rc=0; pull f28c0da..0e985dd, 31 files; hygiene 0/0/0; needrestart OK (14). migrate: main/040_pnl_fields_bbw_snow.sql then main/041_channel_leak_false_rows.sql, each applied on attempt 1 in one transaction with its ledger row — three Finding #477 040 and four Finding #480 041 NOTICE lines (before → after) — done: current=5 bootstrap=0 applied=2. Close-persist contract holds; / HTTP 200.trade_audit_log 274 gross/net/realized −282.10; trading_positions 307 exit 34.66, realized −282.10, pct −11.604183; SNOW 75 net/gross/realized −1.16. Opportunity-log 9381 GIS, 9382 CTAS, 9383 PAYX and 9387 GIS read timing_channel_mismatch (0 leak rows left among them). Ledger: 040 and 041 applied by 0e985dd at 21:56:30._load_pnl_consistency_rows + _evaluate_pnl_consistency, read-only, full history): 214 rows, 0 mismatches, 3 legacy pre-epoch twins (ids 4, 9, 10), so nothing to page. The scheduled 7-day and full-history reconcile runs had not run yet at this record (UNVERIFIED until the next daily run).PNL_EFFECTIVE_SQL under TRADE_METRICS_FILTER_SQL (closed, entry ≥ 2026-08-21 ET): n=13, −$922.2826 — equal to scripts/phase1_pnl_record.json, which no longer needs its BBW override to match the DB.security:db_client_activity ok. The #479 sampler-restart false page did not recur (the old monitor's last cycle ran at 21:58:48; the sampler restarted at 21:59:43).ibgateway-ibc 1215183, xvfb 1215180, x11vnc 1215184 and :4001 java 1537985 unchanged; root crontab sha256 unchanged (99928f0f…22c7). Trader reconnected 21:56:52 (same-thread req* ok (#440); reconciliation in sync); ready, 0 breakers, 0 positions, 0 stops today.42P18 / covered-channel lookup warnings; the only ERROR line is the connect-time IBKR 2107; 0 journal err. The covered-channel path runs in the BMO/AMC cycles, which a Saturday does not exercise.src/earnings/signal_generator.py, src/db/earnings_sync.py, src/db/__init__.py, scripts/sync_earnings_markets.py, scripts/verify_retry_backoff_and_block.py, tests/integration/test_batch_execution.py, deploy/migrate.py, src/data_tracker/migrations/041_channel_leak_false_rows.sql (NEW), tests/unit/test_finding_480_pg8000_bind_typing.py (NEW), tests/unit/test_finding_480_migration_041.py (NEW), tests/integration/test_finding_480_pg8000.py (NEW), tests/unit/test_finding_414_channel_leak_already_admitted.py, tests/unit/test_finding_444_migration_ledger.py, tests/unit/test_finding_474_bootstrap_baseline.py, src/earnings/strategy_changelog.py, docs/OPERATIONS.md, docs/DATABASE_DIRECTIVES.md, docs/DATA_ARCHITECTURE.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.gross_pnl / net_pnl_after_financing and the trading_positions twin stale (BBW#274, SNOW#75) (Sep 26, 2026)PNL_EFFECTIVE_SQL), the daily-loss breaker input (risk_manager.py COALESCE(net_pnl_after_financing, realized_pnl)) and the integrity day-P&L all read net_pnl_after_financing before realized_pnl. BBW's stale net understated the Phase-1 loss by $160.58.DEPLOYED Sep 26, 2026 21:56 UTC (deploy #9 0e985dd, shipped with #480 in PR #5; data correction = migration 040 (sha256 6446315f…), applied by deploy/migrate.py in deploy #9 at 21:56:30 UTC; deploy-wide checks under #480 Post-deploy).trade_audit_log 274 BBW LONG 62 @ 39.2100: exit_price 34.6600, realized_pnl −282.10 (broker fill (34.66 − 39.21) × 62), but gross_pnl = net_pnl_after_financing = −121.52, the $37.25 hardware-stop stamp (37.25 − 39.21) × 62. financing_cost_total 0; exit_commission NULL; total_commission 0.3628.trading_positions 307 (same symbol + entry_time): exit_price 37.25, realized_pnl −121.52, realized_pnl_pct −4.998725. Never corrected.trade_audit_log 75 SNOW SHORT 2 @ 165.8700 (Feb 25, testing era): exit_price 166.4500, realized_pnl = gross_pnl = −1.16, but net_pnl_after_financing = −9.96 = (165.87 − 170.85) × 2, the $170.85 stop stamp. Twin 108 is already correct (166.45 / −1.16).trade_reconciler._exit_price_correction_params + the exec-link UPDATE (#423) rewrote only exit_price, realized_pnl, realized_pnl_pct. The close paths (position_manager.persist_position_close_with_exit_details, the orchestrator execution-ledger close) write realized_pnl, gross_pnl and net_pnl_after_financing together from one :pnl, and write the twin. The correction had its own partial copy. SNOW#75 is the same class through a different writer: the Feb 26 hand repair (row notes "REPAIR 2026-02-26 … not stop_loss at 170.85") set exit/realized, #332 later fixed gross_pnl, and nothing fixed net.net_pnl_after_financing ≈ realized_pnl − financing − total_commission (scan n=0), and no row has realized_pnl ≈ gross_pnl − total_commission (n=0). So COALESCE(net_pnl_after_financing, realized_pnl) − total_commission never double-counts through the net column. SNOW is the stale-stop-stamp class. The full-history scan finds exactly two members: |gross − realized| > 0.005 → {274}; |net − (realized − financing)| > 0.005 → {75, 274}. Both are fixed by 040. SNOW's canonical net −10.9604 → −2.1604 (+$8.80, all-time, not Phase 1).realized_pnl is itself net of a commission: T#10 (price arithmetic 5.32, realized 4.32), SBUX#11 (−14.07 / −15.07) and XOM#19 (−0.84 / −2.84).reconciliation:not_in_ibkr), TXN#9 (audit 12.39 vs twin 42.76 at the same exit 208.03; TXN is a named outlier) and T#10.trade_audit_log only (#353). The detector reports these twins as legacy.UNVERIFIED cause): 34 closed rows have realized_pnl ≠ (exit − entry) × shares by > $0.02, mostly cents. Phase-1 members: HPQ#271 −100.86 vs −100.45, CPB#285 −107.91 vs −107.415, KR#292 −94.00 vs −93.80. realized_pnl is the reference for every derived field, so this class is not flagged.src/earnings/pnl_fields.py:AUDIT_PNL_ASSIGNMENTS is the one SQL fragment for realized_pnl / realized_pnl_pct / gross_pnl = :pnl / net_pnl_after_financing = :pnl − Σ margin_ledger financing;price_pnl() is the one price formula;rewrite_exit_price(conn, audit_id, exit_price) writes the audit row via the fragment, plus the twin matched on (symbol, entry_time) when exactly one twin matches.position_manager primary close now uses the fragment. The SQL is byte-equivalent (the inline gross_pnl = :gross_pnl with gross_pnl = pnl and the inline net expression moved into the fragment).trade_reconciler exec-link UPDATE now sets only link fields. When the fill differs from the stamped exit and the link UPDATE won (rowcount 1), it calls rewrite_exit_price.scripts/backfill_ibkr_executions_from_statement_csv.py rewrote entry_price / direction / exit_price with no P&L. It now re-derives through rewrite_exit_price per row.main/040_pnl_fields_bbw_snow.sql in NEW DATA_MANIFEST:deploy/migrate.py applies DATA_MANIFEST entries once, after every MANIFEST entry, via the existing apply(): psql -1 -f file -c <ledger INSERT>, so the file and its ledger row are one transaction. They are never probed or bootstrapped. sha256 drift fails closed.SET LOCAL lock_timeout '5s', statement_timeout '60s' and client_min_messages = notice. PGOPTIONS already sets warning, which would hide the log lines.DO statement, with no transaction control and no exception handler.total_commission, exit_commission IS NULL, the exit exec id, and for the twin entry_time.GET DIAGNOSTICS n = ROW_COUNT; IF n <> 1 THEN RAISE EXCEPTION, which rolls back all UPDATEs and the ledger row. migrate.py then exits 1 before restarts, with no retry (not a lock timeout).RAISE NOTICE per row with before → after, echoed into /var/log/polymarket/deploy-migrations.log.Exact changes:
| row | column | before | after |
|---|---|---|---|
trade_audit_log 274 BBW |
gross_pnl |
−121.52 | −282.10 |
trade_audit_log 274 BBW |
net_pnl_after_financing |
−121.52 | −282.10 |
trade_audit_log 274 BBW |
notes |
NULL | Finding #477: gross_pnl/net_pnl_after_financing -121.52 -> -282.10 (stop stamp -> broker fill) |
trading_positions 307 BBW |
exit_price |
37.25 | 34.66 |
trading_positions 307 BBW |
realized_pnl |
−121.52 | −282.10 |
trading_positions 307 BBW |
realized_pnl_pct |
−4.998725 | −11.604183 |
trade_audit_log 75 SNOW |
net_pnl_after_financing |
−9.96 | −1.16 |
trade_audit_log 75 SNOW |
notes |
existing | existing + \| Finding #477: net_pnl_after_financing -9.96 -> -1.16 (stop stamp -> repaired fill) |
Plus updated_at = NOW() on the three rows. realized_pnl, exit_price (audit), every commission column and exit_commission (stays NULL, never invented) are unchanged.
- Prod pre-check (read-only, Sep 26 ~18:11 UTC): each of the three WHERE clauses, lifted programmatically from the file, matches exactly 1 row. Prod is PostgreSQL 16.15, and main/040 is absent from schema_migrations.
- Fix (detection): trade_reconciler.reconcile_trades now also runs _load_pnl_consistency_rows + _evaluate_pnl_consistency. It covers closed rows exited or updated_at inside the window (7-day timer) or all rows (--full-history timer). It flags gross_pnl_stale (|gross − realized| > 0.01), net_pnl_stale (|net − (realized − financing_cost_total)| > 0.01) and twin_pnl_mismatch (exactly-one twin whose realized_pnl or exit_price differs by > 0.01).
- NULL gross/net are skipped.
- Twin mismatches before EXEC_LINKAGE_EPOCH_UTC are legacy gaps (#370 pattern).
- Mismatches are trade issues, so the run persists status=error, exits 2 and sends the existing trade_reconciliation alert.
- The tolerance is one cent: stored P&L is NUMERIC(.,2) and the twin's exit_price NUMERIC(10,2) (EB#77 −0.505 stored −0.51 stays clean).
- After 040 the full-history run has 0 new issues; MTB#4 / TXN#9 / T#10 are legacy.
- Placement / merge plan:
- Detection lives in trade_reconciler.py, not integrity_monitor.py. B1 (+385 lines), B2 (+34) and B3 (+112) all edit the monitor; B3 touches trade_reconciler.py at one line (824), far from these hunks.
- migrate.py: B3 inserts 039 inside MANIFEST (after 038) and follow-ups rewrites the baseline/bootstrap code. DATA_MANIFEST sits after MANIFEST's close, and Runner.run() / __init__ are untouched by both, so 039 and 040 apply in either deploy order. --check-baseline / --capture-baseline (follow-ups) iterate MANIFEST only.
- test_finding_444 edits are two lines (_runner(data_manifest=()), the registration set) outside every B3 / follow-ups hunk.
- FINDINGS / changelog / CONTEXT / HANDOFF append at the shared insertion points: add/add, keep both.
- A later integrity rule, after B1+B2 land, can call _evaluate_pnl_consistency; not needed for the alert.
- §B:
- (1) Trace:
- correction: polymarket-trade-reconcile(.full).service → main() → _auto_fix_trade_exec_links → _find_execution_match / log candidates → INSERT ibkr_executions → link UPDATE → rewrite_exit_price → audit UPDATE via AUDIT_PNL_ASSIGNMENTS + twin UPDATE → commit;
- close: ReconciliationManager / exit paths → persist_position_close_with_exit_details → twin + audit UPDATE via the same fragment;
- detection: main() → reconcile_trades → _evaluate_pnl_consistency → _persist_job_result (status) + _send_alert_if_needed → exit 2;
- data: deploy_from_github.sh → deploy/migrate.py → Runner.run() → DATA_MANIFEST loop → apply() → psql -1 -f 040 -c INSERT schema_migrations.
- (2) Runtime:
- BBW replay (unit fake): the audit UPDATE binds exit_price 34.66 and pnl −282.10, and the twin UPDATE binds the same.
- Live Postgres 16.15 (throwaway local cluster), via the real Runner and psql: 040 applied; 274 gross/net −282.10, exit commission NULL; 307 34.66 / −282.10 / −11.604183; 75 net −1.16 with the REPAIR note kept; three NOTICE lines in the runner output; the second run current=2 applied=0 with byte-identical rows.
- SNOW before-value drifted to −9.95: rc 1, BBW rows unchanged, no ledger row.
- Ledger row deleted, then rerun: rc 1 (id=274 matched 0 rows), no double apply.
- Hand psql -1 with the twin row locked and no PGOPTIONS: lock timeout in < 20 s, nothing changed.
- (3) Lines: src/earnings/pnl_fields.py; src/earnings/position_manager.py primary close {AUDIT_PNL_ASSIGNMENTS}; src/data_tracker/collectors/trade_reconciler.py _exit_price_correction_params, exec-link UPDATE + rewrite_exit_price, _load_pnl_consistency_rows, _evaluate_pnl_consistency, reconcile_trades; deploy/migrate.py DATA_MANIFEST + run() loop; src/data_tracker/migrations/040_pnl_fields_bbw_snow.sql.
- (4) What could prevent it:
- The twin has no FK. The (symbol, entry_time) match covers 213/214 closed rows, and the twin write is skipped unless exactly one row matches.
- A 040 before-value that drifts before deploy fails the deploy closed (old code keeps running). Re-read the rows and ship 041.
- The correction runs only when the exec link is new (ibkr_exit_exec_id IS NULL), so BBW is never re-touched by code; hence 040.
- The #340 fallback close still leaves net NULL (margin-ledger aggregates are primary-only by design). Reporting falls back to realized_pnl, the detector skips NULL, and the tripwire pins exactly one such writer.
- (5) Tests (fail on pre-#477 code: 10 of 12 in tests/unit/test_finding_477_pnl_fields.py; the other two are regression guards):
- tests/unit/test_finding_477_pnl_fields.py (12): the correction writes gross/net/twin; no write when the link race is lost or the fill equals the stamp; the shared fragment is used by the close path; a same-pattern tripwire over every active-code UPDATE trade_audit_log literal (realized_pnl ⇒ gross + net, except the one #340 fallback; exit_price ⇒ P&L or rewrite_exit_price); the detector on the prod rows; the reconcile_trades → issues → status=error path.
- tests/unit/test_finding_477_migration_040.py (16): one test per safety property: single transaction / timeouts / exactly one row / exact before-values / ledger idempotent / log line. Mutation-checked: dropping -1, adding COMMIT, splitting the DO, adding an exception handler, removing either timeout, removing or weakening a row guard (> 1), removing a before-value, setting exit_commission, removing the ledger check, removing a NOTICE or the notice level each fail (14/14).
- tests/integration/test_finding_477_postgres.py (5, PNL477_TEST_DATABASE_URL): the live runs in (2).
- §E (exhaustive grep \b(realized_pnl|exit_price)\s*= + UPDATE trade_audit_log / trading_positions over src, scripts, earnings_dashboard, deploy):
- (a) Fixed: the trade_reconciler correction; the statement-CSV backfill script.
- (b) Uses the fragment: position_manager primary close. reconciliation_manager ledger close delegates to it (persist_position_close_with_exit_details).
- (c) Deliberately left:
- the position_manager fallback close (#340: realized + gross, net NULL on the open row);
- the orchestrator execution-ledger close (trading_orchestrator.py ~1745–1850). It already writes all three from :pnl plus the twin; B3 makes it unreachable (delegates to ReconciliationManager); editing it would conflict with B3's rewrite and the docs-sweep comment edits there.
- (d) Stamp sources, consistent by construction: exit_manager.py ~701 and trading_orchestrator.py ~4130 default exit_price to the stop when fill details are missing, then call the close path. The later correction is the repair.
- (e) Not trade-table writers: kwargs into the close path (exit_manager, trading_orchestrator, reconciliation_manager, scripts/close_persist_contract_check.py); hypothetical_exit_price (counterfactual tables); analysis/simulator loaders; src/ibkr/client.py local realized_pnl; archive/scripts/fix_database.py (archive).
- Readers of net: earnings_dashboard/app.py PNL_EFFECTIVE_SQL, risk_manager.py daily P&L and integrity_monitor.py day P&L. They are correct once 040 applies; the Aug 27 historical day-P&L changes, with no live effect.
- Impact after deploy: Phase-1 known net −$922.2826 before BBW's missing exit commission (reviewer-confirmed; the pre-review docs-sweep figure −$761.7026 + (−282.10 − (−121.52)) = −$922.2826). All-time PNL_EFFECTIVE −$151.78 (BBW −160.58, SNOW +8.80). Playbook §1 / strategy report numbers regenerate after 040 (§K/N re-run after material DB changes).
- Changelog: one §O integrity entry; #423's entry AMENDED (superseded_by #477).
- Files: NEW src/earnings/pnl_fields.py, NEW src/data_tracker/migrations/040_pnl_fields_bbw_snow.sql, src/earnings/position_manager.py, src/data_tracker/collectors/trade_reconciler.py, scripts/backfill_ibkr_executions_from_statement_csv.py, deploy/migrate.py, tests/unit/test_finding_444_migration_ledger.py, NEW tests/unit/test_finding_477_pnl_fields.py, NEW tests/unit/test_finding_477_migration_040.py, NEW tests/integration/test_finding_477_postgres.py, src/earnings/strategy_changelog.py, docs/OPERATIONS.md, docs/DATA_ARCHITECTURE.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.
/docs/* pages stay open to others, and that secrets must never appear in docs. Sibling of #457 (committed-secret scrub on reliability/cred-scrub, with its own guard test over tracked files) — the two together are the only controls that keep secrets off the public dashboard.DEPLOYED Sep 26, 2026 22:19 UTC (deploy #11 3138aca, merge of PR #4 d576b7d after the GPT round-3 approval; detached unit polymarket-deploy-20260926T221858Z-2508114717 22:19:01 → 22:22:24 UTC, DEPLOY_EXIT rc=0)./docs/operations rendered docs/OPERATIONS.md verbatim, and that doc had the full DATABASE_URL (including its password) pasted into it. An internet scanner harvested it on Sep 14. This finding is the runtime guard: no dashboard RESPONSE can leak a secret or a sensitive identifier again, regardless of how it gets into the response body (a doc, a log excerpt reflected into an error message, a stray f-string) — independent of #457's scrub of what's currently committed.earnings_dashboard/secret_redaction.py, own module, one line in app.py: install_secret_redaction(app) right after app = FastAPI(...)).os.environ by then, sourced from .env via the systemd unit's EnvironmentFile=) the module snapshots every secret-looking value out of the process environment: name ends in KEY/TOKEN, name contains SECRET/PASS/PASSPHRASE/PRIVATE_KEY, the password part of DATABASE_URL and the whole DATABASE_URL string, and the password segment of ANY env var whose value happens to be a URL with embedded credentials (covers DATABASE_URL without a special case). Values only — names are never logged, and the collected values themselves are never logged either.scheme://user:[REDACTED]@ — covers postgres and every other scheme with one pattern), DigitalOcean AVNS_ passwords, Telegram bot tokens — these fire even when the exact value isn't in the CURRENT environment (a stale/rotated credential left in an old doc).SecretRedactionMiddleware wraps send for every request — HTML, JSON, plain text (covers the btc5m subtree's separate PlainTextResponse docs route too), 404s. A catch-all Exception handler (also installed by install_secret_redaction()) exists for the true-unhandled-exception case, but — corrected post-review, see below — its response does NOT actually pass through SecretRedactionMiddleware: Starlette hands any handler registered for Exception/500 straight to ServerErrorMiddleware itself, which sits outside all user middleware and sends via the ORIGINAL send it was given. Safety on this one path is single-point: the handler calls redact_text() on the message BEFORE building the response, with no ASGI-level backstop behind it.Content-Length; larger/streaming responses switch to bounded chunk-wise redaction, carrying back max(longest current literal, longest shape-pattern match, 4 KiB) so an in-progress match's visible-so-far portion is always fully inside the carried tail, and Content-Length is dropped rather than guessed. Non-text content types pass through unscanned. (Post-review fix — see below: the ORIGINAL shipped version split the raw window before redacting, which could cut a secret in half at the split point.)app.py):openapi_url=None, docs_url=None, redoc_url=None) — /openapi.json, /docs, /docs/oauth2-redirect, /redoc all 404. The dashboard's OWN /docs/{doc_name} markdown route is untouched (different route, registered separately).POST /api/testing-prod/confirm, POST /api/testing-prod/reset, POST /api/set-trading-mode. Each began orchestrator = get_orchestrator(); if not orchestrator: return {"error": ...} — and get_orchestrator() always returns None (the trader runs as its own systemd service; the dashboard is DB/file read-only by design). save_persisted_mode() deleted too — its only caller was the removed set-trading-mode endpoint. GET /api/testing-prod/status (read-only) is unaffected. Stale UI references fixed: dashboard.html's "Manual Confirmation Required" panel no longer names the removed API; docs/OPERATIONS.md/docs/API_REFERENCE.md/docs/CONTEXT.md "Change Trading Mode" instructions point at the file-based path only.earnings_dashboard/identifier_masking.py): IBKR account ids, order/execution ids, wallet addresses, and server-side hostnames/IPs/paths are masked or truncated at the source — where each payload is assembled — not by a second blind response filter; performance and position data (P&L, shares, prices, direction) are untouched. Every site found by an exhaustive repo-wide grep for account/order_id/wallet/host/execution across earnings_dashboard/ and both modular dashboard subtrees:/api/ibkr-status: status.live.account_id / status.paper.account_id (from gateway_monitor.get_gateway_status()) truncated in place — the dashboard JS reads these fields verbatim into the DOM, so the truncated value keeps rendering there instead of going blank.get_active_positions_from_db(): stop_order_id / ibkr_stop_order_ids masked at the two dict-literal sites, AFTER the real DB-vs-IBKR match/mismatch comparison runs on the unmasked values (hardware-stop reconciliation is unaffected). One fix here covers FOUR consumers that all read from the same list: /api/positions, /api/live-positions, /api/state, the main / dashboard HTML, and the ibkr_reality.stop_order_issues overlay.strategies/btc5m/dashboard/routes.py + btc5m_tab.html): /api/btc5m/state and /api/btc5m/live-sample were serving the maker lab's real wallet addresses and the execution host's real hostname/IP/filesystem path (facts.py: WALLET_MAIN, WALLET_TITUS, EXECUTION_HOST="[REDACTED]", EXECUTION_HOST_IP="[REDACTED]", EXECUTION_TREE_PATH="[REDACTED]") in both the top-level wallets/execution_tree JSON fields AND nested inside sample_2026_08_17.wallet/.host; recorder_status.raw_root (a DB-sourced filesystem path) too. btc5m_tab.html also had the hostname hardcoded in static prose. Local, dependency-free mask helpers (_mask_wallet / _mask_location) — not an import from earnings_dashboard.identifier_masking: the btc5m README's "Modularity guarantee" requires the subtree delete cleanly with 5 file reverts elsewhere, so it must never import back from the mounting app. Belt-and-suspenders: the same 5 raw constant values were also added to the response filter's known-identifier literal set (secret_redaction._collect_known_identifier_literals), so they're redacted even if they appear in the subtree's own markdown docs served via /api/btc5m/docs/{slug} — verified against the REAL docs/LIVE_SAMPLE_2026-08-17.md, which does contain the raw wallet/host/IP in prose.strategies/etfstack/dashboard/routes.py + etfstack_tab.html) — found by the same sweep, same isolation rule, same treatment: /api/etfstack/state (paper_account, latest_signal.paper_account_id), /api/etfstack/positions (ibkr_account per row), /api/etfstack/trades (ibkr_order_id per row) masked via local _mask_account_id / _mask_order_id. etfstack_tab.html's static "0.5 — Paper Gateway" changelog row had the literal default paper account DU…91 hardcoded in prose — replaced with the generic DUP* reference already used elsewhere in the same file.app.py module import → app = FastAPI(...) (auto-docs disabled) → install_secret_redaction(app) (middleware + exception handler registered) → every route handler runs inside ExceptionMiddleware inside SecretRedactionMiddleware inside ServerErrorMiddleware → SecretRedactionMiddleware.__call__ wraps send in a per-request _ResponseBodyRedactor → every http.response.start/http.response.body ASGI message passes through it before reaching uvicorn. Identifier masking: get_active_positions_from_db() computes stop_order_id_value/ibkr_stop_ids, uses them for the real stop_match comparison, THEN masks them into the dict it appends to positions → that list is state["positions"] → get_ibkr_reality_overlay(state["positions"]) reads the already-masked value.DATABASE_URL with a fake password, appended it to a copy of docs/OPERATIONS.md, hit /docs/operations — body contains [REDACTED], not the password, not the full URL. Added a throwaway route directly to the live app object returning the same planted secret as HTML, JSON, plain text, and via an unhandled RuntimeError — all four came back redacted, including the 500 (proving the catch-all-exception-handler path, not just the happy path). Ran every parameter-free GET route (56 total; _parameter_free_get_routes() walks app.routes) with the same planted secret active and with requests.get patched to fail instantly (keeps the sweep bounded, ~3-6 s) — zero leaks. Faked a connected IBKR status (account_id="U2…13") and confirmed /api/ibkr-status returns "U2…13", never the raw id. Confirmed via git diff-driven mutation checks (temporarily reverting the registration line, the openapi_url=None block, and the account-id masking block, one at a time) that each removal fails the matching test, then restored the file byte-identical (diff confirmed).earnings_dashboard/secret_redaction.py (new, ~380 lines) · earnings_dashboard/identifier_masking.py (new, ~65 lines) · earnings_dashboard/app.py: import block (~L58-61), app = FastAPI(...) (~L5344-5356), get_active_positions_from_db dict literals (~L3593-3604 — masking AFTER the comparison at ~L3527-3529), save_persisted_mode deleted (was ~L4902-4909), /api/ibkr-status (~L6555-6564), the three removed endpoints (was ~L6813-6899) · strategies/btc5m/dashboard/routes.py (local mask helpers + 4 call sites) + btc5m_tab.html (1 line) · strategies/etfstack/dashboard/routes.py (local mask helpers + 4 call sites) + etfstack_tab.html (1 line).#457; the review overruled that scope boundary (decision D covers every public page, docs included) and this finding now masks it on the rendered /docs/* copy directly.tests/unit/test_finding_467_dashboard_hardening.py — 74 tests (10 classes): env-literal collection rules, generic shape patterns (fire independent of current env), no-false-redaction on hashes/prices/normal URLs, filter registration (static + live app.user_middleware + catch-all handler present), filter-applies-to-any-route (4 trap-route tests: HTML/JSON/plain-text/unhandled-exception), the real /docs/operations + /api/btc5m/docs/readme planted-secret tests, the full 56-route sweep for both a planted secret and the known btc5m/etfstack identifiers (with a bounded-time assertion), streaming/chunk-boundary/binary-passthrough tests, auto-docs-404 + custom-docs-still-works, write-endpoints-404-or-405 + no-stale-UI-reference, and per-subtree identifier-masking + no-cross-import (modularity) tests for both btc5m and etfstack. Every removal/reversion above was mutation-checked to fail the matching test before the file was restored.account/order_id/orderId/execution/wallet/host/raw_root across earnings_dashboard/app.py, strategies/btc5m/dashboard/, strategies/etfstack/dashboard/, and every template under both — every hit is one of: (a) a performance/position field staying visible (P&L, shares, market_value — deliberately unmasked, tested in TestPerformanceAndPositionsStayVisible), (b) our own internal DB row id used for legitimate UI wiring (trading_positions.id for the chart-modal link, etfstack_backtest_runs.run_id, etfstack_rebalance_log.order_trade_id — an FK to our own trades table, not an IBKR id) — left alone, or (c) handled above. Zero unhandled (b)-class matches remain.0e985dd..3138aca, 16 files; hygiene 0/0/0; needrestart OK (14); migrate: current=7 bootstrap=0 applied=0; close-persist contract holds; / HTTP 200. Pre-merge: pytest tests/unit 2680 passed; drift audit CLEAN (12)./, all 27 /docs/* pages, the 26 parameter-free /api/* views the dashboard templates fetch (plus /api/state and /api/btc5m/live-sample) and all 12 btc5m doc slugs return 200. Two non-200s were not views: /api/etfstack/killswitch is the pre-existing POST-only toggle (GET 405; not called), and 7 btc5m archive_sections ids are not doc slugs (404 by the unchanged DOC_SLUGS lookup).U2…13 (docs show U2…13), 0 [hidden], 0 [hidden], 0 other 40-hex addresses; of the known wallet values, WALLET_MAIN, WALLET_TITUS and FUNDER_ADDRESS were absent, but that scan did not check WALLET_NAGI (see Known gap below), so its "0 of 3 known wallets" figure missed the one leak. Other server paths (/var/log/polymarket, /root/, /etc/systemd) still appear in doc prose, which is outside the masked set by design (exact known values only)..env secret values and 0 known-leak shapes on any of the 72 URLs.GET /openapi.json, /docs, /docs/oauth2-redirect and /redoc return 404; the three removed endpoints return 404 on GET (no route; a POST-only route would return 405).:4001 unchanged; root crontab unchanged (99928f0f…22c7).OPEN (found by the GPT sweep review, confirmed on prod 22:52–22:57 UTC): GET /api/btc5m/docs/doc_map (HTTP 200) returns the raw WALLET_NAGI value once. It is the six-character prefix of the third-party benchmark maker's wallet — not a full address and not Marius's wallet — which #467 already treats as sensitive (/api/btc5m/state masks it as nagi_prefix via _mask_wallet()). Cause: _collect_known_identifier_literals() and the route-sweep test's known values include WALLET_MAIN and WALLET_TITUS but not WALLET_NAGI, and test_every_btc5m_doc_slug_is_clean checks account/server identifiers, not btc5m wallets. The value is not written here because /docs/findings is public. Code follow-up: the #467 author is fixing it on a new PR, which ships as its own deploy after GPT approval.2ecb02c, dashboard code unchanged since deploy #11): every public GET route — all 44 parameter-free routes including the JSON APIs, every /docs/* page and every btc5m DOC_SLUGS doc, 82 URLs — against 10 known values (account id, server IP, deploy path, every WALLET_* / EXECUTION_* constant in strategies/btc5m/src/facts.py, and the .env wallet address), any 40-hex address, and the 15 .env secret values. Result: only WALLET_NAGI on /api/btc5m/docs/doc_map (×1); everything else absent; 0 40-hex addresses; 0 secrets. Not covered: /api/trade-chart-data (needs query parameters; 422 without) and the etfstack backtest run routes (no run ids listed).POST to the three write endpoints on the old code (22:17:38, and to the two testing-prod ones again at 22:18:03). They returned {"error":"Orchestrator not available"} (the old get_orchestrator() always returned None) and 422 for set-trading-mode; nothing ran, and trader mode, pause state and breakers were unchanged.earnings_dashboard/secret_redaction.py (NEW), earnings_dashboard/identifier_masking.py (NEW), earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html, strategies/btc5m/dashboard/routes.py, strategies/btc5m/dashboard/templates/btc5m_tab.html, strategies/etfstack/dashboard/routes.py, strategies/etfstack/dashboard/templates/etfstack_tab.html, docs/OPERATIONS.md, docs/API_REFERENCE.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, tests/unit/test_finding_467_dashboard_hardening.py (NEW, 74 tests), this entry.Cross-family review (71dd002, docs/reliability/review-467-dashboard.md): CHANGES REQUIRED — 4 blockers, all fixed same finding:
1. Streaming chunk-boundary leak (high, reproducible). _ResponseBodyRedactor._emit_chunk split the RAW window into an emit/carry pair BEFORE redacting it — a secret straddling that split point had each half scanned independently, so neither half matched the full literal and it shipped raw (the reviewer's exact repro: _MAX_BUFFER_BYTES=16, overlap=8, one 24-byte chunk b'xxxxxxxxxxxxSecretValue1'). Fixed: redact the WHOLE combined window first, THEN split the already-safe result; the carry-back margin is max(longest current literal, 788 [longest shape-pattern match], 4096) via new _SecretStore.max_match_length() — an in-progress match's visible-so-far portion is, by construction, always shorter than the margin, so it's never in the emitted half. Regression: TestStreamingChunkBoundaryRegression sweeps every junk_len around the exact danger band (margin − secret_len, margin) using the reviewer's own margin-shrinking technique; mutation-checked against a reverted split-before-redact implementation (14 of 32 parametrized cases correctly fail against it, 0 against the fix).
2. Public /docs/{doc_name} pages exposed identifiers (high). #457 removes committed secrets, not non-secret identifiers, and decision D covers every public page — docs included; deferring the account number to #457 was the wrong scope boundary. Fix: new identifier_masking.mask_known_public_identifiers() — exact-value replacement (never a path-shaped regex, so Markdown links/code fences survive) of the real IBKR account number (→ U2…13) and the droplet IP + [hidden] (→ [hidden]) on the raw doc text, applied before Markdown conversion in serve_documentation() (source .md files on disk stay untouched) and on btc5m's plaintext docs route via a local, dependency-free duplicate (modularity). Same 3 values also joined the response filter's known-identifier literal set as a backstop. Tests parametrize over the review's own reported routes (operations/agent-handoff/readme/strategy) plus every doc_map slug (26, discovered from the route's own 404 body) and every btc5m doc slug (12).
3. Bare/short/URL-encoded DATABASE_URL passwords (medium). The 8-char minimum literal length silently dropped a short password entirely, and only the URL-as-appears (still percent-encoded) form was ever collected. Fixed: no minimum length for a value with known provenance (name-matched or URL-password-extracted) — literals under 8 chars are anchored to token boundaries ((?<![A-Za-z0-9])...(?![A-Za-z0-9])) instead of being dropped, so they can't flood false-positives as an unanchored substring match either; new _url_password_variants() collects the raw/as-appears form, the URL-decoded (bare) form, and a canonical re-encoded form.
4. The full-route sweep was not mutation-sensitive (medium). It only checked whether a planted secret happened to leak — nothing in this app's NORMAL (no live DB/IBKR in tests) request handling embeds one, so the test passed with the filter removed (reviewer's own mutation check: 44 of 44 routes still zero-leak with SecretRedactionMiddleware deleted from the live app). Fixed: test_every_route_response_traverses_the_redactor instruments redact_bytes directly (call-count before/after each request) to prove the POSITIVE the leak-scan can only assume — every route's response actually reaches the redactor — plus an explicit mutation test using the reviewer's exact method (remove the middleware, rebuild the stack), which reproduces the same "44 of 44 never traverse" result.
Bonus finding made while building the blocker #4 instrumentation (not reviewer-flagged): the catch-all Exception handler's response does NOT pass through SecretRedactionMiddleware — Starlette's build_middleware_stack() hands any handler registered for Exception/500 straight to ServerErrorMiddleware, outside all user middleware, sent via the original send. The module docstring previously claimed the opposite. Corrected the docs; the path stays safe because the handler redacts BEFORE building its response (single-point, not defense-in-depth, for this one path) — added a dedicated test proving both facts (zero redact_bytes calls, zero leak).
Post-review: full unit suite 2348/2348 PASS (125 tests in the #467 file, +51 from this round); scripts/doc_drift_audit.py clean.
Cross-family review round 2 (05c02ed, docs/reliability/review-467-dashboard.md): CHANGES REQUIRED — 1 blocker, fixed same finding:
1. Multibyte/Unicode secrets still leaked across a streaming chunk boundary. Round 1's fix sized the carry-back margin from len(v) — Python character count — and redact_bytes() decoded each window to str with errors="replace" before matching. Both are wrong for a non-ASCII literal: (a) a 1500-character emoji secret is 6000 UTF-8 bytes, so a margin of "1500" was 4x too small to hold its visible-but-incomplete tail, and independently (b) decoding a window that ends mid-way through a multi-byte UTF-8 sequence permanently replaces that sequence's trailing bytes with U+FFFD before the rest of the character ever arrives in the next chunk — no margin size fixes a corruption that already happened. Reviewer's exact repro: "🔐" * 1500 (6000 bytes) split into a 5000-byte chunk and a 1000-byte chunk — old code reported max_match_length()==1500, leaked the raw secret, no [REDACTED] anywhere in the output.
Fixed by matching secrets as UTF-8 bytes throughout the streaming path, never decoding a window at all: new _build_literal_pattern_bytes(), _GENERIC_PATTERNS_BYTES, _redact_url_credentials_bytes(), _apply_generic_patterns_bytes(), and _SecretStore.redact_bytes() (bytes in, bytes out, no str round-trip); _SecretStore._max_len_bytes is now max(788, len(v.encode("utf-8")) for v in literals) — measured in encoded bytes, not characters. redact_text() (str → str, used only by the exception handler and other complete-in-memory-string call sites, which have no chunk boundary to split) is unchanged. _emit_chunk's redact-before-split invariant from round 1 is unchanged; only what it redacts with, and how it sizes the margin, changed.
Regression: new TestStreamingMultibyteBoundaryRegression (10 tests) — direct assertions that the margin is byte-sized for 2-/3-/4-byte characters (é/€/🔐), the reviewer's exact 1500×🔐/5000-1000-byte-split repro run against the REAL (un-overridden) margin, an offset sweep generalizing TestStreamingChunkBoundaryRegression's ASCII sweep to all three character widths (every byte offset around the danger band, including offsets that land mid-character), and two targeted mutation checks — one reverting only the margin formula to character-count, one reverting only redact_bytes to the old decode-based implementation — each confirmed to reproduce a real, unredacted leak. Mutation-checked against the actual pre-fix file (git show HEAD:earnings_dashboard/secret_redaction.py, i.e. the round-1-only version): 3 of the 10 new tests correctly fail against it (test_margin_is_measured_in_encoded_bytes_not_characters, the 🔐-width case of test_margin_scales_with_byte_width_for_each_character_class, test_reviewer_exact_repro_1500_emoji_split_mid_stream); file restored byte-identical afterward (diff confirmed).
Post-round-2: full unit suite 2619/2619 PASS (135 tests in the #467 file, +10 from this round); scripts/doc_drift_audit.py clean (12 checks, 0 violations).
doadmin credential was used. pg_stat_activity was point-in-time only, all ten application sessions had blank application_name, and the database owner could alter in-database audit rows.DEPLOYED Sep 26, 2026 20:55 UTC (deploy #8 614a29d, fast-forward a695050..614a29d after the cross-family review; detached unit polymarket-deploy-20260926T205457Z-3090020335, DEPLOY_EXIT rc=0). Marius approved hardening item E. The deploy installed and enabled polymarket-db-activity-sampler.service, which started 20:58:07 UTC, after the OD8 doadmin reset, on the new .env. Sampler-grace race hardening (PR #8 094a00f): DEPLOYED Sep 26, 2026 22:39 UTC (deploy #12 2ecb02c). Sampler restart-grace fix (PR #7 295fe28): DEPLOYED Sep 26, 2026 22:19 UTC (deploy #11 3138aca). Post-deploy evaluator fix: DEPLOYED Sep 26, 2026 21:30 UTC (deploy #10 f28c0da, merge of PR #6 155f0eb after Opus review; detached unit polymarket-deploy-20260926T213008Z-1418529119 21:30:11 → 21:33:44 UTC, DEPLOY_EXIT rc=0).security:db_client_activity could not evaluate (Rule.evaluate passed the engine to _check_db_activity_status(path)), so the per-new-address CRITICAL page was inactive; the sampler, log and latch kept working and no foreign client appeared (all 143 db_activity.log samples 20:58:08–21:33:28 UTC have empty foreign_client_addrs / untrusted_client_addrs: 142 before the sampler restart, through 21:33:22, and the restarted sampler's first sample at 21:33:28). Fixed DEPLOYED Sep 26, 2026 21:30 UTC (deploy #10 f28c0da) — see Post-deploy defect and fix below.pg_dump; service units supplied no identity; no host-side process sampled pg_stat_activity; the integrity panel had no client-address rule.src/db/connection.py is the single startup-identity helper for SQLAlchemy, psycopg2, and libpq subprocesses. Known entrypoint filenames derive stable production identities in code; DB_APPLICATION_NAME in an installed unit is an override, not a deployment dependency. Thus the seven hand-installed units whose repo twins are not reinstalled by a normal deploy still identify correctly after their normal restart/next run. Startup packets carry identity from connection establishment, not a later SET.polymarket-db-activity-sampler.service samples all rows in pg_stat_activity (all databases plus NULL-database provider workers) every 15 seconds under a 3-second statement timeout, 5-second connect timeout, and read-only transaction. One rotated JSON line per snapshot records only client_addr, usename, application_name, backend_start, state, and server-side query class. It never selects/emits query text or parameters and touches no application table/hypertable.security:db_client_activity stays WARN while any latch exists (true since deploy #10 f28c0da; inactive 20:58–21:33 UTC after deploy #8 — Post-deploy defect above). The monitor dispatches one CRITICAL data_integrity page per new address independent of rule status transitions; CRITICAL bypasses the shared 30-second category limiter, and failed sends are not acknowledged and retry next cycle. No RESOLVED page is sent: disappearance is not proof the access was benign. Malformed/ambiguous addresses and sampler staleness remain panel-only./var/log/polymarket/*.log logrotate policy bounds the activity log daily/at 500 MiB, retains 14 compressed rotations, and the deploy script owns installation/startup of the new unit.application_name → PostgreSQL activity/connection logs attribute the session. Sampler → read-only all-database catalog query → JSONL + persistent foreign latch → integrity monitor → visible WARN plus per-new-address CRITICAL page (live since deploy #10 f28c0da; inactive 20:58–21:33 UTC after deploy #8).[hidden], local workers, or RFC1918/ULA/link-local networks stay OK. A global address seen by any 15-second sample is latched, survives clean later samples and WARN→WARN states, and is retried until its unique CRITICAL page succeeds. A session shorter than the 15-second interval can fall entirely between samples; DigitalOcean's forwarded connection/disconnection logs are the complete connection record.src/db/connection.py::{get_application_name,create_db_engine,connect_psycopg2,postgres_subprocess_env}; src/data_tracker/db_activity_sampler.py::{ACTIVITY_SQL,_merge_foreign_seen,run_once}; src/data_tracker/integrity_monitor.py::{_check_db_activity_status,_send_foreign_client_alert,IntegrityMonitor._dispatch_alerts}.--help from a foreign working directory with PYTHONPATH absent; two consecutive snapshots preserve a disconnected foreign address; a second address pages during an existing WARN; all databases are sampled; the page is CRITICAL; /api/integrity renders the WARN.+pg8000 URL had revived the #418 _db_has_open_position() fallback as a real psycopg2 round-trip on the IB owner thread. That fallback previously failed immediately in production and is now a no-I/O compatibility stub: _has_open_position_for_alert() uses only the orchestrator's in-memory probe and otherwise returns false. The older weekly last-resort Gateway-recycle path still reaches gateway_recycle.py::db_open_position_count on the owner thread; that pre-existing call belongs to B1's Gateway-recycle fixes, not #479.tests/unit/test_finding_479_db_activity_logging.py covers subprocess entrypoints, derived identities, all-database SQL, persistent foreign latches, WARN→WARN/new-address paging, CRITICAL rate-limit bypass, failed-send retry, conditional _send_integrity_alert, and the owner-thread no-DB invariant.f28c0da).614a29d == origin/master; git status --porcelain 0 lines (also with --untracked-files=all). Detached unit polymarket-deploy-20260926T205457Z-3090020335 20:55:00 → 20:58:21 UTC, DEPLOY_EXIT rc=0; hygiene 0/0/0; host-reference ok; needrestart coverage OK (14); migrate: current=5 bootstrap=0 applied=0; close-persist contract holds; / HTTP 200.ibgateway-ibc 1215183, xvfb 1215180, x11vnc 1215184 and :4001 java 1537985 unchanged (NRestarts 0). Root crontab sha256 unchanged (99928f0f…22c7, the cron-a state).same-thread req* ok after connect (#440); reconciliation in sync 20:55:32); effective_trading_state=ready, active_breakers=0, testing_prod_paused=False, 0 stops today, 0 positions (IBKR 0 / DB 0). Integrity 87 rules: 84 ok / 2 warn (two table sizes) / 1 unknown (security:db_client_activity, the #479 evaluator defect, fixed in deploy #10 f28c0da).f28c0da, the #479 rule's TypeError each 5-minute monitor cycle; 0 journal err lines across 9 units.pg_stat_activity (read-only) shows earnings-trader, polymarket-dashboard (2), polymarket-collector, polymarket-live-trades, polymarket-websocket, earnings-conviction-tracker, polymarket-integrity-monitor and db-activity-sampler, all from [hidden]; every other session is DigitalOcean-internal with a NULL address; no blank-named droplet session and no foreign address. Sampler active and enabled (NRestarts 0); db_activity.log growing; status sidecar session_count=20, foreign_seen={}; error log empty. security:db_client_activity read unknown at that point (the evaluator defect, fixed in deploy #10 f28c0da).src/db/connection.py; sampler + integrity monitor; production connection call sites; DB-capable systemd units; deploy installer; tests/unit/test_finding_479_db_activity_logging.py; this entry, CONTEXT, and AGENT_HANDOFF.security:db_client_activity returned UNKNOWN and logged a traceback every five-minute integrity cycle. Because foreign paging consumes this rule's detail, a real new foreign client would have gone unpaged.Rule.evaluate(engine) invokes every registered evaluator with the database engine. _check_db_activity_status(path=None, ...) is a file reader whose first positional argument is a path, but _build_rules() registered it directly. The engine was passed as path, and Path(engine) raised TypeError. Earlier tests called the helper directly with a real path and never exercised the registered rule through Rule.evaluate.evaluator=lambda _engine: _check_db_activity_status(), explicitly adapting the engine-based rule contract to the engine-independent file check.test_registered_db_activity_rule_evaluates_through_monitor_contract drives this exact registered rule through Rule.evaluate(fake_engine). test_every_registered_rule_accepts_monitor_engine_argument pins the registry at exactly 87 rules and requires security:db_client_activity, then drives every entry through the same path. It catches a TypeError escaping an evaluator into Rule.evaluate—including the deployed wrong-first-argument defect; it does not claim to detect every possible kind of callable-signature drift.security:db_client_activity is OK (or a truthful WARN), no evaluator traceback recurs, and a latched foreign address remains eligible for its CRITICAL page.DEPLOYED Sep 26, 2026 21:30 UTC (deploy #10 f28c0da; unit polymarket-deploy-20260926T213008Z-1418529119 21:30:11 → 21:33:44 UTC, DEPLOY_EXIT rc=0; pull 614a29d..f28c0da, 3 files; hygiene 0/0/0; needrestart OK (14); migrate: current=5 bootstrap=0 applied=0; close-persist contract holds; / HTTP 200). Pre-merge: pytest tests/unit 2486 passed; drift audit CLEAN (12).security:db_client_activity = ok ("database clients trusted (19 sessions)") on its first cycle (21:33:34) and again on cycle 1 (21:38:43). The monitor log's evaluator raised / Traceback count went 7 → 8: the 8th is the old process's last cycle (21:33:26), before the restart; the new process logged none. No foreign-client page; no db_activity_alerted.json; sidecar foreign_seen={}, untrusted=[]. Sampler active (restarted 21:33:28), log growing, error log empty. Integrity 87 rules: 85 ok / 2 warn (table sizes) / 0 unknown.3138aca — see Post-deploy false page and fix below): the deploy restarts the sampler just before the integrity monitor. The old monitor's last cycle ran at that moment, systemd:polymarket-db-activity-sampler.service saw deactivating, and it sent a CRITICAL "Data Integrity TRIGGERED" Telegram at 21:33:29; the new monitor sent "RESOLVED" at 21:33:37. Any deploy can repeat this if the old monitor's cycle lands in that window.polymarket-db-activity-sampler.service while the old integrity monitor was finishing its last cycle. The generic always-active service rule sampled the unit mid-restart, sent a false CRITICAL "Data Integrity TRIGGERED" at 21:33:29 UTC, then RESOLVED eight seconds later. The sampler itself recovered and security:db_client_activity was OK; no foreign client was present.systemctl is-active rule, and its sidecar freshness rule had no knowledge of a deliberate service restart. A normal deploy creates a brief, expected non-active/stale window before the integrity monitor itself restarts.StateChangeTimestampMonotonic is within 120 seconds returns OK with explicit restart-grace detail. Missing, unreadable, or stale sampler status gets the same bounded grace. This is isolated to the sampler and does not change the generic Rule/transition machinery, keeping it compatible with Reliability B1's later deploy deferral and two-cycle policy.test_sampler_service_and_freshness_tolerate_short_restart drives both registered rules at 30 seconds after a systemd state change and requires OK/restart-grace. test_sampler_genuinely_down_still_fails_and_pages drives them at 300 seconds and requires CRITICAL FAIL plus freshness WARN.DEPLOYED Sep 26, 2026 22:19 UTC (deploy #11 3138aca, merge of PR #7 295fe28 after Opus review; unit polymarket-deploy-20260926T221858Z-2508114717).systemd:polymarket-db-activity-sampler.service reads ok ("active") and security:db_client_activity ok. No monitor cycle landed inside the sampler's restart window this time, so the 120 s grace path was not exercised on prod; its tests cover it.systemctl show snapshot containing ActiveState, StateChangeTimestampMonotonic, and NRestarts; an already-recovered active state is OK, eliminating the old is-active→show race. Three or more automatic restarts — systemd's NRestarts, counted since the last manual (re)start (a deploy's systemctl restart resets it) — with the last state change within five minutes override grace and FAIL CRITICAL, so a long-running crash loop remains visible. Because the count is cumulative since the last manual start, not a five-minute window, after three scattered automatic restarts with no deploy in between every later single crash pages once (FAIL for up to 300 s, then RESOLVED): loud, not silent (review nit 1). A readable stale sidecar with an ambiguous/untrusted address stays WARN during service grace; client evidence is evaluated before freshness grace.test_sampler_service_uses_one_consistent_recovered_state_read reproduces the reviewer's stop→active race and proves only show is called; test_sampler_restart_loop_overrides_restart_grace proves NRestarts=3 fails while the last state change is inside five minutes; test_sampler_grace_keeps_ambiguous_address_warning proves stale ambiguous evidence is not hidden.DEPLOYED Sep 26, 2026 22:39 UTC (deploy #12 2ecb02c, merge of PR #8 094a00f after Opus review; unit polymarket-deploy-20260926T223934Z-2361616640 22:39:37 → 22:43:08 UTC, DEPLOY_EXIT rc=0). Pre-merge: pytest tests/unit 2683 passed; drift audit CLEAN (12).systemctl show polymarket-db-activity-sampler: ActiveState=active, NRestarts=0 (restarted by the deploy 22:42:43). Monitor log after the pre-deploy baseline: 0 TRIGGERED for the sampler rule, 0 evaluator raised, 0 tracebacks. After cycle 1 (22:47:54): systemd:polymarket-db-activity-sampler.service ok ("active", detail n_restarts: 0, restart_loop: false) and security:db_client_activity ok ("database clients trusted (20 sessions)"). Status file 4–12 s old, untrusted/foreign/foreign_seen empty, no db_activity_alerted.json.OPEN follow-up (not the sampler): the deploy restarts the integrity monitor (22:42:45) and then force-restarts the dashboard; the dashboard took 7 s to stop (deactivating 22:42:45 → 22:42:52), the new monitor's immediate cycle 0 read it at 22:42:48, and systemd:polymarket-dashboard.service sent a CRITICAL "Data Integrity TRIGGERED" Telegram; cycle 1 sent "RESOLVED" at 22:47:54. Same race class as the sampler's, from the deploy script's restart order (integrity monitor before the final dashboard restart) — it predates this PR and missed in deploys #9 and #11 only by timing. Seen again at deploy #14 (systemd:polymarket-dashboard.service deactivating 00:33:57, RESOLVED 00:37:09 Sep 27); it did not page at deploy #13. The fix, PR #9, is pending.systemctl show snapshot containing ActiveState, StateChangeTimestampMonotonic, and NRestarts; an already-recovered active state is OK, eliminating the old is-active→show race. A readable stale sidecar with an ambiguous/untrusted address stays WARN during service grace; client evidence is evaluated before freshness grace. History: deploy #12 initially treated the lifetime NRestarts >= 3 plus a recent state change as a loop; the sliding-window correction below supersedes that over-broad rule.test_sampler_service_uses_one_consistent_recovered_state_read reproduces the reviewer's stop→active race and proves only show is called; test_sampler_restart_loop_overrides_restart_grace proves NRestarts=3 fails inside the five-minute window; test_sampler_grace_keeps_ambiguous_address_warning proves stale ambiguous evidence is not hidden.systemd:polymarket-dashboard.service TRIGGERED page, then RESOLVED it at 22:47:54. Deploys #9/#11 avoided the same race only by timing.systemctl is-active sample, so any deliberate service restart could transition CRITICAL.systemctl show snapshot (ActiveState, StateChangeTimestampMonotonic, NRestarts). activating, deactivating, or inactive states within 120 seconds are OK/restart-grace; recovered active is immediately OK.NRestarts as a baseline, records only positive deltas, clears history when systemd resets the counter, and FAILs CRITICAL at three newly observed automatic restarts inside a rolling 15-minute window. This catches a real tight loop quickly without adding an integrity page to every trader stall restart after the lifetime counter reaches three.DEPLOYED Sep 26, 2026 20:55 UTC (deploy #8 614a29d, after the cross-family review; deploy-wide checks under #479 Post-deploy). Operator decisions #8 and #9, Sep 26, 2026 17:34 UTC.contract:cq2_grs_detection_rejudgment (#389) had been WARN since its Sep 13 due date. The re-judgment it asked for (Rev §II.5 C-Q2, §II.7 G-RS; G-002/G-003/G-004) ran Sep 26, 2026 on 5.7 weeks of clean post-#358 prints (earnings dates Aug 17 – Sep 25: 69 resolved markets, 194,780 prints, 23 real trades). It used a replay at #358 parity with the Gamma fallback bounded, and GPT-5.6 reviewed it (v2 approved with nits).config.py POLYMARKET_*, unchanged) and the Polymarket price source:_check_contract_date(due_date=_CQ2_GRS_DATE)). Once past its date it returned WARN every day forever, with no way to encode "re-judge when there is enough evidence".src/data_tracker/contract_cq2_count.py:cq2_wrong_held_sql(canonical_filter) defines the cohort: unique trade_audit_log positions passing the canonical filter, closed, entered on or after the #358 deploy (Aug 16, 2026 20:22 UTC). The market (earnings_markets by condition_id) must have a final pm_resolution, the position must have been open at the detector's release-window gate (#337: earnings date 04:00 ET for BMO/unknown timing, 15:30 ET for AMC), and the held direction must be contra the resolution._CANONICAL_TRADE_FILTER_SQL, identical to TRADE_METRICS_FILTER_SQL and applied verbatim inside the trade subquery.check_cq2_count_trigger() returns warn at CQ2_COUNT_TRIGGER=12 or on/after CQ2_BACKSTOP_DATE=2026-12-15, otherwise ok with "n/12 … days out". The detail lists every cohort position for the race statistic.integrity_monitor.py:_CQ2_GRS_DATE is deleted._check_contract_cq2_count wrapper does a local import, per the file's convention.contract and WARNING severity, but now has telegram_reminder=False. So _send_integrity_alert returns before any send, and the WARN shows only on the dashboard integrity panel.IMPLEMENTATION_PLAN_2026_08.md C-Q2/G-RS rows;build_revalidation_357(): C-Q2 DECIDED Sep 26, 2026 — KEEP (#478; no trading change), the G-RS monitor criterion, future work, the A-8 note;#389 → AMENDED);trade_audit_log ids 267 (BOX, LONG, exit 2026-08-25 20:05 UTC) and 274 (BBW, LONG, exit 2026-08-27 10:52 UTC). The rule reads ok "2/12 … (backstop 2026-12-15, 80 days out)".IntegrityMonitor cycle → _build_rules() → Rule.evaluate → _check_contract_cq2_count → check_cq2_count_trigger → one SELECT → status row (dashboard /api/integrity). On a transition, _send_integrity_alert returns at its severity/telegram_reminder gate (WARNING ∉ TELEGRAM_SEVERITIES, telegram_reminder=False).default_transaction_read_only=on, 20 s timeout) and returned BOX and BBW in 3 s. The same cohort matched sql/40_count_trigger.sql from the analysis.src/data_tracker/contract_cq2_count.py (cq2_wrong_held_sql, check_cq2_count_trigger); integrity_monitor.py _check_contract_cq2_count and the contract:cq2_grs_detection_rejudgment Rule.pm_resolution (DY, CPB, KR today) never count. That is by design: truth is the market's own resolution (#372/#382).earnings_date rewrites (#442) move the gate, so a position entered for a pre-rewrite date that never spanned the real release is excluded (CBRL id 293).condition_id is unique in earnings_markets (1,353/1,353), so the join cannot double-count; DISTINCT guards it anyway.unknown, never fail.tests/unit/test_finding_478_cq2_count_trigger.py (20 tests):telegram_reminder is False), with a Telegram-path test in which a control reminder does send;_CQ2_GRS_DATE and date(2026, 9, 13) gone;EarningsDetector._RELEASE_WINDOW_*, and the query is resolved and contra only.integrity_monitor.py), 11 of the 20 fail.test_finding_389_contract_reminders.py (the C-Q2 rule is dashboard-only; the date is retired) and test_finding_357_revalidation.py (C-Q2 reads DECIDED)._CQ3_DATE, Oct 12) is the only other date-based contract reminder. It is deliberately unchanged: the operator decided only C-Q2.C-Q2|G-RS|cq2|CQ2|G-00[234] outside archive/, packs/ and strategies/: every current-state match was updated here. Intentional history left as is: the #389/#358/#360/#361 FINDINGS narratives, Rev §10 (preserved verbatim), the frozen scripts/analysis_357/* reports, the build_revalidation_357 docstring's Aug 16 note, and the implementation-queue item "started the ~4-week clean-print clock".c-q2-rejudgment-2026-09.md v2 and its GPT-5.6 review in the Project store (docs/analysis/), with scripts, SQL and outputs under internal/analysis/c-q2-rejudgment-2026-09/. The #358-parity replay for the repo is #475 (reliability/followups).contract:cq2_grs_detection_rejudgment reads ok: "C-Q2/G-RS re-judgment: 2/12 wrong-held positions (backstop 2026-12-15, 80 days out)" (BOX, BBW). The overdue Sep 13 WARN is gone from the integrity panel.src/data_tracker/contract_cq2_count.py, src/data_tracker/integrity_monitor.py, NEW tests/unit/test_finding_478_cq2_count_trigger.py, tests/unit/test_finding_389_contract_reminders.py, tests/unit/test_finding_357_revalidation.py, scripts/strategy_performance_analysis.py, scripts/doc_drift_audit.py, src/earnings/strategy_changelog.py, docs/STRATEGY_REVALIDATION_2026_08.md, docs/IMPLEMENTATION_PLAN_2026_08.md, docs/STRATEGY.md, docs/STRATEGY_PLAYBOOK.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, docs/FINDINGS.md.ibkr_health_monitor.sh, ibkr_restart_warning.sh, sunday_2fa_reminder.sh and ibkr_schedule_audit.sh were git 100644 from creation. Root's crontab exec'd them directly, so every run since the Feb 15, 2026 ~08:42 UTC checkout failed with Permission denied (288/day for the health monitor; 3 Sunday reminders; the daily audit). No Sunday 2FA reminder has arrived since Feb 15. The crontab lived only on the host. It also ran a check_trader_health.sh that was never in git (No such file every 5 min), a Jan 8 one-shot exit_reminder.sh, and /etc/cron.d/monitor_23_45 (a Jan 28 one-shot). gateway_restart_manager.sh (100755 since f7dff71, Mon-Sat 05:58) SIGTERM/SIGKILLed any "newer" install4j.ibgateway.GWClient PID. That violates OD5 (page-only, no Gateway lifecycle), and it sent a daily false "✅ Session preserved" (RC10). Does not place or cancel orders. Hardware stops unaffected. Evidence: Project store internal/reliability/prod-cron-gateway-check-2026-09-26.md (read-only probe 17:25-17:30 UTC); Telegram RCA F2/RC10; B1 review blockers 3 and 4.IMPLEMENTED-PENDING-DEPLOY (reliability Batch B1). The operator's crontab-only mitigation "cron a" has been live since 17:38 UTC Sep 26: the four scripts run via /bin/bash, and the restart-manager line is commented out (backup /root/crontab.pre-cron-a-20260926T173824Z).f7dff71, #437) flipped just the restart manager, an §E miss. (2) Nothing owned the crontab: deploy_ibkr_monitoring.sh scp'd scripts past git and printed lines to paste into crontab -e. (3) The restart manager's premise was obsolete: IBC's AutoRestart restarts the session inside the same JVM, so there is never a second Gateway process. It logged 0 kills in 15 runs Sep 10-26, and its pattern matches neither IBC JVM (ibcalpha.ibc.IbcGateway). But a "fixed" pattern would see live + paper as two Gateways and kill the higher PID, which could be live.deploy/cron/root.crontab is the only definition of root's crontab. It holds SHELL=/bin/bash, the one Sunday reminder (50 5 * * 0) and the 04:00 audit, both called as /bin/bash [hidden]/scripts/… (the cron-a form, so a lost exec bit cannot silence them). sunday_2fa_reminder.sh and ibkr_schedule_audit.sh are also 100755 in git (git update-index --chmod=+x).deploy/install_crontab.py: --check (multiset compare: 0 match / 1 drift / 2 error), --dry-run and --install. The install is a transaction (review R2 blocker 3):KNOWN_LEGACY_LINES: the cron-a and pre-cron-a forms) are retired, and managed lines are kept. Any other line is an operator job, preserved verbatim under a "preserved host lines" block, so a deploy never deletes one; --check then reports it as EXTRA. A line that runs a retired script together with anything else (… ibkr_health_monitor.sh && /root/job.sh) refuses with rc 3 and nothing changed, including /etc/cron.d. Round 3 (majors 3/minor 1): detection tokenizes the command with shlex (punctuation-aware) and matches the retired basenames after normpath, so scripts//x.sh, quoted 'x.sh', cd … && ./x.sh, bash -c "…/x.sh" and ../scripts/x.sh all refuse; an untokenizable line refuses too. The basename match is exact (x.sh.bak is an operator job). Only the five schedule fields are normalized; the command payload is compared byte-exact.fcntl lock (/run/lock/polymarket-crontab.lock, CRONTAB_LOCK_PATH) serializes installers; a second one exits rc 2 without touching anything (Round 3 major 4).mkstemp: /var/backups/polymarket/crontab.pre-deploy-<ts>-<random>), then the live crontab is re-read: if it changed since planning (a concurrent crontab -e), abort rc 2 with nothing written. Then an atomic crontab <file> swap, then a multiset re-check.crontab <file> fails and the live crontab is unchanged, rc 2 "crontab unchanged" (no restore needed). On any other install error or verification mismatch the backup is re-installed and verified (rc 2); if the restore fails too, rc 4 names the backup./etc/cron.d/monitor_23_45 is moved aside, and only when it is exactly the Jan 28 one-shot, re-validated at move time; the destination is a fresh 0600 mkstemp file. If it changed or the move fails, it stays in place with a WARNING and --check reports it as EXTRA (Round 3 minor).deploy_from_github.sh runs --install right after the host gates, before migrations and restarts, and any non-zero rc stops the deploy.deploy/remote_deploy.sh makes git fetch fatal, then, when origin/master carries deploy/install_crontab.py, extracts the candidate installer and root.crontab with git show into a mktemp -d and runs --dry-run against the live crontab. A non-zero rc stops with "crontab pre-flight (#476) failed rc=… — nothing pulled, nothing restarted".origin/master, or remote_deploy.sh --sha <40-hex>, which must be on origin/master and a fast-forward of HEAD, else rc 7). The crontab pre-flight reads <sha>:deploy/install_crontab.py / <sha>:deploy/cron/root.crontab; the checkout moves with git merge --ff-only <sha> (no second fetch or pull), and deploy_from_github.sh receives DEPLOY_TARGET_SHA, uses it for hygiene, never fetches or pulls on that path, and stops before any gate unless HEAD == <sha>. A manual deploy_from_github.sh run fetches once and pins the same way (pre-flight included). A push landing mid-deploy cannot slip in. --poll <id> is format-checked (Round 4 minor).% (the rest is stdin); a dynamic token ($VAR, ${x}.sh, $(…), backticks, globs) refuses when it could expand to a retired basename (a bare $X could be anything, so it refuses). Operator lines are reported as [operator job '<schedule>' runs <program> … sha256:<12>] / [env NAME=… sha256:<12>] in the installer output, --check and the dashboard: never arguments or assignment values. After a failed install the backup is restored only over this install's own output; a crontab that is neither the old one nor ours (another writer) is left alone with rc 4 naming the backup. Residual: crontab has no compare-and-swap, so an operator crontab -e in the milliseconds between the re-read and the swap can still be replaced — do not edit root's crontab during a deploy (OPERATIONS §8.F).gateway_restart_manager.sh (the kill loop is gone with the file, not re-patterned); ibkr_health_monitor.sh (live :4001 paging is ibkr-monitor, #448; running both would double-page every outage); ibkr_restart_warning.sh and the 05:55 duplicate reminder (plan acceptance: one reminder at 05:50); the uninstalled --critical market-hours line (it would re-page every 15 min during an outage); and the check_trader_health.sh / exit_reminder.sh lines (never in git; trader liveness is the Healthchecks earnings-trader slug, #448).scripts/ibkr_schedule_audit.sh checks install_crontab.py --check, the ibgateway-ibc/ibkr-monitor units and IBC AutoRestartTime against scripts/ibkr_schedule.env. That file now holds only the restart time; cron times live in the crontab file (R1). NEW integrity rule cron:schedule_audit (WARNING, dashboard-only) runs the audit every cycle, so drift is no longer silent.deploy_ibkr_monitoring.sh is now a read-only check (no scp/chmod/crontab edits). scripts/ibkr_diagnostics.py uses --check.install_crontab.py --install → crontab -l → diff of active lines vs deploy/cron/root.crontab → unknown-line gate → backup → crontab <file> → --check. Cron 05:50 Sun → /bin/bash sunday_2fa_reminder.sh → /bin/bash send_telegram.sh (#449) → HTTP 200 → last-send artifact. Integrity cycle → _check_cron_schedule_audit → /bin/bash ibkr_schedule_audit.sh → install_crontab.py --check → cron:schedule_audit row.SHELL, reminder, audit). The commented restart-manager line and the empty comment headers go with the old file. monitor_23_45 moves to /var/backups/polymarket/. Sun 05:50: one reminder. A hand-added host line is preserved verbatim and cron:schedule_audit WARNs EXTRA … until it moves into the repo file. A chained retired-script line → rc 3, nothing changed, deploy stops before any restart. A failed or partial crontab write → backup re-installed and verified, rc 2, deploy stops.deploy/cron/root.crontab (NEW); deploy/install_crontab.py (NEW) plan/install/check/_private_file/_verified/_retire_one_shot; deploy/deploy_from_github.sh crontab block; scripts/ibkr_schedule_audit.sh; scripts/ibkr_schedule.env; scripts/sunday_2fa_reminder.sh; deploy_ibkr_monitoring.sh; scripts/ibkr_diagnostics.py check_cron_jobs; integrity_monitor.py _check_cron_schedule_audit + rule; scripts/doc_drift_audit.py RETIRED_MECHANISM_LITERALS.test_finding_476_managed_crontab.py is 50 (12 fail on 2ae3f5f: %, ?h, glob, ${x}, $(…), backtick and bare-variable refusals; operator secrets never in notes, --check or the refusal; the never-restore-over-another-writer case). test_finding_444_detached_deploy.py is 24 (11 fail on 2ae3f5f: master moving mid-deploy cannot change what is deployed; the pre-flight reads <sha>:; --sha deployed exactly; not-on-master refuses rc 7 before touching the checkout; bad --sha/--poll refused locally; the deploy script never fetches or pulls on the pinned path).tests/unit/test_finding_476_managed_crontab.py is 37, and 10 fail on 839f58e: five shell-equivalent retired invocations refuse (//, quoted basename, cd && ./, ../scripts/, untokenizable); the payload is compared exactly; a concurrent edit aborts without overwriting; installs are serialized; a changed one-shot is revalidated and left visible (WARNING + EXTRA); install failure leaves the crontab unchanged. Regression guards: bash -c "…" refuses; x.sh.bak is not a retired basename. tests/unit/test_finding_444_detached_deploy.py (+2, both fail on 839f58e): a pre-flight refusal stops before the pull; a pass continues to the pull. Before Round 3 (26; Round 2 +9 new, and 10 cases fail on 3a6555d: operator line preserved; chained line refuses before any mutation incl. cron.d; install failure → restore; partial write → rollback; restore failure → rc 4; backups 0600 + unique; dry-run mutates nothing; duplicate line is drift; deploy installs before migrations and fails hard): every scheduled script exists, is git 100755 and is called via /bin/bash; send_telegram.sh is 100755; the retired files are gone and unscheduled; one Sunday reminder; no scheduled script contains kill/pkill/systemctl start|stop|restart (the same scan matches the facb218 restart manager); cron a is replaced and backed up; an unknown line refuses with the crontab unchanged; the current file is a no-op; --check reports drift; the one-shot is retired only when exact; the audit uses the installer; the env file has no cron times; the deploy installs; deploy_ibkr_monitoring.sh is read-only; the integrity rule is WARNING and reads the audit. On e94dd0d the file does not exist and the modes are 100644, so the suite fails. tests/unit/test_doc_drift_audit.py test_c2_retired_mechanism_fires_unless_history.deploy/cron/root.crontab. The #449 caller list is the reminder only. Crontab blocks in STRATEGY §8.5/8.6, IBKR_CONTINUITY, OPERATIONS §Cron Jobs/§8.F, STATE_MACHINES (daily cycle), EDGE_CASES 4.6 and CONTEXT (session preservation) are rewritten or history-marked. (c) systemd timers are unchanged (their units are already in git). (a) history: FINDINGS #437/#438 mention the restart manager as then-live; docs/packs/ are frozen; the HANDOFF dated log (below "Recently completed") is left as history.crontab -l equals deploy/cron/root.crontab; /var/backups/polymarket/crontab.pre-deploy-* holds cron a; bash scripts/ibkr_schedule_audit.sh prints PASS; /api/integrity cron:schedule_audit ok; no Permission denied / No such file in cron logs; Sun Oct 4 05:50 one reminder arrives; no process on the host runs a Gateway kill.integrity entry.deploy/cron/root.crontab (NEW), deploy/install_crontab.py (NEW), deploy/deploy_from_github.sh, deploy_ibkr_monitoring.sh, scripts/ibkr_schedule_audit.sh (mode 100755), scripts/sunday_2fa_reminder.sh (mode 100755), scripts/ibkr_schedule.env, scripts/ibkr_diagnostics.py, scripts/gateway_restart_manager.sh (DELETED), scripts/ibkr_health_monitor.sh (DELETED), scripts/ibkr_restart_warning.sh (DELETED), src/data_tracker/integrity_monitor.py, scripts/doc_drift_audit.py, tests/unit/test_finding_476_managed_crontab.py (NEW), tests/unit/test_doc_drift_audit.py, src/earnings/strategy_changelog.py, docs/STRATEGY.md, docs/IBKR_CONTINUITY.md, docs/OPERATIONS.md, docs/STATE_MACHINES.md, docs/EDGE_CASES.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.docs/analysis/c-q2-rejudgment-2026-09.md §2/§8 in the Project store).DEPLOYED Sep 26, 2026 19:05 UTC (deploy #7 a695050, fast-forward facb218..a695050 (no-ff merges of reliability/docs-sweep 5ac9c23 and reliability/followups 1633a17, each approved by its cross-family review); detached unit polymarket-deploy-20260926T190538Z-2875818611 19:05:41 → 19:09:12, DEPLOY_EXIT rc=0). Offline scripts; the deploy only ships the files.f130113): scripts/detection_latency_replay.py:simulate still took the median of the last 5 prints of any age, evaluated per print, and had three modes (old all-of-5, new both-sides, final contra-only). The live EarningsDetector._check_polymarket_price has, since #358 (Aug 16, 2026), used only prints ≤ CONTENT_CROWD_MAX_AGE_SEC (900 s) old, needed ≥ CONTENT_CROWD_MIN_FRESH_PRINTS (3) of them for the upper median, and otherwise fell back to a live Gamma read. It leaves its confirmation state untouched when no value exists, and it runs on the monitor loop's poll grid. crowd_flip_time used the same stale median. scripts/analysis_357/w7_arbiter_sweep.py imported simulate(mode="final"). The C-Q2 re-judgment had to write its own simulator for that reason.simulate(prints, window_open, typical, direction, …) is now a poll-grid port of the live method: #337 gate (grid starts at window open, unknown timing uses the BMO bound, release_bounds), fresh-window median through EarningsDetector.select_fresh_median itself (detector_median), Gamma fallback through a gamma(t) hook (default newest_print_proxy: the newest stored print at or before t, the C-Q2 analysis proxy), no value → state untouched, #341 contra-only grades, sustained confirmation with grace and the decision → resolution upgrade (live 4-tuple state). Every threshold, window, cadence and count is read from Config / EarningsDetector at call time; overrides exist for sweeps (resolution_grade=False for "off").crowd_flip_time uses the same detector median. main() keeps --truth, --positions, --sweep, --since, --min-volume and adds --poll-sec and --gamma-proxy. Without --positions, every market is held on the crowd's side (≥ 0.5 LONG), since live emits nothing without a direction. The retired old / new modes are deleted.w7_arbiter_sweep.py calls the new signature (confirm_sec, grace_sec) and release_bounds.d91be39): the CLI population and truth layers were still stale. Fixed:--truth market reads earnings_markets.pm_resolution (truth_for), not the last stored print (resolved markets can end on an empty-book 0.50 print). w7_arbiter_sweep.py's market-truth column had the same artifact and now reads pm_resolution too.entry_conviction: the trade's recorded conviction_at_entry, else the median print in the 15 min before scheduled_entry, which is SignalGenerator.calculate_entry_exit_times), not prob_1h_before.--positions applies the live TradingSignal.is_valid (entry_direction) with the policy in force at the entry time: before #366 (DEPLOYED Aug 21, 2026 04:34 UTC) the 0.95 LONG cap and the 0.80 small-cap LONG cap (no B-5 pilot); after it, the run's Config (C-1 0.90 cap, B-5 pilot). Small-cap SHORTs are never admitted. This matches the C-Q2 v2 entry-eligibility lens; volume (unless --min-volume), date confidence, depth, regime, capacity and breakers are not modelled. Unknown-timing markets stay eligible (BMO schedule and bounds), as in v2.main() → per resolved market release_bounds → price_snapshots for [window open − 2 h, typical + 8 h] → simulate → per poll detector_median → EarningsDetector.select_fresh_median → gamma → contra grade → state machine → first fire → report rows. w7_arbiter_sweep.main() → simulate per trade and grid cell. No live module imports either script.price_snapshots time-bounded by the BETWEEN, #373). Not run on prod for this change; see (5) for the offline validation.scripts/detection_latency_replay.py release_bounds, newest_print_proxy, detector_median, simulate, crowd_flip_time; scripts/analysis_357/w7_arbiter_sweep.py import + simulate(...) call.condition_id. Older markets' prints are older than the fresh window, so this only matters if two markets of one ticker are live at once.tests/unit/test_finding_475_detection_replay_parity.py (83): 48 seeded random streams (dense and sparse stretches, level shifts across every threshold, whipsaws around the grace window, outlier prints, pre-gate spikes, random Gamma outages; AMC, BMO and unknown timing; LONG and SHORT) each fire at the same poll with the same outcome and grade in simulate and in the live _check_polymarket_price, driven on a simulated clock with only the DB rows and the Gamma read faked; parity under 5 confirmation / grace configs via Config and at the 60 s cadence; the cases cover decision fires on both sides, a resolution fire and silence; targeted: stale prints no longer form the median (Hyatt geometry), grace boundary kept at exactly grace s, a Gamma outage is a gap not a flip, the #337 gate blocks a pre-release pin; no re-typed detector numbers in the port; w7 uses the new call. CLI layer (review Major 1): 13 entry_direction cases, including a post-#366 0.92 LONG (out), the same entry before #366 (in), small-cap SHORTs (out in both eras), the pre-pilot 0.80 vs B-5 small-cap LONG cap and the live B-5 switch; the policy is restored after use and on error; the #366 split follows the scheduled entry (BMO Aug 21 enters Aug 20 = pre, BMO Aug 24 enters Aug 21 15:58 ET = post); recorded conviction preferred; truth_for uses pm_resolution. An end-to-end main() run on a fake database (--positions --truth market) admits 2 of 4 markets (the post-#366 0.92 LONG and the small-cap SHORT are out) and scores a SHORT whose stream ends on an empty-book 0.50 print as a SAVE, with 0 false exits; the market query reads pm_resolution and no price. Mutation-checked: the old static band and truth from the final print each fail it. w7's market truth reads pm_resolution. Review round 2 (look-ahead): the entry-window query is half-open (time >= :a AND time < :b) and entry_conviction drops prints at or after the entry instant; the fake database mirrors each predicate's boundary (and rejects any it does not model). Regression: two outliers stamped exactly at a post-#366 entry would lift the median from 0.885 (LONG) to 0.92 (above the C-1 cap, entry gone); reverting the query, the function filter, or both each fails a test. The two stream-fetch windows ([window open − 2 h, typical + 8 h], here and in w7_arbiter_sweep.py) stay inclusive: simulate reads only prints at or before each poll, the live visibility rule, so their edges cannot leak a later print into an earlier decision. Mutation-checked: the pre-#358 last-5 median, no Gamma fallback, a data gap resetting the clock, >= grace, no #337 gate, decision grade before the typical release, and not keeping the resolution grade on upgrade each fail at least one test. Offline cross-check against the C-Q2 v2 cohort and its independent simulator (internal/analysis/c-q2-rejudgment-2026-09/ in the Project store: read-only extracts, v2 sim.py proxy scenario; no prod query), 10 s, 0.02/0.98, 0.25/0.90, 300 s / 60 s, detector fires from window open to the scheduled exit, before any stop:entry_direction with the recorded / entry-window / snapshot conviction): 26 positions (the v2 count), 2 wrong-way (BOX, BBW); 26/26 identical; 2 fires, both on those two; 0 false exits. RH is a small-cap SHORT and is outside this lens. v2 reports 0 fires before exit here because the hardware stop came first.entry_direction equals the v2 live_gate and scheduled_entry equals the v2 entry time on all 69 markets; the extract has 0 prints exactly at an entry instant.EarningsDetector._live_polymarket_extreme_outcome still reads the last N prints of any age, but #342 retired it from live use ("retained for tests and as a general-purpose extreme probe"); it has no live caller, so it is deliberately left as it is (§C). scripts/analysis_357/w7_report.md describes the Aug-era method as history and is not rewritten. The dashboard's outcome badge (earnings_dashboard/app.py #264-style extreme read) uses the latest print within 12 h for a display label, not a detector median; different class, unchanged. No other script imports detection_latency_replay. Inclusive-boundary sweep (review round 2): the three price_snapshots windows in the replay and w7_arbiter_sweep.py were checked; only the entry-conviction window has before-entry semantics and it is now half-open (see (5)).a695050 == origin/master; git status --porcelain 0 lines (also with --untracked-files=all).removed_untracked=0 reset_identical=0 stashed_divergent=0; host-reference ok; needrestart coverage OK (14 live units); conviction-tracker schema ok; close-persist contract holds; dashboard / HTTP 200.ibgateway-ibc 1215183, xvfb 1215180, x11vnc 1215184 and :4001 java 1537985 unchanged (NRestarts 0). Root crontab sha256 unchanged (99928f0f…22c7, the cron-a state).same-thread req* ok after connect (#440); reconciliation in sync 19:06:14); effective_trading_state=ready, active_breakers=0, testing_prod_paused=False, 0 stops today (threshold 4), 0 positions (IBKR 0 / DB 0); integrity 82 ok / 3 warn / 0 fail (the same 3 warnings)./docs/findings, /docs/agent-handoff, /docs/operations, /docs/readme, /docs/strategy-playbook, /docs/strategy HTTP 200; /api/architecture-findings parses 427 findings with no error and no duplicate IDs (#132 resolves to the Feb 5 PRIMARY).err lines across the 8 units.scripts/detection_latency_replay.py, scripts/analysis_357/w7_arbiter_sweep.py, tests/unit/test_finding_475_detection_replay_parity.py (NEW), docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.DEPLOYED Sep 26, 2026 19:05 UTC (deploy #7 a695050; deploy-wide checks under #475 Post-deploy).f130113): Runner.baseline_problems compared each listed column's information_schema.data_type (no length, precision, nullability or default) and ran SELECT EXISTS … WHERE id = 1 for two btc5m singletons (the _WHERE_RE id-only grammar). A file that stopped half-way, or ran without its later UPDATEs, passed as long as the objects existed by name: a missing index, a default, a NOT NULL, VARCHAR(12) vs (16), NUMERIC(12,4) vs (10,2), a PK/FK/CHECK/UNIQUE constraint, 24 of the 25 btc5m.integrity_rule_catalog rows, or the two catalog UPDATEs.deploy/migrate.py: columns_sql (format_type, attnotnull, pg_get_expr default; a default that is nextval of the column's own serial sequence is recorded as nextval(owned sequence), so sequence names never matter), constraints_sql (pg_get_constraintdef for contypes c/f/p/u/x; NOT NULL is compared per column), indexes_sql (pg_get_indexdef by name), rows_sql (structured {table, key, values} seed predicates; key columns must be identifiers and key values bool/int/plain text, quoted by doubling; everything else raises). Catalog reads run as one JSON array per query with search_path=pg_catalog, so every name is schema-qualified the same way in capture and check. validate_entry rejects malformed or old-format entries (fail closed). Columns compare exactly; baseline constraints, indexes and seed rows must all be live; extra live objects from later migrations are allowed.default_transaction_read_only=on: --check-baseline (probe + full baseline for every entry, ledger or not) and --capture-baseline (the committed structure filled with live facts, JSON on stdout).deploy/migration_baseline.json: rewritten from a read-only prod capture — 5 entries, 207 columns, 29 constraints, 39 indexes (incl. the Timescale *_ts_idx defaults create_hypertable adds) and 27 seed rows: the killswitch (id = 1 with the CHECK-pinned live_quoting_disabled = true), the recorder_status singleton (id = 1), and all 25 catalog rules with cadence / fail_when / phase_wired, the two UPDATEd fail_when values included.f130113, PostgreSQL 16.15; the new migrate.py piped into a self-deleting mktemp dir, prod's .env for the URL, 0 leftovers):--capture-baseline reported everything the file declares, except that btc5m.live_samples has 0 rows: neither 2026-08-17-P2 nor 2026-08-17-STAGEA (the two rows 031 inserts). That is not a partial migration: the Sep 25 read-only incident cleanup archived and truncated that table (strategies/btc5m/tools/README.md, "truncated after their dump"), and no code reads it. The baseline therefore does not require those rows; _about says why. Requiring them would stop a future bootstrap on any restored prod copy for a documented ops decision, while re-running 031 would re-insert data the operator removed. The killswitch and recorder_status mutable columns are not pinned for the same reason.--check-baseline with the committed file: 5/5 entries match, rc 0.--dry-run with the new code and prod's migration files: current=5 bootstrap=0 applied=0 (the deploy path is unchanged).deploy/deploy_from_github.sh:166 venv/bin/python3 deploy/migrate.py | tee -a deploy-migrations.log (exit ≠ 0 → exit 1 before any systemctl restart) → main() → Runner.__init__ loads the baseline (deploy/migrate.py:437) → run(): ledger rows → per MANIFEST entry: in ledger → current (baseline never read); else probe_sql true → baseline_problems (:542, called at :730) → any problem → MigrationError → rc 1; none → bootstrap ledger row. --check-baseline → check_baseline (:656); --capture-baseline → capture_baseline (:601), both from main() (:765) with dry_run=True._CATALOG_OPTIONS :74; builders :236 / :255 / :267 / :288; validate_entry :304; query_json :496; baseline_problems :542.--check-baseline proved 5/5 on prod, and a test round-trips capture == committed file (one query path).--capture-baseline shows the new rendering for review.tests/unit/test_finding_474_bootstrap_baseline.py (41): 18 partial-apply shapes each refused (exit 1, no ledger row for that ID, problem named, only 038's DDL): index missing / non-unique / wrong columns, default missing / different / serial default lost, NOT NULL missing, varchar length, numeric precision, added column missing, PK / FK / CHECK / UNIQUE missing, seed key missing, INSERT without its later UPDATE, singleton missing, file stopped before the catalog INSERT; a complete schema bootstraps all 4 without DDL; extra later objects allowed; old-format and malformed entries fail closed; unsafe tables / key columns / key values never reach SQL; catalog reads pin search_path and stay read-only in dry-run; non-JSON catalog output fails closed; check_baseline read-only and reporting; capture round-trips the committed file; main forces read-only for both tools; per entry, the baseline encodes what the SQL file declares (types, NOT NULL, defaults, constraint counts by type, FK targets, index uniqueness and columns, and no index a later migration created); the 25 catalog rows equal the file's INSERT plus its UPDATEs; the live_samples exclusion is explicit. tests/unit/test_finding_444_migration_ledger.py: its fake database now answers the structured catalog queries (48 still pass). By inspection of the #444 checker at f130113, 16 of the 18 partial-apply shapes would pass (it cannot see indexes, defaults, nullability, lengths, constraints, catalog rows or values; only the missing column and the missing id = 1 singleton were caught).ConvictionTracker.check_schema (F-7), which checks its own columns and indexes by name before a restart and never adopts a migration; left as is (it repairs nothing and adopts nothing). No other code reads migration_baseline.json.migrate: done: current=5 bootstrap=0 applied=0 means all 5 MANIFEST IDs were in the ledger, so bootstrap and the baseline were not consulted and no DDL ran. The schema_migrations ledger (read-only SELECT) still holds the same 5 rows, all written 10:18:06–10:18:08 UTC by deploy #1 884e528. Read-only --dry-run → current=5 bootstrap=0 applied=0 (rc 0); read-only --check-baseline → 5/5 entries match (rc 0).deploy/migrate.py, deploy/migration_baseline.json, tests/unit/test_finding_474_bootstrap_baseline.py (NEW), tests/unit/test_finding_444_migration_ledger.py, docs/OPERATIONS.md (§6 deploy-time migrations), docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.DEPLOYED Sep 26, 2026 19:05 UTC (deploy #7 a695050; deploy-wide checks under #475 Post-deploy).f130113):requirements.txt omitted pg8000 (the driver in every DATABASE_URL, postgresql+pg8000://) and SQLAlchemy (imported by the trader, dashboard, collectors and scripts);fastapi>=0.104.0, pytest-asyncio>=0.21.0 were floors and Starlette was unpinned, so a fresh install took whatever was newest;ib_insync, finance-calendars, yfinance, psycopg2, requests, playwright, pytz and botocore are imported by live code or scripts but were missing (two of them lived in src/ibkr/requirements.txt with floors);src/data_tracker/requirements.txt listed floors for packages nothing imports (alembic, APScheduler, structlog, sentry-sdk, redis, click, schedule, and the PyPI asyncio backport) and none of them is installed on prod; CI installed all three files.f130113): venv/bin/pip freeze --all → Python 3.12.3, pip 25.3, 120 packages besides pip, among them FastAPI 0.128.0, Starlette 0.50.0, SQLAlchemy 2.0.45, pg8000 1.31.5, pytest 9.0.2, pytest-asyncio 1.3.0; pytest-cov is not installed. numpy 2.4.0 is a yanked release on PyPI ("backward compatibility bug"); pip still installs it for an exact pin and warns.requirements.txt: every direct dependency is an exact == pin at prod's version, in runtime / testing / development sections (the repo has no separate test requirements file, so the split is by section). Added SQLAlchemy, pg8000, psycopg2-binary, ib-insync, finance-calendars, yfinance, requests, pytz, botocore, starlette, playwright; pytest-cov dropped (not on prod).constraints.txt (NEW): the full prod freeze minus pip, so pip install -r requirements.txt -c constraints.txt also pins every transitive package to prod.src/data_tracker/requirements.txt and src/ibkr/requirements.txt are now -r ../../requirements.txt (one list; install_collector.sh and CI keep working).install_collector.sh and the README. The CI workflow (.github/workflows/verify_retry_backoff.yml) is not edited: the push token has no workflow scope (GitHub rejected the push). Its three pip install -r lines now all resolve to the pinned list through the redirects; adding -c constraints.txt there is an operator follow-up with a workflow-scoped token.constraints.txt from a new read-only freeze.requirements.txt. deploy/deploy_from_github.sh has no pip call (grep, and tripwire 5 below), remote_deploy.sh only wraps it. The files are read only by a host rebuild (deploy/setup-digitalocean.sh:87, deploy/digitalocean-setup.sh:25), src/data_tracker/scripts/install_collector.sh:84, CI (.github/workflows/verify_retry_backoff.yml, unchanged: its installs reach the pins through the redirects) and a developer following the README.python3.11 -m venv + pip install -r requirements.txt -c constraints.txt on the operator Mac: rc 0; its pip freeze (114 packages) equals prod's version for every package; the 6 prod packages it does not pull in are unused (exchange_calendars, pandas_market_calendars and their korean-lunar-calendar / pyluach, lxml, schedule). The unit suite and drift audit then pass in that venv (see Acceptance).requirements.txt (header + sections), constraints.txt, src/data_tracker/requirements.txt:3, src/ibkr/requirements.txt:3, the install lines above, docs/OPERATIONS.md §2.constraints.txt is the reviewed record.workflow-scoped token, so its transitive packages can differ from prod. CI only runs scripts/verify_retry_backoff_and_block.py.import statements. pg8000 is loaded by SQLAlchemy from the URL, so it has its own assertion.tests/unit/test_finding_473_pinned_requirements.py (11 tests): every line is an exact pin; every pin equals the prod freeze; the freeze header records Python 3.12.3 and a read-only capture; SQLAlchemy / pg8000 / FastAPI / Starlette / pytest / pytest-asyncio / uvicorn / Jinja2 / ib-insync pinned; pg8000 required because code builds postgresql+pg8000 URLs; every third-party module imported under src/, earnings_dashboard/, deploy/, scripts/, tests/, strategies/ and run_*.py maps to a pin; both sub-files are -r ../../requirements.txt; every install command outside CI carries -c constraints.txt; CI installs nothing but the pinned list; the deploy script never runs pip; the yanked numpy pin carries its warning in both files. Mutation-checked: the old requirements.txt fails the pin tests, and dropping ib-insync, SQLAlchemy and psycopg2-binary from the new one fails the import test with the three importing files named.pip install -r site (fixed: constraints; CI via the redirects, see Fix). archive/old_deploy/Dockerfile is archive (not touched). The retired polymarket_sniper/ and dashboard/ trees and the root one-off analysis scripts import packages prod does not have (selenium, undetected_chromedriver, psutil, matplotlib); they do not run on prod and are deliberately left out of the list and the tripwire scope.venv/bin/pip freeze --all is byte-identical to the Sep 26 capture. Met (post-deploy, 19:13 UTC): 121 lines = pip==25.3 + 120 packages identical to constraints.txt (the capture minus pip; sorted compare, 0 diff lines); Python 3.12.3; the deploy log has no pip step.requirements.txt, constraints.txt (NEW), src/data_tracker/requirements.txt, src/ibkr/requirements.txt, src/data_tracker/scripts/install_collector.sh, deploy/digitalocean-setup.sh, deploy/setup-digitalocean.sh, README.md, docs/OPERATIONS.md, tests/unit/test_finding_473_pinned_requirements.py (NEW), docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.doadmin password and committed API secrets scrubbed from HEAD; DB trusted sources = droplet only (OD8 option (a)) (Sep 26, 2026)4899fa3, d54a198), and the cluster's trusted-sources list was empty, so anyone holding the password could connect from any IP. The only gates were the password and TLS, and app TLS is CERT_NONE (src/db/connection.py; T2.11 stays ON-HOLD). Plan fix (2) found one more live secret: the prod FINNHUB_API_KEY (sha256 prefix equal to prod .env) sat as a getenv default in 2 tracked scripts since 4899fa3 (Jan 31), so it needs rotating as well (operator steps).DEPLOYED Sep 26, 2026 20:55 UTC (deploy #8 614a29d, after the reset and ahead of B1; deploy-wide checks under #479 Post-deploy).FINNHUB_API_KEY rotation: OPEN (Marius's step; it is in git history).DONE Sep 26, 2026 16:06 UTC (#457).doadmin password reset (OD8 step 2): DONE Sep 26, 2026 20:49 UTC (#457). Unit od8-doadmin-reset-20260926T204837Z started 20:48:44 UTC on the operator's go and ended RESET COMPLETE: all consumers reconnected on the new password at 20:49:21 UTC, rc 0. All 7 long-running DB services reconnected and the old password is rejected; ibgateway-ibc was not touched.OPEN).OPEN).facb218, Sep 26 15:50 UTC; values never printed, only names, counts and a sha256 prefix):git grep finds the password in exactly 20 tracked files, one line each:docs/OPERATIONS.md (pooling snippet);scripts/{fill_earnings_data,fill_earnings_time,full_audit,get_ibkr_commissions}.py, as os.environ.get("DATABASE_URL") or "<url>";archive/scripts/*.py, as getenv defaults or or "<url>";archive/stocks_earnings/*.py, as a module constant DATABASE_URL = "<url>"..env, the Mac .env and the tracked value share the same sha256 prefix, so it is the live password.doctl databases firewalls list returned no rules (checked again at 15:43 UTC).5715748 (Sep 26 17:20 UTC):resolution_sniper.py, polymarket_sniper/resolution_sniper.py) defaulted TELEGRAM_BOT_TOKEN to a bot token (a different bot from prod's; the bot ids differ) and TELEGRAM_CHAT_ID to the prod chat id, plus 8 vendor-key defaults (ODDS_API_KEY, API_FOOTBALL_KEY, SERPAPI_KEY, NEWSAPI_KEY, FINNHUB_KEY, FMP_KEY, ALPHA_VANTAGE_KEY, ALPHAVANTAGE_API_KEY);docs/archive/POLYMARKET_KNOWLEDGE.md quoted the same bot token and chat id;scripts/earnings_timing_5y_api_study.py fell back to the same Alpha Vantage key;scripts/fill_earnings_data.py and scripts/fill_earnings_time.py defaulted FINNHUB_API_KEY to the live prod key;90ba0a59ae1b, not prod's) was quoted in docs/CODE_AUDIT_RECONCILIATION_2026_08.md, this file's #399 entry (2 lines) and the #399 test.getenv(..., default=)").getenv default. #399's T1.6 guard covered one Polygon literal in 5 named files.polymarket-dashboard listens on 0.0.0.0:8080 without auth (#467), and /docs/operations rendered docs/OPERATIONS.md, including the full DATABASE_URL.docs/reliability/incident-2026-09-db-credential-exposure.md, Sep 26 17:48–18:15 UTC) found two harvests of /docs/operations by Oracle-Cloud (Singapore) scanners:161.118.222.26, Sep 14 10:06–10:07 UTC, which then requested the live URL back as a path;213.35.108.195, Sep 26 02:20 UTC.pg_stat_statements, no commit timestamps and no retained session history./docs/* stays public by decision, and #467 adds a dashboard response-redaction guard on its own branch. This finding's denylist test is the repo-side half of that control.from src.db.connection import get_database_url; db_url = get_database_url(); sys.exit(...) when it is missing. The helper reads the environment first, then [hidden]/.env, so prod runs without an export still work.archive/ files: os.environ["DATABASE_URL"], adding import os to the 3 files that lacked it. pytest only collects tests/ (pytest.ini testpaths), so these modules are never imported by the suite.docs/OPERATIONS.md: the pooling snippet reads os.environ["DATABASE_URL"] after set -a && . ./.env && set +a. §2 "Database Connection" now carries the access and credential rules: trusted sources, SSH-only access, the +pg8000 strip for psql, the private-hostname caveat, .env-only storage and the restart list for a password change.os.environ.get("<NAME>", ""). The Telegram sender already skips when the token or chat id is empty (resolution_sniper.py:1262); the sniper is T2.2 ON-HOLD with no unit.docs/archive/POLYMARKET_KNOWLEDGE.md: line 643 (bot token and chat id) and lines 591, 593, 594, 600, 601 and 607 (the Finnhub, Alpha Vantage, FMP, SerpAPI and NewsAPI keys, plus a second Odds API key, sha f06f043b2317, not the sniper's) are replaced by <redacted, #457>, as is line 636 (the Polygon S3 access key id; its secret was already redacted).aa67462) redacted only line 643, although its commit and this entry said the doc was redacted. Review P2-B1 caught the six key cells.scripts/earnings_timing_5y_api_study.py:441-443: or "", and --event-source alpha without a key exits with a message.scripts/fill_earnings_data.py:20-22, scripts/fill_earnings_time.py:19-21: os.environ.get("FINNHUB_API_KEY", "") and sys.exit(...) when empty.<burned key, sha256 90ba0a59ae1b> in the audit doc and the #399 entry (a redaction of a secret, the one edit this append-only file allows beyond markers). The #399 test compares 32-char tokens by sha256 prefix instead of holding the literal.doctl databases firewalls append b28d3f40-480e-4931-a38d-68c7ef428546 --rule ip_addr:[hidden], rule UUID 46fb1ec6-692a-4722-9bc2-0d0d57c7e9fd.pg_stat_activity sample (15:47:01–16:02:36 UTC, 182 samples every 5 s, 0 errors) showed [hidden] / doadmin as the only non-NULL client address, with up to 9 concurrent sessions._doadmin_monitor system-stats, pg_cron, Timescale workers, background processes). Trusted sources do not apply to them.ss mapped every :25060 socket to one of the 7 prod DB services.:25060 socket, no crontab or LaunchAgent references the DB, and the 3 DB commands in shell history all run over SSH. The only Mac file holding the URL is the checkout's gitignored .env. The C-Q2 extracts use ssh … psql.psql session and a fresh src.db.connection.get_engine() session from the droplet both connect.:25060 sockets held, and 0 DB-error journal lines since 16:06:17.price_snapshots and orderbook_snapshots each received 321 rows between 16:06:17 and 16:08:14.xvfb and x11vnc unchanged; :4001 listening; trader effective_trading_state=ready; integrity 82 ok / 3 warn / 0 fail.nc to :25060 now fails (rc=1). It succeeded in the Sep 25 RCA.[hidden]/.env at process start: systemd EnvironmentFile= (dashboard, integrity, live-trades, websocket, trade-reconcile), source .env in ExecStart (timers), pydantic env_file=".env" (src/config/settings.py), the entrypoints' own .env loaders (run_earnings_trader.py, continuous_collector.py, conviction_tracker.py, backfill_trades.py, deploy/backup_database.py), or the .env fallback in src/db/connection.py:get_database_url().PYTHONPATH=. standing in for the prod sys.path insert:.env: scripts/full_audit.py exits with DATABASE_URL is not set and [hidden]/.env has none;:4001 there);.env only: get_database_url() returns the .env value.scripts/fill_earnings_data.py:26-30, scripts/fill_earnings_time.py:24-28, scripts/full_audit.py:24-28, scripts/get_ibkr_commissions.py:20-24;resolution_sniper.py:1235-1236 (Telegram), :3008-3021 (vendor keys), :5555, identical in the polymarket_sniper/ copy; the study script :441-443; the Finnhub lines above;os.environ["DATABASE_URL"] line in each archive/ file;docs/OPERATIONS.md §2 "Database Connection" and the "Database Connection Pooling" snippet.AVNS_… token, on any *.ondigitalocean.com Postgres URL with a non-placeholder password, and on any DATABASE_URL default URL carrying credentials in a tracked .py file.<8-10 digit id>:<35 chars>) in any tracked file;.py that reads a credential-named env var (*KEY*, *TOKEN*, *SECRET*, *PASS*, *CHAT_ID*) with a non-empty literal default or or "<literal>" fallback;[A-Za-z0-9_:-]{16,} run is hashed, plus its :-parts and any bot-token substring.psql, which now fails by design; use SSH.(5) Test tests/unit/test_finding_457_no_live_db_url.py (44 tests):
DATABASE_URL default, all over git ls-files;getenv default and no burned Polygon key anywhere in git ls-files; the 10 sniper names read ""; both sniper copies identical; the study script and the fill scripts need the env key; the archive row is redacted;bot{token} URL template, timestamps, empty defaults and non-credential defaults (TIMEOUT_SEC, IBKR_HOST) do not;getenv default, a markdown table cell, a Postgres URL user:pw@, a bot<token>/sendMessage URL, a Bot Token: cell and a 64-char NAME=value. Hashes, ids and prose do not fire.Mutation checks: against origin/master (facb218) the fix (1) tests fail 23 of 25; against 5715748 (before fix 2) the 9 fix (2) repo checks fail. The denylist finds all 13 values at facb218 (20 files for the DB password) and exactly the 7 archive-doc cells at aa67462, the P2-B1 miss. The detector self-tests and the sniper-copies-identical check pass on both, as intended. test_finding_399_recon_polygon_timing_priority.py passes with the hash comparison.
- §E:
- Every tracked Postgres URL with a password was classified (36 occurrences): the 20 live ones fixed here; placeholders (PASSWORD, password, pass, test, ${DB_PASS}); and *.example / localhost test fixtures.
- src/data_tracker/models/database.py keeps its credential-free postgresql://localhost:5432/polymarket_tracker fallback. It holds no secret, and T1.6 concerns credentials.
- Fix (2) sweep: every credential-named getenv/environ.get with a literal default in tracked Python (23 hits: 10 per sniper copy, the study script, the 2 fill scripts) is fixed.
- Correction (review P2-B1): the first-pass claim that the scan of literal *KEY*/*TOKEN*/*SECRET* assignments and ?apikey=/token= query strings "found none" was a same-pattern miss (memory.md NO SHORTCUTS #5). The scan looked only for NAME = "value" and query shapes, so it could not see bare values in markdown table cells; docs/archive/POLYMARKET_KNOWLEDGE.md still held six vendor keys. They are redacted now. The denylist makes this class a test, across every file type, instead of a one-off scan.
- Of the 14 Mac .env values, only public ones (POLYGON_RPC_URL, FUNDER_ADDRESS) and the burned Polygon key appeared in tracked files.
- Manual-run recipe: the fill_earnings_* exit messages, OPERATIONS §2 and the memory.md quick-ref now say set -a; . [hidden]/.env; set +a (exports every key; review P2-m1). The 41 older recipes that export named variables are correct for their own commands and are dated history or per-command runbook lines (FINDINGS 9, HANDOFF 10, OPERATIONS 9, CONTEXT 2, STRATEGY_ANALYSIS_ARCHITECTURE 4, etfstack RUNBOOK 4, DATA_ARCHITECTURE 1, a1 report 1, memory.md 1, now changed); they are left as written.
- memory.md §F T1.6 now cites the tripwire and adds "no credentials in any tracked doc; the dashboard serves docs/*" (review P2-m2).
- Revocation stays with the operator (OD8 step 4): BotFather for the leftover bot, the vendor accounts, and the prod Finnhub key found here.
- docs/OPERATIONS.md:115 and the other placeholders are unchanged.
- Acceptance:
- Repo: git grep -E 'AVNS_[A-Za-z0-9_-]{8,}' over HEAD returns 0 files, and the test passes with 0 bot tokens, 0 credential defaults, 0 burned-key hits and 0 denylist hits (all true on the branch, and on 614a29d: 0 AVNS_ files in HEAD; tripwire 44 passed).
- Ops: the firewall lists only the droplet (true); all services connect after the reset with the new password (met: RESET COMPLETE 20:49:21 UTC, and after deploy #8 every app session is attributed from the droplet — #479 Post-deploy); the scrubbed scripts run on prod after the deploy (UNVERIFIED: not run — they are manual one-offs, full_audit.py and get_ibkr_commissions.py connect to IBKR and the fill_earnings_* scripts write to the DB, both outside the deploy's no-orders / no-IBKR rules).
- Post-deploy (21:02 UTC): / and all 27 dashboard /docs/* pages return HTTP 200 with 0 occurrences of any of the 15 current .env secret values (including the DB password) and 0 known-leak shape hits.
- Changelog: none (§O no: no live trading behaviour changes).
- Files: 4 scripts/*.py, 4 archive/scripts/*.py, 11 archive/stocks_earnings/*.py, docs/OPERATIONS.md, tests/unit/test_finding_457_no_live_db_url.py (NEW), docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry. Fix (2): resolution_sniper.py, polymarket_sniper/resolution_sniper.py, scripts/earnings_timing_5y_api_study.py, docs/archive/POLYMARKET_KNOWLEDGE.md, docs/CODE_AUDIT_RECONCILIATION_2026_08.md, the #399 entry, tests/unit/test_finding_399_recon_polygon_timing_priority.py. Review fixes: .cursor/memory.md (§F T1.6 and the quick-ref env recipe).
ibkr-monitor never delivered a page in the life of its process: 80/80 failed Sep 12-25, because the hand-installed unit had no EnvironmentFile, so TELEGRAM_* were unset (the #370 class, an §E miss). It also paged every 5 min under one cooldown key for live and paper. It spawned a client_id=101/102 account probe on system python3, which has no ib_insync; the result was never read. AlertManager counted an unconfigured webhook as a failed send (breaker churn), returned "success" if any channel succeeded, logged a breaker-OPEN skip at DEBUG and a delivery at DEBUG. And there was no dead-man that still pages when Telegram or the host is dead. Does not place or cancel orders. Hardware stops unaffected. Plan #448 + F-9 + OD7; Telegram RCA F1/F3.IMPLEMENTED-PENDING-DEPLOY (reliability Batch B1). The dead-man part is armed only once Marius puts HEALTHCHECKS_PING_KEY in prod .env and creates the four checks (OD7).ibkr-monitor, so no EnvironmentFile, no deploy restart and no review. The Gateway loop had no SLA and no incident model. "Delivered" was never defined, and B2's review found the same gap on the trader side (webhook success counted as Telegram delivery). No external heartbeat existed.deploy/systemd/ibkr-monitor.service: EnvironmentFile=[hidden]/.env, venv python, Restart=always, and deliberately no Requires=/BindsTo=/After= on ibgateway-ibc/xvfb/x11vnc. deploy_from_github.sh installs, enables and restarts it (F-9). A restart failure, or no fresh heartbeat within 90 s, stops the deploy with an ERROR (review R2 Major 4).src/ibkr/gateway_pager.py (GatewayPager) is the only live-Gateway pager, page-only (OD5). Live :4001 closed → page after IBKR_MONITOR_LIVE_SLA_SEC=180 s; only the weekly Sunday 2FA wait gets IBKR_MONITOR_2FA_WAIT_SEC=1200 s (#456). Exactly one outage page and one RESTORED; there is no escalation page (review R2 Major 1), and the duration stays visible in the heartbeat. The text carries the memory.md lever. Paper :4002 has its own key, is tracked in the heartbeat and log, and never pages (its listener closes at the Sunday rotation until the 12:00 reconnect, which has no Telegram by etfstack preference). The window logic is #456.src/ibkr/ibc_log.py: login-pending = the latest auth marker comes after the latest Login has completed and is younger than 20 min (review Major 1).GatewayMonitor is TCP-only on every path (review R2 Major 3): the client_id=101/102 account probe and its include_account_details parameter are deleted, along with the dead _attempt_auto_recovery, _is_off_hours and _is_ibc_smart_restart_sleeping helpers and the old _send_alert (one key for live+paper). The dashboard's passive monitor has no pager.src/earnings/incident_ledger.py, shared with #449: one incident = one page + one resolve; a generation per incident; in-flight / delivered / failed page states; failed pages and resolves retried; process memory kept when the file cannot be written (never silence).live_incident = {generation, opened_at, deadline, page, classification}, so the trader (B2) can tell an outage the monitor owns and has paged, or will page at a stated deadline, from a monitor that is merely alive (B2 R3-3). It also carries ledger_primary_ok / ledger_fallback_ok.last_resolved = None | {generation, resolved_at, resolve} for the most recent closed live incident, resolve being delivered (RESTORED got Telegram HTTP 200), owed (pending or retrying) or dropped (no RESTORED will be sent). It lives in the monitor's ledger, so it survives a monitor restart and stays until the next live incident resolves. B2's OD6 cedes the recovery page to the monitor only on delivered for the generation whose DOWN the monitor delivered; otherwise integrity pages the recovery itself (a late monitor delivery can give a fail-open duplicate).src/ibkr/monitor_heartbeat.py (stdlib only) parse_heartbeat(beat, now=, max_age_sec=) → HeartbeatProof or HeartbeatInvalid. It requires a timezone-aware at no older than the max age and at most 60 s in the future; live_open a real boolean; the live_incident key present; incident and last_resolved fields complete (non-empty string generations, finite times, known page/resolve/classification states); and consistent facts (an open port attests no incident; opened_at ≤ deadline ≤ opened_at + 3600; nothing after the heartbeat itself). Integrity's alerting:ibkr_monitor uses it: anything unparseable or inconsistent is no proof and FAILs ("integrity is the fallback page"). B2 can import the same parser instead of its own schema checks.live_incident and last_resolved is a contradiction and no proof (TestRound5HeartbeatExclusivity; fails on c814593).last_resolved.resolved_at later than the live incident's opened_at is contradictory and no proof (TestRound6HeartbeatOrdering; fails on d862942).src/earnings/alerts.py: NEW DeliveryResult. Delivered = Telegram answered HTTP 200, never a webhook. send/send_alert return it; it is truthy only when delivered, and filtered alerts are filtered=True. A channel without credentials is not attempted and not a breaker failure. A breaker-OPEN skip logs WARNING; a delivery logs INFO (Alert delivered via Telegram), so arrival is provable from the log. B2 (#451/OD6) builds on this definition; send/send_alert accept B2's require_telegram= (a no-op here, because the result is already Telegram-only), and the ledger's public interface is documented as stable in its module docstring.alerting:ibkr_monitor (HIGH) pages when the pager is blind: unit inactive, or heartbeat /var/log/polymarket/ibkr-monitor-heartbeat.json older than 5 min. WARN if its ledger is not persisting (primary and fallback reported separately). Round 3 blocker 4 — stand down only on proof: while :4001 is closed the rule is OK only if live_incident attests the monitor's page delivered, or the page is not yet due (deadline + 60 s, or + 150 s while in_flight). No incident, a malformed one, failed, or an overdue page → FAIL "integrity is the fallback page" with the lever. Sender health overall: alerts:telegram_delivery (#449, dashboard) + alerting:ibkr_monitor + Healthchecks (below).src/healthchecks.py is the single definition. One secret: HEALTHCHECKS_PING_KEY in prod .env (Marius adds it; never logged or printed). URLs https://hc-ping.com/{key}/{slug}. The feature is off and silent when the key is absent (malformed keys too). Integrity config:healthchecks_ping_key is INFO / warn, not Telegram. Four slugs:earnings-trader — heartbeat() from the healthy main loop (run_start + every loop_start), throttled 60 s, gated on process liveness not IBKR readiness. Planned bounces stay inside Healthchecks grace (10 min). Sunday 2FA does not trip it: the loop keeps running while IBKR is disconnected.integrity-monitor — ping() once per completed run_once cycle (5 min, grace 15).prod-host — systemd timer polymarket-healthchecks-host.{service,timer} runs deploy/healthchecks_host_ping.sh → curl -fsS -m 10 --retry 3. Independent of app code. The deploy installs/enables the timer; it does not touch ibgateway-ibc / xvfb / x11vnc.db-backup — backup_database.main pings /start, then success or /fail/{exit_code} with wait=True.
Pings never raise. Send is a daemon thread with a short timeout (default 2 s, GUARD 1–10). Failures log WARNING once per interval, with the key redacted.monitor_23_45 and the missing crontab line retired, with the crontab under version control (#476); disabled channel = not configured; breaker OPEN at WARNING; delivery at INFO; sender health + external dead-man; OPERATIONS 141/152 and IBKR_CONTINUITY 33-46/224-241 fixed. Changed with reason: "account probe on the venv interpreter" became removing the probe from the loop. Its result was never read, and making it work would add a live-Gateway API session every 30 s; the explicit path uses sys.executable. The "Sunday reminder as an integrity rule" stays one managed cron line (#476, operator cron-a direction); the schedule audit is now also an integrity rule (cron:schedule_audit). OD7 arming waits on Marius's key.ibkr-monitor.service → run_gateway_monitor.main → GatewayMonitor(pager=GatewayPager()).start → every 30 s get_status(include_account_details=False) (TCP 4001/4002) → GatewayPager.observe → ibc_log.read_login_state → IncidentLedger.open/set_deadline/page_due → _send_sync → send_alert → DeliveryResult.delivered → mark_page → heartbeat JSON. Integrity → _check_ibkr_monitor. Trader run() → heartbeat(SLUG_EARNINGS_TRADER); integrity run_once → ping(SLUG_INTEGRITY_MONITOR); backup main → ping_start/success/fail; host timer → curl.:99, then one RESTORED; a 1 h outage is still one page. A send that fails (Telegram 5xx, rate-limited, breaker) → page state failed → re-sent next tick. Restart of ibkr-monitor mid-outage → the ledger keeps the delivered page, so no re-page. An unwritable ledger → pages anyway, heartbeat ledger_persist_ok=false, integrity WARN once. Key unset → zero hc-ping traffic.deploy/systemd/ibkr-monitor.service (NEW); run_gateway_monitor.py; src/ibkr/gateway_monitor.py _monitoring_loop/__init__/check_gateway; src/ibkr/gateway_pager.py (NEW); src/ibkr/ibc_log.py (NEW); src/earnings/incident_ledger.py (NEW); src/earnings/alerts.py DeliveryResult/AlertManager.send/_send_channel/send_alert; integrity_monitor.py _check_ibkr_monitor + rule; deploy/deploy_from_github.sh ibkr-monitor block; src/healthchecks.py, deploy/healthchecks_host_ping.sh, deploy/systemd/polymarket-healthchecks-host.*, trading_orchestrator.py run, deploy/backup_database.py.test_finding_456_weekend_autorestart.py TestRound4HeartbeatIsProofOnlyWhenWellFormed, 17 fail on 2ae3f5f): a 20-case malformed/inconsistent matrix (missing/string/null live_open, missing live_incident, far-future/naive/non-string at, empty/numeric generation, string/NaN/early/far deadline, unknown page/classification, future opening, open port with an incident, bad last_resolved state, string ledger flag, non-object) all FAIL; stale rejected, small skew accepted; last_resolved parsed for B2; what the pager writes, the parser accepts.tests/unit/test_finding_448_ibkr_monitor_pager.py (31; the 8 Round 5 cases fail on 839f58e: last_resolved delivered for the same generation, owed on a failed RESTORED until it delivers, nothing attested after monitor death until the restarted monitor delivers, dropped inside the SLA, survives a monitor restart; DOWN text matches the recycle setting unset/true/false; plus the Round 3 page-text assertion) (23 before, incl. B2 require_telegram compatibility and the stable ledger interface; ledger adversarial cases in tests/unit/test_finding_449_incident_ledger.py): weekday drop no page; hang one page + lever + one RESTORED; failed page retried; failed resolve retried; one page for a 1 h outage (no escalation); paper never pages (one log line); ledger write failure still pages; restart does not re-page; old generation never covers a new incident; in-flight blocks then expires; no resolve owed without a delivered page; unit has EnvironmentFile and no Gateway coupling; deploy installs + restarts, and fails without a fresh heartbeat; entrypoint passes the pager; no account probe, clientId, ib_insync, subprocess or recovery helper anywhere in gateway_monitor; dashboard monitor has no pager; webhook success ≠ delivered; unconfigured webhook not attempted and breaker CLOSED; filtered ≠ delivered; breaker OPEN WARNING + delivery INFO. Plus tests/unit/test_finding_448_healthchecks_deadman.py (27) and #456's window tests.gateway_pager only (the cron health monitor is retired, #476; integrity ibkr:weekend_autorestart is replaced, #456). Delivery definition: DeliveryResult for every Python pager. (c) Trader IBKR pages (client.py) are B2 #451, which should defer to ledger key gateway:live when :4001 is down. (a) FINDINGS #436 (auto-recovery off) is history.systemctl cat ibkr-monitor shows EnvironmentFile; the heartbeat is fresh; /api/integrity alerting:ibkr_monitor ok; journalctl -u ibkr-monitor has no Alert delivery FAILED; the next weekday 06:00 AutoRestart sends no Telegram; a real outage gives exactly one page + one RESTORED, and Alert delivered via Telegram is in the journal. With the key set: the four checks are up, and a backup failure records /fail/{rc}.integrity entry.deploy/systemd/ibkr-monitor.service (NEW), run_gateway_monitor.py, src/ibkr/gateway_monitor.py, src/ibkr/gateway_pager.py (NEW), src/ibkr/ibc_log.py (NEW), src/earnings/incident_ledger.py (NEW), src/earnings/alerts.py, src/healthchecks.py (NEW), src/earnings/trading_orchestrator.py, src/data_tracker/integrity_monitor.py, deploy/backup_database.py, deploy/healthchecks_host_ping.sh (NEW), deploy/systemd/polymarket-healthchecks-host.service (NEW), deploy/systemd/polymarket-healthchecks-host.timer (NEW), deploy/deploy_from_github.sh, deploy/install_services.sh, .env.example, tests/conftest.py, tests/unit/test_finding_448_healthchecks_deadman.py (NEW), tests/unit/test_finding_448_ibkr_monitor_pager.py (NEW), src/earnings/strategy_changelog.py, docs/STRATEGY.md, docs/IBKR_CONTINUITY.md, docs/OPERATIONS.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.default_transaction_read_only and mass-killed sessions for 16 h at ~92.5% of the 120 GiB plan (105.1 GiB of databases, ~111 GiB used). No integrity rule looked at any of it (register rows 7-10, 16, 32). Does not place or cancel orders. Hardware stops unaffected. Plan #446; data-path RCA §7.IMPLEMENTED-PENDING-DEPLOY (reliability Batch B1).SHOW default_transaction_read_only, pg_stat_database.sessions_killed/temp_bytes, cluster size vs the plan, open-transaction age, or btc5m size. The intraday resolver and the dashboard's own DB errors were unmonitored, and so were the dashboard's API consumers. The integrity cycle itself fetched and wrote integrity_status first, so an unreachable or read-only DB stopped every page.hypertable_size, no scans):db:read_only CRITICAL — FAIL when SHOW default_transaction_read_only is on.db:sessions_killed HIGH — WARN +1 / FAIL +10 per cycle. Review fix (blocker 5): with no valid previous sample (first deploy cycle, missing or corrupt detail) or after a stats reset (the counter went down), the current value is a baseline and the delta is 0. Prod has sessions_killed=1818, which on e94dd0d would have paged +1818 on the first healthy cycle.db:disk_plan HIGH — WARN 80% / FAIL 90% of PG_DISK_PLAN_GIB=120. Review fix (blocker 6): usage = SUM(pg_database_size) over every connectable database + the WAL reserve MAX(max_wal_size, wal_keep_size) from pg_settings. pg_ls_waldir() is denied to doadmin (probed Sep 26), and prod has max_wal_size 6015 MB / wal_keep_size 6014 MB. Sep 25 shape: 105.1 GiB + 5.87 GiB = 111 GiB = 92.5% → FAIL (the e94dd0d current-database-only number was 87.6% → WARN). Today: 46.52 + 5.87 GiB = 43.7%.db:temp_spill HIGH — FAIL at ≥4 GiB of temp files in one cycle (PG_TEMP_SPILL_FAIL_GIB; Sep 25 CTE 5.2 GiB/pass; post-#445 ~11 MB). Counter baseline as above.db:long_transaction HIGH — FAIL when a client transaction has been open ≥10 min (PG_LONG_XACT_FAIL_MIN, GUARD 2–13). That is below the Sep 25 ~14-min stats pass (review R2 Major 5) and far above the #445 dashboard contract (one transaction per statement, 60 s timeout). PG_LONG_XACT_EXEMPT_APPS (default pg_dump,pghoard: the daily backup runs ~13 min, and pghoard is DO's WAL shipper) exempts verified applications by name.storage:btc5m HIGH — btc5m schema (plain tables + hypertables) ≥15 GiB (PG_BTC5M_MAX_GIB); 0.00 GiB today after the Sep 25 archive.polymarket-resolver-intraday.timer (13 h staleness; its longest gap is 18:45 → 06:15 ET) and .service (last run) are added to TIMERS/BATCH (row 16).dashboard:api_reachable HIGH, 2 cycles — /api/health must answer 200; consumers (etfstack-signal, leverage validation, paper reconnect) degrade silently when it does not (row 32).dashboard:db_errors HIGH, 2 cycles — ≥5 DB-error lines (statement timeout / 57014 / 25006 / 57P01 / read-only / OperationalError) in the last 15 min of the dashboard journal (row 8). Prod Sep 26 before #445 deploy #2: one every ~2 min; since 11:48 UTC: 0.SELECT 1 first. If the DB is unreachable, DB-category rules are UNKNOWN without querying, systemd/artifact/meta rules still evaluate and page, and integrity:meta:db_unreachable pages CRITICAL after 2 cycles. integrity_status writes are best-effort, so a read-only latch no longer blocks the db:read_only page. create_engine(connect_timeout=10) makes an unreachable cluster fail fast.
GUARD_REGISTRY bounds all nine knobs (raising FAIL_PCT past 92 or PLAN_GIB past 130 is a weakening; PG_TEMP_SPILL_FAIL_GIB 1–8, PG_LONG_XACT_FAIL_MIN 5–60, PG_BTC5M_MAX_GIB 1–40, DASHBOARD_DB_ERRORS_FAIL 1–30).run_once → _db_reachable → each evaluator (_check_db_read_only / _check_db_sessions_killed → _prev_rule_detail + _counter_delta / _check_db_disk_plan (_DISK_SQL) / _check_db_temp_spill / _check_db_long_transaction / _check_btc5m_storage / _check_dashboard_reachable / _check_dashboard_db_errors) → best-effort _upsert_rule_status → ledger _track → _dispatch (#449).integrity_monitor.py _prev_rule_detail, _counter_delta, the eight evaluators, _DISK_SQL, the _build_rules #446 block, TIMERS/BATCH intraday entries, IntegrityMonitor.run_once/_db_reachable/_track_db_blind/_send_blind_alert, main connect_timeout.COUNT(*); never page a cumulative counter's first sample; pg_dump is not a long-transaction incident; the dashboard rules need 2 cycles (planned restarts); do not bounce the Gateway.tests/unit/test_finding_446_db_health.py (28): RO off/on; sessions first cycle, first cycle at 1818 (fails on e94dd0d), missing counter, stats reset, +1 warn, +36 fail; disk SQL counts all DBs + WAL (fails on e94dd0d); today 43.7% OK; Sep 25 105.1 GiB → 92.5% FAIL (fails on e94dd0d); 80% WARN; temp spill baseline / 5.2 GiB fail / 12 MB ok; long xact none / the Sep 25 14-min shape FAILS (fails on 3a6555d) / 9 min ok / exempt apps passed as a parameter; btc5m ok/fail; dashboard reachable/down; journal errors 7 → FAIL, quiet OK, no journalctl UNKNOWN; rules wired + hysteresis + intraday timer/service. Monitor-blind: tests/unit/test_finding_449_telegram_delivery.py::test_db_unreachable_pages_monitor_blind_once, ::test_read_only_db_still_pages_db_read_only.storage:<hypertable> rules stay table-level; systemd:polymarket-dashboard.service stays the unit check. (a) none earlier./api/integrity shows db:read_only ok, db:sessions_killed ok (baseline), db:disk_plan ~44%, db:temp_spill/db:long_transaction/storage:btc5m/dashboard:* ok, and the intraday resolver rows present. No Telegram from these on a healthy cluster.integrity entry.src/data_tracker/integrity_monitor.py, tests/unit/test_finding_446_db_health.py (NEW), tests/unit/test_finding_449_telegram_delivery.py, .env.example, src/earnings/strategy_changelog.py, docs/OPERATIONS.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.pg_dump: er truncation) (Sep 26, 2026)polymarket-backup failed during the read-only incident and the only recorded error was pg_dump: er. Without the rest of the line we could not tell kill vs ENOSPC vs auth. Does not place or cancel orders. Hardware stops unaffected. Reliability plan P1-backup / Batch B1. Tracked from Finding #445's incident record.IMPLEMENTED-PENDING-DEPLOY (reliability Batch B1).create_backup set stderr=PIPE and drained it only after stdout EOF (communicate after the gzip loop). A session-killed or blocked dump left a partial log line. The Python print also sliced stderr[:1000]. systemd append: to backup.log then showed eleven characters.deploy/backup_pgdump.py streams stdout (still gzip, still O(chunk) memory — #260) while a daemon thread drains stderr. The full stderr is fsync'd to /var/log/polymarket/backup-last-pgdump.err (BACKUP_PGDUMP_ERR_PATH). The log line is a 400-char preview plus the sidecar path and byte count. One retry after 5 s when stderr looks like a killed/read-only session (57P01 / read-only / connection reset); usage errors do not retry. Review fix (Major 5, e94dd0d review): the sidecar is diagnostics only. persist_stderr_sidecar catches OSError and prints a WARNING, and the child's return code stays authoritative. Before this, a sidecar write failure after rc=0 was raised into the retry wrapper, which deleted and rejected a valid dump.pre_restore / post_restore) and one restore drill onto a new DO instance. Owner: agent + Marius, who creates the instance. Acceptance: row counts for trading_positions, trade_audit_log and earnings_markets match prod at dump time, hypertable chunk counts are listed, and the runbook lands in docs/POSTGRES_OPERATIONS.md. OD10 (offsite dumps) stays deferred.backup_database.create_backup → stream_pg_dump_with_retry → Popen(stdout=PIPE, stderr=PIPE) → drain thread + gzip stdout → persist_stderr_sidecar → write_stderr_sidecar (replace + fsync; an OSError warns only) → print format_pg_dump_failure → #448 /fail if run_backup returns non-zero./var/log, full disk) returns rc=0 on attempt 1, and the dump is kept.deploy/backup_pgdump.py (NEW: persist_stderr_sidecar, stream_pg_dump, stream_pg_dump_with_retry); deploy/backup_database.py create_backup.communicate() the dump into RAM (#260). Do not log PGPASSWORD. Sidecar cap 1 MiB (errors, not a second dump). A diagnostics failure must never turn a good dump into a failed backup. Deploy does not bounce the Gateway.tests/unit/test_finding_447_backup_stderr.py: sidecar is the full error, not 11 chars; format includes path + byte count; concurrent drain of 80 KB stderr; retry once on read-only then OK; no retry on usage; create_backup source has no [:1000] and no inline stderr=PIPE; rc=0 with a raising sidecar writer returns rc=0 after 1 attempt with the dump kept; rc=0 with a real unwritable path returns rc=0 (both fail on e94dd0d with OSError).#448 /fail path is unchanged. (a) #260 streaming stdout kept.pg_dump: error: … (not pg_dump: er); systemd_last_run:polymarket-backup.service still FAIL on non-zero; Healthchecks /fail/{rc} still fires.integrity entry.deploy/backup_pgdump.py (NEW), deploy/backup_database.py, tests/unit/test_finding_447_backup_stderr.py (NEW), src/earnings/strategy_changelog.py, docs/OPERATIONS.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.get_high_conviction_markets() spilled one ~11.3 MB incremental sort per stats pass (≈ 1.2 GB temp writes/hour; 281 ms) on the same class of unbounded-sort pressure as the Sep 25 #445 incident. Does not place or cancel orders. Hardware stops unaffected. Reliability plan P1-f10 / Batch A close-out F-10.IMPLEMENTED-PENDING-DEPLOY (reliability Batch B1).SELECT DISTINCT ON (p.condition_id) … FROM price_snapshots p JOIN markets m … WHERE open Earnings AND p.time > 7 days ORDER BY p.condition_id, p.time DESC sorts every in-window snapshot for every open Earnings market, then keeps one row. The dashboard stats loop ran it often enough to write ~1.2 GB temp files/hour.FROM markets m JOIN LATERAL (SELECT … FROM price_snapshots p WHERE p.condition_id = m.condition_id AND p.time > _HT_RECENT ORDER BY p.time DESC LIMIT 1) lp. Nested-loop index lookup; no DISTINCT ON sort. Filters (Earnings, open, conviction band, LIMIT 15, 7-day bound) unchanged./api/high-conviction → get_high_conviction_markets → LATERAL LIMIT 1 on price_snapshots per open Earnings market → map to JSON. _HT_RECENT is still NOW() - INTERVAL '7 days' (#445).(condition_id, time) instead of sorting the joined 7-day set. Result set still ≤15 rows. Empty engine → [].earnings_dashboard/app.py get_high_conviction_markets.tests/unit/test_finding_471_high_conviction_lateral.py: SQL body has JOIN LATERAL + LIMIT 1 + _HT_RECENT and no DISTINCT ON; open-Earnings + conviction filters remain; same LATERAL shape as the #445 active-earnings lookup.get_high_conviction_markets is the only DISTINCT ON over price_snapshots for this panel; active-earnings already LATERAL. (c) other dashboard hypertables stay under #445. (a) none — first change to this query since the 7-day bound.EXPLAIN of the live statement shows no incremental sort on price_snapshots; #445 bounded-SQL suite still green.reporting entry.earnings_dashboard/app.py, tests/unit/test_finding_471_high_conviction_lateral.py (NEW), src/earnings/strategy_changelog.py, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.ibkr_reality treated a healthy ~60 s IBKR snapshot as stale after 10 s (Sep 26, 2026)/api/state ibkr_reality.ibkr_connected=False + ibkr_snapshot_stale=True while the trader is ready and the snapshot file is fresh with connected: True. Does not place or cancel orders. Hardware stops unaffected. Found by the #468 deploy verification Sat 26 Sep 2026 15:18 UTC (file age 43.4 s). The handover "ibkr_reality lags after a bounce" is the same leftover: a force-write after reconnect is fresh, then the weekend/holiday loop idles 60 s and the overlay paints disconnected at 10 s.IMPLEMENTED-PENDING-DEPLOY (reliability Batch B1; predates nothing in Batch A).logs/ibkr_snapshot.json at most once per write_interval_sec() (IBKR_SNAPSHOT_INTERVAL_SEC=10, floored to 60 s outside 09:00–20:00 ET) and only once per main-loop iteration. On a non-trading day the loop idles 60 s (is_market_holiday → MarketCalendar.is_trading_day False), so cadence = max(write_interval, loop_idle) = 60 s even at 11:18 ET Saturday when the off-hours write floor does not apply. The dashboard's get_cached_ibkr_snapshot set stale when age > IBKR_SNAPSHOT_INTERVAL_SEC (10 s). get_ibkr_reality_overlay then forced ibkr_connected=False. Finding #422 already stopped the Trader Session header from using that 10 s TTL; the overlay was the leftover twin (§M).src/earnings/ibkr_snapshot.py is the single definition. write_interval_sec / trading_loop_idle_sec / nontrading_loop_idle_sec / expected_cadence_sec / stale_after_sec / is_stale. Stale = age > cadence × IBKR_SNAPSHOT_STALE_SLACK_MULT (default 2; GUARD 1–6 so raising it cannot hide a dead writer). Dashboard snapshot["stale"] and _is_ibkr_snapshot_fresh import is_stale. Orchestrator write gate and loop idles call the same helpers (the previous inline hour < 9 or hour >= 20 / idle(60) copies are gone). In-memory dashboard re-read of the file is IBKR_SNAPSHOT_DASHBOARD_CACHE_SEC=2, not the stale threshold. Integrity artifact:ibkr_snapshot stays 5 min (HIGH page for a dead writer — a different consumer).
Review fix (Major 3, e94dd0d review): is_trading_day converts now to ET before MarketCalendar.is_trading_day, which reads dt.date(). The trader's loop decides the day on now_et, so a UTC date would name tomorrow once 20:00 EDT / 19:00 EST passes. Example: Sun Jan 3, 2027 19:30 EST = Mon 00:30 UTC was classed as a trading day (cadence 10 s, stale 20 s) while the trader idled 60 s, which is the false disconnect again._write_ibkr_snapshot_if_due → write_interval_sec(now) → maybe skip → _build_ibkr_snapshot → atomic write. Holiday/weekend continue after nontrading_loop_idle_sec() (60 s); trading days trading_loop_idle_sec() (1 s). Dashboard /api/state → get_ibkr_reality_overlay → get_cached_ibkr_snapshot → age from fetched_at → ibkr_snapshot.is_stale(age, now) → snapshot["stale"] → overlay ibkr_connected / ibkr_snapshot_stale. Jinja header ibkr_connected is still trader session_state (#422), not this overlay flag.connected: True is fresh; overlay ibkr_connected=True. A 300 s file is stale; overlay disconnected. Weekday RTH: cadence=10, stale_after=20 (11 s fresh, 21 s stale) — not a blanket 60 s TTL. Sunday 19:30 EST / 20:30 EDT (Monday by UTC) and a holiday evening stay non-trading days (cadence 60, a 43 s file is fresh); Friday 21:00 EDT (Saturday by UTC) and Thanksgiving eve stay trading days. After a bounce the force-write is fresh until cadence×2, so the overlay no longer flips disconnected 10 s into the 60 s idle.src/earnings/ibkr_snapshot.py (NEW); src/earnings/config.py cadence knobs + bounds; src/data_tracker/integrity_monitor.py GUARD_REGISTRY IBKR_SNAPSHOT_STALE_SLACK_MULT / IBKR_SNAPSHOT_NONTRADING_LOOP_IDLE_SEC; trading_orchestrator.py _write_ibkr_snapshot_if_due + holiday/loop idle; earnings_dashboard/app.py get_cached_ibkr_snapshot / _ibkr_snapshot_age_is_stale / _is_ibkr_snapshot_fresh / overlay comment.STALE_SLACK_MULT above 6 fails GUARD. Naive datetimes are rejected. Disk-cache 2 s can serve one extra overlay tick of an already-stale file; it cannot mark a 43 s weekend file stale.tests/unit/test_finding_472_ibkr_snapshot_cadence.py (17 tests): weekend cadence 60 / stale_after 120; RTH cadence 10 / stale_after 20; off-hours write floor 60; Saturday 11 s and 43.4 s fresh, 121 s stale; Friday RTH 11 s fresh, 21 s stale (proves not a widened constant); missing age stale; naive datetime rejected; GUARD keys present; orchestrator write gate and loop idles call the helpers; overlay 43 s weekend connected + 11 s fresh + 300 s disconnected; dashboard source imports is_stale and does not compare age to INTERVAL_SEC. TestEtTradingDayRollover (5): Sunday evening EST and EDT, Friday evening, the evening before and the evening of Thanksgiving; all five fail on e94dd0d (each crosses a UTC date that names a different trading-day answer).ibkr_snapshot.py (SSOT); orchestrator write + loop idle; dashboard stale / overlay / _is_ibkr_snapshot_fresh; OPERATIONS snapshot-source + §8.D. (c) integrity artifact:ibkr_snapshot 300 s mtime — dead-writer HIGH page, not overlay; IBKR_SNAPSHOT_DASHBOARD_CACHE_SEC 2 s — file re-read. (a) historical 10 s TTL: FINDINGS #422, changelog #422, frozen packs IBKR_VPS_OPERATING_MANUAL "about every 10s", DATABASE_AUDIT_2026_04_27.md. No other live age > IBKR_SNAPSHOT_INTERVAL_SEC / age > _IBKR_CACHE_TTL stale compare.ready and snapshot connected: True aged ~40 s on a weekend: curl /api/state | jq .ibkr_reality shows ibkr_connected=true, ibkr_snapshot_stale=false. A file older than cadence×2 stays stale.reporting entry.src/earnings/ibkr_snapshot.py (NEW), src/earnings/config.py, src/earnings/trading_orchestrator.py, earnings_dashboard/app.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_472_ibkr_snapshot_cadence.py (NEW), tests/unit/test_config.py, tests/unit/test_dashboard_connectivity_state.py, src/earnings/strategy_changelog.py, docs/OPERATIONS.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.Gateway dialog, with no 2FA push and :4001 down, for 1 h 55 and 3 h 23 with no page (register row 21). Does not place or cancel orders. Hardware stops unaffected. Plan #456; IBKR RCA §C; OD5 (Sat 06:49: page and wait, no automatic Gateway restart).IMPLEMENTED-PENDING-DEPLOY (reliability Batch B1). Arm before Sat 3 Oct 06:00: it is armed by the B1 deploy (ibkr-monitor restart).e94dd0d) watched Saturday only and returned OK for all of Sunday and every weekday (review Major 2).ibkr-monitor's GatewayPager (#448), every day, every 30 s. It never restarts anything.src/ibkr/ibc_log.py): pending = the latest Second Factor Authentication initiated / Login attempt: line comes after the latest Login has completed and is younger than 20 min. The newest IBC log keeps growing across in-process AutoRestarts: Saturday's Login has completed is at line 577 of 595 of …_Friday.txt (probed Sep 26), so Sunday's 2FA lands in the same tail. The old "both strings in the last 120 lines" test called that "not pending" and paged DOWN.:4001 closed during the weekly login: the incident gets the 20-min 2FA grace and is latched sunday_2fa (ledger set_detail) until login or 18:00 UTC, so the page at 20 min says "2FA NOT APPROVED" even though the marker has aged past the parser's 20-min recency (review R2 blocker 2). The deadline is re-timed when the prompt appears a few seconds after the port closes.gateway_pager._weekly_2fa_evidence requires all of:auth_pending);Re-starting session line (IBC's scheduled AutoRestart) inside Sun 06:00–06:30 UTC;Second Factor Authentication initiated line (IbcLoginState.last_2fa_at; a bare Login attempt: does not count) at or after that restart and within 600 s of it;Login has completed at or after the restart;Login attempt:, a crash with its own 2FA but no scheduled restart line, a crash after the weekly login already completed, and any Sunday crash later in the day (B2 R3-3) all page at 180 s.Detected: what the monitor saw (port closed N s; the IBC state);Automatic: what the bot does on its own: the trader keeps retrying and attaches the moment :4001 is back (#441). Its last-resort recycle (#438) only acts while :4001 is up and the request path is dead, so it cannot fix a closed port;Fallback if it does not come back: systemctl restart ibgateway-ibc → approve the IBKR push on the phone (#430) → the trader attaches itself.:99 (import -display :99 -window root, no input sent) into /var/log/polymarket/ibkr-screens/.ibkr:weekend_autorestart (the e94dd0d Saturday-only rule) is removed. Integrity alerting:ibkr_monitor pages if this watchdog is blind.IBKR_GATEWAY_AUTO_RECYCLE defaults to true again (gateway_recycle._auto_recycle_enabled, Config), as before B1. GUARD_REGISTRY is back to the #438 form: false needs GUARD-OVERRIDE-UNTIL; enabling needs no override. (Round 2 had defaulted it false with true as a dated exception; that is reverted.)IB() reset first; recycle only after the reset failed; Gateway age ≥ 3600 s; IBKR_RECYCLE_COOLDOWN_SEC (7 days) and one per Sunday week; never Sunday 06:00–18:00 UTC; never while IBC is waiting for a 2FA approval (ibc_auth_in_flight), so it never recycles into the weekly 2FA wait.db_open_position_count (late-login watcher thread only) has connect_timeout=5 and statement_timeout=5000.IBKRClient._maybe_standing_recycle keeps the attempt latched only when recycle_gateway_if_flat restarted the Gateway (ok) or armed the late-login watch (pending_late_login). A deferral, a refusal, a failed systemctl restart or an exception clears _standing_recycle_attempted and _recovery_exhausted, keeps probing, and allows another attempt after a bounded backoff (5 min, doubling, max 1 h). The #439 stamp (budget and cooldown) is written only after a successful restart, unchanged.recycle_defer_reason defers when the Gateway age is unreadable, the recycle stamp exists but is unreadable/malformed/impossible (a missing stamp file is the only "never recycled"), or the IBC login state cannot be read (ibc_auth_in_flight(strict=True); no log, an I/O error or an exception). Why: every restart is a fresh login that needs his phone, so a restart the guards cannot justify trades a dead request path for a 2FA that may go unapproved. Deferring keeps the trader process up and probing the same session (priority 1) and pages as information; it does not add a dependency on him (priority 2), whereas a blind restart would.recycle_gateway_if_flat durably writes the #439 stamp (state: reserved; write + fsync + rename + directory fsync, 5 s deadline) BEFORE systemctl restart ibgateway-ibc. If it cannot be written in time, there is no restart: skipped=True, "weekly budget not provably recorded (fail closed)", and the client pages the deferral. A systemctl timeout, exception or non-zero exit is ambiguous: the reservation stays and the result carries may_have_restarted=True, which the client treats as consumed (no re-arm, no retry). Only a definitive never-ran (systemctl not found / not permitted) releases it (previous stamp restored). A crash anywhere after the reservation leaves the stamp, so the week budget and 7-day cooldown block a second restart. After a clean restart the stamp becomes state: restarted. A process lock stops two threads restarting at once. Net: never a second 2FA prompt inside the budget, even if the stamp or systemctl misbehaves (Marius's priority 2).recycle_lock() is an exclusive cross-process flock(LOCK_EX|LOCK_NB) on logs/ibkr_last_recycle.json.lock (plus a thread lock), taken BEFORE the decisive guard check and held through reservation, systemctl restart and the outcome record; a second thread or process defers (skipped, "another recycle holds the budget lock"), an unusable lock fails closed, a dead holder's lock is dropped by the kernel. Budget writes happen only under it: the slow write + fsync goes to a claim-named temp file in a worker thread with a 5 s deadline; only the lock holder renames it into place and fsyncs the directory. A timed-out writer's temp file is abandoned and never renamed, so a writer that resumes later cannot overwrite newer state. finalize_recycle_budget records restarted only if the stamp still carries this claim. A directory-fsync failure after the rename durably restores the previous stamp (or, if that fails too, keeps the budget and says so: no restart). After systemctl was invoked every result carries may_have_restarted=True, including a failed trader bounce (R6 major 2). A reserved stamp that never became restarted is never auto-released; the deferral says so and names the manual lever.ibkr-monitor, integrity) stay page-only; the trader owns the recycle.systemctl restart ibgateway-ibc, by the trader's last resort or by hand, is a fresh IBKR login and needs a 2FA approval on the phone (#430); there is no weekday restart that logs in on its own. Only IBC's in-process 06:00 AutoRestart keeps the session (Mon–Sat, no 2FA); the Sunday one asks for 2FA by design. So the automatic recycle trades a dead request path for a 2FA prompt that the monitor pages about ("2FA NOT APPROVED" at 20 min if nobody approves).:4001 (Gateway hung or crashed) has no automatic recovery: the trader only recycles while :4001 is up and its request path is dead. The ibkr-monitor page's fallback lever covers it.client.py is unchanged, so B2 owns its alert texts (its page says auto-recovery is running).GatewayPager.observe → read_login_state(now) (newest IBC_LOG_GLOB file, last 256 KiB) → parse_login_state (last_restart_at, last_2fa_at) → latch sunday_2fa if in_sunday_2fa_window(now) and _weekly_2fa_evidence(login, opened_at) → set_deadline(opened_at + 1200 | 180) → claim_page → _page_live (screenshot, Detected/Automatic/Fallback text) → mark_page; port open → resolve → claim_resolves → RESTORED. Trader (unchanged from #438/#439): in-place durable → IB() reset → reset failed → recycle_defer_reason (late watch, age, Sunday quiet, weekly budget, cooldown, ibc_auth_in_flight) → recycle_gateway_if_flat (snapshot file only, no DB) → systemctl restart ibgateway-ibc → late-login watch.Gateway dialog) → page by ~06:03. Sun 05:57 outage with 2FA → 180 s (it did not start in the window). Sun 06:15 crash with only Login attempt:, or with its own 2FA but no scheduled Re-starting session, or after the weekly login completed → 180 s. Sun 14:00 crash with its own 2FA → 180 s. Tue request path dead with :4001 up, IB() reset failed, age and cooldown clear → one automatic recycle, a 2FA push on the phone, the monitor pages if the port stays closed; a second trip inside 7 days → no recycle, keep probing. A latched Sunday outage still down at 18:00 → page at 18:00. Mon/Sat 06:00 ~30 s drop → no page. A 2FA marker older than 20 min → treated as a hang.src/ibkr/ibc_log.py parse_login_state/read_login_state/TWO_FA_MARKER; src/ibkr/gateway_pager.py in_sunday_2fa_window, in_sunday_restart_window, _weekly_2fa_evidence, _observe_live, _page_live, capture_screen; src/ibkr/gateway_recycle.py _auto_recycle_enabled, recycle_defer_reason, recycle_gateway_if_flat; integrity_monitor.py _check_ibkr_monitor (the replacement rule).tests/unit/test_finding_438_recycle_concurrency.py (8, all fail on d862942): 4 real processes racing past the decisive check restart the Gateway exactly once; two threads in the reviewer's order (B passes the guard, acquires after A returned) restart once; a lock held by another process defers, and is free once that process dies; an unusable lock fails closed; a stalled stamp writer that resumes after its timeout never overwrites the newer stamp written by another process; a directory-fsync failure restores the previous state with no restart; a failed trader bounce still reports the restart; every post-restart result carries the flag.test_finding_438_last_resort_recycle.py is 42 (10 new, all fail on c814593): stamp write error → no restart across three retries; stamp write timeout → no restart; crash after saving before the restart → the restarted trader never restarts; crash right after the restart → never a second; systemctl timeout / non-zero / exception keep the budget and report may_have_restarted; a definitive never-ran releases the reservation; success records restarted and blocks a second; the client never re-arms after may_have_restarted.test_finding_438_last_resort_recycle.py is 32 (15 fail on 2ae3f5f): a deferred, failed or crashed recycle can run later; retries back off 300/600/1200/2400/3600/3600 s; a restart that ran or is pending late-login stays latched (guard); the reviewer's reproduction (gateway_age=None, raising auth check) defers; unreadable age (empty, 0, no systemctl), corrupt/impossible stamps (5) and a missing IBC log defer; a missing stamp is a first recycle (guard); unreadable evidence never runs systemctl restart (on 2ae3f5f it restarts). NEW tests/unit/conftest.py gives unit tests healthy evidence by default (a 2 h old Gateway, an IBC log ending in a completed login). Weekly evidence (major 7): restart 06:29:55 + 2FA 06:29:59 first seen closed 06:30:05 keeps the grace (fails on 2ae3f5f); an outage 11 min after the restart is not the weekly login. test_finding_456_weekend_autorestart.py is 70.tests/unit/test_finding_456_weekend_autorestart.py (44): the old tail logic is fooled by the real log shape; new initiation after old completion is pending; completion after initiation is not; stale initiation is not; the hang has no auth marker; file-tail read; naive now rejected; window boundaries 05:59/06:00/17:59/18:00; Saturday never the window; Sunday 2FA waits 20 min; Sunday hang pages by 06:03; 2FA at 05:57 and 18:00 pages at the SLA; weekday + Saturday hang page; a late 2FA prompt extends the deadline; no lifecycle invocation in pager/monitor/parser; the screenshot is a read-only import; alerting:ibkr_monitor stale/inactive/ledger-WARN; the old rule is gone. Round 2: the unapproved weekly 2FA page says NOT APPROVED across the exact deadline (reviewer's shape: marker 06:00:12, port first seen closed 06:00:30, deadline tick 06:20:30, real parser, 30 s cadence); a Sunday 14:00 crash with its own 2FA pages at the SLA; the heartbeat attests live_incident and is 0644. Round 3 — 10 fail on 839f58e: TestWeeklyTwoFaEvidence (the reviewer's timings): crash at 06:15 with only Login attempt: pages at the SLA; crash at 06:15 with its own 2FA but no scheduled restart pages at the SLA; crash after the weekly login completed pages at the SLA; outage opened 05:59:50 is not latched. TestIntegrityBlindRule: a closed port before the monitor's deadline is owned, not paged; a closed port FAILs without proof of delivery (no incident, malformed, failed, in_flight overdue, due overdue — 5 cases). Regression guards: the weekly restart still gets the grace; opened 06:00:00 / 06:29:30 latched, 06:30:00 not; closed and delivered is OK. Recycle, tests/unit/test_finding_438_last_resort_recycle.py (14; 7 fail on 839f58e): the 3a6555d default-true tests are restored, plus TestOperatorDecisionLastResort — recycles after the guarded failure without DB I/O (the DB probe raising must not matter); never twice inside the cooldown; never during the Sunday 2FA wait; never while IBC awaits a 2FA; enabling needs no override. #427/#430/#434 default/guard tests are back to 3a6555d (1 each fails on 839f58e).systemd:ibgateway-ibc.service stays the unit-active check (now with 2-cycle hysteresis, #449). (a) the cron health monitor's has_active_2fa_window is retired with the script (#476).integrity entry.src/ibkr/ibc_log.py (NEW), src/ibkr/gateway_pager.py (NEW), src/ibkr/gateway_recycle.py, src/earnings/config.py, src/data_tracker/integrity_monitor.py, .cursor/memory.md, tests/unit/test_finding_456_weekend_autorestart.py, tests/unit/test_finding_438_last_resort_recycle.py, tests/unit/test_finding_427_farm_recovery.py, tests/unit/test_finding_430_weekly_2fa.py, tests/unit/test_finding_434_connect_first.py, src/earnings/strategy_changelog.py, docs/STRATEGY.md, docs/IBKR_CONTINUITY.md, docs/OPERATIONS.md, docs/STATE_MACHINES.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.data_integrity category, so the AlertManager's 30 s per-category limiter dropped the second HIGH page of a cycle (F4: Sep 22 14:37 trader:restart_tripwire + artifact:ibkr_snapshot, TRIGGERED lost, RESOLVED sent). A dropped page was never retried, because paging was a one-shot on the DB status transition. Planned restarts paged (RC7: systemd:polymarket-dashboard.service "deactivating" 12/12 on deploy days; the tripwire and snapshot rules during #441 bounces). Bash senders swallowed curl errors. Does not place or cancel orders. Hardware stops unaffected. Plan #449; Telegram RCA F4/RC6/RC7.IMPLEMENTED-PENDING-DEPLOY (reliability Batch B1).integrity_status, which fails in a read-only latch._send_integrity_alert sends under category data_integrity:<rule> (per-rule limiter) and returns DeliveryResult.delivered (#448: Telegram HTTP 200).IntegrityMonitor pages through the shared IncidentLedger (/var/lib/polymarket/integrity-alert-ledger.json): a pageable rule (HIGH/CRITICAL or telegram_reminder) that fails/warns opens integrity:<rule>. It pages once when due (atomic claim_page) and resolves once. A failed or filtered send is retried next cycle (in-flight 120 s guard). UNKNOWN neither opens nor resolves./run/polymarket, highest seq wins on load). A failed write, retry or restart cannot drop an incident unless both filesystems fail. primary_ok / fallback_ok report each copy; both copies are fully shape-validated on load and a malformed one is ignored.PAGE_IN_FLIGHT_SEC (120 s) old: resolves_due shows it, and claim_resolves persists it, so a restarted process sends the RESTORED without any late result (fail-open: an extra RESTORED beats silence).claim_resolves is the atomic claim for resolves (prune, then mark each in flight for 120 s); a sender that dies mid-resolve is reclaimed after 120 s. resolves_due is the read-only view.last_resolved(key) persists what became of the latest closed generation: delivered (HTTP 200), owed (pending or retrying) or dropped (no resolve will be sent). The ibkr-monitor heartbeat publishes it for B2's OD6 (#448).<opened_at>-<n> with n from a persisted gen_counter, bumped past any generation already in the ledger (legacy files included), so a same-timestamp reopen, a backward clock or a restart never reuses one and a late result for an old incident can never settle a new one (R4 blocker 4, B2 R6-2). _valid checks every field callers consume (finite timestamps, +inf only for a tracked-only deadline, non-empty string generations, enums, booleans, counters); a higher-seq copy that would crash a caller loses to the valid one (R4 major 1). A stale pending resolve is subject to the 24 h expiry on its first revisit (R4 major 2). claim_page / claim_resolves return a claim token and mark_page(..., claim=) / mark_resolve(..., claim=) ignore a superseded claimant's late result; callers that pass no token behave as before (R4 major 3). A resolve whose page result never arrived is owed with page_unproven=True, and every sender turns it into ONE combined informational message ("was DOWN from X to Y UTC, now RESTORED; the outage page may not have reached you"), never a bare RESTORED; a late delivered result clears the flag (B2 R6-4).page_attempts and resolve attempts must be non-negative integers (not booleans); a copy with a corrupt counter loses to the valid one instead of crashing settlement (TestRound5CounterValidation, 4 of 5 fail on c814593).last_resolved.resolved_at must not be after the open incident's opened_at (and generations differ); open() clamps a new incident's opening to the key's last resolution so a backward clock never produces a copy _valid would reject (TestRound6KeyOrdering, 2 fail on d862942). The #449 deploy window is the one deploy-time suppression (PR #9 is monitor-only): TestDeployWindowCoversTheServiceRestarts pins that stamp_deploy_window 1200 precedes the six-service restart loop and stamp_deploy_window 300 follows the last restart, that the restarted units' systemd:* rules are pageable with 2-cycle hysteresis, and that restarts inside the window never page while a unit still down after it pages once.Rule.min_consecutive failing cycles to page and ok cycles to resolve. 2 for systemd:*, services:systemd_unit_health, artifact:ibkr_snapshot, dashboard:* (_apply_hysteresis_defaults); 1 otherwise (db:read_only pages on the first cycle)./run/polymarket/deploy-window.json is valid. deploy_from_github.sh writes 20 min before its first restart and re-stamps +5 min at the end; windows longer than 1 h are ignored. They also wait in INTEGRITY_MAINTENANCE_WINDOWS (default Sun 01:30-03:00 UTC, DO maintenance). A condition still failing afterwards pages; a restart blip that clears inside the window never pages.integrity_status are adopted as paged (no re-page; one resolve later).integrity_status writes and the JSON snapshot are best-effort; paging continues in a read-only latch. DB-down handling is #446 (monitor blind).trader:restart_tripwire reports starts_24h (all trader starts: deploys, #441 bounces) in its detail and message and pages only on NRestarts > 0.INTEGRITY_MAINTENANCE_WINDOWS must parse and total ≤ 3 h/week; the pager knobs IBKR_MONITOR_LIVE_SLA_SEC 60–600, IBKR_MONITOR_2FA_WAIT_SEC 300–3600 and IBKR_MONITOR_ESCALATE_SEC 300–3600 are guarded too (#448/#456).trader:ibkr_request_path dedupe is B2 #451; it consumes the same ledger + delivery definition.)614a29d): a rule with telegram_when is pageable, and a failing status opens its incident only when telegram_when(status, detail) holds (otherwise it counts as ok for the ledger). #479's per-address foreign-client CRITICAL pages keep their own acknowledgement file (_dispatch_alerts) and run every cycle after the ledger dispatch, never deferred by deploy/maintenance windows.scripts/send_telegram.sh is the bash sender (exit 0 only on HTTP 200). Python TelegramNotifier writes the same /var/log/polymarket/telegram-last-send.json. alerts:telegram_delivery WARNs on the dashboard, and never pages Telegram-down via Telegram (Healthchecks #448 is the dead-man). Review fix (Major 4): the artifact write is best-effort in a subshell and always returns 0, so an unwritable path can no longer turn a delivered send into exit 1. After #476 the only bash caller is sunday_2fa_reminder.sh (via /bin/bash).run_once(now) → _db_reachable → _fetch_prev_statuses → openings_deferred(now) → per rule evaluate → best-effort _upsert_rule_status → _track (streaks, ledger.open/resolve, first-run adopt) → _dispatch: min_consecutive + not deferred → claim_page → _send_integrity_alert → mark_page(delivered); claim_resolves → resolve send → mark_resolve. Bash: caller → send_telegram.sh → curl → write_last_send (never fails the script).db:read_only in an RO latch → page although the status write fails. A Telegram 429 → retried next cycle. HTTP 200 with a read-only /var/log → exit 0 plus a stderr note.integrity_monitor.py Rule.min_consecutive, _apply_hysteresis_defaults, _send_integrity_alert, _pageable, in_maintenance_window, deploy_window_active, openings_deferred, IntegrityMonitor.__init__/run_once/_track/_dispatch, _unit_starts_24h + tripwire; deploy/deploy_from_github.sh stamp_deploy_window; scripts/send_telegram.sh write_last_send; src/earnings/alerts.py record_telegram_last_send.trader:ibkr_request_path" holds (it still evaluates and pages; B2 adds the OD6 dedupe).test_finding_449_incident_ledger.py is 55 (11 fail on 2ae3f5f): same-timestamp reopen + late old result, backward clock, monotonic across restart and fallback (legacy-file guard passes on both); pending debt > 24 h dropped on first revisit after a restart, < 24 h still sent (guard); a caller-crashing higher-seq primary (7 mutations) loses to the valid fallback; stale page and resolve claims cannot overwrite a newer claimant; a monitor that died mid-page sends one combined "was DOWN from 12:00 to 12:03 UTC" message after restart, not a bare RESTORED; a late delivered result makes it a plain RESTORED; integrity's resolve is combined too.tests/unit/test_finding_449_incident_ledger.py (35): Round 2 (all failed on 3a6555d): existing file + failed replace keeps the incident due; a restart after a failed primary loads the fallback; primary recovery writes the newest state; recovery during send survives a restart and owes the resolve; a failed in-flight page owes none; claim_page is atomic; escalation "owed". Round 3/5 — 27 fail on 839f58e: a reopen drops stale debt and the new outage pages; a new outage pages whatever debt is outstanding (owed, in-flight resolve, pending page, expired, legacy — 5 cases); pending-page debt never strands the resolve; owed debt deleted after 24 h; claim_resolves atomic; Gateway and integrity failed-resolve → reopen: no stale RESTORED/RESOLVED and the new outage pages; corrupt/malformed copies ignored (3); R5-1 with a real restart and no late result: mid-send recovery resolves after the sender died; a claimed resolve whose sender died is reclaimed; a late failure after settlement keeps it owed; R5-2: a new outage after 24 h debt expiry pages after a restart; last_resolved: delivered, owed while retrying, dropped when never paged / in-flight page failed / 24 h / superseded (kept until the next generation resolves), persisted across restart, old files load, malformed ignored (2). tests/unit/test_finding_449_telegram_delivery.py (31, incl. the new guard keys): last-send path/redaction; bash sender no key / 200 / 401 / --html; the cron sender delegates; Python notifier records 200/exception; alerts:telegram_delivery ok/warn and dashboard-only; Major 4: HTTP 200 with an unwritable artifact exits 0 (fails on e94dd0d), 401 still exits 1; policy: Sep 22 two-rule replay (fails on e94dd0d), per-rule category, failed page retried, one page + one resolve, 1-cycle blip silent, 2-cycle pages, hysteresis defaults, deploy window defers then pages, >1 h window ignored, blip inside the window never pages, Sunday maintenance defers to 03:00, RO DB still pages, UNKNOWN never resolves, first-run adopt, WARNING never pages, DB unreachable pages blind once and resolves. tests/unit/test_finding_389_contract_reminders.py::test_fires_once_semantics_via_transition_gate is behavioural now (reminder once, re-arm once)._dispatch; all Python pagers use DeliveryResult. (c) resolution_sniper / audit_live_monitor senders are other products. (a) the e94dd0d transition-compare is replaced./var/lib/polymarket/integrity-alert-ledger.json exists; the deploy's own restarts page nothing; /api/integrity rows unchanged in meaning; a real HIGH condition during the next week pages once and resolves once; telegram-last-send.json ok=true after the Sunday reminder.integrity entry.src/data_tracker/integrity_monitor.py, src/earnings/incident_ledger.py (NEW, shared with #448), tests/unit/test_finding_449_incident_ledger.py (NEW), src/earnings/alerts.py, scripts/send_telegram.sh (NEW), scripts/sunday_2fa_reminder.sh, deploy/deploy_from_github.sh, .env.example, tests/unit/test_finding_449_telegram_delivery.py (NEW), tests/unit/test_finding_389_contract_reminders.py, tests/unit/test_finding_448_healthchecks_deadman.py, src/earnings/strategy_changelog.py, docs/OPERATIONS.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.DEPLOYED Sep 26, 2026 23:29 UTC (deploy #13 00607c9, Batch B3 with #455; the exact SHA Opus approved in the Final delta; master fast-forwarded 2ecb02c..00607c9; deploy-wide record under #455 Post-deploy). Soak UNVERIFIED until the next 04:13 reset and 06:00 AutoRestart log in-process Health check passed with no #441 bounce and no double reset. The deploy restarted only the six application services; ibgateway-ibc was not touched.reqCurrentTime passed, then _verify_connection_health(durable=True) immediately sent the first request of reqCurrentTime → qualify SPY → reqCurrentTime. IB Gateway dropped the too-close second current-time request, so it timed out at 10.013 s. The same failure happened before and after a fresh IB() (health_1=false, health_2=false), matching the 36/37 production timeout signature. A second read-only paper probe with paced calls passed currentTime three times plus qualify SPY (0.001–0.032 s each). The owner loop was not poisoned; the health protocol violated Gateway request pacing.reqCurrentTime on the connect-owner thread by at least 2.0 s, using ib.sleep so callbacks continue to pump. The review raised the quiet-paper 1.1 s margin from 0.1 s to 1.0 s because the Gateway's server-side throttle clock is undocumented; normal 30 s heartbeats pay no delay;_ib_call jobs are cancelled and skipped if the owner pumps later, instead of executing after the caller already failed;_inplace_reset_used; only durable recovery (_clear_inplace_recovery_state) or an explicit weekday reattach request opens a new reset budget;request_path_ok=false and block 412 before an attach-bounce can hide the latch;_reconnect_loop SystemExit emits one request-path CRITICAL; only explicit process shutdown (disconnect(process_shutdown=True)) suppresses it;trader:restart_tripwire FAIL after the new process overwrites the normal snapshot. The request-path rule follows the recovered process, avoiding duplicate pages;readonly mode, enabling the exact recovery path to be exercised safely against paper._schedule_reconnect → off-thread _ib_call → owner pump_ib_owner → _attempt_reconnect_once → durable health / _try_one_inplace_reset → connect(readonly=<preserved>) → paced probe_request_path_durable → reqCurrentTime, qualify SPY, reqCurrentTime → _clear_inplace_recovery_state → reconnect callbacks and reconciliation. A genuine fail sets the #412 latch before _maybe_attach_bounce; no Gateway restart is added.src/ibkr/client.py _pace_current_time_probe, probe_request_path, _ib_call/pump_ib_owner, _perform_heartbeat, _mark_request_path_dead_before_bounce, _on_error, _schedule_reconnect, _try_one_inplace_reset; src/ibkr/gateway_recycle.py arm_session_attach_watch._on_error still never nests util.run; no client_id=99; no live 4001 probe; no weekday Gateway restart; a genuinely dead Java/session still fails and takes the existing attach-bounce/last-resort policy. CURRENT_TIME_MIN_INTERVAL_SEC is deliberately a fixed protocol interval, not a guard knob.SystemExit. Read-only paper 4002 reproduced failure and the pre-review 1.1 s branch then recovered exactly (initial_connect=true, reset_recovered=true, request_path_ok=true, 4.082 s). A final 2.0 s paper recheck was UNVERIFIED: paper Gateway emitted 2110 and both initial connects failed before any durable probe (initial_connect=false), so it is not evidence against pacing. No production file copy or live 4001 attach. Post-deploy acceptance remains the live soak: one week with zero double resets and in-process Health check passed at nightly/06:00 events._inplace_reset_used, every reqCurrentTimeAsync caller, _ib_jobs, heartbeat marshal timeout, 1100/1101/1102 handlers, attach watcher Login/2FA branches and SystemExit. Intentional reset-budget clears are durable success, explicit request_ib_reattach, and the TCP-down heartbeat reconnect branch (client.py); order-placement methods are unchanged.ibgateway-ibc to roll this back.execution entry.src/ibkr/client.py, src/ibkr/gateway_recycle.py, tests/unit/test_finding_454_ibkr_session_contract.py (NEW), tests/unit/test_finding_440_ib_owner_thread.py, docs/IBKR_CONTINUITY.md, src/earnings/strategy_changelog.py, this entry. R2 deployment stamps intentionally deferred to the deploy sweep.DEPLOYED Sep 26, 2026 23:29 UTC (deploy #13 00607c9, Batch B3 with #454; migration 039 applied). Known defect (history; fixed by #481 in deploy #14 28b572b and #482 in deploy #15 860af84): the live-trades collector looped a permanently failing row through spool → quarantine → API re-fetch and re-armed its CRITICAL each loop; it sent 26 CRITICAL alerts in five minutes and was stopped by operator decision. Record: internal/incident-2026-09-26-live-trades-spool-loop.md in the Project store; fixed by #481 (patch-forward deploy #14 28b572b) and #482 (deploy #15 860af84). The Sep-25 bounded backfill of 98 missing prints remains OPEN: it is a production DB write and was not run without an explicit operator go.position_reconciliation_log INSERT failed six times (03:58, 06:36, 14:27, 16:07, 16:42, 18:47 UTC), leaving the #399 admission flag false even though book comparison had completed. At 18:36:48–49 the live-trades poller saw 98 unique 25006 failures and added every trade ID to its in-process seen set, so none retried. More dangerous: both live entry paths call _persist_position_open only after IBKR fills and the hardware stop is placed; that writer swallowed all exceptions. A crash then leaves no trading_positions / trade_audit_log row, and reconciliation classifies the real IBKR position EXTERNAL (not added).DB_WRITE_SPOOL_DIR (default /var/lib/polymarket/db-write-spool) uses per-kind directories, collision-free atomic temp files, file+directory fsync, independent per-kind count/byte caps (live-trades cannot consume trader capacity), created-time replay order, and poison quarantine. Trader and live-trades use separate flock scopes so a hung collector cannot starve trader replay;replay_spool=False for polymarket-trade-reconcile). Migration 039 adds entry_persist_key/entry-exec uniqueness to both position tables and a reconciliation persist key. Writers take a transaction-scoped Postgres advisory lock and use ON CONFLICT, covering concurrent replay and commit-before-ACK;ActivePosition, risk exposure and earnings monitoring whenever IBKR confirms shares. If IBKR is flat, an exit execution or close record lets the open spool ACK and the close/reconciler finish the DB history. A same-symbol partial mismatch stays blocked;position_open/position_close record is pending or quarantined. Reconciliation calls _mark_reconciliation_completed_if_safe; it cannot reopen #399 while the fill gate is closed. A pending fill is PERSIST_PENDING, never EXTERNAL;position_close record; replay closes both DB tables and then removes memory/risk/detector state;alerted=true only when send_alert returns true. artifact:db_write_spool read-only-scans metadata and is immediately CRITICAL for position fills/emergency/quarantine and for any other kind older than five minutes;INSERT … SELECT bind. Numeric/time binds use the migration-015 types; text binds use unbounded VARCHAR, so pg8000 gets a type while the destination column still raises 22001 on over-long exec IDs/symbols/exchanges instead of silently truncating. This passes Finding #480's repo-wide tripwire regardless of merge order;released/ with operator/reason evidence—never deletes them;deploy_from_github.sh requires the remote wrapper's pre-pull SHA and an EXIT trap restores it after any failed pre-restart gate (host, needrestart, migration, conviction schema, spool write).ON CONFLICT; on failure → atomic kind spool → confirmed page + integrity. Restart → hydrate IBKR-confirmed payload into position/risk/detector state → background locked replay → DB commit → ACK. Reconciliation can set #399 true only when no fill spool exists.durable_write_spool.py lock/quarantine/stats; migration 039; orchestrator hydration/gate/background replay/open writer; PositionManager execution+close replay; ReconciliationManager safe #399/log replay; ExitManager execution-before-close; live-trades retry/quarantine; integrity spool rule.db_write_spool_admin.py release* command records operator verification. Historical 98-print repair remains a separate approved write._pending_fill_spool_blocks_entries (pending/quarantine/emergency/sticky), called by both entry paths. If replay misbehaves, stop live-trades, preserve/copy the spool, and patch forward. Release only through the audited operator command after IBKR + both DB rows are verified. Never revert migration 039 or delete records. A migration failure automatically resets the checkout to the pre-pull SHA before any restart. No Gateway restart.SELECT ibkr_entry_exec_id, count(*) FROM trade_audit_log WHERE ibkr_entry_exec_id IS NOT NULL GROUP BY 1 HAVING count(*) > 1 returned 0 rows; /var/lib/polymarket/db-write-spool absent; COMPLETE-STATE flat (0 positions IBKR/DB, 0 working stop orders in the trader snapshot, 0 breakers, not paused, 0 stops); next wave Monday (4 markets Sep 28).polymarket-deploy-20260926T232940Z-285816666 23:29:43 → 23:33:38 UTC, DEPLOY_EXIT rc=0; pull 2ecb02c..00607c9, 39 files; hygiene 0/0/0; needrestart OK. deploy-migrations.log: main/039_trade_persist_idempotency.sql: applied (attempt 1: 4 ALTER TABLE, the in-file duplicate DO pre-flight, 5 CREATE INDEX, ledger insert) then done: current=7 bootstrap=0 applied=1 (040/041 current). Spool directory created by the deploy's write test.earnings-trader (23:30:04) and polymarket-live-trades (23:30:35) came up active; ibgateway-ibc PID 1215183, ActiveEnter Fri 25 Sep 04:03:11 unchanged; xvfb/x11vnc unchanged; :4001 listening; root crontab unchanged. Trader reconnected 23:30:08 (same-thread req* ok (#440); reconciliation in sync); ready, 0 breakers, 0 positions.artifact:db_write_spool = FAIL (CRITICAL), not OK: "DB-write spool has 2 → 12 quarantined record(s)", from the live-trades loop (Known defect above). The trader spool is empty (no trader/ directory). The rule's first FAIL logged 0 transitions and did not page.live_trades_db_write alerts to 23:37:56; systemctl stop polymarket-live-trades 23:39:08 (operator go; not restarted; stays stopped, NRestarts=0); the integrity monitor's expected single CRITICAL for systemd:polymarket-live-trades.service at 23:43:36. Spool and quarantine left untouched as evidence: 0 pending, 24 quarantine files (12 payloads + 12 .error.json), one logical key.security:db_client_activity and the sampler rule ok; 0 new evaluator raised; the deploy's dashboard restart did not page this time (that race is still OPEN). Widened identifier scan (82 URLs, every public GET route): only the known OPEN WALLET_NAGI gap on /api/btc5m/docs/doc_map; 0 40-hex addresses; 0 secrets. Logs: 2107 once; pre-existing noise only.trading_positions, trade_audit_log and ibkr_executions with an empty spool): unexercised — no fill yet (weekend). #454 soak: UNVERIFIED until 04:13 and 06:00.integrity entry (post-fill live behavior and audit semantics).atomic_write.py, durable_write_spool.py, alerts, orchestrator, PositionManager, ReconciliationManager, ExitManager, live-trades, trade-reconciler ownership flag, integrity monitor; unit + real-Postgres integration tests; CONTEXT/HANDOFF/OPERATIONS/DATA_ARCHITECTURE/changelog/FINDINGS. R2 deployment stamps intentionally deferred to the deploy sweep.git clean on prod (Sep 26, 2026)git pull --ff-only mid-deploy; (2) a broad git clean on prod would destroy live IBKR safety state. The only FAIL row ("checkout clean") in the independent Batch A post-deploy verification. Reliability plan P1-hyg, pulled forward from B1 by the operator.DEPLOYED Sep 26, 2026 15:15 UTC (deploy #5 f130113, fast-forward c40de42..f130113 after the cross-family GPT-5.6 review; detached unit polymarket-deploy-20260926T151501Z-1297330884 15:15:05 → 15:18:48, DEPLOY_EXIT rc=0). Prod moves DONE Sep 26, 2026 14:59 UTC (#468) (operator go 14:54 UTC).d3c7eef, Sep 26 13:25–13:35 UTC; re-listed at c40de42 14:50 UTC, same set):[hidden] had 254 untracked files, 750,566,547 B, 0 tracked modifications and 0 path collisions with master;data/api_health/*, written by polymarket-api-health.timer and read by integrity artifact:api_health_latest_json;logs/earnings_trader*.log.*);trading_block.txt, login_attempt_state.json, logs/ibkr_last_recycle.json;scripts/phase3_*.json, including phase3_strategy_candidate_kpis.json, which deploy_from_github.sh regenerates on every deploy;scripts/_sec_ticker_to_cik.json;logs/*.log.1 (746,175,585 B, 99.4%, all from one Aug 21 09:46 rotation, no reader);.env.bak.polygon-rotate-20260822* (0600, full credential copies);*_backfill_state.json.archived_20260510_052113;strategies/btc5m/src/umpire/ tree;*.py.test scratch copies..gitignore carried scripts/_sec_ticker_to_cik.json # ~10 MB …. Git has no trailing comments, so the whole line was the pattern and it never matched.git clean on prod destroys safety state..gitignore gets the breakdown's narrow rules: data/api_health/, login_attempt_state.json, trading_block.txt, logs/ibkr_last_recycle.json, logs/*.log.*, scripts/phase3_*_results.json, scripts/phase3_simulator_baseline_cross_check.json, scripts/phase3_simulator_gate_retune_sweep.json, scripts/phase3_strategy_candidate_kpis.json, *_backfill_state.json.archived_*, and the defensive .env.bak.*.strategies/btc5m/src/umpire/ and *.py.test. Ignoring source-like files hides drift.docs/OPERATIONS.md §8.A2: never run git clean in any form on prod. It lists the four live-state paths with their readers and writers, and notes that -x/-X become the dangerous forms once these paths are ignored. The rule is move, never clean./proc/*/fd sample showed none open. Every move was re-hashed at its destination./var/lib/polymarket/archive/legacy-logs-2026-08-21.tar.zst (15,107,494 B, sha256 5f3c47a6…ed972). The archive was verified with zstd test, name and size listings, tar --compare, and a per-file sha256 match of 42/42. Only then were the originals removed..env.bak.* files went to /root/secret-backups/ (0700/0600). They are deleted after the OD8 rotation./root/btc5m-umpire-2026-09-26/umpire/, and a Mac copy was made under ~/Backups/polymarket-btc5m/umpire-2026-09-26/. Both match 18/18, and the tree is identical to the Mac checkout's untracked copy. It is handed to the btc5m owner (OD11).*.py.test copies went to /var/lib/polymarket/archive/checkout-hygiene-2026-09-26/files/, with relative paths kept.0700). After the moves: untracked 254 → 187, all 187 matched by the new rules. The protected state was byte-identical before and after. No restart; Gateway PID 1215183 unchanged; trader ready.git status / git ls-files --others --exclude-standard read .gitignore, so matched paths leave the untracked set.git_hygiene_cleanup (#303) parses git status --porcelain ?? lines, so ignored paths never reach it. It only ever removed files byte-identical to origin/master..gitignore. The four state paths are still read and written at their absolute or env-default paths (src/ibkr/client.py IBKR_TRADING_BLOCK_FILE, scripts/auto_login_gateway.py LOGIN_STATE_FILE, src/ibkr/gateway_recycle.py IBKR_RECYCLE_STAMP_PATH, integrity artifact:api_health_latest_json).git check-ignore --no-index over the 254 prod paths matched 234; the 20 unmatched are exactly the umpire tree and the two *.py.test files, both moved out in (c)/(d). Since the deploy, prod git status --porcelain is empty (post-deploy check below). New server-written paths still show up as untracked, which is intended..gitignore:100-101 (SEC rule) and .gitignore:115-140 (#468 block); docs/OPERATIONS.md §8.A2.git clean -x/-X now deletes the state. OPERATIONS §8.A2 covers this.login_attempt_state.json and trading_block.txt are unanchored, as in the breakdown, so they match at any depth. No tracked file matches either..git/info/exclude could mask results, so the test counts only repo .gitignore sources.(5) Test tests/unit/test_finding_468_checkout_hygiene.py (59 tests):
.gitignore line;.gitignore pattern carries a trailing comment;*.py.test paths and tracked neighbours (.env.example, scripts/phase3_simulator/replay.py, polymarket_sniper/data/api_health/latest.json, logs/audit_log_20260104_090153.jsonl) are not;git clean, the four paths and -x/-X.Mutation-checked: a trailing comment on the SEC rule, ignoring the umpire tree, ignoring *.py.test, dropping trading_block.txt, removing §8.A2, and widening to scripts/phase3_* each fail.
- §E:
- .gitignore has no other trailing-comment line; the test now enforces this. The nested strategies/pm_earnings_arb/reports/.gitignore has none either.
- Already-ignored server state (trading_mode.txt, *_backfill_state.json, logs/events/, logs/ibkr_snapshot.json, scripts/strategy_analysis_report.json from #377/OPS-3) is unchanged.
- Docs that call the phase3 outputs "regenerated on prod, not tracked" (#314.x/#315) stay true.
- OPERATIONS "Trading blocked" recovery (explicit removal of trading_block.txt) is unchanged and is not a git clean.
- Acceptance: after the deploy, prod HEAD == master, git status --porcelain is empty, and trading_block.txt, login_attempt_state.json, logs/ibkr_last_recycle.json and data/api_health/ are unchanged in place.
- Post-deploy (15:22 UTC): acceptance met.
- Prod HEAD f130113 == origin/master.
- git status --porcelain shows 0 lines (also with --untracked-files=all).
- The four safety-state paths have the same sha256, size and mtime as the 14:56 baseline (api_health 141 files; 28 trader rotations).
- Deploy pre-pull hygiene: removed_untracked=0 reset_identical=0 stashed_divergent=0; migrations applied=0.
- The 6 app services were restarted. ibgateway-ibc 1215183, xvfb 1215180, x11vnc 1215184 and :4001 java 1537985 are unchanged (NRestarts 0).
- The trader reconnected at 15:15:33 (same-thread req* ok after connect (#440); reconciliation in sync) with effective_trading_state=ready. Integrity trader:ibkr_request_path is ok; integrity is 82 ok / 3 warn / 0 fail.
- Changelog: none (§O no: no live trading behaviour changes).
- Files: .gitignore, docs/OPERATIONS.md, tests/unit/test_finding_468_checkout_hygiene.py (NEW), docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.
check_*_signals, bounded price_snapshots fallback, IB owner pumping, thread dump to file) remains #453 in plan Batch B2 and removes this override.DEPLOYED Sep 26, 2026 10:18 UTC (Batch A deploy #1 884e528; rode the deploy's trader restart; no extra restart).trading_orchestrator._main_loop_watchdog_loop os._exit(1)s after MAIN_LOOP_STALL_TIMEOUT_SEC=120 s without _mark_main_loop_progress. The AMC/BMO mark is set only after verify_earnings_dates() returns, and the following check_*_signals conviction/price_snapshots fallbacks take 20–99 s per ticker on the IB owner thread. Sep 21: 7 kills, Sep 22: 6 kills, at 14:32 and 19:52 UTC (sep21-22-stalls.txt), with IBKR connected. Each restart redid the same work. The stage label (amc_verify_dates / bmo_verify_dates) is stale by design. #442 is not the blocking call.Config.MAIN_LOOP_STALL_TIMEOUT_DATED_OVERRIDES = {"2026-09-28": 300} plus Config.main_loop_stall_timeout_sec(now), which returns max(MAIN_LOOP_STALL_TIMEOUT_SEC, override for now's ET calendar date). The watchdog re-reads it every poll, because the thread starts at the weekend deploy and lives into Tuesday, and logs Main loop watchdog budget 120s -> 300s / back on each change. Done in code rather than in prod .env (the plan's option a): a .env value with # GUARD-OVERRIDE-UNTIL: does not expire; it only turns integrity consistency:guard_overrides FAIL (a page) after the date. The code override lapses at Tue 29 Sep 00:00 ET = 04:00 UTC with no page and no .env edit. 300 ≤ 600, the GUARD_REGISTRY safe bound for MAIN_LOOP_STALL_TIMEOUT_SEC. The override never lowers an env-raised base._start_main_loop_watchdog → thread _main_loop_watchdog_loop → each wait(poll) → Config.main_loop_stall_timeout_sec() → stalled_for < timeout_sec → continue, else CRITICAL + alert + os._exit(1) → systemd restart.src/earnings/config.py MAIN_LOOP_STALL_TIMEOUT_DATED_OVERRIDES + main_loop_stall_timeout_sec; src/earnings/trading_orchestrator.py _main_loop_watchdog_loop (budget read inside the loop) + start log.tests/unit/test_finding_453_stall_monday_stopgap.py (21 tests: ET 23:59:59 Sun → 120, 00:00 Mon → 300, 23:59:59 Mon → 300, 00:00 Tue → 120; the same boundaries in UTC (03:59:59 / 04:00); entry slots 300; next year's Sep 28 → 120; never lowers a 400 s base; naive datetime rejected; the override dict is exactly Mon → 300 and ≤600; the watchdog loop driven directly: a 200 s cycle survives Monday and is killed Tuesday, a 301 s hang is killed Monday; budget read inside the loop).os._exit; the trader log shows the budget change at ~04:00 UTC Mon and back at ~04:00 UTC Tue.MAIN_LOOP_STALL_TIMEOUT_SEC has one runtime reader (the watchdog, now via the accessor; the start log line too). integrity_monitor.GUARD_REGISTRY still guards the env key (≤600). No other watchdog uses it.execution entry.src/earnings/config.py, src/earnings/trading_orchestrator.py, tests/unit/test_finding_453_stall_monday_stopgap.py (NEW), src/earnings/strategy_changelog.py, docs/OPERATIONS.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.qualifies / conviction_ok can say TRADE while the trader skips. Pre-existing at c9c0072; data-path RCA §3; CODE_AUDIT_RECONCILIATION F30.IMPLEMENTED-PENDING-DEPLOY (Batch B2, branch reliability/b2; moved from B1 to B2 by the operator because it is trader code). History: an implementation shipped in the Batch A branch as 8ba88d0 and was reverted by f8c972c after the cross-family delta re-review (bc8ceff); that version kept the builder's env edges outside the helper, passed a missing book on a 0.5 default and rendered 0% as missing.earnings_dashboard/app.py get_polymarket_earnings_from_db builds conviction_ok from LONG 70 ≤ pct < 95 (#333, pre-C-1) and small-cap LONG 70–80 (#207; ignores B5_PILOT_ENABLED). The trader's gate is TradingSignal.is_valid: LONG 0.70 ≤ c < Config.SIGNAL_LONG_MAX_THRESHOLD, small-cap LONG below Config.small_cap_long_max_conviction(), SHORT ≤ 0.30 and never small cap. _build_trading_signal direction edges are Config.SIGNAL_LONG_THRESHOLD / SIGNAL_SHORT_THRESHOLD.depth_quality buckets TOTAL bid+ask depth (≥$1,000 → 1.0), so a one-sided $5,000/$0 book scores 1.0 (pass). The trader's ConvictionCalculator._calculate_depth_quality uses min-side depth, spread and asymmetry, and turns a 0 side into "unknown" (0.3), so is_reliable is False and the trader skips (#283).conviction_ok.SIGNAL_LONG_THRESHOLD / SIGNAL_SHORT_THRESHOLD), not literals.[hidden]/.env, 2026-09-26 09:22:55 UTC, re-checked 16:02:30 UTC at prod facb218; these keys only):SIGNAL_LONG_MAX_THRESHOLD=0.90 (line 67).SIGNAL_LONG_THRESHOLD: absent, so the Config default 0.70 applies.SIGNAL_SHORT_THRESHOLD: absent, so the Config default 0.30 applies.B5_PILOT_ENABLED: absent, so the Config default True applies.earnings-trader / polymarket-dashboard unit Environment= sets none of them.scripts/phase3_simulator/depth_gate_variants.py imports the depth constants from ConvictionCalculator (offline factor attribution).scripts/check_earnings_today.py prints >>> TRADE from 70/30 and a stale $2,000 volume floor, but it is dead: it selects the non-existent earnings_markets.symbol (follow-up F-3).src/earnings/signal_gates.py, the one definition. Every edge is read from config.Config at call time (the module is looked up per call, so a reloaded config is never stale): direction_gate (the builder's first stage: LONG ≥ SIGNAL_LONG_THRESHOLD, C-1 cap SIGNAL_LONG_MAX_THRESHOLD, SHORT ≤ SIGNAL_SHORT_THRESHOLD, else deadband), small_cap_gate, is_b5_pilot, signal_band_reason (fixed direction, for is_valid), band_reason (direction chosen as the builder does), short_miss_confidence_skip + admission_reason (adds the priority filter's SHORT miss-confidence ≥ 90% SKIP), the depth gate (depth_quality, orderbook_depth_quality, depth_reliable, snapshot_value) and orderbook_is_one_sided (bid < 0.05 and ask > 0.95). Reject codes are the trader's existing skip reasons (conviction_deadband, long_conviction_max_threshold, small_cap_short, small_cap_long_above_80, priority_not_tradeable)._build_trading_signal calls direction_gate and small_cap_gate (the class-level LONG_THRESHOLD / SHORT_THRESHOLD import-time copies are removed); TradingSignal.is_valid and b5_pilot call the helper; _direction_hypothesis_for_log uses direction_gate; priority._calculate_base_score's hard SKIP band calls signal_band_reason (its _long_max_conviction / _small_cap_long_max_conviction helpers, now unused, are removed); ConvictionCalculator depth constants, depth score, is_reliable, both one-sided checks and the informational is_high_conviction call the helper._dashboard_gate_fields (both builder branches) returns conviction_ok / conviction_reason (from admission_reason) and depth_quality / depth_quality_ok / depth_source. Depth fails closed when the trader would not use the orderbook row: no row in 7 days (no_orderbook; the trader falls back to price_snapshots, then live Gamma), a one-sided book (one_sided_fallback), or a read error (error). The dashboard does not reproduce the snapshot fallback because that query is the 20–99 s-per-ticker read behind the stalls. The calendar entry forwards all five fields; the priority display's direction inference uses direction_gate; the template context gets gate (gate_thresholds_pct).gate.*: 0% conviction renders as a value (e.conviction is not none, three cells); new states ✗S (priority SKIP), NO BOOK / 1-SIDED / ERR depth labels and a PM FALLBACK TRADE? badge.GUARD_REGISTRY gains SIGNAL_LONG_THRESHOLD (safe ≥ 0.70) and SIGNAL_SHORT_THRESHOLD (safe ≤ 0.30): with the literals gone these knobs are live, so loosening one needs a dated # GUARD-OVERRIDE-UNTIL: (memory §F). Prod sets neither, so integrity stays OK.is_valid and priority floored LONG/SHORT at 0.70/0.30 (LONG=0.65 built a 0.67 signal that is_valid then rejected; LONG=0.75 made 0.72 a builder deadband while the dashboard said pass). Now the env value is the gate everywhere.check_bmo_signals / check_amc_signals / reservation → get_*_signals_for_* → _get_signals_for_timing → get_conviction_at_entry_time (orderbook → one-sided? → snapshots → Gamma; depth via signal_gates) → is_reliable → _build_trading_signal (direction_gate → volume → small_cap_gate → regime → session stop …) → TradingSignal.__post_init__ (is_b5_pilot, calculate_priority_score → signal_band_reason + miss-confidence) → is_valid (signal_band_reason) → prioritize_signals (is_tradeable). Trace (dashboard): get_polymarket_earnings_from_db → _dashboard_gate_fields → pm row → get_earnings_with_polymarket entry → Jinja / JS.long_conviction_max_threshold / NO EDGE; 0.05 shows priority_not_tradeable / ✗S; a missing book shows NO BOOK / PM FALLBACK.src/earnings/signal_gates.py; signal_generator.py TradingSignal.__post_init__ / is_valid, _direction_hypothesis_for_log, _build_trading_signal direction + small-cap blocks; priority.py _calculate_base_score; conviction_calculator.py is_high_conviction, get_conviction_at_time, _get_latest_conviction, _calculate_conviction_from_orderbook, _calculate_depth_quality; earnings_dashboard/app.py _dashboard_gate_fields + both branches + calendar entry + priority direction + template context; dashboard.html Min Required / OB Quality / Direction / TRADE? cells + mobile JS; integrity_monitor.py GUARD_REGISTRY.earnings_markets.market_cap_category, the trader's is classify_market_cap(KNOWN_MCAPS / yfinance) — an input difference, recorded as follow-up B2-F1 (not this finding's gate-definition scope); (c) a missing/one-sided book is fail-closed on the dashboard, so it can show PM FALLBACK for a row the trader trades on a stable snapshot window (the safe direction: never a false ✓ TRADE); (d) the dashboard reads the latest book within 7 days (#373 bound) while the future-target trader path reads the latest row unbounded — an older book is again fail-closed.tests/unit/test_finding_450_dashboard_gate_parity.py (76 tests): old-logic oracles at prod values (verbatim pre-#450 builder band stage, is_valid, priority incl. direction inference, pilot flag, direction label, is_high_conviction, depth quality, one-sided predicate) over a 29-value edge grid × large/mid/small × B-5 on/off; real-builder parity (_build_trading_signal → is_valid → priority filter vs _dashboard_gate_fields, reason codes equal) at prod values and under env overrides of each threshold loaded through Config's parser (LONG 0.75/0.65, SHORT 0.25/0.35, cap 0.85/0.95, all three together) just below / at / just above each edge plus 0.29/0.30/0.31/0.69/0.70/0.89/0.90/0.91/0.95; dashboard depth == the trader's _calculate_conviction_from_orderbook on 16 books; missing book fails closed for insufficient / stable / unstable snapshot windows; one-sided and read-error books fail closed; rendered cells for 0.0 / 0.30 / 0.70 / None conviction and every new state; surface tripwires; guard entries. Mutation-checked: a literal LONG or SHORT edge, dropping the priority SKIP, a passing missing book, scoring a one-sided book, the falsy-0 Jinja test, literal direction inference in priority, and cap inclusivity > each fail. Existing tripwires moved to the new definition without weakening: test_finding_366_b4_c1_priority.py, test_finding_366_b5_pilot.py, test_finding_399_recon_polygon_timing_priority.py.git grep for band literals (>= 0.70, <= 0.30, < 0.90, conviction_pct), LONG_THRESHOLD / SHORT_THRESHOLD, best_bid < 0.05, conviction_ok, depth_quality, is_high_conviction across src/, earnings_dashboard/, scripts/, run_*.py. Live-code hits are all routed through signal_gates. Intentional non-gate hits: signal_generator.py M-C1 shadow log 0.90 <= conviction < 0.95 (monitors the closed band); priority.py B-4 rank sub-bands (ranking, not a gate); the dashboard's conviction colour classes (display). Offline analysis buckets, not live gates: scripts/analyze_entry_aligned.py, scripts/hl_earnings_analysis.py, scripts/strategy_performance_analysis.py (SQL bucket labels), scripts/detection_latency_replay.py (pre-C-1 "0.70-0.95" band label; that script's rewrite is P1-cq2 follow-up 1). scripts/check_earnings_today.py stays dead (F-3). No integrity rule re-derives the band.bac9f25, blockers 7 and 9 + nits):src/earnings/market_cap_tier.py. resolve_market_cap_billions(symbol): KNOWN_MCAPS → the shared cache file MARKET_CAP_CACHE_PATH (default [hidden]/logs/market_cap_cache.json; last yfinance answer per symbol, a failed lookup included, 24 h; fcntl lock + atomic replace; process copy on failure) → yfinance (written to the cache) → unknown. tier_for_billions: > $100B large, < $10B small, else mid, None → small (the trader's thresholds; market_cap_data.classify_mcap_value differs at exactly $10B and is not used by the gate). SignalGenerator.get_market_cap_for_symbol and classify_market_cap delegate to it; the dashboard builder resolves canon_tier per row (network only outside the cold-bootstrap db_only pass) and passes it to _dashboard_gate_fields (is_small = (tier or "small") == "small"); the calendar row shows that tier (mcap_tier, mcap_source). earnings_markets.market_cap_category (legacy mixed-format, #298) no longer feeds any gate; it still labels rows without a PM market._dashboard_gate_fields and both trader one-sided checks (get_conviction_at_time, _get_latest_conviction) use float(x) if x is not None else None for best bid / ask. Prod (read-only, Sep 26, last 7 days): 1,512,658 orderbook rows, 0 with bid = 0 and 0 with ask = 1 (missing sides are NULL: 2,426 / 6,520), so no current trader decision changes.gate; env overrides of the three edges are process-start configuration — restart the trader and the dashboard (the calendar cache rebuilds on the dashboard restart)._build_trading_signal → get_market_cap_for_symbol → resolve_market_cap_billions → classify_market_cap → signal_gates.conviction_band(is_small_cap=…); dashboard get_polymarket_earnings_from_db → market_cap_tier.resolve_tier → _dashboard_gate_fields → signal_gates.conviction_band; both read the same KNOWN dict and cache file. Book: _get_latest_conviction → best bid/ask (is not None) → orderbook_is_one_sided → snapshot fallback; dashboard → same predicate → one_sided_fallback, fail closed. (2) Runtime: the 5 KNOWN tickers of the 11 upcoming (review probe) are unchanged; the 6 dynamic ones (CAG, CCL, FDS, KMX, LW, MKC) now get the same tier on both surfaces from the shared yfinance answer; an unknown small-cap SHORT shows small_cap_short on the dashboard, as the trader rejects it. (3) Lines: market_cap_tier.py (whole); signal_generator.py get_market_cap_for_symbol, classify_market_cap, class thresholds removed; app.py _dashboard_gate_fields, builder (canon_tier + two call sites + two row dicts), calendar mcap_tier / mcap_source; conviction_calculator.py two one-sided checks; dashboard.html live-config rows. (4) Limits: during the cold-bootstrap pass (db_only, no network) a symbol not yet in the cache shows Small until the next full refresh (fail-safe direction); a yfinance answer is reused for 24 h (was: the process lifetime in the trader); the two processes can briefly differ only if one fetched after the other's entry expired. (5) Test tests/unit/test_finding_450_market_cap_and_quote_parity.py (37). Pristine bac9f25 cannot collect it (new module); bac9f25 + only market_cap_tier.py: 8 fail / 29 pass (missing-tier small-cap SHORT, None tier, 0.0 bid on both surfaces, resolver wiring). The round-1 file likewise fails at collection on its pristine parent (ba39d5c; imports signal_gates); its red evidence is the listed mutation runs.{"billions": finite positive number | null, "at": finite number}); a string / list / NaN / ±inf / ≤ 0 / bool payload or missing field is ignored (warning), i.e. missing → refetched when network is allowed, else unknown = Small; a corrupt or non-dict file is ignored on read and rewritten from valid entries on the next lookup; yfinance NaN / ±inf / ≤ 0 / non-numbers are unknown; tier_for_billions maps any non-finite or non-positive value to Small; resolve_market_cap_billions never raises. Tests: TestMalformedCache in test_finding_450_market_cap_and_quote_parity.py (13 bad-entry shapes offline and refetched, 4 corrupt files, yfinance garbage, trader + dashboard gate survive, resolver never raises)._read_cache replaces the process copy with the file's valid entries on every read, so a malformed entry, a corrupt / non-dict file or a deleted file reads as "no entry" in a warm process exactly as in a cold one → refetch and rebuild when network is allowed, else unknown = Small. The process copy is used only without a path or on an I/O error opening the file. §B (1) trader get_market_cap_for_symbol / dashboard resolve_tier → _resolve → _read_cache (validate, replace mirror) → refetch → _write_entry (rebuild from valid entries). (2) Runtime: a damaged file is rebuilt by the first online lookup; until then the offline dashboard shows Small, as the trader would without network. (3) market_cap_tier.py _read_cache. (4) While the file cannot be written, each process re-fetches instead of trusting its own copy (consistent, at the cost of extra yfinance lookups through the limiter). (5) TestWarmCacheCannotMaskDisk (11: warm 150 B + malformed / corrupt / non-dict / deleted file online → refetch + rebuild; warm vs cold agree offline; valid disk value wins; trader and dashboard gate agree; I/O error uses the process copy); 9 fail on 69d33e2.market_cap_tier.cache_health() exposes it. The process copy is used only when no path is configured. §B (1) _resolve → _read_cache (OSError → _set_io_failure, {}) / _write_entry (False → unknown). (4) While the cache is unusable every non-KNOWN symbol is Small on both surfaces (restrictive; parity kept) and each lookup re-fetches through the limiter. (5) TestCacheIoFailureParity (6: warm/cold agree on Small for read and write failures incl. both gates, warm copy unused, unwritable fetch never feeds the gate, loud once + recovery logged, rebuilt after recovery) + 2 updated resolver tests; 7 of the new/updated cases fail on 12d0f93.cache_health() adds scope (all / unshared). Test test_failure_scope_wording_is_accurate. Round 7 (R6-6): read and write health are tracked separately (read_error / write_error); each clears only after its own successful operation, so a later write failure never masks a read failure (scope stays all while reads fail). Tests: test_failure_scope_wording_is_accurate (rewritten), test_combined_read_and_write_failure_reported_as_all./api/earnings-calendar rows carry conviction_ok / conviction_reason / depth_quality_ok / depth_source, and TRADE? agrees with the trader's opportunity-log skip reasons for the next wave (Tuesday's AMC/BMO windows).selection entry (gate definition unified; trader identical at prod values; overrides now honoured; dashboard display changes).src/earnings/signal_gates.py (NEW), src/earnings/signal_generator.py, src/earnings/priority.py, src/earnings/conviction_calculator.py, src/earnings/config.py (comments), src/data_tracker/integrity_monitor.py (GUARD_REGISTRY), earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html, tests/unit/test_finding_450_dashboard_gate_parity.py (NEW), tests/unit/test_finding_366_b4_c1_priority.py, tests/unit/test_finding_366_b5_pilot.py, tests/unit/test_finding_399_recon_polygon_timing_priority.py, src/earnings/strategy_changelog.py, .cursor/memory.md (§M line), docs/STRATEGY.md §2.1, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, docs/CODE_AUDIT_RECONCILIATION_2026_08.md (F30), this entry.#373 (bounded hypertable scans) to earnings_dashboard/.)DEPLOYED Sep 26, 2026 11:48 UTC (deploy #2 3d7f1b1, with the deploy-verification fix; deploy #1 884e528 10:18 UTC failed one acceptance check — below).db-s-2vcpu-4gb, 120 GiB plan maximum, ~111 GiB base = 92.5%) flipped default_transaction_read_only and mass-killed sessions every ~11–12 min (84 unique minutes in the btc5m recorder log up to 19:10:59; post-pause flips 19:22:25 and 19:34:30 in the dashboard journal; sessions_killed 1782 at 19:10 → 1806 at 19:34:45 → 1818 at 20:08:19).position_reconciliation_log INSERT failed 6× (03:58, 06:36, 14:27, 16:07, 16:42, 18:47; #399 flag not set on those passes). live-trades lost 98 prints at 18:36:48–49 (tracked for #455). polymarket-backup failed 05:12:27 with the error truncated to pg_dump: er (tracked for #447). The integrity monitor lost 3 cycles. The IBKR socket and the hardware stops were unaffected.systemctl disable --now btc5m-recorder.service, operator-approved). This stopped ~1.35 GiB/day of growth but not the flips.systemctl disable --now polymarket-dashboard, operator-approved at 20:04). sessions_killed stayed flat at 1818. Cause not isolated: the last flip was 24 min before the stop, and the operator's cleanup had been dropping chunks since ~19:51.ALL_DONE 20:47:37. All 200 btc5m chunks went via drop_chunks and trade_groups was truncated. Database 105.1 → 46.16 GiB, ~52 of 120 GiB with WAL (43%). Archive: /mnt/btc5m_raw_nyc2/db_archive/ (2.7 GB, per-table MANIFEST.json, sha256 + row counts, all verified). Tooling and restore commands: strategies/btc5m/tools/README.md, plus the dated note in strategies/btc5m/docs/RUNBOOK.md (btc5m-only commit).sessions_killed 1818 throughout, never read-only, peak ≈57.3 GiB. Each pass still spilled 5.2 GiB and held ONE transaction ~14 min (e.g. xact_start 21:09:47 → commit ~21:23:40).earnings_dashboard/app.py at c9c0072):_fetch_db_stats_from_db(full=True), run by _refresh_cache every loop, joined all ~6.3k markets to EVERY row of price_snapshots, trades and orderbook_snapshots and did COUNT(DISTINCT) + MIN/MAX per category. This was an unbounded sort to disk at work_mem 4 MB.COUNT(*) / MAX(time) on the three hypertables (#373 violation), plus an unbounded DISTINCT-ON active-earnings join, recent-trades scan and per-market all-history counts.engine.connect() block, including subprocess calls, so the transaction lasted the whole pass. After a 57P01 kill the aborted connection failed every later query in the pass (7 in the 19:22 cascade).get_polymarket_earnings_from_db held its connection and transaction across the 3–15 s+ Gamma batch fetch. get_active_positions_from_db did the same across yfinance.get_earnings_with_polymarket logged "No data from earnings_markets table - run 'python3 -m src.db.earnings_sync'", which was wrong on a DB error._query_data_availability pre-aggregates each hypertable to one row per condition_id over the last DASHBOARD_RECENT_DAYS=7 (HashAggregate, no big sort), then joins to markets. _get_data_availability caches it for DATA_AVAILABILITY_TTL_SEC=6 h and serves the last good value on failure. The payload carries window_days + computed_at.approximate_row_count(). MAX(time), active earnings (LATERAL LIMIT 1), recent trades, latest price, latest orderbook depth, the positions crowd-read and high-conviction all carry time > NOW() - INTERVAL '7 days'. The per-market table counts over MARKET_AVAILABILITY_DAYS = 7 (= DASHBOARD_RECENT_DAYS) for the 25 displayed rows only — one grouped COUNT per hypertable, cached 6 h (deploy-verification follow-up below; the first version counted 90 days and timed out on prod)._install_dashboard_session_limits: a SQLAlchemy connect listener sets statement_timeout=60 s and idle_in_transaction_session_timeout=60 s on every dashboard connection (the pool is disposed once so no pre-listener connection survives)._PerStatementReads: the stats pass, the earnings calendar and the positions view check out a pooled connection per statement and buffer the rows (Result.freeze()). There is no multi-minute transaction, a killed connection fails one statement (pool_pre_ping replaces it), and no connection is held across Gamma, yfinance or subprocess. This is a checkout per statement, not per-query AUTOCOMMIT: POSTGRES_OPERATIONS.md cautions against AUTOCOMMIT without a verified pool strategy._pm_db_last_error: on a DB failure the caller logs "earnings_markets read failed (…) — not a sync problem". The earnings_sync hint now appears only when the table is really empty._data_availability_refresh_lock, non-blocking): a caller that finds a refresh in flight gets the last good value at once instead of starting a second copy of the query. Delta re-review Major 3: a failed refresh records _data_availability_cache["failed_at"] under _data_availability_lock and no refresh runs for DATA_AVAILABILITY_RETRY_SEC = 15 min (last good value, or None, keeps serving); a success clears it. Before, a failure left the cache time stale, so the 30 s loop re-ran the heaviest query every cycle while it kept failing (up to the 60 s statement timeout each time).*_last_sync, tables[].last_updated) keeps its pre-#445 meaning, the GLOBAL latest write: _hypertable_latest reads the newest chunk's range_start from timescaledb_information.chunks (26 ms on prod) and runs MAX(time) … WHERE time >= :since as a separate statement (plans and scans one chunk: 1.6–7 ms on prod). As a subquery the planner walks every chunk (EXPLAIN alone > 10 s on trades), so the bound is always a parameter. Delta re-review Minor 3: an empty newest chunk no longer blanks the value — up to HYPERTABLE_LATEST_MAX_CHUNKS = 8 newest chunk ranges are listed (ORDER BY range_end DESC LIMIT :n) and tried newest first, each MAX(time) … WHERE time >= :since AND time < :until bounded to its own chunk (duplicate space-partition ranges queried once); None only if all 8 are empty. Read-only on prod Sep 26 09:32 UTC: catalog 23–120 ms; each bounded MAX plans one chunk (Index Only Scan on the chunk's time index), 4–6 ms.start_background_cache → _refresh_cache (every loop) → _fetch_db_stats_from_db(full=True) → with _per_statement_reads(engine) as conn → bounded table counts → categories → _get_data_availability(conn) (cache or _query_data_availability) → bounded active/recent/freshness/per-market → cache db_stats. _refresh_cache → get_earnings_with_polymarket → get_polymarket_earnings_from_db → per-statement earnings SELECT → Gamma (no checkout) → bounded latest-price / depth SELECTs. get_db_engine → _install_dashboard_session_limits → listener on connect.app.py): _install_dashboard_session_limits / _apply_dashboard_session_limits / _PerStatementReads (after get_db_engine); _query_data_availability / _get_data_availability (before _fetch_db_stats_from_db); table-count loop and the bounded queries inside _fetch_db_stats_from_db; get_polymarket_earnings_from_db (_per_statement_reads, price + depth bounds, _pm_db_last_error); get_active_positions_from_db; get_high_conviction_markets; get_earnings_with_polymarket message; dashboard() context market_availability_days.approximate_row_count is an estimate (the KPI says ≈). The 60 s statement timeout cancels any dashboard query that runs longer (logged at the call site). The cause of the flips is INFERRED, not isolated. #446 adds the RO / sessions_killed / disk rules.tests/unit/test_finding_445_dashboard_bounded_sql.py (28 tests; deploy-verification follow-up: per-market counts are one :since-bounded grouped COUNT per hypertable (no correlated per-row COUNT), window starts after the reliable-price cutoff, no hypertable SQL without markets, cached between passes, failure backoff serves last good, the measured prod plan of the exact statements is under half the timeout, template states the counts' age; newest-chunk last-updated (parameters, not subquery; None without chunks; filled by the stats pass); delta Minor 3: walks past an empty newest chunk with each MAX bounded to its own chunk, walk bounded to 8 and None when all empty; delta Major 3: after one failure three immediate calls run the query once, last good value served, retry after the window, success clears the backoff; retry window minutes-scale; single-flight coverage refresh under two threads; static AST scan of every hypertable SQL string in app.py; runtime stats pass issues only bounded hypertable SQL + one checkout per statement; coverage TTL; killed statement does not poison the pass; session limits; listener once; no checkout during Gamma; accurate error text vs the sync hint; template ≈ + window). These tests fail on c9c0072; the four delta tests (empty-chunk walk ×2, backoff ×2) also fail on the pre-delta app.py.sessions_killed flat for 60 min; temp_bytes growth < 100 MB per stats pass, measured across a commit (baseline 5.2 GiB / 1,490 files); no dashboard transaction older than 60 s (pg_stat_activity.xact_start; baseline ~14 min); PM rows on the calendar.884e528 (10:18:18 UTC restarts). Every DB-protection criterion passed in the 60-min watch (read-only sampler every 30 s, /tmp/batch_a_445_watch.csv on prod): temp growth ≈ 11 MB (one file) per ~2-min pass vs the old pass's final 5,634.5 MB / 1,490 files at 10:20:00 (the pre-deploy CTE's backend outlived the restart); no client transaction older than 60 s (pre-deploy 489 → 849 s); sessions_killed flat at 1818; read-only off; coverage computed (window_days 7); global last-updated current; 20 calendar rows, 11 with PM.statement_timeout on 4 of 4 passes (10:19:57, 10:21:57, 10:23:53, 10:25:41 UTC) and market_availability came back empty. Cause (read-only EXPLAIN on prod): four correlated COUNT(*) subqueries per upcoming market over NOW() - INTERVAL '90 days'; each count is a ChunkAppend over ~91 daily chunks, 16 markets → ≈ 5,800 chunk scans in one statement. The pre-#445 query had no window and no timeout, so it completed inside the 14-min transaction. No dashboard hold (coordinator, 10:35 UTC): the documented trigger (disk guard firing, sessions_killed stepping) was not met.reliability/batch-a-2): _query_market_availability = the upcoming rows (unchanged join, LIMIT :n = 25), then ONE grouped SELECT condition_id, COUNT(*) FROM <hypertable> WHERE condition_id IN :cids AND time >= :since GROUP BY condition_id per hypertable, :since = now − MARKET_AVAILABILITY_DAYS (7) as a parameter, so only the window's chunks are planned. ps_reliable = ps_total: the window starts after the 2026-01-08 17:53 reliable-price cutoff (PRICE_RELIABLE_SINCE, tested). Served through the same 6 h single-flight cache and 15-min failure backoff as coverage (_single_flight_cached, now shared by both; coverage behaviour unchanged). Payload adds market_availability_computed_at; the subtitle shows it.EXPLAIN (ANALYZE, BUFFERS), 10:36 UTC, 16 markets, first/cold run): orderbook 15,025.7 ms, price 993.0 ms, trades 81.0 ms (+ 75–269 ms planning); 8 chunks each (Index Only Scan on idx_orderbook_condition_time / pkey); 0 temp blocks. A warm re-run of the orderbook statement: 3,488 ms, then 761 ms. Worst case ≈ 16.5 s total, three separate statements (each in its own transaction under the 60 s timeout), at most once per 6 h refresh or 15-min retry. Recorded in the test as PROD_MARKET_AVAILABILITY_PLAN, tied to _market_availability_count_sql() so a changed statement fails the test until it is re-measured./tmp/batch_a2_445_watch.csv on prod, 0 sampler errors): market_availability 16 rows, all 16 with orderbook counts (computed 11:51:29, 0.35 s after coverage); 0 statement-timeout cancels, 0 coverage / market-availability / earnings_markets read failures, 0 tracebacks; temp 1,167.7 MB / 103 files in 60 min (≈ 11.3 MB per ~35 s stats pass, max 22.7 MB per 30 s sample — under the 100 MB-per-pass criterion); the per-pass file is get_high_conviction_markets()'s 7-day DISTINCT ON incremental sort (1,385 blocks, 281 ms, read-only EXPLAIN ANALYZE; candidate follow-up F-10); sessions_killed 1818 → 1818; longest client transaction 2 s; read-only off.earnings_dashboard/ were enumerated. 9 were unbounded and are fixed (above). 4 were already bounded and are unchanged: freshness ×3 (> NOW() - INTERVAL '1 hour'/'6 hours', > :cutoff) and chart bars (equity_bars_1m ts BETWEEN). The integrity monitor's hypertable rules are bounded since #373.systemctl disable --now polymarket-dashboard (the trader does not need it; consumers degrade as listed in OPERATIONS §7), then patch forward with a new commit and redeploy.reporting entry (cross-family review M6: row counts became catalog estimates and coverage a 7-day window — measurement/reporting semantics per memory.md §O; no trading behaviour change).earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html, tests/unit/test_finding_445_dashboard_bounded_sql.py (NEW), docs/DATA_ARCHITECTURE.md, docs/POSTGRES_OPERATIONS.md, docs/OPERATIONS.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry. Dated btc5m note: strategies/btc5m/docs/RUNBOOK.md (separate btc5m-only commit).#349 (migrations before restarts) and #383 (lock_timeout on the archive path, never given to deploy).)DEPLOYED Sep 26, 2026 10:18 UTC (Batch A deploy #1 884e528; F-7/F-8 deploy #2 3d7f1b1 11:48 UTC); no Gateway restart.deploy/deploy_from_github.sh ran psql -f on 029_earnings_date_confidence.sql and 030_trading_errors.sql on EVERY deploy, with no ledger, no lock_timeout, and >/dev/null 2>&1. ALTER TABLE earnings_markets ADD COLUMN IF NOT EXISTS takes ACCESS EXCLUSIVE even when the column exists, so the ALTER queued behind the dashboard's ~8 min SELECT DISTINCT condition_id FROM earnings_markets (#445) and every writer queued behind the ALTER (the #383 lock-queue class). In a read-only window the ALTER failed after git pull, the script exited, and the next #441 bounce would start the pulled code (UNVERIFIED — inferred in the plan, never observed). The etfstack 030 / btc5m 031 blocks only ran when the caller exported DATABASE_URL (the standard §H command does not). Same pattern at runtime: ConvictionTracker._ensure_schema issued an unconditional ALTER TABLE conviction_history ADD COLUMN IF NOT EXISTS … + CREATE INDEX IF NOT EXISTS on every start of earnings-conviction-tracker / polymarket-data-backfill, no lock_timeout — the trader's main loop INSERTs into conviction_history (trading_orchestrator.py _log_conviction_history, stage log_conviction_history).038_schema_migrations.sql (migration_id, sha256, method applied|bootstrap, applied_at, git_sha). (2) deploy/migrate.py (stdlib + psql, credentials in libpq env vars, not argv): MANIFEST = 038, main 029, main 030, etfstack 030, btc5m 031 — every one fatal (cross-family review M1: a sibling-tree failure no longer lets the deploy restart services). IDs in the ledger are skipped only when the file sha256 still matches; a mismatch fails closed (review M2 — numbered migrations are immutable, ship a new number). A missing ID whose objects ALL exist (catalog probe: schemas, relations incl. indexes, columns, hypertables) AND whose reviewed baseline matches (deploy/migration_baseline.json, review minor 3: 207 columns with their information_schema data types captured read-only from prod Sep 26 + the btc5m.killswitch/recorder_status seed rows; extra later columns allowed) is recorded bootstrap — the file is NOT executed (btc5m 031's seed INSERTs would otherwise refill tables the operator truncated on Sep 25). Otherwise: lock-holder preflight on the tables the DDL must lock (transactions older than 5 s, plus the pg_blocking_pids queue), then psql -1 -f file -c <ledger INSERT> in one transaction under lock_timeout=5s, statement_timeout=120s, idle_in_transaction_session_timeout=60s; 3 attempts 10 s apart on lock timeout; any failure exits 1 before any restart. Objects present but baseline missing/mismatched also exits 1 (neither bootstrap nor DDL). --dry-run forces default_transaction_read_only=on and only reports. (3) The deploy script calls the runner right after the pycache clear, before the restart loop, and tees its output to /var/log/polymarket/deploy-migrations.log; the old 029/030 loop and both sibling psql -f blocks are deleted. (4) §E: ConvictionTracker._ensure_schema reads the catalog first and returns without DDL when the 4 columns + 2 indexes exist; otherwise column DDL and index DDL are gated independently (an index-only gap issues no ALTER; review minor 1), each in one transaction under SET LOCAL lock_timeout = '5s'; a DDL failure or a schema still incomplete on re-check fails initialization (RuntimeError → the unit exits, systemd/integrity surface it) instead of running on a partial schema.bash deploy/remote_deploy.sh → systemd-run --unit=polymarket-deploy-<id> --collect (F-8) → flock -n /run/lock/polymarket-deploy.lock (else DEPLOY_EXIT rc=3) → git pull --ff-only && bash deploy/deploy_from_github.sh → hygiene → pull → pycache clear → venv/bin/python3 deploy/migrate.py | tee -a deploy-migrations.log → Runner.run(): to_regclass('public.schema_migrations') → ledger rows → per MANIFEST entry: skip / probe_sql → bootstrap INSERT / apply() → exit code → script exit 1 before systemctl restart or continue to the restart loop. (2) Runtime: first deploy on prod = 1 DDL (CREATE TABLE schema_migrations, a new relation — no lock on existing tables) + 4 bootstrap INSERTs; every later deploy = 2 catalog/ledger SELECTs + 5 probes skipped, 0 DDL. Read-only proof on prod 2026-09-26 ~07:1x UTC: migrate.py --dry-run from a /tmp copy → 038 would APPLY, 029/030/etfstack 030/btc5m 031 would record as bootstrap, current=0 bootstrap=4 applied=1, rc 0, 1.0 s wall. conviction_history has all 4 columns + both indexes (read-only catalog read) → tracker start issues 0 DDL. (3) Lines: deploy/migrate.py MANIFEST, probe_sql, Runner.run, Runner.apply, libpq_env; deploy_from_github.sh migrations block (after the pycache clear); conviction_tracker.py _ensure_schema. (4) Constraints: a NEW deploy-applied migration must be added to MANIFEST (tripwire fails for any src/data_tracker/migrations/0NN ≥ 038 not registered); bootstrap proves object existence + the baseline column types + the two btc5m seed rows; it does NOT check constraints, defaults, index definitions or the other seed keys (numbered follow-up F-5, delta re-review Minor 1); lock_timeout bounds a queued ALTER to 5 s per attempt (3 attempts), it does not remove it; psql must be on the droplet (it is: 16.15). (5) tests/unit/test_finding_444_migration_ledger.py (30 tests: current ledger → 0 -f and 0 writes; prod-state first run → only 038 DDL; missing objects → one -1 transaction with timeouts, no password in argv; lock timeout → 3 attempts then exit 1; long holder → no DDL; sibling failure FATAL; no non-fatal flag exists; sha drift exits 1 before DDL; bootstrap refused on a changed column type / missing column / missing seed row / missing baseline entry; baseline covers every MANIFEST entry and every column each file's CREATE TABLE declares; dry-run writes nothing; manifest declares every object each file creates; deploy script has no raw psql -f / /dev/null migration and runs the runner before the first restart; tracker: no DDL when complete, lock_timeout before DDL, index-only gap → no ALTER, DDL failure raises, incomplete-after-DDL raises). Read-only prod dry-run with the baseline, Sep 26: objects + baseline match for all 4, current=0 bootstrap=4 applied=1, rc 0.884e528; branch reliability/batch-a-2):earnings-conviction-tracker.service (long-running, conviction_tracker.py --run) was never in the deploy's restart list, so the #444 gated DDL reached it only on an incidental restart (prod pid 1215137, running since Fri 25 04:03:10 on pre-deploy code). Now: after migrations and before any restart, the deploy runs conviction_tracker.py --check-schema — ConvictionTracker.check_schema opens a READ ONLY session, reads the catalog (_conviction_history_catalog, now a classmethod) and exits 0 (complete: a restart issues no DDL), 1 (missing column/index: the deploy stops before any restart; fix with a numbered migration) or 2 (check failed: stops). Then the tracker is restarted with the other services. Prod dry-run of the new check from /tmp (read-only, 10:40 UTC): ok (4 columns, 2 indexes present; restart issues no DDL), rc 0. Before deploy #2, the step-7 needrestart catch-up restarted the tracker at 11:27:22 UTC (PID 1564476) onto 884e528 code: its startup schema path issued no DDL (no Schema updated line in /var/log/polymarket/conviction_tracker.log) and collected normally at 11:27:28; deploy #2's F-7 restart repeats it after the check. §E (prod, every running unit whose unit file references [hidden]): outside the restart set remain the paper Gateway stack (ibgateway-ibc-paper, xvfb-paper, x11vnc-paper — Gateway stack, never restarted by a deploy), polymarket-integrity-monitor (restarted at the deploy's tail) and ibkr-monitor (run_gateway_monitor.py, since Sep 3; unchanged by Batch A) — deliberately left: a Gateway health monitor's restart semantics are an operator decision; candidate follow-up F-9.ssh … bash deploy/deploy_from_github.sh | tee); the local client died, and the remote script was killed after the strategy regen (10:21:14 UTC): the close-persist contract check, integrity-monitor restart, logrotate and final dashboard restart + HTTP check never ran, and the exit status was lost. NEW deploy/remote_deploy.sh (operator machine) runs the pull + deploy as systemd-run --unit=polymarket-deploy-<id> --collect (owned by PID 1; WorkingDirectory=[hidden], HOME=/root), logging to /var/log/polymarket/deploy-<id>.log ending DEPLOY_EXIT rc=N, and polls with a fresh SSH call each time (a dropped poll is retried; --poll <id> resumes). One deploy at a time (patch-forward review Major 1): the list-units pre-check alone was check-then-start, so two callers could both start units; the unit now takes flock -n on /run/lock/polymarket-deploy.lock before git fetch and holds it for the whole deploy (fd 9, closed for every child), and a unit that cannot take it writes DEPLOY_EXIT rc=3 and touches nothing; <id> = UTC timestamp + random suffix, so two callers never share a unit name. Start handoff (review Minor 1): the unit name and resume command are printed before the start; if the start call fails, the exact unit, its log and journal are checked — polled if it ran, rc 6 if it never did. Exit = deploy rc; 3 = another deploy running (refused or lost the lock); 4 = still running / polls failing (resume); 5 = unit gone without DEPLOY_EXIT; 6 = start failed, nothing ran. memory.md §H and OPERATIONS §8.A1 make it the only deploy method.tests/unit/test_finding_444_migration_ledger.py (+4: check 0 read-only no DDL / missing 1 no DDL / error 2; the deploy runs the check after migrations and before restarts and restarts the tracker) and NEW tests/unit/test_finding_444_detached_deploy.py (14: the real script against fake ssh / systemd-run / systemctl / git and the real flock syscall (a fcntl.flock shim on hosts without util-linux flock) — rc 0 and 1 propagate, a failed pull skips the deploy, a dropped poll is retried, a second deploy is refused, --poll resumes, a unit gone without DEPLOY_EXIT is reported; two wrappers launched concurrently past a barrier in the pre-check both start units and exactly one reaches git fetch (rc 0 + rc 3); a unit that cannot take the lock touches nothing; the resume command precedes the start; a start whose SSH drops after systemd accepted the unit is found and polled; a start that never reached the server is rc 6; the lock precedes git fetch and children get 9>&-; memory §H + OPERATIONS use it). All fail on 884e528; the six lock/handoff tests also fail on d49948a.pg_stat_activity + deploy-migrations.log current=5 bootstrap=0 applied=0); pull-to-restart gap < 30 s.src/earnings/audit_logger.py _ensure_audit_table (trader start; CREATE TABLE/INDEX IF NOT EXISTS on audit_events — ShareLock at most, and the only writer is the trader itself, not yet running at init); src/data_tracker/collectors/data_correction.py create_data_gaps_table (manual one-shot script, no unit); src/data_tracker/models/trade_history.py CREATE_TABLE_SQL (never executed); src/data_tracker/scripts/install_collector.sh:206 (echoed manual bootstrap hint).lock_timeout. Once 038 is applied the ledger stays (old code ignores it). A runner defect is fixed forward with a new commit and redeployed; because the runner fails before any restart, services keep running the previous code meanwhile (resolve before the next ~04:15 / ~06:00 UTC #441 bounce, which would start the pulled code). A conviction-tracker init failure is fixed forward (the unit stays down; the trader does not depend on it).src/data_tracker/migrations/038_schema_migrations.sql (NEW), deploy/migrate.py (NEW), deploy/deploy_from_github.sh, src/data_tracker/collectors/conviction_tracker.py, tests/unit/test_finding_444_migration_ledger.py (NEW), deploy/migration_baseline.json (NEW, review minor 3), F-7/F-8: deploy/remote_deploy.sh (NEW), tests/unit/test_finding_444_detached_deploy.py (NEW), .cursor/memory.md §H, docs/OPERATIONS.md, docs/POSTGRES_OPERATIONS.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, this entry.xvfb.service / x11vnc.service: apt bounced Xvfb and took the Gateway down (Sep 26, 2026)#369 / #417.)DEPLOYED Sep 26, 2026 10:18 UTC (Batch A deploy #1 884e528): conf install only; no Gateway, Xvfb or x11vnc restart. The apt timer hold ended 11:27 UTC (below).#369 added qr(^xvfb-.*\.service$) / qr(^x11vnc-.*\.service$) to deploy/needrestart/polymarket.conf, which match only xvfb-paper / x11vnc-paper. The live units are xvfb.service / x11vnc.service (hand-installed, not in git). ibgateway-ibc.service has Requires=xvfb.service, so when unattended-upgrade → dpkg → needrestart restarted Xvfb after a library upgrade, the live Gateway went down with it and needed a 2FA. Evidence (IBKR RCA §A; uu-blocks + syslog, each kill within ~1 s of a dpkg RESTARTED … xvfb.service line): Sep 1 04:17:26 + 04:17:47 (zlib, bzip2), Sep 2 04:02:40 (libgcrypt), Sep 11 04:06:06 (libc6; :4001 down until ~04:42), Sep 22 04:04:42 (libexpat; 2FA 21 min / 4 pushes), Sep 25 04:03:10 (libexpat; login 04:13:19). Corrections: FINDINGS #429/#430 call Sep 2 04:02 a "Java crash"; it was needrestart (uu-blocks 04:02:37, syslog 04:02:40). The Sep 25 04:03 Gateway + dashboard restart was needrestart, not a weekday AutoRestart. OPERATIONS.md said this "should no longer happen" (corrected in the same commit).DONE Sep 26, 2026 11:27 UTC (#443): history: systemctl disable --now apt-daily-upgrade.timer, the only path that runs dpkg + needrestart on this host (drop-in OnCalendar 04:00 + 30 min random, Persistent=true). apt-daily.timer (update + download only) was left running. 0 security upgrades were pending. Residual vector: the hourly droplet-agent-update.timer (dpkg on a new agent version only). Planned end: re-enable (systemctl enable --now apt-daily-upgrade.timer) as the FINAL Batch A step, after the deploy is verified and the live coverage check passes, with the operator on the phone (operator decision Sat 26 Sep 06:58 UTC, plan OD4 option a). Not during market hours, 04:00–04:30 UTC or the Sunday 06:00 window. The catch-up run (≤~30 min random delay) is then this finding's acceptance test. If Batch A is not live by Sun 27 Sep 18:00 UTC, the hold stays until Tue 29 Sep 20:30 UTC (OD4 option b). A deploy does not lift the hold, and the agent does not re-enable the timer without the operator.qr(^xvfb(-.*)?\.service$) and qr(^x11vnc(-.*)?\.service$). (2) NEW deploy/needrestart/check_coverage.pl loads the confs with needrestart's own semantics (needrestart.conf + conf.d/*.conf as Perl). A unit counts as protected only if at least one override_rc key matches AND every matching key is 0, because needrestart takes the first match in hash order. (3) NEW deploy/needrestart/gate.sh, run by deploy_from_github.sh right after git pull and BEFORE migrations and every restart (cross-family review M3): fails immediately if the repo conf, the checker, perl, /etc/needrestart/conf.d or /etc/needrestart/needrestart.conf is missing; installs the conf; requires xvfb.service, x11vnc.service, ibgateway-ibc.service and earnings-trader.service in the live inventory; runs the checker over the complete conf stack needrestart itself loads — host needrestart.conf, then every conf.d/*.conf in Perl sort (byte, LC_ALL=C) order, dotfiles excluded like needrestart's glob (delta re-review Major 1: a later snippet that re-allows a restart or replaces the override_rc hash fails the gate) — over the live xvfb*, x11vnc*, ibgateway*, earnings-trader*, ibkr-monitor*, websocket, collector, live-trades and integrity-monitor units; any gap exits 1 before anything restarts. (4) Plan P0-6 bullet 2 (review M4): the exact live xvfb.service, x11vnc.service and apt drop-ins (apt-daily-upgrade.timer.d/override.conf, apt-daily.timer.d/override.conf) are committed under deploy/host-reference/ (fetched read-only Sep 26; live ibgateway-ibc.service is byte-identical to deploy/systemd/ibgateway-ibc.service). deploy/check_host_reference.sh compares all five against the host (deploy/host-reference/MANIFEST) before the needrestart gate and fails the deploy on any difference or missing file, printing the diff. Compare-only by design: nothing installs them — rewriting these units would need a daemon-reload and a restart of Xvfb (= the live Gateway via Requires=), which only the operator does deliberately; the repo copy is then updated in a commit.apt.systemd.daily install → unattended-upgrade → dpkg → DPkg::Post-Invoke /usr/lib/needrestart/apt-pinvoke -m u → needrestart override_rc lookup (/usr/sbin/needrestart ~1092: foreach $re (keys override_rc) { next unless $rc =~ /$re/; …; last }) → 0 = list only, never restart. Deploy: pull → pycache clear → check_host_reference.sh (exit 1 on drift) → needrestart/gate.sh (prerequisites → install conf → inventory → check_coverage.pl over needrestart.conf + every conf.d/*.conf in byte order; exit 1 on any gap) → migrations → restarts. (2) Runtime, read-only on prod Sep 26 ~07:3x UTC, with the host's real needrestart.conf + systemctl list-unit-files (14 units): with the installed conf 12 are ok and xvfb.service + x11vnc.service are UNPROTECTED; with this conf (from /tmp, not installed) 14/14 ok. (3) Lines: deploy/needrestart/polymarket.conf xvfb/x11vnc rules; check_coverage.pl; deploy/needrestart/gate.sh; deploy/check_host_reference.sh + deploy/host-reference/MANIFEST; the two if ! bash … gate calls ahead of the migrations block in deploy_from_github.sh. (4) Constraints: needrestart still LISTS the units (the operator restarts them deliberately in a window); a live unit with a new name outside the enumerated globs is not checked; paper Xvfb :100 still maps deleted libs (plan #464); the live units and apt drop-ins are committed compare-only (a live change must be mirrored by a commit, or the next deploy stops); installing them automatically is deliberately excluded. (5) tests/unit/test_finding_443_needrestart_live_units.py (38 tests: 14 live names each resolve to 0 in Python; the Perl checker passes on this conf and FAILS on the c9c0072 conf naming exactly xvfb.service + x11vnc.service; empty unit list / missing conf rejected; deploy runs host check → needrestart gate → migrations → restarts with immediate exits; gate prerequisites; gate passes on the live inventory, fails on missing conf.d / needrestart.conf / required unit / a system rule allowing restart; delta Major 1: the gate evaluates the whole conf.d stack (static), FAILS when a second snippet zz-allow.conf re-allows xvfb.service, FAILS when a snippet replaces the override_rc hash, loads snippets in needrestart order (the same key re-allowed in zz- fails, in 00- is overwritten by polymarket.conf and passes), ignores dotfile / non-.conf snippets and passes a benign one — the five delta tests fail on the pre-fix two-file gate; host-reference manifest = the 5 live files; committed units encode ibgateway-ibc Requires=xvfb.service + DISPLAY=:99 / Xvfb :99 / x11vnc Requires=xvfb; needrestart covers every committed host unit; host check passes identical, fails on drift with a diff and on a missing live file; host check and deploy never install host-reference files). Read-only on prod Sep 26 08:35 UTC from a /tmp copy: host check 5/5 ok; gate (conf installed into /tmp only) coverage OK for 14 live units; /etc/needrestart/conf.d/polymarket.conf untouched (d3be1c24…). Prod conf.d read-only Sep 26 09:27 UTC: only polymarket.conf + README.needrestart (not *.conf), so the full stack at deploy is needrestart.conf (baacdd68…) + the installed conf — the same two files as the 14/14 proof. Live acceptance after the hold ended (step 7, Sep 26 2026): apt-daily-upgrade.timer enabled 11:27:08 UTC; the catch-up run (11:27:08–11:27:28) installed only libpcap0.8t64, and needrestart restarted only earnings-conviction-tracker (new PID 1564476); ibgateway-ibc, xvfb, x11vnc, the :4001 Java process and the trader kept their PIDs. Limit: the ibgateway-ibc rule was exercised directly; xvfb / x11vnc were not using stale libraries, so their rules were exercised only through the -paper twins (deferred), not on the live units. Next timer run Sun 27 Sep 04:25:24 UTC.systemctl enable --now apt-daily-upgrade.timer at 11:27 UTC Sep 26, 2026, operator on the phone (step 7, OD20), after deploy #1 was verified and the live coverage check passed. Catch-up run 11:27:08–11:27:28 UTC installed only libpcap0.8t64; needrestart restarted only earnings-conviction-tracker (new PID 1564476, normal collection 11:27:28); ibgateway-ibc, xvfb, x11vnc, the :4001 Java process and the trader kept their PIDs. Acceptance limit: the ibgateway-ibc rule was exercised directly; xvfb / x11vnc were not using stale libraries, so their rules were exercised only through the -paper twins (deferred). Next timer run Sun 27 Sep 04:25:24 UTC.host-reference: ok ×5, needrestart: evaluating conf stack …needrestart.conf …/conf.d/polymarket.conf and needrestart: coverage OK for 14 live units (2 conf files) BEFORE the migration lines. After the attended timer re-enable, the catch-up run's dpkg / /var/log/unattended-upgrades/unattended-upgrades-dpkg.log lists no xvfb.service / x11vnc.service / ibgateway-ibc.service under RESTARTED; ibgateway-ibc MainPID and ActiveEnterTimestamp unchanged; :4001 stays LISTEN; no weekday 2FA from apt.systemctl disable --now apt-daily-upgrade.timer (the old conf leaves live Xvfb unprotected) and keep it disabled until a fixed conf is deployed and the gate passes. A host-reference drift stop is resolved by reconciling deliberately (commit the new live file, or restore the live file in an operator window) — never by bypassing the check.#417 changelog entry AMENDED → #443.deploy/needrestart/polymarket.conf, deploy/needrestart/check_coverage.pl (NEW), deploy/needrestart/gate.sh (NEW), deploy/check_host_reference.sh (NEW), deploy/host-reference/MANIFEST + systemd/{xvfb.service,x11vnc.service,apt-daily-upgrade.timer.d/override.conf,apt-daily.timer.d/override.conf} (NEW, compare-only), deploy/deploy_from_github.sh, tests/unit/test_finding_443_needrestart_live_units.py (NEW), src/earnings/strategy_changelog.py, docs/OPERATIONS.md, docs/IBKR_CONTINUITY.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, #369/#417 status markers, this entry.#349/#424. Display + admission + timeline in one commit.)DEPLOYED Sep 13, 2026. Do not revert #349/#424.earnings_markets by ticker — the only upcoming CBRL PM row was the Sep 14 confirmed slug, so date_confidence=confirmed / was_entered=true / the 09-14 URL landed on the 23rd. Timeline derived earnings day from scheduled_exit_time (Mon 14). verify_earnings_dates loaded [today, today+5]; on Fri 11 Sep NASDAQ's official 23rd could not vote, so Sep 14 was confirmed and #349-locked. Votes were not written on confirm-in-place. #424 IR map was ASO-only.(TICKER, YYYY-MM-DD) via lookup_pm_for_calendar_date — no ticker-only fallback. (2) Verify DB window + NASDAQ/Finnhub fetch = 21 days; official_calendar_blocks_confirm refuses confirm when an official date sits past the window; official_calendar_pair (NASDAQ+Yahoo) may rewrite a locked PM date before the hardcoded map; CBRL Sep 14→23 BMO is last-resort IR; vote set is inserted on every confirm including in-place. (3) Timeline keeps the scheduled-exit plan date for the hold bar and flags earnings_date_source_mismatch (Jinja + JS DATE SPLIT). Does not move the live exit.get_earnings_with_polymarket looks up PM by (ticker, calendar date) → no Sep 14 fields on the Wed 23 row. BMO/AMC verify → 21-day SELECT + gather_source_readings 21-day NASDAQ → CBRL Sep 23 votes → pair or IR map rewrites the locked row → audit votes=. Positions API → plan_earnings_date_from_exit vs NASDAQ cache → DATE SPLIT on CBRL. (2) Runtime: after deploy, /api/earnings-calendar?days=14 CBRL 2026-09-23 has has_market=false (or a same-date market only); /api/state CBRL earnings_date_source_mismatch=true, plan_earnings_date=2026-09-14, calendar_earnings_date=2026-09-23. (3) Lines: earnings_date_surfaces.py; app.py pm_match_key / lookup_pm_for_calendar_date / timeline fields; earnings_date_verifier.py lookahead + pair + CBRL map; signal_generator.py verify_earnings_dates; dashboard.html DATE SPLIT. (4) Constraints: Yahoo rate-limit leaves pair empty — hardcoded CBRL still fires; single NASDAQ voice never rewrites (#349); scheduled exit stays Mon 14 unless the operator CONFIRMED a hold. (5) tests/unit/test_finding_442_earnings_date_surfaces.py.src/earnings/earnings_date_surfaces.py (NEW), src/earnings/earnings_date_verifier.py, src/earnings/signal_generator.py, earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html, tests/unit/test_finding_442_earnings_date_surfaces.py (NEW), src/earnings/strategy_changelog.py, this entry + R2 surfaces.#440/#421/#432/#439.)DEPLOYED Sep 9, 2026. #440 leftover (same-thread marshal; 1102 still util.run inside the error callback). Do not revert #412–#440._verify_connection_health(durable=True) from the ib_insync error callback — nested util.run inside ib.sleep, 10s timeout, poisoned the owner loop. IB() reset then logged same-thread req* ok (handshake currentTime) and the next reqCurrentTime:health timed out. Sticky never cleared (only durable success clears it), so _mark_recovery_exhausted kept probing and #439 blocked last-resort recycle. 06:00 weekday AutoRestart in-process reattach hit the same poisoned loop. Trader process stayed up; reconnect_attempt_count 200+. Monday #440 trader-only bounce on this Gateway restored req* — that lever was never used.util.run from _on_error. 1102 / weekday reattach disarms farm sticky. After IB() reset fails while TCP is up: one bounce_trader (attach-only, no Gateway 2FA) before last-resort recycle. 1100 stop_heartbeat(join=False) so the callback cannot deadlock the owner. Do not mute trader:ibkr_request_path. Do not recycle Gateway for this._disarm_farm_recovery → _schedule_reconnect → owner _attempt_reconnect_once / IB() reset → durable fail → _maybe_attach_bounce → systemd-run restart earnings-trader. No _verify_connection_health in the 1102 callback. (2) Runtime: deploy + bounce trader only; Gateway ActiveEnter stays Mon 7 Sep 07:43:55 (Java is the Wed 06:00 AutoRestart child); same-thread req* ok then no 10s durable timeout; integrity trader:ibkr_request_path ok; no new IBC 2FA. (3) Lines: client.py _on_error 1101/1102, _disarm_farm_recovery, _maybe_attach_bounce, _mark_recovery_exhausted, request_ib_reattach, stop_heartbeat(join=). (4) Constraints: a wedged Java after bounce still times out on the new process — latch stays honest; do not attach client 99; do not mute the rule. (5) tests/unit/test_finding_441_1102_no_nested_run.py.src/ibkr/client.py, tests/unit/test_finding_441_1102_no_nested_run.py (NEW), tests/unit/test_finding_421_ibkr_session_machine.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.#87/#412/#438.)DEPLOYED Sep 7, 2026. #87 leftover (heartbeat thread still called ib_insync). Do not revert #412–#439.Connected U2…13 from managedAccounts() (push/handshake). Heartbeat thread called util.run(reqCurrentTimeAsync) on a different asyncio getLoop(). ib_insync is not thread-safe (#87). Tape: 03:57:50 IB() reset on the reconnect thread → 03:57:54 RESTORED + 03:57:57 Health check passed (same thread) → 03:58:30 heartbeat timeout → Mode B 04:03. After the 07:43 2FA, connect on main never same-thread proved; every probe was the heartbeat thread. Integrity trader:ibkr_request_path opened at 07:52 because we latched a false dead path._ib_thread on connect(). probe / durable / heartbeat / reconnect / IB() reset / connect from any other thread enqueue to the owner. idle() / pump_ib_owner() on the orchestrator loop. Same-thread req* before start_heartbeat. Heartbeat marshal timeout is a skip, not a death. Do not mute trader:ibkr_request_path. Do not invent net-liq. Do not recycle Gateway to “fix” a false latch._ib_call → owner util.run → req* uses the connect loop. Owner time.sleep without pump → skip, not Mode B. Off-thread probe() does not call reqCurrentTimeAsync on that thread. (2) Runtime: bounce trader onto this SHA (no Gateway recycle); same-thread prove after connect; no 07:52-style DEAD from heartbeat-only timeouts while Java answers on owner. (3) Lines: client.py _bind_ib_owner / _ib_call / idle / pump_ib_owner / marshal on connect/probe/_perform_heartbeat/_attempt_reconnect_once; trading_orchestrator.py run idle. (4) Constraints: a true wedged Java still times out on the owner — latch stays honest; do not attach client 99; do not mute the rule. (5) tests/unit/test_finding_440_ib_owner_thread.py.src/ibkr/client.py, src/earnings/trading_orchestrator.py, tests/unit/test_finding_440_ib_owner_thread.py (NEW), src/earnings/strategy_changelog.py, this entry + R2 surfaces.#438/#435.)DEPLOYED Sep 5, 2026. #438 leftover (last-resort + #435 3600s cooldown → hourly recycle) AMENDED. Do not revert #412–#438.#438 last-resort recycled whenever Gateway age ≥1h and last stamp ≥1h. Tape: ibgateway-ibc 18:33:45 / 19:37:14 / 20:39:44 UTC, each IB() reset health failed, each IBC Second Factor Authentication initiated (18:33:53, 19:37:21, 20:39:50). The second and third 2FAs did not add a new lever — Java was still wedged.IBKR_RECYCLE_MAX_PER_WEEK=1 (Sunday 06:00 UTC week). IBKR_RECYCLE_COOLDOWN_SEC default 604800. No Python recycle Sunday 06:00–18:00 UTC (AutoRestart is the planned 2FA). After the week's last-resort: keep IB() reset / probes until Sunday. false AUTO_RECYCLE hold stays. Do not mute trader:ibkr_request_path. Do not invent net-liq.recycle_defer_reason #439 → no systemctl restart ibgateway-ibc. Cooldown 7d also defers. Sunday 10:00 UTC → Sunday window. Next week after Sunday 06:00 + cooldown elapsed → one last-resort still allowed after IB() reset. (2) Runtime: this Saturday no 4th 2FA; Sunday 06:00 still IBC AutoRestart; next Mode B same week keeps probing. (3) Lines: gateway_recycle.py sunday_week_start_utc / last_resort_used_this_week / sunday_autorstart_quiet / _cooldown_sec 604800; Config + GUARD. (4) Constraints: a wedged Java after one last-resort waits for Sunday — do not recycle again to try; do not mute the latch. (5) tests/unit/test_finding_439_weekly_recycle_budget.py.#438 leftover AMENDED.src/ibkr/gateway_recycle.py, src/earnings/config.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_439_weekly_recycle_budget.py (NEW), leftovers #435/#438, src/earnings/strategy_changelog.py, this entry + R2 surfaces.req* must work; (2) 2FA only Sunday, or if really needed to hold (1). Minimize extra 2FA; do not refuse recycle while the trader stays path-dead. Complements #437/#436/#435/#434.)DEPLOYED Sep 5, 2026. Hourly last-resort leftover AMENDED by #439. #437 leftover (recycle stays off after a failed IB() reset → Saturday dashboard still dead) AMENDED. #436 never-recycle default AMENDED. #435 age/cooldown/late-watch stays (cooldown default raised by #439). Do not revert #412–#437.#436 ranked Sunday-only 2FA above stay-connected. #437 kept minting IB() and heartbeating, then stopped at that ladder. 5 Sep 13:15 UTC: first #437 IB() reset durable-failed; header honest path_dead; Gateway live since 3 Sep 12:29; no recycle. Principle 1 stayed broken.IB() reset (repeat while #435 defers or never-recycle hold) → last-resort systemctl restart ibgateway-ibc only after _inplace_reset_used and not farm-recovery. IBKR_GATEWAY_AUTO_RECYCLE default true. false is a dated never-recycle hold (# GUARD-OVERRIDE-UNTIL). PATH_DEAD / Mode B / heartbeat trip does not recycle before an IB() reset. Reconnect 300s floor only after the first reset. Heartbeat never skipped for recycle-on. ibc_auth_in_flight defers recycle. Farm sticky #432 still no 2FA. Live book still not a refuse. Do not mute trader:ibkr_request_path. Do not invent net-liq._inplace_reset_used → keep probing, no ibgateway-ibc restart. IB() reset fail + Gateway age ≥3600s + cooldown elapsed → last-resort recycle + 2FA wait / late-login bounce. Young Gateway / cooldown / late-watch / 2FA-in-flight → another IB() reset after 300s. AUTO_RECYCLE=false → keep probing. 1100 sticky → no recycle. (2) Runtime: this Saturday after ship — IB() reset then recycle if still dead (approve 2FA); next 04:14 still #432; next weekday 06:00 still #437 reattach; next first timeout still no 2FA. (3) Lines: client.py _last_resort_recycle_ready / _mark_recovery_exhausted / _reconnect_delay_sec / _skip_zombie_heartbeat; gateway_recycle.py _auto_recycle_enabled default true / recycle_defer_reason auth-in-flight; Config + GUARD invert. (4) Constraints: cannot mint req* on a wedged Java without a new login — that is the last-resort 2FA; do not recycle on the first timeout; do not mute the latch. (5) tests/unit/test_finding_438_last_resort_recycle.py.#437 leftover AMENDED.src/ibkr/client.py, src/ibkr/gateway_recycle.py, src/earnings/config.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_438_last_resort_recycle.py (NEW), leftovers #418/#419/#426/#427/#430/#432/#433/#434/#435/#436/#437, src/earnings/strategy_changelog.py, this entry + R2 surfaces.:4001 up, IBC Login completed 06:00 without 2FA, request_path_ok=false, recovery_exhausted=false, 75 reconnects, last durable miss after one IB() reset at 06:14. Complements #436/#433/#432.)DEPLOYED Sep 5, 2026. Recycle-off leftover after a failed IB() reset AMENDED by #438. #436 leftover (one IB() reset then in-place forever; heartbeat skip while path-dead; attach down_since sticky → Fri/Sat 06:01 trader bounce) AMENDED. Periodic IB() reset and weekday reattach stay. Do not revert #412–#436.#436 keep-probed the same IB() after _inplace_reset_used. Heartbeat skipped reqCurrentTime while TCP-up + path-dead (#426 zombie skip). Weekday IBC AutoRestart drops :4001 ~30s; down_since was never cleared, so +60s wall clock bounced earnings-trader (Fri 06:01:02, Sat 06:01:01). After bounce, one reset failed and the loop only durable-probed the dead client. Saturday 04:49 1102 + durable Health check passed proved Java can answer — we then stopped minting a new IB(). gateway_restart_manager.sh is git 100644; cron 05:58 is Permission denied (does not stop in-process AutoRestart anyway).IBKR_INPLACE_RESET_COOLDOWN_SEC (300) allow another _try_one_inplace_reset. _force_ib_reset / request_ib_reattach on weekday IBC Login has completed (no 2FA) — new IB() in this process, no trader bounce. Attach down_since only while the port is still down. Heartbeat keeps probing when recycle is off. Bounce remains for 2FA / port down ≥60s continuously. Manager script is executable. Do not mute trader:ibkr_request_path. Do not recycle midweek. Do not invent net-liq._try_one_inplace_reset. Path-dead + recycle-off → _skip_zombie_heartbeat false → reqCurrentTime. Port down 30s then up → no bounce. New Login, no 2FA → request_ib_reattach. 2FA + Login → bounce. Recycle-on still one-reset-then-exhaust (#419). (2) Runtime: this Saturday bounce onto #437 then durable prove; next weekday 06:00 reattach no bounce; next 04:14 still #432; Sunday 06:00 still one 2FA then bounce. (3) Lines: client.py _inplace_reset_ready / request_ib_reattach / _attempt_reconnect_once / _skip_zombie_heartbeat; gateway_recycle.py set_session_reattach / attach down_since / weekday Login branch; Config cooldown; scripts/gateway_restart_manager.sh +x. (4) Constraints: cannot mint req* if Java is truly wedged — we keep resetting IB(); do not recycle to try; do not mute the latch. (5) tests/unit/test_finding_437_saturday_reattach.py.#436 leftover AMENDED.src/ibkr/client.py, src/ibkr/gateway_recycle.py, src/earnings/config.py, scripts/gateway_restart_manager.sh, tests/unit/test_finding_437_saturday_reattach.py (NEW), tests/unit/test_config.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.#434/#435 recycle-on + 3 Sep 06:27 / 06:48 / 07:14 / 11:26 2FAs. Complements #433/#432/#430/#428.)DEPLOYED Sep 3, 2026. One-reset / heartbeat-skip / sticky-down_since leftover AMENDED by #437. #434 leftover (recycle-on default) AMENDED. #435 leftover (rate-limit, still recycles after 1h) AMENDED. #430 weekly-2FA default restored. #433 keep-probing is the standing path. #432 farm sticky stays. Do not revert #412–#426.systemctl restart ibgateway-ibc is a new IBKR login. #427/#434 made that the restore for a wedged req*. It broke weekly 2FA (24 pushes 28 Aug–2 Sep; four more on 3 Sep). #435 still recycled at 11:26 once the Gateway was ≥1h old. Sunday AutoRestart and Java-crash systemd already create a new session; late-login was only armed after Python recycled first.IBKR_GATEWAY_AUTO_RECYCLE default false. true needs # GUARD-OVERRIDE-UNTIL. Mode A #432 and Mode B #433 keep probing the same session. arm_session_attach_watch (from connect()) bounces earnings-trader on a new IBC 2FA or :4001 down ≥60s then Login has completed — never restarts Gateway. connect_ibkr waits the late-login window and keeps retrying while login is in flight. ibkr-monitor auto-login is off. IBC ExitInterval template 1800. Weekday 06:00 session preserve stays. Do not mute trader:ibkr_request_path. Do not invent net-liq.recycle_gateway_if_flat → WEEKLY_2FA_HOLD, no systemctl restart ibgateway-ibc. Mode B mark-exhausted → no latch → extended_60s. New 2FA + Login + port → systemd-run trader bounce. Weekday seek-past Login does not bounce. connect_ibkr after 10 fails still retries if :4001 down. (2) Runtime: next 04:14 1100 still #432; next Mode B no 2FA; next Sunday 06:00 one tap then attach; Java crash one 2FA (systemd) then attach; weekday 06:00 no attach unless port died. (3) Lines: gateway_recycle.py _auto_recycle_enabled / arm_session_attach_watch / keep_connect_retry; client.py _arm_session_attach_once / _mark_recovery_exhausted; run_earnings_trader.py connect_ibkr; Config + GUARD invert; gateway_monitor.py _attempt_auto_recovery. (4) Constraints: cannot mint req* on a wedged live Java without a new login — that wait is until Sunday or a crash; do not recycle to try; do not mute the latch. (5) tests/unit/test_finding_436_weekly_2fa_default.py.#434/#435 leftovers AMENDED. #430 default restored.src/ibkr/gateway_recycle.py, src/ibkr/client.py, src/ibkr/gateway_monitor.py, run_earnings_trader.py, deploy/earnings-trader.service, deploy/ibc/config-paper.ini.template, src/earnings/config.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_436_weekly_2fa_default.py (NEW), leftovers #430/#434/#435/#427, src/earnings/strategy_changelog.py, this entry + R2 surfaces.#434. Tape: ibgateway-ibc recycled 06:27 / 06:48 / 07:14 UTC, each from Mode B heartbeat failures in window while TCP up (#429), each a phone 2FA. Complements #434/#429/#428.)DEPLOYED Sep 3, 2026. #434 leftover (Mode B window → recycle immediately; new trader process resets _standing_recycle_attempted) AMENDED. Recycle-on standing leftover AMENDED by #436. Hourly cooldown leftover AMENDED by #439. #435 min-age stays. #429 window stays ACTIVE (detection). #432 farm sticky stays. Do not revert #412–#434.#434 made recycle the next step after Mode B. Light reqCurrentTime flakes for ~10 min after a fresh login (Error 2119 farm connecting). 8 fails / 600s fired, trader bounce cleared the in-process “already recycled” flag, and a Gateway 20 min old was killed again. Principle 2 (minimize 2FA) was ignored while principle 1 (stay connected) was already available via in-place reconnect on the same session.recycle_gateway_if_flat defers (keep probing) when a late-login watch is armed, Gateway ActiveEnterTimestamp is younger than IBKR_RECYCLE_MIN_AGE_SEC (default 3600), or last recycle stamp is inside IBKR_RECYCLE_COOLDOWN_SEC (default 604800 since #439; was 3600 — the hourly leftover). _mark_recovery_exhausted does not latch when deferred. Recycle still runs when req* is dead, in-place is exhausted, and the Gateway is old enough. Farm window still no recycle. Live book still not a refuse. Do not mute trader:ibkr_request_path. Do not invent net-liq._maybe_schedule_request_path_reconnect. Path dead + young Gateway / cooldown / late-watch → keep extended_60s. Path dead + Gateway ≥3600s + cooldown elapsed → systemctl restart ibgateway-ibc + stamp. (2) Runtime: next Mode B flake after a 2FA does not recycle; next 04:14 1100 still #432; a wedged Java on a ≥1h Gateway still 2FAs once; weekend same. (3) Lines: gateway_recycle.py recycle_defer_reason / stamp / recycle_gateway_if_flat; client.py _mark_recovery_exhausted / _note_heartbeat_failure; Config + GUARD. (4) Constraints: cannot mint req* without farms; a true wedge younger than MIN_AGE waits up to 1h while we keep probing — then one recycle. (5) tests/unit/test_finding_435_recycle_once.py.#434 leftover AMENDED.src/ibkr/gateway_recycle.py, src/ibkr/client.py, src/earnings/config.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_435_recycle_once.py (NEW), leftovers #429/#428/#433/#434/#427, src/earnings/strategy_changelog.py, this entry + R2 surfaces.#430 default-off + live-book refuse left req* dead on 3 Sep while CPB 99 was open. Complements #433/#432/#430/#427.)DEPLOYED Sep 3, 2026. #430 leftover (recycle off / refuse live book) AMENDED. Mode B every-20-min 2FA leftover AMENDED by #435. Recycle-on standing leftover AMENDED by #436. #433 keep-probing is the default path. #432 farm sticky stays (no recycle during 1100). Do not revert #412–#433.#430 ranked weekly 2FA above a live request path. #433 then kept probing forever when recycle was off. Mode B Java wedge cannot be Python-fixed. Header was honest after #433; the path was still dead.IBKR_GATEWAY_AUTO_RECYCLE default true. false needs # GUARD-OVERRIDE-UNTIL. After the no-2FA budget (farm sticky #432, or Mode B / zombie exhaust) recycle ibgateway-ibc even with a live book. Hardware stops stay. Late-login attaches the trader on a live book. Farm window still does not recycle. Do not mute trader:ibkr_request_path. Do not invent net-liq.#433 keep probe. (2) Runtime: next 04:14 1100 still no-2FA; next Mode B 2FAs midweek if farms are not the cause; weekend same; open position still recycles. (3) Lines: gateway_recycle.py _auto_recycle_enabled / recycle_gateway_if_flat / start_late_login_watch; client.py _maybe_standing_recycle; Config + GUARD. (4) Constraints: recycle during a real farm outage still 2FAs into dead farms — farm window blocks that; cannot mint req* without Gateway or farms. (5) tests/unit/test_finding_434_connect_first.py.#430 AMENDED.src/ibkr/gateway_recycle.py, src/ibkr/client.py, src/earnings/config.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_434_connect_first.py (NEW), tests/unit/test_finding_430_weekly_2fa.py, tests/unit/test_finding_427_farm_recovery.py, tests/unit/test_finding_428_late_login.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.request_path_ok=true; #429 Mode B window exhausted at 05:16:23 while last durable success was 05:15:03 and the latch stayed true. Dashboard get_trader_runtime_status painted Request path dead because #421 let session_phase=exhausted win. Recycle off — exhaust-and-stop had no next step. CPB 99 open. Complements #432/#430/#429/#421.)DEPLOYED Sep 3, 2026. #432 leftover (Mode A only) AMENDED. #429 leftover (window stops probes when recycle is off) AMENDED. #421 leftover (exhausted wins header while latch is true) AMENDED. Recycle-off standing default AMENDED by #434. Keep-probing stays for a dated override. #432 stays ACTIVE (sticky 1100). Do not revert #412–#432.#430 removed the only action exhaust was for (systemctl restart ibgateway-ibc). #432 kept Mode A probing; Mode B still called _mark_recovery_exhausted and _schedule_reconnect returned. Header used exhausted OR path_ok false. After bounce, 8 light heartbeat fails in 600s looked like Mode B while qualify was still succeeding. Operator screenshot 01:26 ET = that fake-red._mark_recovery_exhausted returns without latching when IBKR_GATEWAY_AUTO_RECYCLE is false; keeps durable probes; one #433 page. Recycle-on still exhausts then recycle. session_phase: path_ok false first; exhausted only with a dead latch. Dashboard header: Request path dead only if request_path_ok is False or session_phase==path_dead. Do not mute trader:ibkr_request_path. Do not recycle with a live book. Do not invent net-liq._recovery_exhausted stays false → extended_60s continues. Path_ok true + stale exhausted flag → session_phase=ready / header Connected / client 99 not skipped. Path_ok false → header dead. Recycle true → still exhausts. (2) Runtime: next 04:14 1100 still #432 sticky; next heartbeat flake after bounce keeps probing, no 2FA; header matches latch; weekend same; live book still forbids recycle. (3) Lines: client.py _mark_recovery_exhausted / _schedule_reconnect / session_phase; earnings_dashboard/app.py get_trader_runtime_status; trade_reconciler.py _trader_request_path_dead. (4) Constraints: cannot mint a midweek login without 2FA; cannot make a true Java wedge answer req* — we keep asking. (5) tests/unit/test_finding_433_keep_probing.py.#432/#429/#421 leftovers AMENDED.src/ibkr/client.py, earnings_dashboard/app.py, src/data_tracker/collectors/trade_reconciler.py, tests/unit/test_finding_433_keep_probing.py (NEW), tests/unit/test_finding_421_ibkr_session_machine.py, tests/unit/test_finding_426_gateway_zombie.py, tests/unit/test_finding_427_farm_recovery.py, tests/unit/test_finding_432_daily_reconnect.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.#430 refused recycle; CPB 99 open. Operator asked for weekly 2FA and daily reconnect without 2FA. Complements #430/#427/#426/#412.)DEPLOYED Sep 3, 2026. #427 leftover (900s timer then exhaust) AMENDED. #427 stays ACTIVE (arm farm recovery). #430 stays ACTIVE (recycle off). Mode B exhaust-and-stop leftover AMENDED by #433. Do not revert #412–#430.#427 cleared Mode A on “1102 + durable success” and treated window expiry as a #426 zombie. Rotated trader logs 16–29 Aug: nightly 1100, almost no 1102. IBKR often never sends the restore message. After #430 turned recycle off, a long farm drop stayed latched until Sunday. A new IBKR login is always a 2FA — daily reconnect cannot be a Gateway restart. It is keep-probing the existing session until req* works, which is what pre-#412 nights were doing silently._arm_farm_recovery sets _farm_recovery_sticky = True. _in_farm_recovery() is sticky OR the 900s timer. Sticky clears only in _clear_inplace_recovery_state (durable success). After the timer, TCP-up trip / _mark_recovery_exhausted / heartbeat skip still refuse exhaust and keep extended_60s + reqCurrentTime. Mode B exhaust-and-stop leftover AMENDED by #433. Recycle stays off. Do not mute trader:ibkr_request_path. Do not recycle with a live book. Cannot make a wedged no-1100 Java answer req* without a Gateway restart.req* fails → PATH_DEAD + reconnect, not EXHAUSTED. Timer expires → sticky still true → trip / mark-exhausted refuse → extended_60s continues. Durable qualify streak → _clear_inplace_recovery_state → READY. No-1100 TCP-up trip leftover AMENDED by #433. Recycle helper still #430 hold. (2) Runtime: next 4 AM 1100 keeps probing past 15 min; latch clears when farms answer; no systemctl restart ibgateway-ibc; next Mode B keep-probing leftover AMENDED by #433; weekend same; IBC 06:00 is not recycle; live book still forbids recycle. (3) Lines: src/ibkr/client.py _arm_farm_recovery / _in_farm_recovery / _clear_inplace_recovery_state / _trip_request_path_dead / _mark_recovery_exhausted / _schedule_reconnect / _heartbeat_loop. (4) Constraints: cannot mint a midweek IBKR login without 2FA; a true Java wedge still cannot answer req* — #433 keeps asking; do not mute the latch; do not invent net-liq. (5) tests/unit/test_finding_432_daily_reconnect.py.#427 window-expiry leftover AMENDED. #430 recycle-off stays.src/ibkr/client.py, tests/unit/test_finding_432_daily_reconnect.py (NEW), tests/unit/test_finding_427_farm_recovery.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.coverage:channel_window_leak FAIL + canned “never traded by any channel” on a market BMO already gated). Zero trading-rule change. Complements #414/#384 (does not mute true MA/WWD leaks).DEPLOYED Sep 2, 2026 — trader + integrity-monitor bounce (clears the leftover AEO leak row without deleting it). #414 leftover (emit/admit-only) AMENDED. #414 stays the DKS narrative. #384 stays ACTIVE (classifier + sweep).#414 treated “already covered” as skip_stage IN ('signal_emitted','admitted'). AEO is BMO 2026-09-02 (earnings_markets, volume $3,613). BMO evaluated it five times on Sep 1 (14:30–19:50 UTC) at generate_signal / long_conviction_max_threshold / conviction 0.915 — C-1 0.90 cap. AMC at 14:30:03 UTC Sep 2 resolved BMO, past the 00:00 ET deadline, stamped timing_channel_leak. Integrity FAILed. Swept detector 0 (non-timing rows exist). Not a coverage loss.CHANNEL_ALREADY_COVERED_SQL and the explicit integrity NOT EXISTS use skip_stage IS DISTINCT FROM 'timing'. Stamp site keeps timing_channel_mismatch + INFO when any past-timing row exists. True timing-only leaks still stamp + FAIL. Fail-soft unchanged. Do not mute coverage:channel_window_leak. Do not enter AEO.generate_signal → mismatch, not leak. Leftover AEO leak row excluded by NOT EXISTS → rule OK. (2) 4 AM / weekend / restart: leftover row stays in the audit trail and does not FAIL; later AMC probes do not re-FAIL AEO; MA/WWD timing-only still FAIL. (3) signal_generator.py CHANNEL_ALREADY_COVERED_SQL / suppress log; integrity_monitor.py _check_channel_window_leaks explicit SQL. (4) Constraints: C-1 skip stands; do not mute the rule; do not DELETE opp-log rows. (5) tests/unit/test_finding_431_channel_leak_evaluated.py.#414 emit/admit-only leftover AMENDED.src/earnings/signal_generator.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_431_channel_leak_evaluated.py (NEW), tests/unit/test_finding_414_channel_leak_already_admitted.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.Second Factor Authentication initiated lines. Every one lines up with ibgateway-ibc process restart after #427 auto-recycle shipped. Same nights, IBC 06:00 AutoRestart logged Login has completed with no new 2FA — 29/30/31 Aug, 1/2 Sep. Weekly contract was intact; recycle broke it. Complements #429/#427/#426/#412.)DEPLOYED Sep 2, 2026. Recycle-off standing default AMENDED by #434 (connect-first) then restored by #436. #427 stays ACTIVE (arm farm recovery). #429 leftover (exhaust → standing recycle) AMENDED. #429 stays ACTIVE (quiet-reset + Mode B window). Mode A post-window latch leftover AMENDED by #432. Do not revert #412–#429.systemctl restart ibgateway-ibc is a new IBKR login. IBC then sends a phone 2FA. ReloginAfterSecondFactorAuthenticationTimeout=yes retries ~15 min if the first tap is missed (Aug 31 04:30 → five 2FAs; Sep 2 04:13 → four more after the 04:02 crash 2FA). IBC daily AutoRestart at 06:00 plus gateway_restart_manager.sh (Mon–Sat 05:58) already preserve the weekly token — that path does not 2FA. Java crash + systemd Restart=always still 2FAs (Sep 2 04:02); that is not recycle.IBKR_GATEWAY_AUTO_RECYCLE default false. Exhaust still latches PATH_DEAD / EXHAUSTED and pages #430 (Gateway was not recycled). true is a midweek 2FA and needs # GUARD-OVERRIDE-UNTIL. Sunday 06:00 AutoRestart + Mon–Sat session preserve stay. Late-login watch stays for Sunday / crash / a manual recycle. Do not mute trader:ibkr_request_path. A wedged req* has no Python-only restore — next Mode B stays latched until Sunday or a human recycle._mark_recovery_exhausted → _maybe_standing_recycle → recycle_gateway_if_flat sees AUTO_RECYCLE off → no systemctl restart ibgateway-ibc → one Telegram #430. 06:00 AutoRestart still logs in. true + dated override still recycles. (2) Runtime: next overnight wedge pages, no phone 2FA; next Sunday 06:00 still one weekly 2FA; Java crash still 2FAs; weekend same. (3) Lines: src/ibkr/gateway_recycle.py _auto_recycle_enabled / WEEKLY_2FA_HOLD; src/ibkr/client.py _maybe_standing_recycle / exhaust alert; Config + GUARD. (4) Constraints: cannot restore wedged req* without a Gateway process restart; do not recycle with a live book; do not mute the latch; do not invent net-liq. (5) tests/unit/test_finding_430_weekly_2fa.py.#427 recycle-default leftover AMENDED. #427 stays ACTIVE (farm window).src/ibkr/gateway_recycle.py, src/ibkr/client.py, src/earnings/config.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_430_weekly_2fa.py (NEW), tests/unit/test_finding_427_farm_recovery.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.reqCurrentTime:health timeouts, consecutive heartbeat counter reset to 1 after every lucky probe, never latched, never recycled. IBC/Java died 04:02:41 → 2FA → login 04:12:06 → trader connected 04:12:52 → immediate health fail → exhaust → standing recycle 04:13:06. Late watch armed 04:14:37, expired 04:29:37. IBC finished 2FA at 04:56:32 — 27 min after expiry. Trader PID from Sep 1 14:43:55 stayed exhausted until a human bounce at 17:29:25. Same leftover as Aug 31 04:47 expiry / 05:03 login. Book was flat. Complements #428/#427/#426/#412.)DEPLOYED Sep 2, 2026 — software ship this commit. Trader already bounced 17:29:25 UTC (Gateway was already logged in; session_phase=ready, request_path_ok=true). #428 leftover (hard 900s late-login clock; Mode B lucky-probe reset) AMENDED. Standing-recycle leftover AMENDED by #430. #428 stays ACTIVE (90s first wait + systemd-run bounce + one watcher). #427/#426 stay ACTIVE.#428 late watch is a hard 900s timer. IBC's 2FA retry loop on this host is ~15 min between attempts (5 attempts, login at +32–40 min). Quiet expiry during attempt N misses login on attempt N+1. After expiry, _late_watch_started stays true, recycle already attempted, #426 skips probes. (2) Mode B (req* timeouts, no 1100) never reached 10 consecutive heartbeat fails because a lucky reqCurrentTime called _clear_inplace_recovery_state and zeroed consecutive. Overnight wedge sat until Java/IBC died.extend_on_auth=True): each new IBC Second Factor Authentication initiated / Login attempt: resets the quiet deadline (IBKR_GATEWAY_LATE_LOGIN_SEC default 1800). First 90s wait stays hard. Heartbeat: wall-clock window — 8 fails in 600s while TCP up and not in the farm window → exhaust. Standing recycle after exhaust AMENDED by #430 (weekly 2FA — Gateway is not killed). Light heartbeat success no longer clears inplace recovery / the fail window. Durable / reconnect success still clears. Still refuse a live book. Still systemd-run bounce after a login that does happen. IBC 06:00 is not recycle. Do not mute trader:ibkr_request_path. Do not revert #412–#426 (that was fake-green).Login has completed + :4001 + flat → systemd-run bounce. No 2FA → expire at 1800. First wait 90s does not extend. 8 heartbeat fails in 600s + TCP up + no farm → _mark_recovery_exhausted (#429) → one recycle. Lucky probe zeros consecutive only. Farm window still skips window-exhaust. (2) Runtime: next overnight Mode B recycles within ~10 min if flat; next recycle 2FA that takes 40 min still attaches; 4 AM 1100 still uses the 900s farm window; IBC 06:00 still ignored; weekend same. (3) Lines: src/ibkr/gateway_recycle.py wait_gateway_ready extend_on_auth; src/ibkr/client.py _note_heartbeat_failure / _heartbeat_loop; Config + GUARD. (4) Constraints: cannot make a wedged Gateway answer req* without recycle; do not bounce onto a dead port; do not recycle with a live book; do not mute trader:ibkr_request_path; do not invent net-liq; do not treat IBC 06:00 as recycle. (5) tests/unit/test_finding_429_late_login_quiet.py.#428 leftover (hard 900s + Mode B reset) AMENDED. #428 stays ACTIVE.src/ibkr/gateway_recycle.py, src/ibkr/client.py, src/earnings/config.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_429_late_login_quiet.py (NEW), tests/unit/test_finding_428_late_login.py, tests/unit/test_config.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.exhausted until a human bounce at 13:57:16 — 7.6h off a live Gateway. Book was flat. Complements #427/#426/#412 — farm window / zombie-exhaust / latch stay).DEPLOYED Aug 30, 2026 — software ship this commit. Trader already bounced 13:57:16 UTC (Gateway was already logged in). #427 leftover (2FA abort is terminal; in-process systemctl restart earnings-trader looks like failure) AMENDED. Hard-900s / Mode B lucky-probe leftover AMENDED by #429. #428 stays ACTIVE (90s first wait + systemd-run bounce + one watcher). #427 stays ACTIVE (farm window + first recycle).#426 skips reqCurrentTime while TCP looks up. _standing_recycle_attempted is once per process. systemctl restart earnings-trader from inside the trader at 04:30:43 logged failure (empty stderr) even though systemd bounced it at 04:30:52.IBKR_GATEWAY_LATE_LOGIN_SEC, shipped at 900, default 1800 since #429) keeps reading IBC for a new Login has completed (seek past current log size so 06:00 AutoRestart does not count) plus :4001. Quiet timer resets on each IBC 2FA / Login attempt (#429). When that lands, re-check flat book in the same call (snapshot + trading_positions open=0 + probe), then systemd-run --on-active=5s /bin/systemctl restart earnings-trader. Pending-late-login Telegram, not “did not run.” One watcher per process. Happy-path bounce also uses systemd-run. Still refuse a live book. IBC 06:00 is not recycle. Do not mute trader:ibkr_request_path.Login has completed + port → book flat → systemd-run bounce. Position appeared → no bounce. Client pending_late_login pages #428. Second start_late_login_watch is a no-op. (2) Runtime: next Sunday 2FA past 90s still attaches when login lands if flat; 4 AM 1100 still uses the 900s farm window; IBC 06:00 still ignored; weekend same. (3) Lines: src/ibkr/gateway_recycle.py wait_gateway_ready / start_late_login_watch / bounce_trader / recycle_gateway_if_flat; src/ibkr/client.py _maybe_standing_recycle; Config + GUARD. (4) Constraints: cannot make a wedged Gateway answer req* without recycle; do not bounce onto a dead port; do not recycle with a live book; do not mute trader:ibkr_request_path; do not invent net-liq. (5) tests/unit/test_finding_428_late_login.py.#427 leftover (2FA-abort-no-attach) AMENDED. #427 stays ACTIVE.src/ibkr/gateway_recycle.py, src/ibkr/client.py, src/earnings/config.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_428_late_login.py (NEW), tests/unit/test_finding_427_farm_recovery.py, tests/unit/test_config.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.#426 zombie-exhausted at 04:30:05 — 61 seconds after a real farm drop. No 1102 through 06:10. Overnight before the 1100: 241 reqCurrentTime:health timeouts with no 1100 — Mode B Java/req* wedge. Book was flat. Complements #412/#413/#415/#418/#419/#420/#421/#426 — detection/hysteresis/keep-session/one-session/zombie-exhaust stay).DEPLOYED Aug 29, 2026 — software ship this commit. Immediate recycle of the then-exhausted session already done 06:47:56 UTC (ibgateway-ibc) + trader bounce 06:49:19 UTC (2FA approved; Login has completed 06:48:28; connected 06:49:21). #426 leftover (real 1100 + TCP-up treated as zombie) AMENDED. AUTO_RECYCLE-default-true leftover AMENDED by #430. Window-expiry exhaust leftover AMENDED by #432. #426 stays ACTIVE for the no-1100 wedge. 2FA-abort-no-attach leftover AMENDED by #428. #427 stays ACTIVE (arm farm recovery).req* still times out; IBKR will keep doing this; 1102 + durable success is the clear. Mode B is a Java/req* wedge with no 1100: TCP + IBC login stay up; req* dies for hours. #426 correctly refused the 300s lie-loop on Mode B, then exhausted a real 1100 in 61s because TCP looked up. IBC 06:00 AutoRestart logs in and is not a Gateway recycle. The only lever that has restored a wedged req* path on prod is systemctl restart ibgateway-ibc. There is no Python-only permanent fix that makes a wedged Gateway answer req*._arm_farm_recovery() for IBKR_FARM_RECOVERY_SEC (default 900). Inside the window: _trip_request_path_dead / _mark_recovery_exhausted / _schedule_reconnect do not zombie-exhaust; keep extended_60s durable probes even if TCP is up; heartbeat still sends reqCurrentTime. 1102 + durable success clears via _clear_inplace_recovery_state. After the window (or a no-1100 TCP-up trip): #426 zombie-exhaust still fires. Then standing recycle (src/ibkr/gateway_recycle.py) runs once if the live book is flat in the same call (snapshot portfolio empty, trading_positions open=0, open-position probe false): systemctl restart ibgateway-ibc → wait IBC Login has completed + port 4001 → systemctl restart earnings-trader. 2FA/login timeout pages and stops — do not bounce the trader onto a dead port. Refuse recycle with a position. IBKR_GATEWAY_AUTO_RECYCLE default True (GUARD: false needs # GUARD-OVERRIDE-UNTIL). Do not treat IBC 06:00 as recovery. Do not mute trader:ibkr_request_path.req* fails → PATH_DEAD + reconnect, not EXHAUSTED. After window → TCP-up trip exhausts. No-1100 TCP-up trip still zombie-exhausts. Recycle helper refuses when snapshot/DB/probe shows a position. Heartbeat skip is false during the farm window. (2) Runtime: next 4 AM 1100 keeps probing 15 min; 1102 + durable success returns READY; window expiry + still-dead + flat book auto-recycles Gateway then trader; hours of req* timeouts with no 1100 + flat book auto-recycles (Mode B). Position open → page, no recycle. 2FA timeout → page, stop. Weekend same. IBC 06:00 is not recycle. (3) Lines: src/ibkr/client.py _arm_farm_recovery / _in_farm_recovery / _trip_request_path_dead / _mark_recovery_exhausted / _maybe_standing_recycle / _schedule_reconnect / _heartbeat_loop / _on_error 1100/1300; src/ibkr/gateway_recycle.py; src/earnings/config.py IBKR_FARM_RECOVERY_SEC / IBKR_GATEWAY_AUTO_RECYCLE; integrity_monitor.py GUARD_REGISTRY. (4) Constraints: cannot make a wedged Gateway answer req* without recycle; do not recycle with a live book; do not mute trader:ibkr_request_path; do not invent net-liq; do not treat IBC 06:00 as recycle. (5) tests/unit/test_finding_427_farm_recovery.py.#426 leftover (1100-as-zombie) AMENDED. #426 stays ACTIVE (no-1100 wedge).src/ibkr/client.py, src/ibkr/gateway_recycle.py (NEW), src/earnings/config.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_427_farm_recovery.py (NEW), tests/unit/test_finding_426_gateway_zombie.py, tests/unit/test_config.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.reqCurrentTime:health timeouts 00:00–10:48 UTC Aug 28 while Gateway systemd looked Connected and accountValues said healthy; PATH_DEAD at 10:42:57 then 300s in-place reconnects that also timed out. Book was flat. Complements #412/#413/#415/#418/#419/#420/#421 — detection/hysteresis/keep-session/one-session stay).DEPLOYED Aug 28, 2026 — Gateway recycle CONFIRMED at 11:22:14 UTC (ibgateway-ibc + earnings-trader); software ship this commit. #421 leftover (light-health-as-healthy + 300s loop while TCP up) AMENDED. #419 leftover (durable=False as healthy; TCP-up heartbeat quiet-reconnect) AMENDED. #413 leftover (one reconnect per 5 min while path dead, TCP-up only) AMENDED. Real-1100-as-zombie leftover AMENDED by #427. #426 stays ACTIVE for the no-1100 wedge. #425 stays ACTIVE.req* API was wedged. TCP/process up ≠ IBKR will take an order. #412–#421 correctly latched PATH_DEAD and stopped self-disconnect, then assumed the Gateway would start answering. It did not. IBC daily AutoRestart 06:00 logged in and left req* dead. 10:42:06 Health check passed: 124 account values was ib_insync push caches; 10s later reqAllOpenOrders + reqCurrentTime timed out. Extra reconcile client_id=99 attached twice around 10:12 onto the same zombie. #422–#425 fixed real satellites; they were not why the trader could not talk to IBKR.durable=True (reqCurrentTime + qualify SPY + reqCurrentTime). durable=False is fail-only even if the light probe succeeds. _trip_request_path_dead while _tcp_up() → _mark_recovery_exhausted("gateway zombie…") — do not _maybe_schedule_request_path_reconnect(). TCP-down still reconnects. Heartbeat: skip reqCurrentTime while TCP up and path-dead/exhausted; 10 heartbeat fails while TCP up → exhaust, not another in-place loop. Reconcile: _trader_request_path_dead() reads the snapshot; skip client_id=99 when request_path_ok is False or session_phase in {path_dead, exhausted}. Do not mute trader:ibkr_request_path. Do not auto-recycle Gateway.req* fails → PATH_DEAD + EXHAUSTED, _schedule_reconnect not called. Light health success → False. Durable health True only if durable probe True. Snapshot request_path_ok=False → reconcile skips IBKR connect. Heartbeat TCP-up 10-fail → exhaust. TCP-down trip still maybe-schedules reconnect. (2) Runtime: recycle already done 11:22 UTC; trader bounce on this deploy (0 open) re-proves path; next 1100 + TCP down still reconnects; next zombie while TCP up exhausts and pages recycle CONFIRMED — no 300s lie-loop. Weekend same. Extra client 99 stays off a dead snapshot. (3) Lines: src/ibkr/client.py _verify_connection_health / _trip_request_path_dead / _heartbeat_loop / _attempt_reconnect_once; trade_reconciler.py _trader_request_path_dead / main. (4) Constraints: cannot make a wedged Gateway answer req* without recycle; recycle stays CONFIRMED; do not mute trader:ibkr_request_path; do not invent net-liq; do not attach client 99 onto a dead path. (5) tests/unit/test_finding_426_gateway_zombie.py.#421/#419/#413 leftovers AMENDED (TCP-up zombie only). #425 stays ACTIVE.src/ibkr/client.py, src/data_tracker/collectors/trade_reconciler.py, tests/unit/test_finding_426_gateway_zombie.py (NEW), tests/unit/test_finding_419_ibkr_inplace_recovery.py, tests/unit/test_finding_418_ibkr_reconnect_amplifier.py, tests/unit/test_finding_413_ibkr_latch_hysteresis.py, tests/unit/test_finding_412_ibkr_request_path.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.systemd_last_run:etfstack-signal + services:systemd_unit_health; paper signal only — no live orders). Complements #350 (Polygon ETF fallback does not carry ^VIX/^VXN).DEPLOYED Aug 28, 2026 — etfstack-signal re-run after ship.ExecMainStatus=3. Rotated log: yfinance: ['^VIX']: KeyError('chart') → 0 bars → Missing CRITICAL data for ['^VIX']; aborting. signal_runner hard-aborts if VIX/VXN/QQQ/SPY missing. Yahoo download() is the only live path.fetch_yfinance_daily: keep yf.download; on empty / chart KeyError try Ticker.history (via yfinance_limiter); then Cboe published daily CSV (VIX_History.csv / VXN_History.csv, 30s timeout); then last-good cache (ETFSTACK_VOL_CACHE_DIR, max 7 calendar days). Successful live fetch writes the cache. Still abort if every source is empty. IBKR live VIX quote is not 900d history — not used.strategies/etfstack/src/data_provider.py fetch_yfinance_daily / _yfinance_history_fallback / _fetch_cboe_vol_daily / _read_vol_cache / _write_vol_cache. (4) Constraints: cannot make Yahoo chart healthy; do not mute systemd_last_run:etfstack-signal; do not invent bars. (5) tests/unit/test_finding_425_integrity_satellites.py.strategies/etfstack/src/data_provider.py, strategies/etfstack/tests/unit/test_data_provider.py, tests/unit/test_finding_425_integrity_satellites.py (NEW), src/earnings/strategy_changelog.py, this entry + R2 surfaces.consistency:earnings_dates_disputed; ASO untradeable until resolved). Complements #349 (do not invent a date; confirmed locks Polymarket sync; every change writes earnings_date_audit).DEPLOYED Aug 28, 2026 — apply_official_ir_resolutions on prod.2026-09-01 BMO date_confidence=disputed (date_verified_at 2026-08-27 14:30Z). Zero earnings_date_audit rows. Verifier left it disputed (calendar voices vs Sep 1, no ≥2-vote winner). Integrity window CURRENT_DATE + 7 days included Sep 1 on Aug 28.OFFICIAL_IR_RESOLUTIONS[(ASO, 2026-09-01)] → Sep 9 BMO + (ASO, 2026-09-09) lock so a later cycle cannot re-stamp disputed. apply_official_ir_resolutions UPDATEs only rows still on the registered date, writes earnings_date_audit.source='official_ir'. verify_earnings_dates applies the map on DISPUTED instead of restamping disputed. Deploy runs apply so the rule clears before the next 10:30/15:50 cycle.official_ir. Later verify on Sep 9 + IR lock → stay confirmed. (2) Runtime: deploy apply clears integrity before next cycle; 4 AM / weekend: same map. (3) Lines: src/earnings/earnings_date_verifier.py OFFICIAL_IR_RESOLUTIONS / official_ir_resolution / apply_official_ir_resolutions; signal_generator.py verify_earnings_dates. (4) Constraints: do not write ASO to any date other than 2026-09-09 BMO; do not mute consistency:earnings_dates_disputed; other genuine disputes still FAIL. (5) tests/unit/test_finding_425_integrity_satellites.py.src/earnings/earnings_date_verifier.py, src/earnings/signal_generator.py, tests/unit/test_finding_425_integrity_satellites.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.systemd_last_run:polymarket-trade-reconcile exit 2; missing_exec_id on a post-epoch trade; stamped P&L used the stop price). Complements #370 (post-epoch missing exec is a hard error).DEPLOYED Aug 28, 2026 — targeted reconcile of trade 274.BBW#274 exit: missing_exec_id. LONG BBW entry 2026-08-26 19:58:11Z order 1711991 exec 00018d2a.6a8efab6.01.01 @ $39.21 qty 62. Exit 2026-08-27 10:52:10Z stop_loss:hardware, exit_order_id NULL, ibkr_exit_exec_id NULL, stamped exit $37.25 (the stop). Real fill in [hidden]/logs/earnings_trader.log.1: hardware STP 1711997 cancelled 10:47:07 after fill-timeout; software LMT 1727167 filled 62 @ $34.66 at 10:47:37. (34.66−39.21)*62 ≈ −$282 matches IBKR. Three reasons autofix missed it: (1) TRADER_LOG_PATH was /var/log/polymarket/earnings_trader.log (0 bytes since May 9); live log is [hidden]/logs/earnings_trader.log + .1; (2) autofix ran before IBKR reqExecutions; (3) _get_recent_stop_fill accepted only STP → empty fill_details → persist hardware path with stop price and no exec..1 via _trader_log_paths. Autofix: shares-only retry when stamped price is the stop; logfill logfill.BBW.1727167.<epoch>; rewrite exit_price / realized_pnl when fill ≠ stamped stop. After IBKR connect, ingest get_recent_executions(hours_back=168) then re-run autofix. Exit manager: if no STP fill, accept any matching-side fill in last 1800s..1 → logfill for 1727167; next hardware+LMT race persists exec from any recent fill; IBKR ingest then re-autofix. (2) Runtime: next daily 00:00 UTC reconcile exit 0 if no new post-epoch gaps; logrotate still drops .1 after another day — IBKR ingest is the durable path. (3) Lines: trade_reconciler.py _trader_log_paths / _load_log_execution_candidates / _ingest_ibkr_executions / _exit_price_correction_params; exit_manager.py _get_recent_stop_fill. (4) Constraints: do not mute missing_exec_id; do not reset-failed while the unit would still exit 2; do not invent exec IDs without a log/IBKR fill. (5) tests/unit/test_finding_425_integrity_satellites.py.src/data_tracker/collectors/trade_reconciler.py, src/earnings/exit_manager.py, tests/unit/test_finding_425_integrity_satellites.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.#421 (PATH_DEAD/EXHAUSTED still win).DEPLOYED Aug 28, 2026 — dashboard restart with trader bounce.#421 leftover. get_trader_runtime_status treated a 10s snapshot TTL (IBKR_SNAPSHOT_INTERVAL_SEC=10) and/or a stale reqCurrentTime heartbeat as the session. Probe ~09:46 UTC: session_phase=ready, request_path_ok=true, trader_ibkr_connected=true, header still unknown. Aug 28 13:44 UTC+4 screenshot: Gateway Connected, Trader Session Unknown.session_phase == "ready" and request_path_ok is not False and connected → session_state = "connected". Stale snapshot / missed heartbeat must not paint Unknown while the machine says READY.session_phase. (3) Lines: earnings_dashboard/app.py get_trader_runtime_status. (4) Constraints: cannot make Yahoo chart or a half-dead Gateway healthy; do not mute trader:ibkr_request_path. (5) tests/unit/test_finding_425_integrity_satellites.py.#421 Unknown leftover AMENDED (header-priority leftover only; #421 stays ACTIVE).earnings_dashboard/app.py, tests/unit/test_finding_425_integrity_satellites.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.errorEvent. #420 _real_disconnect then forced stop_heartbeat + reset + connect every ~300s while isConnected() stayed true. Path DEAD 04:14:47. Reconnect 23+. Three false STILL DISCONNECTED pages. Dashboard said Reconnecting because reconnect_in_progress was checked before request_path_ok. Snapshot still showed ghost MRVL stop 1741024 qty 9 on an empty book. Book was flat. Complements #412/#413/#415/#416/#418/#419/#420 (detection/hysteresis/keep-session/in-place-escape/CancelledError-swallow stay).DEPLOYED Aug 28, 2026 — trader bounce (0 open). #420 leftovers (skip-in-place / skip-exhaust / missed 1102 / header priority / ghost last-good stops) AMENDED. Light-health-as-healthy + 300s TCP-up loop leftover AMENDED by #426. CancelledError swallow + skip VIX/snapshot flood stay.#420 treated _real_disconnect as a permanent "this is a real drop — never exhaust, always reset" latch. After 1102 restored the farms, _real_disconnect stayed True because errorEvent was bound after ib.connect(), so the 1102 during handshake was missed. Every later fail reset the IB object while TCP was up (#418 amplifier again). Dashboard header preferred Reconnecting over PATH_DEAD. _remember_ibkr_book overlaid last-good stops onto an empty live portfolio (ghost MRVL).SessionPhase (READY | REAL_DROP | PROBING | PATH_DEAD | EXHAUSTED) exported on health / snapshot / dashboard. TCP up → in-place always; reset the IB object only when TCP is down. connect() success or 1102/1101 clears _real_disconnect. Bind errorEvent before ib.connect(). 1102 probes in place (does not set _real_disconnect). extended_60s / STILL DISCONNECTED only if TCP is down; TCP up + probes failing → _mark_recovery_exhausted. Exhaust refuses only when TCP is down (keep retrying a real drop). Dashboard: PATH_DEAD / EXHAUSTED win the header; Reconnecting only when the path is still ok. Empty live book (snapshot connected) remembers the empty book — no last-good stop inherit. Overlay last-good stops only while portfolio is populated. Keep #419 one in-place reset then exhaust; keep #420 CancelledError swallow + skip VIX/snapshot flood. No new env. No Gateway recycle.connect() success or 1102 → _real_disconnect=False. Later TCP-up fails → in-place, one reset at N=3, then exhaust. No 5-min self-disconnect. No STILL DISCONNECTED while connect() succeeds. Dashboard path-dead+reconnecting → Request path dead. Live empty book → no last-good MRVL stop. Heartbeat TCP-up 10-fail still exhausts (#419). CancelledError / VIX skip stay (#420). (2) Runtime: book flat now; trader bounce re-proves path; 4 AM next 1100+1102: handler runs, in-place after restore, exhaust if probes fail while TCP up. Weekend same. Recycle CONFIRMED-only. (3) Lines: src/ibkr/client.py SessionPhase / session_phase / connect bind-before / _attempt_reconnect_once / _mark_recovery_exhausted / _schedule_reconnect / _on_error 1101/1102; earnings_dashboard/app.py get_trader_runtime_status; trading_orchestrator.py _remember_ibkr_book / _finalize_ibkr_snapshot / snapshot session_phase. (4) Constraints: cannot make a half-dead Gateway answer req*; recycle stays CONFIRMED; do not mute trader:ibkr_request_path; do not invent net-liq. READY+stale Unknown leftover AMENDED by #422. etfstack / trade-reconcile / ASO leftovers AMENDED by #423/#424/#425. Light-health-as-healthy + 300s TCP-up loop leftover AMENDED by #426. (5) tests/unit/test_finding_421_ibkr_session_machine.py.#420 AMENDED (1100-misclassify leftovers only).src/ibkr/client.py, src/earnings/trading_orchestrator.py, earnings_dashboard/app.py, tests/unit/test_finding_421_ibkr_session_machine.py (NEW), tests/unit/test_finding_420_ibkr_nightly_1100.py, tests/unit/test_finding_419_ibkr_inplace_recovery.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.1711997/1711994 were live; dashboard stamped IBKR UNREADABLE on a populated book; 155 leftover timeouts after exhaust). Complements #412/#413/#415/#416/#418/#419 (detection/hysteresis/keep-session/in-place-escape stay).DEPLOYED Aug 27, 2026 — trader bounce (BBW/DG hardware stops stay). #419 leftovers (1100-misclassify + cancel-kill) AMENDED. Skip-in-place / skip-exhaust / missed 1102 / header priority / ghost last-good stops AMENDED by #421. CancelledError swallow + skip VIX/snapshot flood stay.isConnected() flickered true 13s later. #419 treated that as an in-place wedge: 3 light fails → one IB() reset → EXHAUSTED at 04:15:02. Earlier, #419 leftover-future cancel wiped wrapper._futures after a reqAllOpenOrders:snapshot timeout; that cancelled the sibling heartbeat reqCurrentTime. asyncio.CancelledError is BaseException on 3.12, so except Exception missed it and the heartbeat thread died (00:12 and again 04:15). After path-dead, VIX qualify + snapshot open-orders kept hitting the same dead socket (76 + 75 timeouts). _remember_ibkr_book wrote stops={} over last-good hardware stops. _ibkr_snapshot_unreadable treated trader_ibkr_connected (connected AND path-ok) as TCP-down. reconciliation_guard paged every 300s after exhaust.bounded_request catches CancelledError, returns None, does not wipe wrapper._futures (qualify-only cancelContractDetails). Heartbeat loop also swallows CancelledError. Error 1100/1300 (and 1101/1102 until durable health) set _real_disconnect; in-place TCP-up exhaust is skipped; _mark_recovery_exhausted returns without latching; after 5 attempts the loop keeps extended_60s. Heartbeat TCP-up 10-fail still uses the #419 in-place machine. skip_noncritical_ibkr_request() gates VIX qualify (three twins) and snapshot reqAllOpenOrders when path-dead or exhausted. Last-good stops overlay when the live book arrives with stop_orders={}. UNREADABLE: live/last-good portfolio → readable; empty + path-dead / empty + reconnect stays UNREADABLE. Recon-guard cooldown 1800s after exhaust. No new env. No Gateway recycle.CancelledError in bounded_request → None, heartbeat thread lives. 1100 → _real_disconnect → reset+durable, 5 fails → extended_60s even if TCP up, no exhaust. Heartbeat TCP-up 10 fails still exhausts (#419). Path dead → VIX qualify skipped, snapshot open-orders skipped, last-good stops kept. Snapshot with BBW+DG + path dead → not UNREADABLE. Empty + path dead still UNREADABLE. Recon guard after exhaust not every 300s. (2) Runtime: BBW/DG live; trader bounce re-proves path; hardware stops stay; 4 AM next 1100 retries via extended_60s until 1102/durable; weekend same. (3) Lines: src/ibkr/client.py bounded_request / _cancel_timed_out_ib_request / _on_error 1100/1300 / _attempt_reconnect_once / _mark_recovery_exhausted / _schedule_reconnect / _clear_inplace_recovery_state / skip_noncritical_ibkr_request / _heartbeat_loop; trading_orchestrator.py _get_ibkr_index_quote / _build_ibkr_snapshot / _remember_ibkr_book / _finalize_ibkr_snapshot; trading_utils.py get_ibkr_index_quote; market_regime_provider.py _ibkr_index_quote; earnings_dashboard/app.py _ibkr_snapshot_unreadable; reconciliation_manager.py guard cooldown. (4) Constraints: cannot make a half-dead Gateway answer req*; recycle stays CONFIRMED; do not mute trader:ibkr_request_path; do not invent net-liq. (5) tests/unit/test_finding_420_ibkr_nightly_1100.py.#419 AMENDED (1100-misclassify + cancel-kill leftovers only).src/ibkr/client.py, src/earnings/trading_orchestrator.py, src/earnings/trading_utils.py, src/earnings/market_regime_provider.py, src/earnings/reconciliation_manager.py, earnings_dashboard/app.py, tests/unit/test_finding_420_ibkr_nightly_1100.py (NEW), tests/unit/test_finding_419_ibkr_inplace_recovery.py, tests/unit/test_finding_416_ibkr_unreadable.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.reconnect_in_progress + false ibkr_disconnect STILL DISCONNECTED pages while HPQ 82 LONG + hardware STP LMT 1696519 were live and filling). Complements #412/#413/#415/#416/#418 (detection/hysteresis/keep-session stay).DEPLOYED Aug 26, 2026 — trader bounce (HPQ hardware stop stays). #418 leftover (infinite in-place / no escape / heartbeat still probing) AMENDED. #416 leftover (UNREADABLE on reconnect_in_progress even with a live book) AMENDED. 1100-misclassify + cancel-kill leftovers AMENDED by #420. Light-health-as-healthy + TCP-up quiet-reconnect leftover AMENDED by #426.#418 kept TCP up, _verify_connection_health always ran probe_request_path_durable (3× 10s), heartbeat kept sending reqCurrentTime on the same ib_insync loop, and _schedule_reconnect entered extended_60s forever with notify_disconnect=True. 42 heartbeat timeouts after the BBWI fill, then 10 consecutive → loud reconnect; 136 keep-session lines; 246 timeouts; 7 false disconnect pages. Latch never re-tripped (#415), so the 300s floor never applied. HPQ filled at 14:34 UTC while the trader said Reconnecting. #416 keyed UNREADABLE off reconnect_in_progress even when last-good HPQ was on the snapshot.notify_disconnect=False); skips the probe while reconnect/durable-probe is running; does not reschedule after _recovery_exhausted. In-place retries use a light health check (caches + one reqCurrentTime). After REQUEST_PATH_FAIL_THRESHOLD (3) fails, exactly one _reset_ib_connection_object + durable verify. Fail + TCP still up → _mark_recovery_exhausted (one ibkr_request_path page, no extended_60s). Real TCP-down still uses the existing loud reconnect + 60s loop. Error 1100/1300/1101/1102 clears exhausted so a real drop can recover. bounded_request timeout cancels leftover wrapper futures. _ibkr_snapshot_unreadable: live/last-good book → readable even during in-place reconnect; empty + reconnect or empty + path-dead stays UNREADABLE; TCP down still UNREADABLE. No new env. No Gateway recycle.ibkr_request_path page, reconnect_in_progress=False. Timeout in bounded_request → qualify-only cancel (futures wipe AMENDED by #420). Snapshot with HPQ + reconnecting → not UNREADABLE. TCP down → existing loud reconnect + extended_60s. 1100 clears exhausted (1100-while-TCP-up exhaust AMENDED by #420). (2) Runtime: HPQ live; trader bounce re-proves path; hardware stop stays; 4 AM next 1100 still disconnects via error handler; weekend same; exhausted + later 1102/1100 can recover. (3) Lines: src/ibkr/client.py _heartbeat_loop / _schedule_reconnect / _attempt_reconnect_once / _try_one_inplace_reset / _mark_recovery_exhausted / _verify_connection_health(durable=) / _cancel_timed_out_ib_request / _on_error 1100/1300; earnings_dashboard/app.py _ibkr_snapshot_unreadable. (4) Leftovers: 1100-misclassify (TCP flicker → in-place exhaust) + leftover-future cancel killing the heartbeat thread AMENDED by #420. Cannot make a half-dead Gateway answer; recycle stays CONFIRMED; do not mute trader:ibkr_request_path; do not invent net-liq. (5) tests/unit/test_finding_419_ibkr_inplace_recovery.py.#418 AMENDED (infinite in-place leftover only). #416 AMENDED (UNREADABLE + live book leftover only).src/ibkr/client.py, earnings_dashboard/app.py, tests/unit/test_finding_419_ibkr_inplace_recovery.py (NEW), tests/unit/test_finding_418_ibkr_reconnect_amplifier.py, tests/unit/test_finding_416_ibkr_unreadable.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.1684238 were live; scheduled 10:32 ET exit refused until the path works). Complements #412/#413/#415/#416 (detection/hysteresis/unreadable badge stay).DEPLOYED Aug 26, 2026 — trader bounce (BBWI hardware stop stays). #415 leftover (300s floor still tore the session down) AMENDED. Infinite in-place leftover AMENDED by #419._reconnect_loop treated a failed durable health check as "tear it down." connect() succeeded (TCP up) → probe_request_path_durable timed out → self.disconnect() → next iter _reset_ib_connection_object(). Hours 05–07 UTC Aug 26: 0 new IBKR 1100s; the loop was entirely ours. Since Aug 25 04:00: ~12 Error-1100 log hits vs 224 reconnects and 187 "reconnected but health check failed" self-disconnects. #415 floored sleep to 300s but still killed the socket a qualify could have landed on. _build_ibkr_snapshot always started portfolio: {} and returned immediately when not connected — reconnect wrote a blank book. Process start reloaded persisted 412 from trading_block.txt (06:09:58 UTC). get_account_info called _ensure_trading_allowed("market/limit/entry+stop order") — 1 Hz refuse-order spam.isConnected(), retry probe_request_path_durable in place. Reset the IB object only when TCP is down, Error 1100/1300, or connect() failed. Snapshot keeps last-good portfolio + stop_orders (in-memory and previous snapshot file), marked ibkr_book_stale / ibkr_book_as_of. Skip loading 412 from disk — this process must re-prove 3 failures; other critical blocks (502/504/1300) still load. 412 / path-dead blocks admission/entry only; scheduled/instant exit still attempts a bounded send (get_quote(..., allow_when_path_dead=True)). get_account_info is not an order; last-known net-liq while unread (do not invent capital). One Telegram when an open position + 3 failed in-place durable probes; re-arm on RESTORED. Client does not import the orchestrator (callback + DATABASE_URL fallback).disconnect(); TCP down → reset+connect; 412 file on start → not blocked; exit get_quote(allow_when_path_dead=True) → _ensure("scheduled exit") allows; entry get_quote still skips; empty live snapshot while dead → last-good BBWI written; 3 in-place fails + open position → one Telegram. (2) 4 AM / weekend / restart: no inherited 412; last-good seeds from the previous snapshot file; next 1100 keeps the socket. (3) src/ibkr/client.py _attempt_reconnect_once / _load_trading_block_from_file / _ensure_trading_allowed / get_account_info / get_quote; trading_orchestrator.py last-good book; order_executor.py is_exit. (4) leftover: this package keeps the socket so a qualify can land; it does not make IBKR answer req* if Gateway is still half-dead — AMENDED by #419 (infinite in-place / heartbeat still probing / extended_60s forever). 10:32 ET exit still needs a live request path or the hardware stop. Gateway recycle still CONFIRMED. Do not mute trader:ibkr_request_path. (5) tests/unit/test_finding_418_ibkr_reconnect_amplifier.py.#415 AMENDED (session teardown leftover only).src/ibkr/client.py, src/earnings/trading_orchestrator.py, src/earnings/order_executor.py, tests/unit/test_finding_418_ibkr_reconnect_amplifier.py (NEW), src/earnings/strategy_changelog.py, this entry + R2 surfaces.freshness:trades 60 min is a false FAIL (Aug 26, 2026)#369 (does not mute a truly-dead poller in RTH).DEPLOYED Aug 26, 2026 — needrestart conf + integrity bounce (trader bounce rides #415). #369 needrestart list AMENDED (added the two units). xvfb/x11vnc patterns AMENDED by #443 (they missed the live xvfb.service / x11vnc.service).#369 /etc/needrestart/conf.d/polymarket.conf excluded trader/gateway/collector/websocket. unattended-upgrade at Aug 26 04:29:46 UTC still clean-restarted polymarket-live-trades. Integrity freshness:trades last_ts=04:20:34Z; next prints 05:51/05:55 (91 min > 60 min RTH threshold). First FAIL 05:25:44 UTC. Overnight CLOB tape is sparse; a 60-min threshold is an RTH rule.polymarket-live-trades.service + polymarket-integrity-monitor.service to the needrestart override (deploy-managed). freshness:trades overnight ET hour <9 or >=20 uses max_age_seconds=10800 (180 min); RTH stays 3600. Lookback stays max(4×threshold, 48h).deploy/needrestart/polymarket.conf; integrity_monitor.py _trades_freshness_max_age_seconds + rule. (4) leftover: a dead poller in RTH still FAILs at 60 min; do not mute freshness:trades. (5) tests/unit/test_finding_417_needrestart_tape.py.#369 needrestart list AMENDED.deploy/needrestart/polymarket.conf, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_417_needrestart_tape.py (NEW), src/earnings/strategy_changelog.py, this entry + R2 surfaces.1684238 were live). Complements #412/#413/#415 (does not change the latch).DEPLOYED Aug 26, 2026 — dashboard bounce (trader bounce rides #415). UNREADABLE+live-book leftover AMENDED by #419.get_active_positions_from_db set ibkr_position_present=bool(ibkr_data). During #412 dead / reconnect the snapshot portfolio is {} and connected=false, so merge is skipped, but the badge still fired IBKR MISSING. Overlay appended BBWI to missing_in_ibkr. Header 0% was missing net-liq (trading_capital), not a closed position. Entry "Aug 25, 3:58 PM ET" at 1:56 AM is yesterday's fill (#362), not a timezone bug._ibkr_snapshot_unreadable (request_path_ok False OR connected false OR reconnect_in_progress). When unreadable: ibkr_position_present=None, ibkr_position_unreadable=True, exclude_from_totals=False; overlay does not append missing_in_ibkr / stop issues; JS badge IBKR UNREADABLE. IBKR MISSING only when present === false and not unreadable.earnings_dashboard/app.py helper + positions + overlay; dashboard.html badge. (4) leftover: header -- / 0% while net-liq is unread is honest unknown capital; do not invent a number. UNREADABLE on reconnect_in_progress even with a live/last-good book AMENDED by #419. (5) tests/unit/test_finding_416_ibkr_unreadable.py.earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html, tests/unit/test_finding_416_ibkr_unreadable.py (NEW), src/earnings/strategy_changelog.py, this entry + R2 surfaces.extended_60s by ~06:00; BBWI 142 LONG hardware stop stayed on the exchange). Complements #412/#413 (detection/block/3-success clear stay).DEPLOYED Aug 26, 2026 — trader bounce (BBWI hardware STP LMT stays). #413 re-trip leftover AMENDED. Session-teardown leftover AMENDED by #418.#413 hysteresis is one-way. Clear needs 3 successes incl. qualify. Re-trip is any 3 bounded_request failures. After RESTORED, heartbeat + VIX qualify + SPY qualify are three 10s leftovers and re-trip in 21s. Error 1100 already sets _reconnect_in_progress, so _maybe_schedule_request_path_reconnect never applies the 300s floor; the loop is 5 exp-backoff then forever every 60s and disconnects after each failed health check. get_quote still called _ensure_trading_allowed("bracket order") + qualify while dead → refuse-order spam._clear_request_path_dead, set _request_path_retrip_durable_only=True. _note_request_result(False) re-trips only while _in_durable_probe (set around probe_request_path_durable; recursion-guarded). get_quote returns None before _ensure_trading_allowed / qualify when path dead. _reconnect_loop (exp-backoff and extended_60s) uses max(delay, REQUEST_PATH_RECONNECT_COOLDOWN_SEC) while request_path_ok is false. No new env.src/ibkr/client.py _note_request_result / probe_request_path_durable / _clear / _reconnect_delay_sec / get_quote. (4) leftover: IBKR 1100 still happens; cannot make a half-dead socket healthy; 300s floor still tore the session down after each failed durable health — AMENDED by #418. Do not mute trader:ibkr_request_path. (5) tests/unit/test_finding_415_ibkr_latch_retrip.py.#413 AMENDED (re-trip leftover only).src/ibkr/client.py, tests/unit/test_finding_415_ibkr_latch_retrip.py (NEW), tests/unit/test_finding_413_ibkr_latch_hysteresis.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.#384 (does not mute true MA/WWD leaks).DEPLOYED Aug 25, 2026 — trader + integrity-monitor bounce (clears the leftover DKS leak row without deleting it). #384 explicit detector AMENDED (true-leak path stays). Emit/admit-only leftover AMENDED by #431.#384 stamps timing_channel_leak when the resolved channel's entry deadline has passed, and coverage:channel_window_leak FAILs on any post-epoch leak row. DKS was admitted on BMO at Aug 24 19:50 UTC (73.5% LONG, S2c $2,212.22). The 15:58 batch was 0/0 (#412). At Aug 25 07:11:13 UTC an AMC scan (readiness probe; the live 10:30 cycle would have done the same) resolved DKS as BMO, past the earnings-day 00:00 ET deadline, and stamped leak. Integrity first FAIL 07:13:56 UTC. Canned text "market never traded by any channel" was false. Swept detector 0 (admitted/emitted rows exist). Money miss is #412, already shipped.polymarket_opportunity_log for the same condition_id (or symbol + earnings day) with a non-timing row (shipped as emit/admit; any non-timing row since #431). If found → keep benign timing_channel_mismatch + INFO (not ERROR). Explicit integrity SQL adds the same NOT EXISTS. Existing DKS leak row stays in the audit trail and stops FAILing. True leaks (timing-only) still stamp + FAIL. Fail-soft: lookup error keeps the leak stamp._already_covered_on_resolved_channel → if admit/emit exists, resolve_timing_mismatch_reason → mismatch; else leak ERROR. Integrity explicit query skips covered cids → OK; Telegram RESOLVED on next 5-min cycle. (2) 4 AM / weekend / restart: leftover leak rows clear without DELETE; a later probe does not re-FAIL DKS; a true MA/WWD with only timing rows still FAILs. (3) signal_generator.py CHANNEL_ALREADY_COVERED_SQL / resolve_timing_mismatch_reason / _already_covered_on_resolved_channel / mismatch site; integrity_monitor.py _check_channel_window_leaks explicit SQL. (4) leftover: DKS 15:58 slot is gone (#412); do not adopt/enter DKS. (5) tests/unit/test_finding_414_channel_leak_already_admitted.py.#384 AMENDED (explicit detector only).src/earnings/signal_generator.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_414_channel_leak_already_admitted.py (NEW), src/earnings/strategy_changelog.py, this entry + R2 surfaces.DEPLOYED Aug 25, 2026 — trader + dashboard + integrity bounce (recycles the flapping #412 process). Complements #412 detection (does not supersede). #354/#369 stay ACTIVE. Re-trip leftover AMENDED by #415. TCP-up reconnect-while-dead leftover AMENDED by #426.#412 tripped correctly after Error 1100/1102 at 04:13:50 UTC, then _note_request_result(True) cleared on one reqCurrentTime:health, re-armed _request_path_alerted, and _schedule_reconnect("request path dead #412") queued ibkr_disconnect. One flap = four CRITICALs. 89 DEAD / 89 RESTORED / 128 disconnect in ~2.4h. CRITICAL skipped category cooldown. Overnight 172 timeouts did not page (successes interleaved).qualify* in the streak. Health check uses probe_request_path_durable (reqCurrentTime + qualify SPY + reqCurrentTime). Heartbeat stays a single probe (must not clear alone). One reconnect per 300s, notify_disconnect=False on #412 trips (no DISCONNECTED/FAILED/RECONNECTED pair). Telegram DEAD once until durable RESTORED. CRITICAL ibkr_request_path / ibkr_disconnect same-polarity cooldown 300s. Knobs: IBKR_REQUEST_PATH_CLEAR_THRESHOLD=3, IBKR_REQUEST_PATH_RECONNECT_COOLDOWN_SEC=300, IBKR_ALERT_CATEGORY_COOLDOWN_SEC=300 (GUARD).ibkr_disconnect. (2) 4 AM / weekend: next 1100 = one 1100 page + one DEAD; stay red until qualify works in a 3-success streak; reconnect at most once/5 min. (3) src/ibkr/client.py _note_request_result / probe_request_path_durable / _maybe_schedule_request_path_reconnect / _schedule_reconnect(notify_disconnect=); src/earnings/alerts.py polarity cooldown; config.py + GUARD. (4) leftover: one-way hysteresis — any 3 leftover fails re-tripped 21s after RESTORED; 1100 loop ignored the 300s floor — AMENDED by #415. (5) tests/unit/test_finding_413_ibkr_latch_hysteresis.py.#412 AMENDED (clear/reconnect/alert only). #413 re-trip leftover AMENDED by #415.src/ibkr/client.py, src/earnings/alerts.py, src/earnings/config.py, src/data_tracker/integrity_monitor.py, tests/unit/test_finding_413_ibkr_latch_hysteresis.py (NEW), tests/unit/test_finding_412_ibkr_request_path.py, tests/unit/test_config.py, src/earnings/strategy_changelog.py, this entry + R2 surfaces.DEPLOYED Aug 25, 2026 — detection/block/dashboard/integrity stay. Clear/reconnect/alert AMENDED by #413 (history: first ship cleared on one reqCurrentTime and reconnected every trip — Aug 25 Telegram storm). Complements #354/#369 (does not supersede)._verify_connection_health and _perform_heartbeat treated accountValues() / managedAccounts() push caches as proof the request-response path worked. isConnected() stayed true. #354/#369 timeouts stopped the hang but returned None and continued — dashboard connected, heartbeat fresh, no 1100 Telegram. reqContractDetails:DKS / qualifyContracts:DKS then 10s-timed out at 15:58 ET; batch 6f7eb6d7 0/0. socket.send() exceptions all day.bounded_request failures: latch request_path_ok=False, trading block code 412, Telegram CRITICAL category ibkr_request_path once, dashboard session request_path_dead (red), integrity trader:ibkr_request_path CRITICAL. Heartbeat uses probe_request_path() (reqCurrentTimeAsync via bounded_request). Missing snapshot key = OK (not-yet-rolled). #354/#369 timeouts unchanged. (history: also one _schedule_reconnect and clear on first probe — superseded by #413.)_note_request_result → 3rd fail → _trip_request_path_dead → _ensure_trading_allowed / get_quote refuse → snapshot trader_ibkr_connected=false + request_path_ok=false → dashboard request_path_dead + integrity FAIL + Telegram. Durable success streak → clear 412 (#413). (2) 4 AM / weekend: same latch; reconnect on cooldown (#413); stay red if recycle fails. (3) src/ibkr/client.py bounded_request / probe / latch; trading_orchestrator.py snapshot; earnings_dashboard/app.py + dashboard.html; integrity_monitor.py rule + GUARD; config.py IBKR_REQUEST_PATH_FAIL_THRESHOLD. (4) leftover: cannot eliminate IBKR half-dead sockets; DKS 15:58 slot is gone; first-ship flap closed as #413. (5) tests/unit/test_finding_412_ibkr_request_path.py.#354/#369 stay ACTIVE.src/ibkr/client.py, src/earnings/trading_orchestrator.py, src/earnings/config.py, src/earnings/alerts.py, earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html, src/data_tracker/integrity_monitor.py, tests/unit/test_config.py, tests/unit/test_finding_412_ibkr_request_path.py (NEW), src/earnings/strategy_changelog.py, .cursor/memory.md (lean Think-Before-Coding clause), this entry + R2 surfaces.services:systemd_unit_health, not a proof fail).DEPLOYED Aug 24, 2026 — systemd units + dashboard/integrity; trader not bounced (mid-session; no trading-path change).TimeoutStartSec=10800 + --max-chunks 40 assumed 2–3 min/chunk. Aug 24 06:35–09:35 UTC: Result=timeout, SIGTERM, 39 proof-pass log lines, last ledger 2026-01-04 at 09:33:19 still 69,098 live rows. Dense chunks 550–704s. 149 eligible remaining; #390 grace through 2026-08-29.TimeoutStartSec=21600. ARCHIVE_MAX_CHUNKS_PER_RUN=18 (script default + ExecStart). Timer OnCalendar 00:35 UTC and 06:35 UTC. Proof gate / drop-after-prove unchanged. Do not reset-failed as a cover-up — first clean exit 0 clears the unit.--max-chunks 18 → archive_one proof gate → ledger → drop_chunks (defer on lock). systemd will not SIGTERM before 6h. (2) 4 AM / weekend: both windows are outside RTH; 00:35 UTC is after the 15:58 ET BMO reserve. (3) deploy/systemd/polymarket-orderbook-archive.service TimeoutStartSec + ExecStart; .timer both OnCalendar; scripts/orderbook_archive.py ARCHIVE_MAX_CHUNKS_PER_RUN. (4) leftovers: ibgateway.service ghost failed/not-found (pattern ibgateway-* does not match; leave it). Do not start --archive mid-session. (5) tests/unit/test_finding_411_archive_pacing.py.deploy/systemd/polymarket-orderbook-archive.service, deploy/systemd/polymarket-orderbook-archive.timer, scripts/orderbook_archive.py, tests/unit/test_finding_411_archive_pacing.py (NEW), src/earnings/strategy_changelog.py, src/data_tracker/integrity_monitor.py (comment), deploy/deploy_from_github.sh, this entry + R2 surfaces.DEPLOYED Aug 22, 2026 17:10:33 UTC — docs/changelog/FINDINGS/builder/test; dashboard restart after pull (3e9ba01); trader not bounced (ActiveEnterTimestamp still 2026-08-22 15:23:13 UTC).DEPLOYED markers, missing §B: lines, missing §O changelog keys on live-behavior IDs, and build_revalidation_357() updated= used ranges instead of every #N token. The previous closer treated changelog+§B+builder as the whole job. Operator clarified: walk A–H/§DOC/§O/§J/§E for every ship. Do not invent a DONE-CLAIM table claiming someone walked A–H on Aug 21.src/ + named tests + server import-visibility. Record completed: official markers + compact §B from existing fix text + backfilled changelog keys + per-token builder mentions. No trading-logic / exit / sizing / admission change.§B: + DEPLOYED or SUPERSEDED/NO TEST waiver; live-behavior IDs have changelog keys; builder updated= contains every token. (2) weekend: display/record only; trader process unchanged. (3) FINDINGS.md this entry; strategy_changelog.py #410 + backfills; strategy_performance_analysis.py updated=; test_finding_410_ship_verification.py. (4) leftover: Cursor canvases live only on the operator workstation (C10 checks when the directory exists). (5) python3.11 -m pytest tests/unit/test_finding_410_ship_verification.py tests/unit/test_strategy_changelog.py tests/unit/test_doc_drift_audit.py -q plus python3.11 scripts/doc_drift_audit.py.tests/unit/test_finding_410_ship_verification.py comments + the parent return.4e4ffa1 after #409. Both fixed in the closer pass. No strategy-rule change.docs/FINDINGS.md, src/earnings/strategy_changelog.py, scripts/strategy_performance_analysis.py, tests/unit/test_finding_410_ship_verification.py (NEW), R2 surfaces + live canvases when present.DEPLOYED Aug 22, 2026 15:23:13 UTC — weekend dashboard + trader restart (defe78f). 0 open positions. Holiday skip. Recon in sync. This docs-stamp commit is a later SHA than the trader process.#393…#408). build_revalidation_357() and live canvases were not in that list, so they were skipped. The failure is not "forgot a canvas." It is declaring done without knowing whether the request was followed.DEPLOYED finding is parsed from FINDINGS.md; required on every R2 surface including scripts/strategy_performance_analysis.py; live earnings canvases checked when that directory exists; #393/#400 stay as package anchors. (3) C11: that FINDINGS block must have §B: and tests/unit/test_finding_N_*.py or NO TEST:. (4) #408 R2 misses backfilled on the builder + live canvases in this commit.#N is missing from builder/canvases/docs. (2) weekend / 4 AM: no trading-path change; dashboard restart picks up builder text. (3) .cursor/memory.md DONE-CLAIM; doc_drift_audit.py C10/C11; test_finding_409_done_claim.py; test_doc_drift_audit.py synthetic C10/C11. (4) leftover: Cursor canvases outside the repo are checked only when the directory exists (absent on the server — cited). (5) python3.11 -m pytest tests/unit/test_finding_409_done_claim.py tests/unit/test_doc_drift_audit.py tests/unit/test_strategy_changelog.py -q plus python3.11 scripts/doc_drift_audit.py..cursor/memory.md, scripts/doc_drift_audit.py, tests/unit/test_finding_409_done_claim.py (NEW), tests/unit/test_doc_drift_audit.py, docs/DOCUMENTATION_DISCIPLINE.md, scripts/strategy_performance_analysis.py, live canvases (when present), this entry + R2 surfaces.DEPLOYED Aug 22, 2026 14:49:44 UTC — weekend market-safe trader + dashboard restart (4e4ffa1). 0 open positions. This docs-stamp commit is a later SHA than the trader process.run() already used ExitManager for periodic scheduled exits and PositionManager.persist_ibkr_execution on the live exit path, but two orchestrator copies still sat on other live callees. Twin A: TradingOrchestrator._persist_ibkr_execution (SQL twin of PositionManager.persist_ibkr_execution) still ran from execute_entry and batch entry. Twin B: __init__ called orchestrator _check_missed_scheduled_exits → orchestrator execute_scheduled_exit → _finalize_position_exit while ExitManager.check_missed_scheduled_exits / execute_scheduled_exit already existed and owned the periodic path. Same class as #401/#402 (tests must inspect run()/__init__ callees, never unused twins).PositionManager.persist_ibkr_execution; delete orchestrator _persist_ibkr_execution; reroute execute_entry + _execute_batch_entry to self.position_mgr.persist_ibkr_execution(..., source=, execution_time=). Fail-open (log warning, swallow) unchanged; SQL/upsert untouched. Twin B — __init__ after _load_persisted_positions calls self.exit_mgr.check_missed_scheduled_exits(); process_scheduled_actions 'exit' calls self.exit_mgr.execute_scheduled_exit. After exhaustive grep showed zero remaining live callers, deleted orchestrator _check_missed_scheduled_exits, execute_scheduled_exit, _finalize_position_exit, and unused _verify_ibkr_position_for_exit (ExitManager already owns verify_ibkr_position_for_exit). Did not rewrite exit bodies. Did not touch leverage alerts / ENABLE_LEVERAGE_ENGINE, persist SQL, RTH skip, IBKR verify fail-closed, working-order rules, #352/#393 exit fail-open cluster.exit_mgr.check_missed_scheduled_exits → exit_mgr.execute_scheduled_exit → finalize_position_exit → position_mgr.persist_ibkr_execution. Periodic run() still exit_mgr.check_scheduled_exits. Entry/batch persist → position_mgr.persist_ibkr_execution. (2) weekend / 0 open: missed-exit is a no-op; restart still required so __init__ takes the new callee. (3) trading_orchestrator.py __init__ ~417, process_scheduled_actions 'exit', execute_entry persist, _execute_batch_entry persist. (4) leftovers (intentional): scripts/backfill_trade_audit_from_flex.py _persist_ibkr_executions_from_flex (offline Flex backfill — different function); archive/paper_trade_test.py orchestrator.execute_scheduled_exit (archive, no live test import). Leverage-alert helpers left (out of scope). (5) NEW tests/unit/test_finding_408_one_helper_twins.py + #402 tripwire + changelog schema.__init__ / process_scheduled_actions callee change; not a strategy-rule change).src/earnings/trading_orchestrator.py, tests/unit/test_finding_408_one_helper_twins.py (NEW), src/earnings/strategy_changelog.py, this entry + R2 surfaces.DEPLOYED Aug 22, 2026 13:39:29 UTC — weekend market-safe trader + dashboard restart (7fbfe05). 0 open positions. Limiter importable on prod.caching.py; fetch_finnhub_calendar and outcome_resolver._finnhub_earnings_actual_single issued raw requests.get to finnhub.io. Live yfinance in outcome_resolver, counterfactual_pricing, market_regime_provider._compute_ma_history, and etfstack daily fallbacks bypassed yfinance_limiter. PRNewswire already had the #395 30s cache; nothing failed a second fetch of that RSS URL. Process gaps (8–10 stack practices) had no lean §F/§H clauses.src/utils/finnhub_rate_limiter.py (flock+token-bucket, default 40/min combined / burst 10, fail-open). Wired every live/scheduled Finnhub GET: caching.py profile/quote/price (via _finnhub_throttle), earnings_date_verifier calendar, outcome_resolver earnings. Live/scheduled yfinance now goes through yfinance_limiter (_is_rate_limited / _record_call / _set_rate_limited). PRN: no second cache; tripwire that _get_prnewswire_rss is the only fetch of the financial RSS URL (#395 tests extended). Five lean memory.md clauses merged into existing §F/§H (no new numbered sections; #385 lean-down stands).finnhub_rate_limiter.acquire() before requests.get; limiter-file broken → fail-open, fetch proceeds. Live yfinance → limiter. Detector PRN → one cached urlopen / 30s. (2) weekend / 4 AM: combined Finnhub budget holds across trader + resolver + verifier. (3) finnhub_rate_limiter.py acquire; caching.py _finnhub_throttle; earnings_date_verifier.py fetch_finnhub_calendar; outcome_resolver.py _finnhub_earnings_actual_single + _yahoo_earnings_actual_single; counterfactual_pricing.py _yf_daily_closes; market_regime_provider.py _compute_ma_history; etfstack fetch_yfinance_*. (4) leftovers (intentional): classify_regime_for_date / fetch_historical_vix (historical/offline); scripts/archive one-offs (fill_earnings_time.py, fill_earnings_data.py, bmo_exit_timing_backtest.py); resolution_sniper.py + polymarket_sniper/ (T2.2 ON-HOLD, not live); dashboard health ping uses a different PRN URL; import-only import yfinance availability checks. Did not audit orchestrator leftover helpers (_persist_ibkr_execution etc.) — next pass; those leftover helpers closed as #408. (5) tests/unit/test_finding_407_http_quota.py (unregistered consumer = fail) + #350 + #395 + changelog schema.ON-HOLD — they are not HTTP-quota leftovers. T2 leftover HTTP quota (unnumbered) closed here.src/utils/finnhub_rate_limiter.py (NEW), src/utils/caching.py, src/earnings/earnings_date_verifier.py, src/data_tracker/collectors/outcome_resolver.py, src/earnings/counterfactual_pricing.py, src/earnings/market_regime_provider.py, strategies/etfstack/src/data_provider.py, tests/unit/test_finding_407_http_quota.py (NEW), tests/unit/test_finding_395_prn_one_poller.py, .cursor/memory.md, src/earnings/strategy_changelog.py, docs/OPERATIONS.md, .env.example, this entry + R2 surfaces.ON-HOLD rows (T2.2 / T2.6 / T2.8 / T2.11 / T2.13 / T2.15) (Aug 22, 2026)DEPLOYED Aug 22, 2026 07:53:22 UTC — weekend market-safe trader + dashboard restart (88014f4). Report regen included the six ON-HOLD rows.ON-HOLD Action Queue row per deferred ID in scripts/strategy_performance_analysis.py:build_action_queue() (never hand-edit the Strategy tab). IDs: T2.2 (IH-SNIPER 26k+11k + dead units), T2.6 (EventBus), T2.8 (dashboard twins F11/F30/F34/F35), T2.11 (ssl.CERT_NONE), T2.13 (detector maps / pending claims), T2.15 (hygiene bundle). T2.15 market_calendar naive datetime is not the T2.12 INTRADAY→AMC pattern — left deferred.next_action states the agreed defer reason. Do-wave shipped as #401–#405.scripts/strategy_performance_analysis.py (build_action_queue), this entry, canvas tier2-decisions-2026-08, Rev §II / plan row.Config.PDT_RESERVE_CAPITAL + integrity always-on list matches Restart=always collectors + unread Config knobs deleted (T2.9 / T2.10 / T2.5 · F18 / F16 / F36 / F9 / F15) (Aug 22, 2026).env or health inventory drifts).DEPLOYED Aug 22, 2026 07:53:22 UTC (88014f4).deploy_from_github.sh omitted websocket + live-trades. Wrong at HEAD — #358 already restarts both. Gap was only SERVICES_ALWAYS_ACTIVE in integrity_monitor.py. Did not invent leverage/backup/earnings-sync/conviction-tracker as always-on (those are timers). (2) T2.5 listed ENTRY_FALLBACK_TIMEOUT / EXIT_FALLBACK_TIMEOUT / EXIT_VERIFICATION_WAIT_SEC as unread. OrderExecutor uses those as class attrs — wiring Config would invent wiring and change live timeouts. Deleted unread Config knobs only. (3) Config.HEARTBEAT_INTERVAL_SEC is READ by earnings_dashboard/app.py — kept. MAX_HEARTBEAT_FAILURES was unread — deleted.Config.PDT_RESERVE_CAPITAL = _env_float(..., 17450) is the one default. Live readers (risk_manager, run_earnings_trader, trade_reconciler, app.py, __init__ example) import it. test_finding_366_capital_step.py rewritten — no longer pins four typed 17450 env-default copies. SERVICES_ALWAYS_ACTIVE now includes polymarket-websocket.service + polymarket-live-trades.service (both Restart=always, already in deploy). Deleted unread Config: ENTRY_FALLBACK_* / EXIT_FALLBACK_* / EXIT_VERIFICATION_WAIT_SEC / LIMIT_BUFFER_* / MAX_HEARTBEAT_FAILURES / ALERT_* rate/circuit / DB_POOL_* / POLYMARKET_CONFIRMATION_SEC. Kept: HEARTBEAT_INTERVAL_SEC, INSTANT_EXIT_LIMIT_BUFFER, STOP_LIMIT_BUFFER, POLYMARKET_DECISION_CONFIRMATION_SEC, recon knobs. test_config must not green unread knobs..env of a deleted knob is theater (no live reader). (2) restart: Config reload. (3) config.py PDT; integrity_monitor.py SERVICES_ALWAYS_ACTIVE; deploy list unchanged. (4) leftover: OrderExecutor class-attr timeouts (intentional — not Config). (5) test_finding_405_pdt_deploy_knobs.py + rewritten test_finding_366_capital_step.py.src/earnings/config.py, src/earnings/risk_manager.py, run_earnings_trader.py, src/data_tracker/collectors/trade_reconciler.py, earnings_dashboard/app.py, src/data_tracker/integrity_monitor.py, tests/unit/test_config.py, tests/unit/test_finding_366_capital_step.py, tests/unit/test_finding_405_pdt_deploy_knobs.py.setup_schedule / schedule.every deleted (T2.14 / T2.7 · F19 / F31 / F38) (Aug 22, 2026)DEPLOYED Aug 22, 2026 07:53:22 UTC (88014f4).reconciliation_manager.py (except Exception: pass → logger.exception): IBKR disconnected (~277), stale session (~305), IBKR=0 / DB open abort (~370), recon issues (~721). Other except Exception: pass in that file are int-compare / timezone — not Telegram; left. setup_schedule() + schedule.every + the 03:50 PM log deleted (grep-proof zero callers). Live clocks remain should_run_scheduled_job.run(). (4) leftover: non-Telegram except: pass in recon. (5) test_finding_404_telegram_schedule.py (+ #402 tripwire).src/earnings/reconciliation_manager.py, src/earnings/trading_orchestrator.py, tests/unit/test_finding_404_telegram_schedule.py.INTRADAY; then every such name would get an AMC schedule).DEPLOYED Aug 22, 2026 07:53:22 UTC (88014f4).determine_earnings_timing P1 only accepted BMO/AMC; anything else fell through to COMPANY_TIMING_LOOKUP, whose non-"BMO" default was AMC.classify_earnings_timing_token() is the one mapper (INTRADAY/DMH/DURING MARKET HOURS/MIDDAY → INTRADAY; unknown → None). P1 and P2 skip INTRADAY/unknown — no AMC fall-through. calculate_entry_exit_times raises ValueError if timing is not BMO/AMC (the previous else: # AMC is gone). Same-pattern: calendar unknown already returned None; fallback already returned INTRADAY. Conviction calculator / market_calendar else branches are not AMC-guess classifiers — T2.15 left deferred.INTRADAY → skip, never AMC entry/exit. Unknown token → None skip. BMO/AMC unchanged. (2) restart required (in-process classifier). (3) signal_generator.py classify_earnings_timing_token + determine_earnings_timing P1/P2 + calculate_entry_exit_times. (4) leftover: text-parse AMC keywords still match explicit AMC words only. (5) test_finding_403_intraday_skip.py (red-proven: INTRADAY must not be AMC).src/earnings/signal_generator.py, tests/unit/test_finding_403_intraday_skip.py, docs/STRATEGY.md §2.5.CapitalManager deleted after lockstep tests retargeted the live function (T2.1 · F12) + dead orchestrator ExitManager copies gone (Aug 22, 2026)pilot_tag / lockstep cult).DEPLOYED Aug 22, 2026 07:53:22 UTC (88014f4).test_finding_359_* / 362_* / 366_* at TradingOrchestrator.check_* / the helper → then delete src/earnings/capital_manager.py. Do not start by deleting the class (fails CI). Do not keep twins in lockstep.ReconciliationManager dropped the unused capital_manager param (recon flag already lives on the orchestrator, #399). Package no longer exports it. Dead orchestrator copies ExitManager already owns — AST-deleted, grep-proof zero callers: execute_instant_exit, check_positions_for_exit, check_scheduled_exits, check_stop_loss_backup, setup_schedule. KEEP execute_scheduled_exit (still called from live paths / process_scheduled_actions) — history: that KEEP closed as #408 (startup + process_scheduled_actions now call exit_mgr.execute_scheduled_exit; orchestrator def deleted).run() does not import CapitalManager; exits go through exit_mgr. (2) restart required. (3) orchestrator __init__ / run(). (4) leftover: none of the deleted methods. (5) test_finding_402_capital_manager_gone.py — fails if tests still inspect CapitalManager.src/earnings/capital_manager.py; src/earnings/trading_orchestrator.py, src/earnings/reconciliation_manager.py, src/earnings/__init__.py, lockstep tests, tests/unit/test_finding_402_capital_manager_gone.py.current_exposure at 10:30 under-reserved AMC).DEPLOYED Aug 22, 2026 07:53:22 UTC (88014f4).#335 netting admission already does.src/earnings/exposure.py (affordability.py shape). Live formula is cost-basis shares * entry_price. Do not activate the unused live-price calculator (would change admission dollars). capital_freed_from_imminent_exits(positions, window_seconds) + reservation_free_book(max_book, current_exposure, capital_freed). Windows: 300s (10:32 at 10:30), 600s (15:57 at 15:50). Orchestrator admission wrappers and _calculate_observed_set_bmo_reservation import the helper. Dead unused copies deleted after the helper existed.free_book credits cost-basis → reserved dollars rise (test: $15k book + $2500 leaving → reserve $2500, not $0). 15:50 admission already netted; wrappers now call the same helper. (2) restart required. (3) exposure.py; orchestrator _calculate_observed_set_bmo_reservation ~3361; wrappers ~1074 / ~1090. (4) leftover: none of the live-price calculator. (5) test_finding_401_exposure_reservation.py (red-proven: full book with 10:32 exit must reserve more than a full book with no exit).src/earnings/exposure.py (NEW), src/earnings/trading_orchestrator.py, tests/unit/test_finding_401_exposure_reservation.py, docs/STRATEGY.md §4.4.get_positions fail-closed: PositionsLookupError on not-connected / exception; successful empty book is still [] (T0.3 leftover · F26 hole the #393 abort could not see) (Aug 22, 2026)DEPLOYED Aug 21, 2026 23:00:26 UTC — weekend market-safe trader + dashboard restart (e857f5e). Pre: 0 open DB+IBKR (live ib.positions()), 0 breakers. Do not write “Aug 22 ~23:00 UTC” — systemd is Aug 21. This docs-stamp commit is a later SHA than the trader process.e857f5e pulled + deploy_from_github.sh. NRestarts=0. Recon “DB and IBKR in sync”. IBKR connected, portfolio 0, active_breakers=[]. Server get_positions raises PositionsLookupError (no return [] in except). Polygon REST probe HTTP 200 (key masked; not the burned default). Integrity 82 rules / 0 fail / 1 warn (storage:orderbook_snapshots #390 drain-grace). Dashboard HTTP 200. Trader-process SHA e857f5e; origin/master after this docs-stamp differs — do not claim they match.IBKRClient.get_positions (src/ibkr/client.py 883–914) returned [] when not connected AND on any exception. #393 F26 MARKET abort only runs if get_positions raises. A live disconnect therefore looked like “no position” and MARKET fallback could place. The prior implementer documented this swallow as an accepted leftover in #393/#396/#399 — that was wrong. Empty book after a successful ib.positions() is a different event and must stay [].PositionsLookupError on not-connected and on ib.positions() exception. Successful empty book still returns []. Same-pattern live earnings callers already abort-on-raise (order_executor.py ~687 F26, batch_order_manager.py ~306 / ~846, exit_manager.py ~89, trading_orchestrator.py ~2931 / ~4045). _check_ibkr_position (~796, unused) no longer swallows to 0. CapitalManager ~646 dead twin fail-closes too (left in place per §C / TIER 2). Operator scripts under scripts/ catch and sys.exit(1) instead of treating [] as flat. etfstack / sniper / archive scripts not rewritten.ib.positions() throw → PositionsLookupError → F26 except → position_check_failed_no_market_fallback, no place_market_order. Honest empty book → [] → F26 proceeds (RTH gate still holds); batch entry duplicate-check sees no names and continues. (2) restart: new process, no cached book. (3) src/ibkr/client.py get_positions; F26 order_executor.py ~715–724 / batch_order_manager.py ~865–872. (4) leftovers (intentional): event-pump ib.sleep on the fill path (not F2; cite #396); dead orchestrator execute_instant_exit copy (TIER 2); CapitalManager unused (TIER 2, fail-closed at the lookup). (5) tests/unit/test_finding_400_positions_lookup.py; #369 source tripwire extended (return [] banned).get_positions / ibkr.get_positions / order_executor.ibkr.get_positions in live earnings: 7 sites listed above. Scripts: 7 operator scripts catch+exit 1. etfstack / sniper / archive: out of scope (not live earnings).src/ibkr/client.py, src/earnings/batch_order_manager.py, src/earnings/capital_manager.py (comment), scripts/check_capital.py, scripts/full_audit.py, scripts/check_tonight_capital.py, scripts/check_aapl.py, scripts/verify_duplicate_check.py, scripts/check_position_sizing.py, scripts/check_tonight_proper.py, tests/unit/test_finding_400_positions_lookup.py, tests/unit/test_finding_369_bounded_ibkr_calls.py, docs per R2.ActiveEnterTimestamp is 2026-08-21 21:43:10 UTC. (2) “operator must still ROTATE” / “prod .env still holds the burned default” — WRONG; burned key is only in rotate bak files; operator rotated; first new key 401’d; regenerated key REST probe HTTP 200 on Aug 22 ~22:38 UTC. (3) “fully memory.md” — WRONG; Rev doc and IMPLEMENTATION_PLAN had zero #393–#399 mentions; canvas still said “Proposed #393–#398”; STRATEGY.md banner still #366; HANDOFF claimed origin/master 4292bdb after docs stamp c9c1d59..env or first-paint drift).DEPLOYED Aug 21, 2026 21:43:10 UTC — weekend market-safe trader + dashboard restart (4292bdb). T1.6 CLOSED on the wire Aug 22 ~22:38 UTC (POLYGON_KEY rotated + REST probe HTTP 200). Leftover get_positions [] swallow → #400.ReconciliationManager.reconcile_positions_with_ibkr wrote capital_mgr._reconciliation_completed; the orchestrator flag (checked only on the dead execute_entry) stayed False forever; live _execute_batch_entry had no check — #155 was documentation. (2) F27: literal Polygon key <burned key, sha256 90ba0a59ae1b> committed in five files (the #350 free-tier drop was this key). (3) F28: Jinja TRADE? all_ok omitted timingKnown (time in ['BMO','AMC']); JS already had it — INTRADAY/TBA painted ✓ TRADE on first paint (§M miss). (4) F29: priority.LONG_MAX_CONVICTION = 0.90 was a numeric twin of Config.SIGNAL_LONG_MAX_THRESHOLD (the #334 lesson, never closed).ReconciliationManager takes orchestrator= and sets orchestrator._reconciliation_completed = True when the method completes without throwing; _execute_batch_entry fail-closes (clears the pending batch, alerts, no orders) if the flag is False. Dead execute_entry already checked the same flag. Operator approved this. (2) Scripts raise SystemExit without POLYGON_KEY; sniper files read env with empty fallback (TIER 2, not live); archive doc redacted. No new key committed. (3) Config.KNOWN_EARNINGS_TIMINGS + Config.timing_is_known is the one definition; API JSON ships timing_known; Jinja first paint and JS refresh both consume it. INTRADAY never renders ✓ TRADE. (4) priority._long_max_conviction() reads Config.SIGNAL_LONG_MAX_THRESHOLD lazily (same pattern as _small_cap_long_max_conviction)._execute_batch_entry. If recon threw, flag stays False → no batch. (2) any scheduled Polygon script without env → exit, no silent free-tier. (3) /api/upcoming_earnings → Jinja e.timing_known → TRADE? (4) env change of the cap → priority and signal gate move together. (3) lines: recon reconciliation_manager.py ~732–734; gate trading_orchestrator.py ~5140–5150; timing config.py ~429–435 + app.py ~2896 + dashboard.html ~1377–1381 / ~4419; priority priority.py ~86–92. (4) leftover: get_positions [] swallow CLOSED by #400; CapitalManager unused flag (TIER 2); sniper empty-string fallback (TIER 2). (5) unit: test_finding_399_recon_polygon_timing_priority.py; Jinja INTRADAY first-paint never ✓ TRADE; priority._long_max_conviction() moves when Config is patched.<burned key, sha256 90ba0a59ae1b> → 0 repo hits. LONG_MAX_CONVICTION as a constant → 0 (only history comments / SMALL_CAP_*). proceeding with caution → 0. timing_is_known is the one definition.src/earnings/reconciliation_manager.py, src/earnings/trading_orchestrator.py, src/earnings/config.py, src/earnings/priority.py, scripts/fetch_scheduled_exit_prices.py, scripts/earnings_timing_5y_hourly_polygon.py, polymarket_sniper/resolution_sniper.py, resolution_sniper.py, docs/archive/POLYMARKET_KNOWLEDGE.md, earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html, tests/unit/test_finding_399_recon_polygon_timing_priority.py, tests/unit/test_finding_366_b4_c1_priority.py..env still holds the burned default” was WRONG — burned key is only in rotate bak files; operator rotated; first new key 401’d (Unknown API Key); regenerated key verified HTTP 200 on api.polygon.io aggregates (AAPL prev bar). Do not write the live key into any repo file.deploy_from_github.sh + trader restart (calendar date in +0400 was already Aug 22; systemd is Aug 21). Pre: 0 open DB+IBKR, 0 breakers, 0 stops today. Post: SHA 4292bdb, NRestarts=0, ActiveEnterTimestamp 2026-08-21 21:43:10 UTC, recon "DB and IBKR in sync", IBKR connected, contract:k_b5_revert_decision ok (0 fills / 0 gap-throughs / no breaker), integrity 82 rules 0 fail (only #390 drain-grace warn), dashboard HTTP 200, Jinja timing_known + NEEDS TIMING on first paint. get_positions on server has no reqMktData/sleep. Zero "proceeding with caution" in exit files. Disk HEAD after the docs stamp was c9c1d59 — origin/master was not 4292bdb. Leftover #400 closed the [] swallow.s2c_split_pct and recomputes AFTER the #362 day-filter (T1.2/T1.5 · F4/F32) (Aug 22, 2026)DEPLOYED Aug 21, 2026 21:43:10 UTC — same trader restart as #399 (4292bdb). Prior stamp “Aug 22 ~21:43 UTC” was WRONG.calculate_position_size(..., s2c_split_pct=) so the daily-loss tier cut applied to the split. calculate_batch_position_sizes called calculate_position_size(signal) with no split — HIGH (base_pct *= 0.25) and ELEVATED (*= 0.50) cut flat-25 instead, then often did not scale (8 × 6.25% = 50% << 150% book). Separately, run_bmo_cycle / run_amc_cycle sized then ran _filter_batch_signals_for_today — survivors kept the pre-filter equal-split (undersize; opposite direction from F4).s2c_split_pct = calculate_s2c_equal_split_pct(len(signals), ...) on this wave and passes it through. BMO/AMC cycles filter first, then size, so the survivor count is the n that splits.calculate_batch_position_sizes → pending batch → 15:58/10:34 _execute_batch_entry uses those dollars. (2) HIGH day, 4 survivors after a 6-name filter: each gets 25% of the HIGH-cut split of 4, not flat-25×0.25 of 6. (3) risk_manager.py ~1387–1404; trading_orchestrator.py run_bmo_cycle ~4930–4941, run_amc_cycle ~5019–5025. (4) CapitalManager twins still size their own way (TIER 2 / unused). (5) test_finding_398_batch_sizing.py.src/earnings/risk_manager.py, src/earnings/trading_orchestrator.py, tests/unit/test_finding_398_batch_sizing.py._exit_in_progress set: orchestrator aliases ExitManager; queue/batch route through exit_mgr.execute_* (T1.1 · F1) (Aug 22, 2026)check_scheduled_exits could double-place; a failed batch stuck the orchestrator set forever because batch never wrote _exit_in_progress_times).DEPLOYED Aug 21, 2026 21:43:10 UTC — same trader restart as #399 (4292bdb). Prior stamp “Aug 22 ~21:43 UTC” was WRONG.#115/#141 split the in-process lock. Init aliased active_positions (416–418) but not the exit set. run() → _execute_batch_exit marked the orchestrator set without times; same iteration exit_mgr.check_scheduled_exits used its own set. Queue path used orchestrator execute_instant_exit; detector-driven path used exit_mgr.execute_instant_exit. #352 is the IBKR-level mutex on the second place_* — double-place required F3 (#393). The stuck-set hole was live: failed batch, no times → #141 5-min clearer never fired → queue path blocked until restart.self._exit_in_progress = self.exit_mgr._exit_in_progress and the same for _exit_in_progress_times (identity, not a copy). _execute_batch_exit → exit_mgr.execute_batch_exit() (which writes times on mark). _process_instant_exit_queue → exit_mgr.execute_instant_exit. Tripwire: assert orchestrator._exit_in_progress is exit_mgr._exit_in_progress.#141 clearer can fire. (2) restart: new process, empty set (correct). (3) trading_orchestrator.py ~420–423, ~5323–5329, ~5580–5595; exit_manager.py ~759–762. (4) orchestrator still has dead check_scheduled_exits / execute_instant_exit copies (TIER 2 — do not delete this commit). (5) test_finding_397_exit_set_alias.py.src/earnings/trading_orchestrator.py, src/earnings/exit_manager.py, tests/unit/test_finding_397_exit_set_alias.py.get_positions is cheap (shares/avgCost, no reqMktData+0.5s); #369 FILES widened to the exit path (T0.6/T0.7 · F10/F2) (Aug 22, 2026)#354 class of window risk).DEPLOYED Aug 21, 2026 21:43:10 UTC — same trader restart as #399 (4292bdb). Prior stamp “Aug 22 ~21:43 UTC” was WRONG. Leftover [] swallow → #400.IBKRClient.get_positions did per-name qualify_contract_bounded + reqMktData + ib.sleep(0.5). Live callers (exit_manager.py 89–104, batch_order_manager.py 306/743/796, order_executor.py 687) only read .symbol / .shares. #369 FILES omitted the three exit files and did not ban reqMktData.get_positions returns ib.positions() symbol/shares/avgCost; market_value / unrealized_pnl are 0.0. get_quote stays the expensive API (still reqMktData+1s). #369 FILES += order_executor.py, batch_order_manager.py, exit_manager.py; new tests: no reqMktData/sleep in get_positions; 20-position call does not sleep; exit files have no raw reqMktData.get_positions → ib.positions() only. Marks still go through get_quote. (2) 20 names: no 10s sleep. (3) src/ibkr/client.py ~883–914. (4) leftover (intentional): get_quote still reqMktData+ib.sleep(1) (client.py ~1178–1179); event-pump ib.sleep(0.3–1) remains on the fill path in order_executor.py / batch_order_manager.py (not the F2 N×0.5s pattern). The [] swallow leftover is CLOSED by #400. (5) test_finding_369_bounded_ibkr_calls.py.src/ibkr/client.py, tests/unit/test_finding_369_bounded_ibkr_calls.py.DEPLOYED Aug 21, 2026 21:43:10 UTC — same trader restart as #399 (4292bdb). Prior stamp “Aug 22 ~21:43 UTC” was WRONG._check_prnewswire fetched it per symbol and keyed _http_check_failures as prnewswire:{symbol}. Combined with F7: detector thread (EARNINGS_POLL_ACTIVE_SEC default 10) and the ~1s main loop check_positions_for_exit → check_earnings both hit PM+PRN+Yahoo._get_prnewswire_rss with a 30s process-wide TTL cache; failure key is per-feed ("prnewswire"). ExitManager.check_positions_for_exit and the orchestrator twin are no-ops — they do not call check_earnings. Main loop drains _instant_exit_queue; the detector thread remains the one poller. Accepted detection latency is 10s._monitoring_loop → check_earnings → _check_prnewswire → one urlopen per 30s regardless of N symbols. Main loop: queue drain only. (2) two symbols in one second → one fetch. Feed 5xx → one 300s backoff for the whole book, not per name. (3) earnings_detector.py ~1140–1174, ~1242–1244; exit_manager.py ~518–521; trading_orchestrator.py ~4559–4562. (4) check_position_for_exit still exists on the detector (used by the thread / scripts); not called from the 1s loop. (5) test_finding_395_prn_one_poller.py.src/earnings/earnings_detector.py, src/earnings/exit_manager.py, src/earnings/trading_orchestrator.py, tests/unit/test_finding_395_prn_one_poller.py._persist_position_open now writes pilot_tag (T0.4 · IH-PILOT) (Aug 22, 2026)#392 ledger were blind to live fills).DEPLOYED Aug 21, 2026 21:43:10 UTC — same trader restart as #399 (4292bdb). Prior stamp “Aug 22 ~21:43 UTC” was WRONG.PositionManager.save_position wrote pilot_tag to trading_positions + trade_audit_log. The live persist TradingOrchestrator._persist_position_open (called from _execute_batch_entry) did not. test_finding_366_b5_pilot.py inspected the unused twin. count_open_pilot_positions() reads DB pilot_tag → always 0 after a live fill → cap was wave-local only. Changelog text claiming “every pilot entry writes pilot_tag” was false for the live path.inspect.getsource(TradingOrchestrator._persist_position_open)._persist_position_open → both tables get pilot_tag='b5_smallcap_long' when signal.b5_pilot. Next admission count_open_pilot_positions() sees it. #392 contract:k_b5_revert_decision ledger sees it. (2) non-pilot signal → NULL tag (unchanged). (3) trading_orchestrator.py ~2446–2501 (positions) and the twin audit INSERT (~2554–2608). (4) PositionManager path still writes (crash-recovery / unused live). (5) test_finding_366_b5_pilot.py::test_live_orchestrator_persist_writes_pilot_tag.src/earnings/trading_orchestrator.py, tests/unit/test_finding_366_b5_pilot.py.#352 lookup fail-closed, MARKET fallback abort on position-check exception (T0.1/T0.2/T0.3 · F25/F3/F26) (Aug 22, 2026)#352 lookup fail-opened into a second place; MARKET fallback proceeded on get_positions exception).DEPLOYED Aug 21, 2026 21:43:10 UTC — same trader restart as #399 (4292bdb). Prior stamp “Aug 22 ~21:43 UTC” was WRONG. Leftover get_positions [] swallow → #400.execute_batch_exits cancelled hardware stops before _submit_limit_orders(..., is_exit=True) and did not restore on submit/monitor failure. place_instant_exit already captured+restored (order_executor.py ~1125/1203/1454). place_scheduled_exit comments say do not cancel first. Batch was the hole. Software backup check_stop_loss_backup sat after detector work in the same loop. (2) F3: _get_working_exit_order_details except Exception: return []. Callers treat working_qty==0 as “place.” Quote-unavailable in _working_exit_orders_marketable already fail-safed to defer — inconsistent in the wrong direction. (3) F26: both MARKET fallback sites logged “proceeding with caution” and placed anyway (PFE/CME/ON duplicate-entry class). RTH gate still held._cancel_stop_for_exit captures stop info, try/finally restores for any name that did not fill (submit exception included). Also populates BatchResult.execution_results (was empty — callers persist from it). (2) lookup raises WorkingExitLookupError; place_instant_exit / place_scheduled_exit / marketability callers do not submit. (3) both fallback sites return/fail the order with position_check_failed_no_market_fallback. Zero “proceeding with caution” left.exit_mgr.execute_batch_exit → execute_batch_exits → cancel+capture → submit/monitor → finally restore unfilled. Instant/scheduled: lookup raise → no place. Fallback: get_positions raise → no MARKET. (2) submit raises: finally restores. IBKR openTrades raises: no second exit. (3) batch_order_manager.py ~533–595, ~865–872; order_executor.py ~91 (WorkingExitLookupError), ~715–724, ~1465–1503, callers ~1074 / ~1536 / ~1599 / ~1776. (4) leftover: get_positions [] swallow CLOSED by #400. Event-pump ib.sleep on the fill path left in place (see #396). Dead orchestrator execute_instant_exit copy (TIER 2). (5) test_finding_393_exit_fail_open.py; #352 test flipped from fail-open to fail-closed.cancel_order + stop on exit paths): order_executor._cancel_stop_for_exit / _restore_stop_order = the capture+restore pair (instant ~1125/1203/1454, scheduled restore ~1897, batch ~554/591). Other cancel_order hits are limit-cancel-before-MARKET or working-order cancel — not hardware-stop cancels. Count quoted in the implementation commit.src/earnings/batch_order_manager.py, src/earnings/order_executor.py, tests/unit/test_finding_393_exit_fail_open.py, tests/unit/test_finding_352_exit_order_stacking.py.contract:k_b5_revert_decision, was consistency:b5_pilot_auto_revert HIGH/FAIL); conditions unchanged verbatim, only the autonomy framing goes (Aug 21, 2026)DEPLOYED Aug 21, 2026 ~19:05–19:25 UTC — date already in this entry; official marker added #410.consistency:b5_pilot_auto_revert (#366) end-to-end — it ONLY ever alerted. It computed the three pre-registered revert conditions + the re-score point and returned FAIL (HIGH → Telegram) with a message instructing "AUTO-REVERT: set B5_PILOT_ENABLED=false…". No code path anywhere flips B5_PILOT_ENABLED or blocks pilot admissions off the rule (verified: the flag is read from env in config.py only; the ≤2-concurrent cap is contract-term admission logic, independent of the rule). The autonomy existed only in the FRAMING (rule name, FAIL severity, imperative message) and in the records claiming "only K-B5 acts autonomously" (#366/#389 era). The change is therefore semantic — no action path existed to remove, so no trader-side deletion queues; the trader carried none of this.contract:k_b5_revert_decision, category contract, WARNING severity, telegram_reminder=True (📋 "Contract Reminder DUE" phrasing, fires once on transition, re-arms per the monitor table). A tripped condition or the 25-fills/Dec-15 re-score point now WARNs "K-B5 REVERT DECISION DUE — … Alert-only — outcome returns to the operator"; the operator decides and, if reverting, sets B5_PILOT_ENABLED=false + trader restart at the next safe window (mechanism unchanged). The pre-registered conditions are unchanged verbatim (per-trade net ≤$0 sign-robustly ex-top-2 at re-score · >3 blind-spot gap-throughs · breaker firing with ≥2 pilot stops): same SQL, same constants, same boundaries (locked by the existing #366 boundary tests, updated to the new WARN semantics).integrity_monitor.py (evaluator + registration + the #389 section comments), config.py B-5 comment, strategy_changelog.py #366.b5 monitor field (amendment appended; dated fields left as history), strategy_performance_analysis.py builder texts (B-5 queue row, §II.2 decision row, K-B5 monitor row, decisions-done list), dashboard.html (6 B-5 badge/tooltip texts), Rev doc (§II.7 header note + K-B5 row + §II.2/§II.3 rows + timeline row; §12.5 study appendix annotated, proposal text preserved), plan doc (P3 header note + K-B5 row + P1 row 6), STRATEGY.md small-cap rules, STRATEGY_PLAYBOOK.md, FINDINGS #366/#389 (amendment notes marking the stale claims), CONTEXT.md, AGENT_HANDOFF §11. Frozen history (the PKG-357 manifest, "original queue row preserved in full", dated approval rows) left verbatim per convention. K-S2C's "auto-revert" contract language is untouched — that was always contract-outcome wording, never framed as autonomous.test_finding_366_b5_pilot.py updated (trips assert WARN + "Alert-only", new test_no_status_is_ever_fail red-line, registration asserts the new name/category/severity/reminder flag AND that the old name is gone); test_finding_389_contract_reminders.py census extended to eleven rules + the alert-only doc check tightened. Red-proven by stash-reverting integrity_monitor.py: 12 failures on the old code, green after. Full unit suite + drift audit green (python3.11).src/data_tracker/integrity_monitor.py, src/earnings/config.py, src/earnings/strategy_changelog.py, scripts/strategy_performance_analysis.py, earnings_dashboard/templates/dashboard.html, tests/unit/test_finding_366_b5_pilot.py, tests/unit/test_finding_389_contract_reminders.py, docs per the sweep list above.polymarket-integrity-monitor.service + polymarket-dashboard.service restarts (trader untouched — earnings-trader ActiveEnterTimestamp still the 15:17 UTC restart) outside the 15:45–16:15 ET corridor; strategy_analysis_report.json regenerated (5 k_b5_revert_decision mentions, 8 #392, remaining "AUTO-REVERT" strings are K-S2C contract wording only). Live census: 82 rules, 0 fail — contract:k_b5_revert_decision evaluates ok (0 fills / 0 gap-throughs / no breaker involvement), the only warn is #390's expected drain-grace; the old consistency:b5_pilot_auto_revert status row was deleted from integrity_status (one-row hygiene DELETE — it would have sat frozen-"ok" under the retired autonomous name forever) and is gone from /api/integrity after the next cycle. Dashboard HTTP 200.DEPLOYED Aug 21, 2026 — date already in this entry; official marker added #410.docs/DASHBOARD_CLEANUP_PROPOSAL_2026_08.md; the operator approved that proposal in full the same day. This ships all 10 per the proposal's own shipping order (§7) and target sketch (§6).#rules-of-engagement-box). The all-green project tracker for the COMPLETED Apr–May revamp was chrome; history lives in 📜 Strategy Evolution (#338) + STRATEGY_ANALYSIS_ARCHITECTURE.md §0.7.renderPhase3SimulatorStatus() (~215 JS lines incl. its 2 collapsibles + 5 tables) + build_phase3_simulator_status() in scripts/strategy_performance_analysis.py (385 lines) + its report key + its REQUIRED_TOP_LEVEL_KEYS row. Archive JSON stays on disk. Proposal correction recorded honestly: the proposal claimed Strategy Evolution "already carries" the Phase 3 entries as the replacement — partially wrong (the changelog is for shipped strategy changes per memory §O; the #318/#319 KEEP decisions rightly have no entries there). The replacement pointer lives in the Unified Simulator umbrella instead (see C-5).<details> ("📦 Archive … decided #311 · superseded by Capacity-Aware Sweeps"); content INSIDE unchanged incl. the earnings-season peak-week calendar table (kept per the operator's instruction) and renderPhase3SimGateRetune() (renders into the collapsed block; JSON untouched).#regime-strip): same six values (regime/SPY/MA20/MA50/MA200/VIX), same DOM ids so renderRegime() is unchanged (only its never-used regime-card lookup dropped); the classification legend moved into the strip's tooltip; the standalone 6-card section + legend box deleted. Bonus: that section's trailing stray </div> was the template's ONE pre-existing div-balance defect (an extra close at old line 4437) — the tab's div structure now scans perfectly balanced (stack-scan clean, was 1 extra close).renderProjectionsTable() + table + the strategy-sample-note line). Ladder coverage verified first per the proposal: all headline rows (win rate long/short, EV/trade, stop-hit rate, ROI vs the frozen pre-launch model) are in the 📊 ROI Re-baseline 3-epoch ladder (#322.d). The four secondary rows (avg-win/avg-loss % longs, slippage, commission projected-vs-actual) were NOT added to the ladder: their live actuals have canonical homes (Direction+Timing matrix; the #331 net-P&L/commission surfaces) and the frozen baselines remain in PROJECTIONS + docs/FINANCIAL_PROJECTIONS_2026_05.md. The builder's projected_vs_actual key still exists (producer untouched); it just has no dashboard consumer.renderStopReversalAnalysis() pruned to title + table.docs/FINDINGS.md #318/#319 and the replay scripts (replay_regime_v2.py / replay_depth_gate_v2.py).pnl_net_def set once at the template top; the Trading-tab header card and History-tab P&L Summary card tooltips (previously two slightly-different copies of the same definition) both render it.phase-status-banner, 13 phase3-sim-* ids, projections-table-body, strategy-sample-note, strat-roi-target, regime-section/regime-card, phase3-selection-research-section, 4 stop-rev-* card ids, renderPhase3SimulatorStatus, renderProjectionsTable, build_phase3_simulator_status, phase3_simulator_status) has ZERO remaining live references (archival comments only); every kept-moved id (regime-value, spy-*, regime-vix, stop ids, stop-reversal-trades*) appears exactly once in HTML + its JS references. Template checks: Jinja compile OK, node --check OK on the stripped script blocks, div stack-scan clean (better than HEAD), <details> balanced, py_compile OK.doc_drift_audit CLEAN (9 checks, 0 violations).earnings_dashboard/templates/dashboard.html (all candidates), scripts/strategy_performance_analysis.py (builder retirement), docs/DASHBOARD_CLEANUP_PROPOSAL_2026_08.md (header → APPROVED + IMPLEMENTED, post-cleanup census), docs/FINDINGS.md (this), docs/CONTEXT.md, docs/AGENT_HANDOFF.md §11, docs/IMPLEMENTATION_PLAN_2026_08.md.storage:orderbook_snapshots made DRAIN-AWARE (FAIL→WARN only while the #383 backlog drain is provably active AND a dated GRACE-UNTIL holds; self-reverting, no threshold change) (Aug 21, 2026)DEPLOYED Aug 21, 2026 — date already in this entry; official marker added #410.consistency:earnings_dates_disputed: FIVE@2026-08-26 (Five Below), the #349 workflow executed end-to-end:disputed → untradeable (correct #349 behavior; A-007's record of never being wrong holds — see below).earnings_date 2026-08-26 → 2026-09-02, earnings_time AMC (unchanged, matches IR), date_confidence='confirmed' (write-locks the date against all three Polymarket-derived sync writers per #349), date_verified_at=NOW(); audit row in earnings_date_audit (source='operator_ir_check', full IR citation in detail — same convention as the SNEX Jul-31 resolution). Before/after captured in-transaction.consistency:earnings_dates_disputed back to ok on the next 5-min cycle; the upcoming-earnings view carries FIVE at Sep 2 AMC confirmed. Sep 2 sits outside the verifier's 5-day window until ~Aug 28; when it re-enters, calendars updated to the IR date make consensus (the confirmed lock protects the row meanwhile).storage:orderbook_snapshots 20.4 GB vs 12 GB (FAIL at 18 = 1.5×): drain-aware grace, the honest fix:apply_drain_grace, pure + unit-tested): a size FAIL downgrades to WARN only while BOTH hold: (a) companion rule archive:orderbook_backlog evaluates OK (drain active + on-schedule — consulted only on the FAIL path, no happy-path cost), and (b) today ≤ ORDERBOOK_DRAIN_GRACE_UNTIL = 2026-08-29 (dated constant, #356 GUARD-OVERRIDE-UNTIL style). The WARN message carries the original size fact + the grace provenance. Self-reverting: backlog rule not-ok → FAIL "grace WITHHELD… drain has stalled"; date past with size still above → FAIL stays FAIL. History: the original post-expiry sentence said the drain should have finished; #487 keeps the FAIL and names a finished drain versus a stall. OK/WARN-band size states pass through untouched. No threshold change — 12 GB stands (locked by the #383 test AND a new #390 wiring test).drain_grace: active after the targeted integrity-monitor restart; reverts to FAIL on its own if the drain stalls or Aug 29 passes above threshold.tests/unit/test_finding_390_storage_drain_grace.py (9 tests: grace applies incl. inclusive boundary day; both revert paths; pass-through of ok/warn-band; rule wiring uses the drain-aware evaluator + keeps 12 GB + WARNING severity; the dated constant). Red-proven by stash-reverting integrity_monitor.py (collection error on old code). Full unit suite + drift audit green (python3.11).polymarket-integrity-monitor.service restart (its own unit per the #362/#389 precedent — NOT the trader, NOT inside the 15:45–16:15 ET corridor). Part 1 needed no deploy (data write).src/data_tracker/integrity_monitor.py (grace constant + apply_drain_grace + drain-aware evaluator + wiring), NEW tests/unit/test_finding_390_storage_drain_grace.py, docs/OPERATIONS.md (OPS-1 automation paragraph — drain-aware note), docs/FINDINGS.md (this), docs/CONTEXT.md, docs/AGENT_HANDOFF.md §11. Prod data: earnings_markets FIVE row + earnings_date_audit audit row (no schema change).contract:* integrity rules compute every K-contract, D-trigger and scheduled re-judgment condition LIVE and send a 📋 Telegram reminder when due — ALERT-ONLY (every contract outcome returns to the operator; only K-B5's encoded auto-revert acts autonomously [stale — superseded the same evening by the #392 amendment: K-B5 is alert-only too, nothing acts autonomously]); plus the operator's C-Q3 REFRAME recorded across all four surfaces (Aug 21, 2026)polymarket-integrity-monitor.service restart — its own unit, separate from the trader and the dashboard; outside the 15:45–16:15 ET corridor; trader untouched). Verified live: 82 rules (72 + the 10 contract rules), all ten evaluating real prod counts and correctly ARMED-not-due (D-1 193/240 · K-RES 22/50 · D-5 17/40 · D-4 1/2 firings · K-S2C 0/40 · C-Q2 23 days out · C-Q3 52 days out); only the 2 known pre-existing FAILs (storage drain-until-~Aug-27 + FIVE disputed date); dashboard HTTP 200 and /api/strategy-analysis serves the reframed C-Q3 + 10 self-alerting texts after the report regen.contract in src/data_tracker/integrity_monitor.py with a telegram_reminder flag on the Rule dataclass: WARNING-severity reminder rules now DO telegram (reminders are the point — the existing severity gate only telegrammed HIGH+), with distinct 📋 "Contract Reminder DUE"/"RE-ARMED" phrasing so they cannot be mistaken for system failures. Fires ONCE on the ok→fail transition (the monitor's existing status-transition dedup — no daily nagging), re-arms per the §II.7 table semantics when the operator's re-score resets the underlying condition. ALERT-ONLY by construction and by statement: every rule message ends "Alert-only — outcome returns to the operator"; no rule writes anything or touches trading. Only K-B5's #366-encoded auto-revert (consistency:b5_pilot_auto_revert) acts autonomously. (#392 amendment, same evening: that last sentence is now stale — the operator removed K-B5's special status; its rule became the eleventh alert-only contract reminder, contract:k_b5_revert_decision. It had never actually acted autonomously — it only alerted — but the framing implied it would.)contract:k_s2c_rescore — S2c-era fills ≥40 (entries since the Aug 21, 2026 15:17 UTC S2c activation) OR unexecutable-admission incidents >2 in any calendar month (A-7 min_share_size skip rows) → "K-S2C re-score due".contract:k_b4_recheck — ordering-decided slots ≥25 OR date ≥ Nov 30, 2026 → "K-B4 re-check due". No direct ordering-decided marker is logged; the rule computes the BEST AVAILABLE PROXY (post-PKG-357 non-reservation exposure_cap skips = nights where priority ordering decided who got the slot) and SAYS SO in its message.contract:k_res_reledger — cumulative reservation-driven skips ≥50 (the #366 reservation skip rows; 22 at arming) OR date ≥ Nov 30, 2026 → "K-RES re-ledger due".contract:d2_vix_rescore — closed real LONG entries with VIX≥20 at entry ≥25 (post-#357 epoch) → "D-2 VIX re-score due".contract:d4_breaker_reopen — any session-stop-breaker firing where ≥3 counted stops share one entry wave (S2c same-wave cluster, the #363 extension), OR a second wave-blocking firing day → "D-4 re-open".contract:d5_smallcap_short_retest — live CF-priced small-cap SHORT rows ≥40 (17 at arming) → "D-5 SHORT re-test due".contract:d8_downtrend_retest — downtrend-rule firings ≥30 → "D-8 regime re-test due".contract:d1_volume_gate_reprice — canonical trade count ≥240 (VERBATIM twin of the dashboard's TRADE_METRICS_FILTER_SQL, equivalence locked by test; 193 at arming) → "D-1 $2k re-price due".contract:cq2_grs_detection_rejudgment — date ≥ Sep 13, 2026 → "C-Q2 re-judgment + G-RS re-sweeps/re-TUNE due" (shared reminder, same date, same evidence basis).contract:cq3_capacity_plan — date ≥ Oct 12, 2026 → "C-Q3 capacity question due" (reframed text, below).build_revalidation_357() dashboard texts, and the C-Q3 reminder rule's own message.contract); §II.7 + the plan's P3 table + the Strategy tab now carry "self-alerting (#389: rule name)" per row.tests/unit/test_finding_389_contract_reminders.py (registration census incl. severity/category/reminder flags; per-rule boundary tests over fake DB rows — below/at threshold, epoch filters, month-bucket A-7 counting, D-4 shared-wave clustering, date rules against frozen clocks; the reminder-telegram path incl. the WARNING-severity gate exception, red-proven by flipping the flag; the D-1 filter verbatim-twin lock; the alert-only wording check). Full unit suite + drift audit green (python3.11).src/data_tracker/integrity_monitor.py (Rule.telegram_reminder + reminder alert path + 10 evaluators + registration), NEW tests/unit/test_finding_389_contract_reminders.py, scripts/strategy_performance_analysis.py (§II.7 builder texts + C-Q3 reframe), docs/STRATEGY_REVALIDATION_2026_08.md (§II.5 C-Q3 + §II.7 self-alerting notes + timeline row), docs/IMPLEMENTATION_PLAN_2026_08.md (P3 rows), docs/FINDINGS.md (this), docs/CONTEXT.md, docs/AGENT_HANDOFF.md §11.effective_cumulative_volume_at_entry persisted on both entry-persist twins (migration 036; backfill evaluated and SKIPPED with evidence) · F-321 counterfactual price_data_unavailable rows now retry DIRECTLY with a bounded budget and a terminal state (migration 037) (Aug 21, 2026)DEPLOYED Aug 21, 2026 ~18:15–18:30 UTC — date already in this entry; official marker added #410.strategy_analysis_report.json regenerated — the downtrend bucket now carries avg_ret_pct: 0.9039 (was 0.009; renders 0.90%), verified in the artifact; SCRIPT-1 proven by the live run. F-298's writing code is on the server's disk but INERT until the next safe-window trader restart (QUEUED).scripts/check_earnings_sources.py): both import sites said from src.earnings.priority import COMPANY_TIMING_LOOKUP but the constant lives in signal_generator.py (built from get_all_timings()). Fixed both; proven with a live run on the server (script executes end-to-end and prints the source comparison). Status: done.AVG(realized_pnl_pct) = 0.9039 (PERCENT — the column stores 0.9039 for +0.90%, n=25). Live artifact: strategy_analysis_report.json carried avg_ret_pct: 0.009 for that bucket and the dashboard rendered "0.01%" — 100× under-display, exactly the #324 §E suspicion. Root cause chain: fetch_all_trades ÷100s realized_pnl_pct into a FRACTION at load; analyze_performance_by_regime_vix averaged those fractions straight into a field NAMED avg_ret_pct; the template renders it bare (.toFixed(2)+'%'). Fix at the PRODUCER (×100 with a units comment), keeping the template consistent with the learning-loop tables' canonical percent units (#324). NEW tests/unit/test_finding_324_regime_vix_units.py: prod-repro test (0.9039% bucket must emit 0.90, not 0.009) + a template-contract tripwire asserting dashboard.html does NOT re-×100 avg_ret_pct. Red-proven by stash-reverting the fix. Status: done (dashboard artifact regenerates on the weekly timer or next manual regen).effective_cumulative_volume_at_entry) — the HPQ post-mortem gap: the volume figure the gate ACTUALLY evaluated (effective cumulative = max(markets.volume_total, live Gamma cumulative), carried on signal.volume_total) was never persisted; entry_volume_24h/volume_24h_at_entry are wrong-unit archaeology. Migration 036 adds the honestly-named column to BOTH trading_positions and trade_audit_log (same twin-surface rationale as 031); both entry-persist paths (position_manager.py + trading_orchestrator.py) now bind it from signal.volume_total. Backfill evaluated and SKIPPED (fix-forward): measured on prod — only 96/201 historical trades join to an opp-log row with volume_total inside a defensible window, and the opp-log figure is evaluation-time, not entry-persist-time; a 48%-coverage time-skewed backfill would poison the post-mortem use case the column exists for. NULL = pre-036 entry. NEW tests/unit/test_finding_298_effective_volume_persist.py (migration targets both tables; both twins carry the column + the bind). Trader-side: the migration is additive/nullable and safe to apply ahead; the WRITING code activates at the next safe-window trader restart — QUEUED per the Friday-corridor rule, NO trader restart in this ship.price_data_unavailable permanent gaps): the #321 self-heal only re-selected unavailable rows whose equity_bars_1m got cached by ANOTHER job — but the CF pricer fetches Polygon directly and never writes those bars, so transient Polygon failures became permanent gaps (85 rows on prod). Fix: migration 037 adds polymarket_opportunity_log.cf_retry_count; the resolver's Pass 3 drops the EXISTS-bars gate and re-selects unavailable rows DIRECTLY with cf_retry_count < 5 after a 3-day cooldown; every failed attempt increments the counter; at 5 the row is stamped price_data_unavailable_final_v1 — TERMINAL, never re-selected (constants in counterfactual_pricing.py; compute_counterfactual_pnl.py counts first attempts identically). Rate limiter respected by construction — all fetches go through the shared #350 token-bucket helper. The 85 backlog rows start at 0 and get the full budget (healing is the point). Tests extended in tests/unit/test_outcome_resolver.py (bounded-select SQL, terminal-version distinctness, retry-increment binds).scripts/check_earnings_sources.py, scripts/strategy_performance_analysis.py (×100 + units comment), NEW tests/unit/test_finding_324_regime_vix_units.py, NEW src/data_tracker/migrations/036_effective_cumulative_volume_at_entry.sql, src/earnings/position_manager.py, src/earnings/trading_orchestrator.py, NEW tests/unit/test_finding_298_effective_volume_persist.py, NEW src/data_tracker/migrations/037_cf_retry_count.sql, src/earnings/counterfactual_pricing.py (retry constants + terminal version), src/data_tracker/collectors/outcome_resolver.py, scripts/compute_counterfactual_pnl.py, tests/unit/test_outcome_resolver.py, docs/IMPLEMENTATION_PLAN_2026_08.md (P4 closures), docs/FINDINGS.md (this), docs/CONTEXT.md, docs/AGENT_HANDOFF.md §11.DEPLOYED Aug 21, 2026 — date already in this entry; official marker added #410.IMPLEMENTATION_PLAN_P1_SURFACES.md §1 method (that doc is frozen history; surface set re-counted from scratch): 6 tabs · 56 sections · ~85 cards · 57 tables · 5 collapsibles + header chrome ≈ 210 surfaces, each classified KEEP / FIX / DEAD / CONSOLIDATE-CANDIDATE / REMOVE-CANDIDATE. → Census superseded by the post-cleanup counts in Finding #391 (Aug 21, 2026 — the operator approved the proposal in full the same day): sections 55 → 51 (Strategy 32 → 28), metric-cards 38 → 26, tables 57 → 51 by mechanical count; details in #391 + the proposal's updated §0. Mechanical sweeps (every DOM id vs every literal JS/HTML reference; every function def vs call sites) + full read-through for stale text. etfstack/btc5m subtrees out of scope (separate labs).dashboard.html):renderBacktestTable() + renderCohortTimingTable() + renderScorecard() function bodies (~145 lines) that #320 (May 31) had left orphaned "to bound this diff". Their 7 DOM-id lookups (backtest-table-body, cohort-timing-bmo/amc/note, scorecard-bmo-amc/conviction/stops) were the LAST references to those ids anywhere (grep-verified: remaining mentions are archival comments only). Producers + JSON stay on disk per #320. setHTML (flagged in an earlier sweep) no longer exists anywhere in the earnings dashboard — nothing to remove.docs/DASHBOARD_CLEANUP_PROPOSAL_2026_08.md — 7 CONSOLIDATE-CANDIDATEs + 3 REMOVE-CANDIDATEs, all on the Strategy tab (33 of 56 sections) except one borderline History item; top candidates: remove the all-green Strategy-Analysis-Revamp phase banner (keep the red rules-of-engagement box) + the completed-project Phase 3 Simulator Status tracker; collapse the superseded Gate Retune Sweep into an archive block. Includes a per-tab target-dashboard sketch (Strategy 33 → ~26 sections) + suggested shipping order.earnings_dashboard/templates/dashboard.html (all FIX/DEAD edits), NEW docs/DASHBOARD_CLEANUP_PROPOSAL_2026_08.md, docs/FINDINGS.md (this + #320 tail note), docs/CONTEXT.md, docs/AGENT_HANDOFF.md §11, docs/IMPLEMENTATION_PLAN_2026_08.md header. Dashboard-only deploy (NO trader restart)..cursor/memory.md following doc-basename references, with/without .md (_ is a word char, so OPERATIONS never false-matches inside POSTGRES_OPERATIONS). Verified on the live tree: 13 tier-2 docs referenced directly from memory.md, 19 tier-3 all reachable (18 via AGENT_HANDOFF.md §2's doc index, 1 via CONTEXT.md), 0 orphans. R4-exempt: docs/archive/** and any packs/ tree (frozen history — covered by C9 instead). A NEW unlinked doc fails the whole unit suite with instructions: link it from its tier or archive-and-index it.docs/archive/INDEX.md — the SSOT for historical docs. Every entry: what it was / why historical + froze date / what superseded it. Contents: 20 archived files (16 top-level + 4 stocks_earnings/) + 7 banner-frozen docs still in docs/ (IMPLEMENTATION_PLAN_P1_SURFACES, FINANCIAL_PROJECTIONS_REVISED_87_TRADES, DATABASE_AUDIT_2026_04_27, AUDIT_FIX_QUEUE, TESTING_FINDINGS_PENDING, PAPER_TRADING_TEST_PLAN, OPS_TIMING_VERIFICATION) + a §3 for out-of-docs artifacts (scripts/analysis_357/ working notes, the frozen Epoch-1 PROJECTIONS constant). C9 enforces all three directions: (a) every file under docs/archive/ has an entry, (b) every path the index cites exists, (c) every doc outside archive/ whose first 15 lines carry an R4 banner token (FROZEN AS HISTORY / SUPERSEDED HISTORICAL / DEPRECATED) is indexed.DATABASE_AUDIT_2026_04_27.md (dated Phase-0 audit snapshot), PAPER_TRADING_TEST_PLAN.md (pre-live plan, already stale-warned Apr 26), OPS_TIMING_VERIFICATION.md (Mar 2026 #213 verification capture).OPERATIONS.md § "Hypertable retention & archive (OPS-1 — #383)" exists and OPERATIONS.md is tier-2 (directly referenced from memory.md), so the data-archive runbook was already reachable; INDEX.md §4 and the DOCUMENTATION_DISCIPLINE R4 section now point to it explicitly (ledger orderbook_archive_log + scripts/orderbook_archive_restore.py) as the historical-DATA counterpart, alongside the Jan-2026 trades CSV archive runbook.DOCUMENTATION_DISCIPLINE.md R4 (the framework anchor) and AGENT_HANDOFF.md §2's docs/archive/ section; R6's check table gained the C8/C9 rows in the same commit per its own contract.python3.11 scripts/doc_drift_audit.py = CLEAN (9 checks, 0 violations) on the real tree; full unit suite green.git pull — no dedicated deploy needed. DEPLOYED Aug 21, 2026scripts/doc_drift_audit.py (C8 + C9 + docstring + dynamic check count), tests/unit/test_doc_drift_audit.py (10 red-proofs), NEW docs/archive/INDEX.md, docs/DOCUMENTATION_DISCIPLINE.md (R4 archive-tracking + reachability rules, R6 table rows), docs/AGENT_HANDOFF.md (§2 archive pointer + §11 bullet), docs/CONTEXT.md (weekly bullet), R4 banners in docs/DATABASE_AUDIT_2026_04_27.md / docs/PAPER_TRADING_TEST_PLAN.md / docs/OPS_TIMING_VERIFICATION.md.[hidden]/.cursor/memory.md copy synced at the Aug 21, 2026 15:17 UTC market-safe trader-restart deploy (it rode that window as planned, with #378/#379/#380/#382-detector/#384). DEPLOYED Aug 21, 2026 15:17 UTCscripts/analysis_357/memory_lean_proposal.md (inventory table, duplicate/drift/superseded findings with citations, merged wordings, size math). Operator approved in full, including the flagged policy decision: §DOC/R4 ("superseded text is marked-as-history or deleted") supersedes the old MAIN-DIRECTIVES line "NEVER remove content from documentation unless clearly obsolete" — the old phrasing predated #374 and invited the drift the census counted.earnings_markets SSOT) + Principle 4 (verify-all-callers); NO-SHORTCUTS self-audit ↔ §G final-grep item (one list, referenced from §G); Principle 5's confirmation bullets → pointer at the TRADE EXECUTION PROHIBITION section; §K quintiles line (#218) → the "re-run after material DB changes" clause of §K/N; old §F/Principle-2 twin statements of the #220 earnings_time rule → single §F line (with the "runtime web fallback stays in SignalGenerator" nuance kept).docs/archive/ (2b.1); (3) the old "NEVER remove docs content" directive → §DOC/R4 (2b.7, operator decision); (4) Related-Guidance finding rows #219/#220/#227/#228 + the FINANCIAL_PROJECTIONS_REVISED_87_TRADES row + the stale "(re-ranked Apr 25)"/"Apr 25 added..." descriptors (2a/2b.2/2b.3/2b.6 — hand-maintained cross-reference lists that §DOC/R1 forbids; FINDINGS.md is the index); (5) Hyperliquid API details → 1-line pointer (zero live-code uses — only scripts/hl_earnings_analysis.py offline; docs/HYPERLIQUID_API.md verified to carry every dropped detail); (6) verbatim IBKR/RiskManager/SignalGenerator snippets + ports table → moved to CONTEXT.md "Module quick reference" (NEW section, same commit); (7) Server-Connection/Service-Management/Quick-Script-Template/Conviction-Tracker blocks → deleted, OPERATIONS.md verified to already cover each (server+SSH+venv, systemctl/journalctl commands, script templates at its L700+, conviction tracker §"Conviction Tracker Service" incl. entry times + tables + commands); a ~13-line Quick Reference of the repeat-mistake gotchas (client_id=99/port 4001, method/attr names, trading_mode= not mode=, timezone, account-as-constants) stays in memory.md.test_finding_350_polygon_limiter.py wired-consumers parametrize; test_finding_369_bounded_ibkr_calls.py FILES list), so new code in new files is invisible to them; the memory duty is the only registration mechanism. Each line now names its tripwire AND the register-new-code duty explicitly.refresh_open_orders_bounded (the #369 extension was missing); §M gained the current volume definition (#282, threshold 1000 per #311 — verified against config.py); the CLB-1/#366 consecutive-loss retirement notes carry over with history phrasing intact.python3.11 scripts/doc_drift_audit.py CLEAN; full unit suite green (test_doc_drift_audit.py::test_memory_md_references_the_framework pins that memory.md cites DOCUMENTATION_DISCIPLINE.md + doc_drift_audit.py and contains no "Latest: #N" — the new file satisfies all three); section letters §J/§K/§M/§N preserved (external references in FINDINGS.md, the dashboard Win-Rate tooltip, STRATEGY_ANALYSIS_ARCHITECTURE.md, and phase3_simulator code comments resolve against the new file)..cursor/memory.md (695 → 316), scripts/analysis_357/memory_lean_proposal.md (header), docs/CONTEXT.md, docs/AGENT_HANDOFF.md, docs/FINDINGS.md (this entry).closed=true (the #375 exclusive-filter trap) so it got [] for exactly the markets it existed to save (TMUS/AXP/PYPL class); (2) channel-window leaks: inconsistent timing resolution made BOTH channels say "not mine" until the right channel's entry moment passed (MA: AMC one scan, BMO the next; WWD: only ever seen by the wrong channel) — and every one of these died at DEBUG/WARNING with a generic skip_reason (Aug 21, 2026)conviction_calculator, signal_generator) — DEPLOYED Aug 21, 2026 15:17 UTC (the Friday-midday market-safe trader restart; landed EARLIER than the Aug 23-24 window the epoch assumed, same restart as #378/#379/#380/#382-detector). The two integrity rules were already live on the integrity monitor; their shared epoch (Aug 23) is unchanged — conservative: the fixed trader scans from Aug 21, rule enforcement begins Aug 23. §G probe at deploy: the fallback found DE's CLOSED Aug-20 market via the closed=true retry and correctly refused its resolved 1.000 print with the loud #384 WARNING. Explicit leak detector AMENDED by #414 (DKS already-admitted false positive; true MA/WWD path unchanged).closedTime ≈ 12:35–13:39 UTC on earnings day — Polymarket closes earnings markets around the 9:00 ET open. The AMC conviction check runs at 10:29 ET (#366): by then the collector had stopped snapshotting the closed market (no orderbook/price rows in the lookup window) and the #333 last-resort live-Gamma fallback fetched condition_ids=<cid> WITHOUT closed=true — which per #375 is an EXCLUSIVE filter, so Gamma returned [] for the closed market and the fallback silently died. The qualified signal then skipped as conviction_unavailable at WARNING (Gamma path) / DEBUG (calendar path). A single transient HTTP error had the same effect — the scan runs ONCE per channel per day, so one 5s blip was permanent.determine_earnings_timing resolved MA as AMC on the Jul-29 scan and BMO on the Jul-30 scan (earnings-date/time correction landed between scans) — each channel logged timing_channel_mismatch ("the right channel will take it") and moved on; no channel ever took it. WWD (an AMC market) was only ever surfaced by the BMO channel's lookahead query; its own AMC channel never selected it. In both shapes the tell was checkable AT MISMATCH TIME: the resolved channel's entry moment had already passed, so "the right channel will take it" was provably false — but the code treated all mismatches as benign and logged nothing.closed=true retry in the live-Gamma fallback (conviction_calculator._get_conviction_from_gamma_live): plain fetch → on empty, retry WITH closed=true (#375 pattern, now applied at its 4th call surface). The closed market's outcomePrices still carry the final traded print until resolution; genuinely RESOLVED degenerate prints (≤0 / ≥1) stay rejected. Plus a 2-attempt transient-HTTP retry (1s pause), and every failure mode now logs at WARNING naming the finding (the silent logger.debug swallow hid this class for months).signal_generator.classify_timing_mismatch): if the RESOLVED channel's entry moment already passed (AMC: earnings-day 10:39 ET; BMO: earnings-day midnight ET — the 15:58-ET-prior-day entry has unambiguously passed by then; INTRADAY never leaks), the skip is stamped with the NEW distinct skip_reason='timing_channel_leak' and logged at ERROR ("CHANNEL-WINDOW LEAK … market will NOT be traded"). Benign hand-offs keep timing_channel_mismatch. The decisive prior-afternoon scan itself is not classifiable (the wrong resolution is all anyone has at that instant) — the leak fires loudly on the NEXT scan, same day as the loss. AMENDED by #414: if the same condition_id already has signal_emitted/admitted, keep mismatch (DKS already-admitted false positive).conviction_unavailable sites (Gamma path + calendar path) elevated to ERROR naming the integrity rule — a qualified signal dropped because EVERY fallback failed is never routine.coverage:conviction_fetch — any post-epoch skip_stage='conviction' row is a named FAIL (the fixed chain still failed; look the same day). coverage:channel_window_leak — post-epoch rows carrying the explicit timing_channel_leak marker, PLUS a structural sweep that works with ANY trader version: a market whose earnings passed ≥1 day where EVERY opportunity-log row is a skip_stage='timing' skip (the MA/WWD signature — every channel that looked said "not mine"). AMENDED by #414: explicit detector excludes leaks whose condition_id (or symbol+earnings day) already has signal_emitted/admitted.tests/unit/test_finding_384_coverage_bugs.py (22): leak classification (WWD same-day shape, MA earnings-day shape, benign hand-off evening-before, decisive-scan non-classifiability, 10:39-ET boundary, INTRADAY never leaks, naive-datetime ET convention), closed=true retry (closed-market rescue, no-retry-when-open, resolved-print still rejected THROUGH the retry, transient-retry success, persistent-failure loud, not-found-anywhere loud), mismatch-site wiring tripwires (classifier called, classified reason emitted, ERROR text present ×2 conviction sites), rule registration/severity/epoch/SQL tripwires. Existing #333 fallback tests pass unchanged. Full unit suite green (python3.11).src/earnings/conviction_calculator.py (closed=true + transient retry + loud logging), src/earnings/signal_generator.py (classify_timing_mismatch + leak skip_reason + ERROR logging at 3 skip sites), src/data_tracker/integrity_monitor.py (2 rules + shared epoch), src/earnings/strategy_changelog.py (#384 per §O), NEW test file, docs (plan A-14 row, revalidation §II.6 A-14 + tally, queue texts, canvas, handoff/context).orderbook_snapshots chunks >90 d are now losslessly archived to DigitalOcean Spaces behind a MANDATORY per-chunk byte-identity proof gate, then dropped from the live hypertable — acceptance-proven end-to-end on the TTD/RKLB/SG baseline (archive→drop→ArchivedRangeError→restore→replay byte-match→re-drop), nightly automation armed, and the operator's binding documentation condition satisfied with a full runbook (Aug 21, 2026)orderbook_snapshots was 21.7 GB and WARN since Jul 27; steady state after backlog drain ≈ 6–9 GB live + ~0.4 GB archived).DEPLOYED Aug 21, 2026docs/OPERATIONS.md §6 "Hypertable retention & archive" — THE single-source runbook (#374 R2); POSTGRES_OPERATIONS / DATA_INTEGRITY / analysis-notes cite it.ops1_compression_fidelity_proof.py::OB_COLUMNS, ORDER BY condition_id, time, jsonb as compact json.dumps, NULL as empty — deterministic and round-trippable) → sha256 → zstd-9 (~50–80× measured) → upload data.csv.zst + manifest.json to private bucket polymarket-orderbook-archive (nyc3) → download-back hash check → proof gate: restore the DOWNLOADED copy into a scratch table, canonical re-dump must byte-match the original sha256, live range re-dumped and must still match (no mid-flight change) → ledger row in orderbook_archive_log (migration 035 — the SSOT for what's archived/dropped) → drop_chunks bounded to exactly that range. Byte-identity of the canonical dump ⇒ every SQL query on restored data returns exactly what the live chunk returned. Any gate failure keeps the chunk live, writes proof_status='fail', stops the pipeline.drop_chunks' ACCESS EXCLUSIVE queueing behind a long dashboard aggregation — and every live collector INSERT then queued behind THAT (~5 min write stall, mid-US-session). Fix: lock_timeout 5s + 6 retries; if contention persists the drop is DEFERRED (range is archived + proven; the next run's re-drop path re-dumps live data, hash-checks it against the manifest, and only then drops — never re-uploads). Deferrals were observed working exactly as designed during market hours.ConvictionCalculator correctly raised ArchivedRangeError (message carried the exact restore command) → orderbook_archive_restore.py brought all 987,497 rows back hash-verified → the full fidelity harness re-ran on the restored data and byte-matched the pre-archive baseline on every surface: raw-dump sha256 f290894d…/b35570f6…/50124d8e… (4,426/4,426/4,424 rows), conviction 0.785/0.71/0.91, point-in-time books, gap-aware counterfactual P&L −$400/−$400/+$544.37 (proof JSONs identical ex-timings) → re-drop re-verified every range against its manifest and dropped again (2 drops lock-deferred to the nightly timer — the designed behavior). Artifacts: /root/ops1_proof/{pre,pre2,post}; logs /root/ops1_{acceptance_archive2,redrop,restore,final_redrop}.log. Latency note: restored-data queries are identical to live (same table, same indexes — restore is physical re-insertion, not a view); the only cost is the restore itself (~3.5 min per ~65k-row day-range).ConvictionCalculator raises ArchivedRangeError — with the exact restore command in the message — when a historical conviction window overlaps a dropped range (ledger consulted only for windows older than 80 d; live-trading path pays nothing; degrades to no-op where migration 035 is absent). All analysis_357 harnesses inherit this through ConvictionCalculator; scripts/analysis_357/populations.md documents the workflow.scripts/orderbook_archive_restore.py --list [--from --to] shows every archived range + live/DROPPED state; --restore --from --to downloads, hash-verifies object AND decompressed CSV against the ledger, and inserts the rows back into the live hypertable — after which studies need to do NOTHING differently. --scratch-table variant for side-table inspection. Re-drop when done: scripts/orderbook_archive.py --archive.archive:orderbook_proof_gate (HIGH — any proof_status='fail'), archive:orderbook_backlog (timer progress while eligible chunks exist; warn 8 d idle), archive:orderbook_spaces_reachability (HIGH — the bucket holds the ONLY copy of dropped ranges), archive:orderbook_spot_verify (monthly re-download + re-hash; grace until the first monthly fire). storage:orderbook_snapshots re-armed 20 → 12 GB for the 90-day steady state — it keeps warning truthfully until the backlog drains, then clears honestly.ORDERBOOK_ARCHIVE_SPACES_KEY/SECRET/BUCKET/REGION in the prod .env (root-only) — DELIBERATELY not the generic SPACES_* names, which would silently activate the DB-backup Spaces upload the operator chose to keep local-only.scripts/orderbook_archive.py, scripts/orderbook_archive_restore.py, src/data_tracker/migrations/035_orderbook_archive_log.sql, 4 systemd units, tests/unit/test_finding_383_orderbook_archive.py (25 tests: canonical jsonb round-trip, gate identities, header-drift tripwire, deferred-drop lock semantics, guard raise/degrade, rule wiring + threshold); conviction_calculator.py (ArchivedRangeError guard), integrity_monitor.py (4 rules + threshold), deploy_from_github.sh (timer install), OPERATIONS.md §6 runbook, POSTGRES_OPERATIONS.md correction, DATA_INTEGRITY/DATA_ARCHITECTURE/STATE_MACHINES rule counts, scripts/analysis_357/populations.md pointer.earnings_detector basis guard (GAAP markets: Yahoo street claims downgraded to UNKNOWN; wire claims refused persistence-alone admission) is live in the trader process.pm_resolution, pm_resolved_at, actual_beat_source, benchmark_basis, benchmark_comparator on earnings_markets (additive, nullable, commented).outcome_resolver.py Pass 1): tier 1 checked FIRST (Gamma resolution, degenerate-only, closed=true), locked via the CASE guard — tier-2/3 writers are structural no-ops on pm_resolution_v1 rows (tripwire-tested); tier 2 grades the market's stated benchmark with basis-matched actuals only (non-GAAP → Yahoo/Finnhub street; GAAP → Polygon SEC; cent-rounded, strict >, tie → NO, abstain on unparseable); tier 3 = the w357 sustained-crowd verdict, fills only unlabeled rows. RETIRED: the pre-event conviction cascade and the single-snapshot 0.95/0.05 read. Pass 4 corrective. Non-degenerate resolutions never auto-written (0 observed).earnings_market_fetcher stores the benchmark contract at market discovery via the NEW shared src/earnings/benchmark_contract.py (§M single definition: discovery/resolver/backfill/tests); fill-once (frozen at creation); abstain-on-unparseable.~provisional on tier-2/3 labels (both Jinja+JS twins; outcome_provisional in the API); trading stats expose provisional_prediction_flags; pm_prediction_accuracy excludes crowd labels (landed via f290194); the detector basis guard (deployed Aug 21 15:17 UTC, above).labels:benchmark_parse (WARN on unparsed recent contracts); consistency:exit_label_vs_resolution (#344) no longer evaluates against tier-3 crowd labels.pm_resolution_v1 — zero legacy/crowd-labeled rows remain); benchmark contracts filled on 1,264 rows (1,245 + 19 under parser v2); 0 conflicts. Trade side: 2 condition_id repairs vs census 1 — trade #29 CME per plan PLUS trade #19 XOM (also NULL, canonical, unambiguous single-market join; the census's §4 only counted trades already joinable). 85 trade corrections landed: the census's 12 poisoned prediction_correct flags (recomputed post-relabel inside the same transaction — the dry-run's pre-relabel list is not the landed set), ~71 actual_earnings_result NULL fills, 3 aer corrections, plus the two repaired trades. Verification (§G): a1_resolution_compare.py re-run → 0 mislabels (was 141), 0 poisoned flags (was 12), 0 fillable NULLs (was 34), 1 trade-market unresolved (DY 2026-08-19, Gamma still pending — tier 2/3 carries it); displayed prediction accuracy 87.3% (172/197) vs the predicted ~88.0% (flips nearly cancel; population grew by the repaired trades). Idempotency: a second --dry-run plans zero changes in every category. Both audit CSVs (6,572 + 38 field changes, before/after per row) kept with this finding: scripts/analysis_357/backfill_372_audit{,_v2}.csv.labels:benchmark_parse WARN, 36 rows. 30 were upcoming markets awaiting the first post-deploy sync fire; 6 were REAL template drift — whole-dollar strikes render with NO pt fraction (hum-...-07-29-2026-7 = $7.00; HUM/TSN/WYNN/CHYM/PLBY/DG) — invisible to the v1 parser AND to the legacy eps parser (eps_estimate was NULL on all six). Parser v2 (pt part optional, PARSE_VERSION v2, commit 7d622a5) + a backfill re-run filled all six; rule now OK (0 unparsed in 30 days; 1,294/1,307 all-time have a parsed contract — the rest are slugless/deleted-market rows outside the rule's window).pm_prediction_accuracy now reads LONG 83.7% (797/952), SHORT 73.5% (61/83) on the tier-1-labeled, crowd-excluded population (the last committed snapshot predates months of trades AND the relabeling — per #377 the report lives on disk, so no clean like-for-like delta exists; the population change is the point: labels are now the market's own resolutions). The headline all-trades win rate is P&L-derived and structurally unaffected by relabeling (0.3834 current); the label-derived displayed prediction accuracy moved 88.0% → 87.3% as predicted ("barely moves — flips nearly cancel").tests/unit/test_finding_372_resolution_as_truth.py (58: contract parsing incl. v2 whole-dollar + both slug eras + nongaap-before-gaap; strict->/tie→NO/cent-rounding/gte-drift; tier-1 degenerate-only; tier order + lock tripwires on the Pass-1 SQL; cross-repo no-unauthorized-writer sweep; street-never-Polygon / GAAP-never-Yahoo basis matching; backfill planner relabel/fill/stamp/conflict/idempotency; dashboard provisional markers incl. §M twin checks; detector basis guard incl. crowd-agree-still-admits); test_outcome_resolver.py re-pointed (retired proxies gone — tripwired). Full suite green (python3.11; 1,591 passed at the implementation commit — the 4 standing failures are pre-existing on clean HEAD: 2 IBKR live-gateway, 2 sibling-territory, reproduced on a HEAD worktree).benchmark_contract.py, resolver, fetcher, integrity, dashboard, detector, backfill script, tests, OPERATIONS.md — jointly carried the in-flight A-9/#379 detector change to keep the shared worktree's suite green, credited there); parser v2 7d622a5; records + audit CSVs this commit. Changelog: #382 entry (detector basis guard — detection-behavior change, #379 precedent); the resolver/backfill parts are labels/records infrastructure (no entry per #338).counterfactual_computed_at < exit_datetime_scheduled; DELL 5/28 stored +2.2% vs +32.1% true — a $200 outlier under a $14 label): the job selected any unpriced row regardless of whether the simulated exit had happened and _pick_exit_bar silently returned the last available bar; the resolver's skipped-signals pass had the same hole for yesterday's AMC rows (they exit TODAY 15:57 ET, priced at the 06:00 pass) (Aug 21, 2026)DEPLOYED Aug 21, 2026scripts/compute_counterfactual_pnl.py selected counterfactual_computed_at IS NULL rows with NO exit-time filter, so rows logged before their trade finished were priced immediately; counterfactual_pricing._pick_exit_bar returns the latest bar ≤ scheduled exit, so a window that ends mid-flight silently yields the mid-flight price as the "exit". Same class in outcome_resolver Pass 2: its earnings_date < CURRENT_DATE filter still admits yesterday's AMC rows whose scheduled exit is later TODAY. The i1 census (§11.2) repaired its own analysis copy via bar-by-bar replay (99.2% validated agreement) and A-13 commissioned the production fix.run_counterfactual_for_row REFUSES to price when the scheduled exit + 30-min settle buffer is in the future (cf_refused_pending_exit_v1, checked before any API call) or when the bar window has a >5-min coverage gap at entry/exit without a stop firing inside covered bars first (cf_refused_truncated_bars_v1 — the census's validated coverage rule; a stop-hit before the gap is complete evidence and passes). is_cf_refusal() is the caller contract: refusals are NEVER persisted. (2) cron — the SELECT now excludes rows whose exit_datetime_scheduled is not ≥30 min past (NULL-exit rows still flow to the no-sim marker); refusals leave the row unpriced for the next run. (3) resolver Pass 2 — explicit settle-buffer skip + refusal skip (previously a refusal would have been terminally marked no_data_v1). (4) integrity rule consistency:cf_window_truncation (HIGH): any post-epoch stored price with the truncation signature is a named FAIL (epoch Aug 21, 2026 16:00 UTC — the fixed pipeline's deploy).--requeue-truncated one-shot on the cron — NULLs counterfactual_computed_at on the ~1,100 signature rows so the FIXED job re-prices them from complete windows (ran live post-deploy; the ~18 permanent bar-gap markets now stay honestly unpriced as refusals instead of carrying mid-flight prices). Analyses that consumed the i1-repaired copies are unaffected; analyses reading raw stored CF rows get clean data as the requeue drains (limit 500/run on the data-backfill timer).tests/unit/test_finding_381_cf_truncation.py (12): pending-exit refusal fires before any Polygon call, settle-buffer holds 5 min post-exit, bars-end-early refusal (PLTR class), bars-start-late refusal, stop-hit-before-gap stays valid, complete-coverage prices normally, tolerance pinned to the census's 5-min rule, and structural tripwires (cron SELECT filter + refusal check + requeue mode present; resolver refusal/settle guards present; integrity rule registered with the exact signature SQL). Full unit suite green with python3.11.src/earnings/counterfactual_pricing.py (guards + refusal contract), scripts/compute_counterfactual_pnl.py (selection filter + refusal skip + requeue mode), src/data_tracker/collectors/outcome_resolver.py (Pass-2 guards), src/data_tracker/integrity_monitor.py (rule + epoch), src/earnings/strategy_changelog.py (#381 per §O), NEW test file, docs (plan A-13 row, revalidation §II.6 A-13, queue texts, canvas, handoff/context).earnings_trader.log is real at [hidden]/logs/ (systemd append: target, OUTSIDE the #274 logrotate coverage), while the A-11 "0 B since May 9" observation was the legacy /var/log/polymarket/ twin that stopped receiving writes; journald additionally held 3.9 GB with NO retention bound (Aug 21, 2026)RULEB_* prod-.env lines removed with a dated comment at this deploy as planned).RuleB[dry_run] log lines (8,288 counted live in the trader log) validating nothing. DELETED: src/earnings/ruleb_window_gate.py, the orchestrator's _process_ruleb_window_gate / _ruleb_log_once / _ruleb_fire_cancel / _ruleb_fire_rearm + the main-loop hook (252 lines), the four RULEB_* Config knobs (+ history note left in place), the RULEB_MODE GUARD_REGISTRY entry, tests/unit/test_ruleb_window_gate.py. KEPT as history: the phase3_simulator RuleB replay arms + their tests (the #330/#357 refutation evidence — the reference implementation stays pinned), analysis scripts, all findings/changelog/doc history. Changelog: #330 → AMENDED by #380 (the refutation stands; only the "dry-run code remains" residue changed); NEW #380 entry. Leftover RULEB_* prod-.env lines are inert (nothing reads them); they'll be removed with a dated comment at the restart-window deploy.earnings-trader.service writes StandardOutput=append:[hidden]/logs/earnings_trader.log — that file is 2,333,648,370 B (2.33 GB), first line Jan 6, 2026, still growing (+ earnings_trader_error.log 536 MB beside it), and [hidden]/logs/ was NEVER covered by the #260/#274 logrotate config (which covers /var/log/polymarket/*.log only). The /var/log/polymarket/earnings_trader.log the A-11 audit saw IS rotated and has been 0 B since May 9 19:50 — a legacy path that stopped receiving writes at the May-9 service-unit change. Journald: 3.9 GB, /etc/systemd/journald.conf all-defaults, no drop-ins — unbounded until the disk fills. Fixes: (1) logrotate coverage extended to [hidden]/logs/*.log (same daily + maxsize 500M + 14d-compressed + copytruncate stanza semantics as #274); (2) NEW journald retention bound SystemMaxUse=2G + MaxRetentionSec=14day via /etc/systemd/journald.conf.d/polymarket.conf (deploy twin: deploy/journald/polymarket.conf), mirroring the 14-day logrotate convention; OPERATIONS.md runbook updated. Both installed live today (neither touches the trader process).tests/unit/test_finding_380_ruleb_removal.py — structural tripwires: the ruleb module is gone, Config has no RULEB_* attrs, the orchestrator source has no ruleb hook/method, GUARD_REGISTRY has no RULEB key, the logrotate deploy file covers [hidden]/logs/, the journald deploy twin pins SystemMaxUse; full unit suite green with python3.11. Live verification: logrotate dry-run clean, journald disk-usage bounded post-vacuum.src/earnings/config.py, src/earnings/trading_orchestrator.py, src/data_tracker/integrity_monitor.py (registry entry), DELETED src/earnings/ruleb_window_gate.py + tests/unit/test_ruleb_window_gate.py, deploy/logrotate/polymarket, NEW deploy/journald/polymarket.conf, src/earnings/strategy_changelog.py (#380 + #330 amend), NEW test file, docs (OPERATIONS.md, plan A-12 row, revalidation §II.6 A-12, queue texts, canvas, handoff/context; the DOCUMENTATION_DISCIPLINE R5 example annotated — the canonical "unverified claim" turned out TRUE for a different file than its refutation assumed).EarningsDetector initialized (sources: polymarket, prnewswire (corroboration-only, #379), yahoo) — BusinessWire absent). Commit provenance: the detector/test-pin CODE rode the A-1 sibling's #372 commit (79122bd, noted in its message) because both changes touch _arbitrate_content_claim and the shared worktree's suite had to stay green; the #379 tests + records land in this finding's own commit.CONTENT_EXIT_CORROBORATION_SEC un-vetoed when the crowd is neutral/stale/missing. #372 closed that for GAAP-basis markets only (basis-unknown wire figures); on the 62.4% non-GAAP majority, a solo PRNewswire claim could still time its way into an exit with no second witness at all — sole-trigger authority in the exact crowd-blind conditions (stale/thin prints, §3 density collapse) where a parse error is least catchable.EarningsDetector.CORROBORATION_ONLY_SOURCES = frozenset({'prnewswire'}) — a frozen class constant, deliberately NOT an env knob (no GUARD_REGISTRY entry needed; there is nothing to tune or override). Inside the arbiter's persistence branch: a contra claim whose only witnesses sit in the set is REFUSED persistence-alone admission and keeps waiting; the pending clock stays armed so a later independent second source (or crowd flip) still corroborates. Crowd-agree admission (two independent witnesses) and 2-source admission (prnewswire+yahoo) remain open; the crowd-veto reset is unchanged. BusinessWire: _check_businesswire + its check_earnings call deleted; content witnesses are now PRNewswire (corroboration-only) + Yahoo.tests/unit/test_finding_379_prnewswire_demotion.py (9 tests): solo-persistence refusal (the demotion itself), pending-stays-armed after refusal, yahoo-only persistence still admits (path intact for non-demoted witnesses), crowd-agree + 2-source paths open for PRNewswire, PNC-class veto reset unchanged, structural tripwires (frozen set consulted inside _arbitrate_content_claim, _check_businesswire gone, no businesswire feed URL or call anywhere in the module). The #342/#372 persistence pins re-pointed at yahoo (with #379 notes) so they keep testing THEIR branches. Full unit suite green with python3.11.src/earnings/earnings_detector.py (constant + arbiter refusal + poller deletion + doc/comment sweep), src/earnings/strategy_changelog.py (#379 entry per §O), tests (NEW #379 file; #342/#372/detector test updates), docs (plan A-9 row + deploy queue, revalidation §II.6 A-9, queue row texts, canvas, handoff/context).skip_stage='admitted' rows were the May-29 synthetic backfill of pre-Apr-22 markets (backfill_opp_log_pre_apr22.py, timing_channel='backfill'; coverage ends Apr 8, 2026), so the W2 trade census read the backfill's coverage edge as a "silent April 2026 stop". The live funnel now writes a POSITIVE admitted row at batch scheduling, and a coverage integrity rule makes any future silence a named FAIL (Aug 21, 2026)skip_stage='admitted' as "traded" (populations.md's "369 admitted rows overlap P-REAL") was actually standing on synthetic backfill rows and missed ALL real trades since the log went live.signal_emitted plus NEGATIVE admission outcomes only (admission/exposure_cap/batch_schedule/priority_filter — #285/#359/#362 additions). NO live code path ever wrote skip_stage='admitted'. The 369 admitted rows in the DB: ALL have evaluated_at ≤ Apr 8, 2026 (before the table existed), ALL carry the backfill markers (timing_channel='backfill', snapshot {"source": "backfill_v1"}) — they are the May-29 synthetic reconstruction of pre-Apr-22 markets (#310 amendment, PR 2.d.2). Monthly counts (Sep-25 10 → Apr-26 4 → zero) trace the backfill's market coverage, not a live writer dying. The census's "silently stopped April 2026" was a misread of that edge — nothing stopped, because nothing had started.TradingOrchestrator._log_admitted_signal — writes the admitted-stage row (full admission-time fields: volume_total/volume_24h/threshold, conviction, direction, priority, schedule, snapshot + admitted_batch) via the shared _emit_pm_opportunity_log funnel (one serialization path, §M). Called in run_bmo_cycle + run_amc_cycle for every signal in the FINAL batch — after ALL admission guards (#359/#362/B-5/A-7/exposure) and the #362 batch-date filter — so one row means "scheduled for entry in today's batch" (admitted ≠ filled stays a trade_audit_log question by design). Failure path logs at WARNING (not debug) naming the integrity rule — silent loss is exactly this finding's class. The CapitalManager extraction twins deliberately get NO write (opp-log writing is orchestrator-side by the documented #285 convention; the twins are uncalled).consistency:opp_log_admitted_coverage (HIGH) — any position entered after the epoch (Aug 23, 2026 — set when a weekend restart was assumed; the restart actually landed Friday Aug 21 15:17 UTC, so the writer is live from Aug 21 and the epoch stays as a conservative enforcement start) without a matching admitted row within [entry−48h, entry+1h] is a named FAIL. The original gap survived 4 months and was only found by a census; this rule makes recurrence self-detecting.skip_stage='admitted' are SYNTHETIC (backfill_v1, pre-Apr-22 markets only) — filter them by timing_channel='backfill'. For real fills between Apr 22 and the #378 deploy, trade_audit_log remains the only admitted-set source. Fix-forward: post-deploy, admitted rows are live and carry admission-time volume for A-3's display semantics class of analyses too.tests/unit/test_finding_378_admitted_stage.py (13 tests): writer emits stage admitted with full fields + batch marker; fail-soft on emit errors + missing signal_generator; loud WARNING path names the rule; BOTH cycles call the writer after the #362 filter and before the pending-batch store (source-order tripwires); signal_generator never writes admitted (no double-write); backfill markers intact (history stays history); integrity rule registered HIGH + FAIL/OK behavior on a stubbed engine + tz-aware epoch. Full unit suite green with python3.11.src/earnings/trading_orchestrator.py (_log_admitted_signal + 2 call sites), src/data_tracker/integrity_monitor.py (_check_opp_log_admitted_coverage + rule + ADMITTED_LOG_EPOCH_UTC), NEW tests/unit/test_finding_378_admitted_stage.py, scripts/analysis_357/w2_report.md (§6.1 correction note, history marked not rewritten), docs (plan A-5 row + deploy queue, revalidation §II.6 A-5, queue row texts, canvas, handoff/context).strategy_analysis_report.json untracked + gitignored so the pile can never regrow (the weekly regen timer + dashboard + integrity rule all read it from DISK — unaffected), 3 stray .env backups key-audited and archived out of the repo dir, the deleted-probe-script cruft resolved, and the orphan MAX_TOTAL_EXPOSURE=500 env line removed with a dated comment (Aug 21, 2026)DEPLOYED Aug 21, 2026scripts/strategy_analysis_report.json stashes (the #303 deploy auto-stash of the weekly-regenerated report — timestamps May 28 → Aug 21, 2026, matching the deploy log). SIX contained anything else, ALL archived to /root/stash_archive_20260821/stash_{0,66,67,68,69,70}.patch (+ stash_list_before_prune.txt) BEFORE git stash clear: stash@{0} report + the DELETION of scripts/_pm_live_fetch_probe.py (the OPS-3 "deleted probe script" working-tree cruft — the Aug-21 deploy swept it into the stash, restoring the tracked file to disk; tree is clean, probe kept: it's a committed #288 §G artifact); stash@{66} report + a 2-line strategies/pm_earnings_arb/reports/h1_longshot_24h.csv delta (research CSV); stash@{67} ("pre-deploy stash 20260414T100528Z", the one true mixed bag) — 18 tracked files + untracked tmp_mcap_deploy//tmp_sync_deploy/ scp-era staging dirs from the Apr-14 mcap deploy, all long since committed properly via their own findings; stash@{68} report + a May-2026-era strategy_performance_analysis.py draft of analyze_direction_timing_combo/analyze_stop_losses/weekly/top-trades helpers — all present in today's repo (superseded); stash@{69} ("pre-deploy-local-sync") old config/position_manager/orchestrator edits, superseded by the committed versions; stash@{70} (oldest, "Align history P&L" era) docs + orchestrator + ibkr client drafts, superseded. Nothing unarchived was dropped; nothing in any stash was a live prod-only edit still missing from the repo.scripts/strategy_analysis_report.json is REGENERATED weekly on the server by polymarket-strategy-analysis.timer (and consumed from disk by the dashboard Strategy tab + the artifact:strategy_analysis_report integrity mtime rule) — tracking it guaranteed divergence from HEAD after every regen, and the #303 deploy hygiene correctly auto-stashed it on every deploy → the pile. Fixed at the source: git rm --cached + .gitignore entry (file stays on disk, still produced, never tracked). Verified compatible with the deploy hygiene: its untracked-file pass only removes files that EXIST in origin/master byte-identically, so the now-untracked report is permanently left alone; the dashboard and integrity rule read the filesystem and never git..env.bak-355 (Aug 5), .env.bak_flex_20260705155422 (Jul 5), .env.before_dome_removal_20260510_060233 (May 10). Key-name audit vs the live .env: the ONLY key present in a backup but not live is DOME_API_KEY (dome-removal backup) — the Dome API was EOL'd Apr 28, 2026 (#287-#289 migration), a dead credential. Value-level diff (names only): historical PDT_RESERVE_CAPITAL / SIGNAL_LONG_MAX_THRESHOLD / pre-Jul-5-rotation IBKR_FLEX_TOKEN+QUERY_ID — all superseded, no unique live secret. Moved (not destroyed) to root-only /root/env_backup_archive_20260821/ together with a pre-edit copy of the live .env; operator can purge the archive at leisure.MAX_TOTAL_EXPOSURE=500 removed from the prod .env with a dated comment (#356 hygiene convention). Re-verified before removal: the only reader in the entire repo is the legacy polymarket_sniper stack (src/config/settings.py pydantic field, imported solely by polymarket_sniper/main.py/engine/risk_manager.py) — no sniper service exists on the server (systemctl list-units empty for sniper), the live resolution_sniper.py imports neither, and the pydantic default is the identical 500.0, so even the dead path is value-unchanged..gitignore (+git rm --cached scripts/strategy_analysis_report.json), docs/IMPLEMENTATION_PLAN_2026_08.md (OPS-3 rows → CLOSED). Server-only: stash clear + archives, env-backup archive, .env line swap.orderbook_snapshots (21.7 GB, WARN since Jul 27) cannot be implemented transparently on the current instance; every fallback benchmarked (TOAST tuning = zero gain, measured), decision pack delivered, NOTHING lossy improvised per the operator's standing instruction; fidelity-proof harness committed with the pre-change baseline captured (Aug 21, 2026)storage:orderbook_snapshots WARN has fired continuously since Jul 27 and is truthful).orderbook_snapshots IS a Timescale hypertable (312 daily chunks, 2025-10-14 → today; 221 chunks fully older than 90 days; ~12.7M rows; 24 columns incl. the full jsonb books bids_yes/asks_yes/bids_no/asks_no which dominate row size — old rows ≈1.16 KB of which ≈1 KB is inline jsonb). Exhaustive consumer census (every non-archive orderbook_snapshots reference, categorized): writers (always now(), never historical): continuous_collector, websocket_collector, gamma_collector. Live/recent readers (≤ hours-old data): conviction_calculator.get_conviction_at_entry_time (trader entry-time path), dashboard freshness probes (1-h window), resolution_sniper (in-memory dict only, NOT the DB table), integrity freshness rule. Historical (>90 d) readers — the ones a compression scheme must not break: the replay/counterfactual harnesses (scripts/phase3_simulator/replay_depth_gate_v2.py point-in-time reconstruction; scripts/analysis_357/{d2,e1,f1,i1,pkg,w5,w6,w9}_common.py via ConvictionCalculator; conviction_source_bakeoff.py), conviction_tracker --backfill point-in-time subqueries, dashboard coverage/stats aggregations (app.py ~963-995/1186-1194/1633-1636), data_correction.py (manual). The #351 crowd witness reads price_snapshots/live windows — NOT old orderbook data. Dead: gap_monitor.py (no unit runs it).ALTER TABLE orderbook_snapshots SET (timescaledb.compress, ...) → ERROR: functionality not supported under the current "apache" license. SHOW timescaledb.license → apache (TSDB 2.22.1 on PG 16.15). This is structural: the Timescale License forbids third-party managed services from shipping TSL features, so DO (like RDS) only offers the Apache edition — no toggle, no config flag (the DO config API's timescaledb object exposes only background-worker count).toast_tuple_target=128, lz4 already the default): real-day bench (63,098 rows copied into twin temp tables) → byte-identical, zero gain. Root cause: PostgreSQL only invokes the toaster when a tuple exceeds the ~2 KB compile-time TOAST_TUPLE_THRESHOLD; these rows average ~1.1 KB, so the jsonb books stay inline and uncompressed forever. There is NO transparent lossless compression lever on stock PG for sub-threshold rows.segmentby condition_id, orderby time DESC matches every consumer's query shape), operator condition FULLY satisfiable; cost: we own backups/HA/upgrades (lose managed-DB). (B) Timescale Cloud → same features, managed; cost: migration + new vendor. (C) stay on DO: lossless zstd archive of >90 d chunks to DO Spaces + drop_chunks (Apache-legal) → 21.7 GB → ~0.4–0.5 GB archived; fails the "transparent at query time" condition as stated (replay harnesses would need a rehydration step) — only valid if the operator explicitly relaxes the condition. (D) stopgap: scale DO storage (buys runway, solves nothing). Recommendation: A or B if the counterfactual-replay surface stays strategic (it does — #357/#372 lean on it); C only with explicit relaxation; D meanwhile if a decision needs time.scripts/ops1_compression_fidelity_proof.py — replays the established pipeline per market (full raw dump → sha256; ConvictionCalculator.get_conviction_at_entry_time at the historical decision time; point-in-time book; run_counterfactual_for_row gap-aware minute-bar replay; per-query latency). Pre-change baseline captured on 3 untraded markets whose data sits entirely in the >90 d range (TTD/RKLB/SG, 2026-05-07: 4,426/4,426/4,424 rows; conviction 0.785/0.71/0.91; CF P&L −$400/−$400/+$544.37 on $10k notional; sha256 of full dumps recorded) → /root/ops1_proof/pre. ANY future scheme must re-run it and byte-match before "done".scripts/ops1_compression_fidelity_proof.py, docs/OPERATIONS.md §6 (new "Hypertable retention & compression" runbook section), docs/POSTGRES_OPERATIONS.md (storage section corrected — an interim line in the #373 commit had presumed compression would land), docs/IMPLEMENTATION_PLAN_2026_08.md (OPS-1 rows → BLOCKED-OPERATOR-DECISION).markets.volume_total writers; closed-market Gamma fetches fixed via a closed=true RETRY (the closed param is an EXCLUSIVE filter — verified empirically, an unconditional param would have blinded every OPEN-market fetch); real pagination for the events sync (API silently caps at 100/page; the old limit=500 single-shots were no-ops beyond page 1); entered/past dashboard rows now display the ADMISSION-TIME volume from the opportunity log with alarm-vs-info icon semantics; the 24h-rolling fallback carries a visible 24h~ marker (Aug 21, 2026)DEPLOYED Aug 21, 2026/events/pagination silently caps every response at 100 events regardless of limit (verified live Aug 21: limit=500 → exactly 100 rows, pagination.hasMore=true, totalResults=1310). Both sync passes were single-shot limit=500 requests, so during heavy earnings weeks any market beyond the first 100 events fell out of coverage and its volume_total froze (#365 BABA: stale since Aug 16). Fixed with _fetch_events_paginated — offset loop keyed on pagination.hasMore, page size pinned to the real cap, 50-page safety bound, and the ever-growing closed pass (1,277 events and counting) ordered endDate DESC with a 60-day early-stop cutoff so it stays bounded forever.closed param is an EXCLUSIVE filter, not an include flag — verified empirically Aug 21 (closed market + no param → []; OPEN market + closed=true → [] too). #365's remediation text said "add closed=true"; done literally that would have broken every open-market fetch. Implemented as a closed=true RETRY on empty in all four fetch sites: volume_policy.fetch_gamma_cumulative_single (§M shared trader/dashboard single-shot — trader behavior unchanged: pre-entry markets are open, the retry never fires), the dashboard batch fetch (new phase 1b batch retry for the missing ids), the dashboard per-market individual retry, and (same-pattern sweep) continuous_collector.get_market_metadata + gamma_collector.get_market_by_condition_id.markets.volume_total writers blind-overwrote — a degraded/partial API read could regress a stored final. All three now write GREATEST(COALESCE(volume_total, 0), EXCLUDED/:v): continuous_collector upsert, gamma_collector.sync_earnings_markets upsert, scripts/backfill_markets_volume.py. volume_24h deliberately stays unguarded — it is a ROLLING window that must be allowed to fall (GREATEST-guarding it would freeze the 24h figure at its all-time high; tripwire-tested both ways).volume_total at skip_stage='signal_emitted', not today's live reading. get_polymarket_earnings_from_db batch-joins the latest signal-emitted row + the trading_positions entered set; entered/past rows render that volume with an @adm marker. The new pure helper compute_volume_display (unit-tested) decides display source + icon class: ⛔ alarm = below gate AT DECISION (admission volume < the threshold logged at decision — a REAL breach; PNC/CFG-style threshold-0 rows correctly excluded: a config-disabled gate passes by design) vs ⓘ info = below gate NOW only (the #365 display-artifact class — gate PASSED at decision, live number is post-event noise). Encoded in BOTH twins (Jinja first paint + JS refresh, §M).max() surfaced, the row sets volume_is_rolling_fallback and the cell renders a warning 24h~ marker + tooltip ("lower bound only, NOT cumulative") in both twins. BABA's unmarked $930 can no longer impersonate cumulative volume.markets.volume_total finals become trustworthy only FIX-FORWARD (post-deploy). Every final-volume analysis (the W1 stored-final-volume class) must re-baseline on post-#375 syncs; pre-#375 finals for closed markets remain suspect (stale-at-close + no pagination + closed-fetch blindness) — the standing W1/A-3 measurement ban on historical stored finals stays until a post-fix backfill pass (optional scripts/backfill_markets_volume.py --apply, now GREATEST-guarded + closed-retry-capable via the shared fetcher) refreshes them.polymarket-collector / earnings-feed sync) + dashboard service restarts only — NO trader restart needed: the only trader-imported change is volume_policy.fetch_gamma_cumulative_single, whose retry is a no-op for the open markets the trader gates pre-entry (the running trader kept identical behavior until the Aug 21, 2026 15:17 UTC market-safe restart picked up the code).tests/unit/test_finding_375_volume_integrity.py (22 tests): GREATEST guard present in all 3 writers + unguarded-write tripwire + volume_24h-stays-rolling tripwire; closed-retry behavior (closed market → 2 calls, open market → exactly 1, both-empty → None) + source tripwires on all fetch sites; pagination loop (hasMore following, 100-cap aggregation, page-cap bound, closed-pass endDate early-stop) + &limit=500 regression tripwire; compute_volume_display semantics (BABA case, real-breach case, no-admission fail-soft, threshold-0-at-decision not-a-breach); template twin tripwires (@adm, 24h~, alarm/info titles in BOTH Jinja + JS). Empirical API probes recorded in-code (exclusive-filter semantics, 100-cap, hasMore/totalResults metadata). Full unit suite green with python3.11.src/data_tracker/collectors/continuous_collector.py (pagination helper + guard + metadata retry), src/data_tracker/collectors/gamma_collector.py (guard + retry), src/earnings/volume_policy.py (closed retry), scripts/backfill_markets_volume.py (guard), earnings_dashboard/app.py (batch retry passes + admission join + compute_volume_display + twin field sets), earnings_dashboard/templates/dashboard.html (volume + min-volume cells, Jinja + JS twins), NEW tests/unit/test_finding_375_volume_integrity.py, docs (plan A-3 row, revalidation §II.6 A-3, queue row texts, canvas, handoff/context) + the mangled #365 header repaired (lost in a prior rebase).docs/DOCUMENTATION_DISCIPLINE.md (rules R1–R7) + NEW scripts/doc_drift_audit.py (7 mechanical checks) wired into the unit suite so known drift classes now FAIL the build (Aug 21, 2026)DEPLOYED Aug 21, 2026analyze_finding_a_full.py but MISSING from the Phase-3 population.py twin, folded-conviction handling split between w6_common.py and the CF admission loader; contradictions between docs 6 — the three plan-doc contradictions (PLAYBOOK §2 ON-HOLD rows vs its own Aug-16 note; A-10 double-listing; A-12 trader-log), the doc-vs-canvas HOLDS/DORMANT drift, disagreeing "N of 14" queue tallies; orphaned/vestigial 4 — MAX_TOTAL_EXPOSURE_PCT (no live consumer since #315, its comment narrating lockstep discipline for a dead constant), stale mcap constants, the P1_SURFACES pre-ship audit still reading as current state; unverified-claim 1 — the A-12 "2.3 GB unrotated trader log" recorded as fact, contradicted by the live server.docs/DOCUMENTATION_DISCIPLINE.md, referenced from memory.md §DOC): R1 single source of truth per fact class (SSOT table; mirrors cite or are generated, never hand-copied); R2 doc-impact map — when a config/deploy/decision/finding/loader event happens, its enumerated doc set updates in the SAME commit; R3 status vocabulary — machine markers only, banned status phrases in free prose; R4 history must be marked (supersession banners / history tokens / frozen-artifact banners) or deleted; R5 verified-or-UNVERIFIED on any unproven live-behavior claim; R6 the drift audit runs in the unit suite and grows a check whenever a new drift class is fixed; R7 parallel-worker rules (scope to your finding, never flip a sibling's markers, verify your finding number at commit time, re-audit after rebase).MAX_TOTAL_EXPOSURE_PCT REMOVED with a hasattr tripwire; mcap corrections UPS $87B/Mid, PANW $285B/Large, CI $73B/Mid); EDGE_CASES Cat-5 rewritten to the post-CLB-1 breaker reality (SSOT-keyed to the risk-manager docstring); the Phase-3 CF loader now EXCLUDES pre-Apr-22 backfill rows (folded-conviction trap, W3 §7.1) and w6_common.build_universe normalizes them; the #353 error-ledger filter added to population.py in lockstep with the reference SQL under an explicit CHANGE-CONTRACT; the deploy-status sweep completed across STRATEGY.md/revalidation/plan/playbook/CONTEXT/handoff mirrors; queue tallies aligned ("7 of 14" everywhere, A-11 struck); BMO/AMC deep-dive re-based against C-008; P1_SURFACES frozen-as-history banner; memory.md's own stale "Latest: #228" pointer replaced with the lookup rule.scripts/doc_drift_audit.py, exit-1 on violation, run by tests/unit/test_doc_drift_audit.py inside the unit suite): C1 banned status phrases in prose (+C1b pending-markers naming already-deployed packages) · C2 known-superseded literals re-stated as current · C3 KNOWN_MCAPS value/tier consistency · C4 twin-SQL byte-parity · C5 folded-conviction guards present · C6 "N of M" tally cross-file agreement · C7 "Latest: #N" pointer ban. History-token/dated-log/frozen-banner lines are structurally exempt, so marked history never false-positives. Each check has a synthetic-drift red test proving it still fires.python3.11 scripts/doc_drift_audit.py → CLEAN (7 checks, 0 violations) on the fixed tree; full unit suite green with python3.11; red-capability proven per check on synthetic drift (a tripwire that cannot fail is not a tripwire).docs/DOCUMENTATION_DISCIPLINE.md, NEW scripts/doc_drift_audit.py, NEW tests/unit/test_doc_drift_audit.py; .cursor/memory.md (§DOC hard-rule block); fixes across docs/STRATEGY.md, docs/STRATEGY_REVALIDATION_2026_08.md, docs/IMPLEMENTATION_PLAN_2026_08.md, docs/STRATEGY_PLAYBOOK.md, docs/EDGE_CASES.md, docs/CONTEXT.md, docs/HOLISTIC_REVIEW_PROMPT.md, docs/BMO_AMC_TIMING_DEEP_DIVE.md, docs/IMPLEMENTATION_PLAN_P1_SURFACES.md, src/earnings/{config,market_cap_data,risk_manager,signal_generator}.py, scripts/analysis_357/w6_common.py, scripts/phase3_simulator/{counterfactual_admission,population}.py, scripts/analyze_finding_a_full.py, scripts/strategy_performance_analysis.py (queue texts), tests/unit/test_config.py, tests/conftest.py, the implementation-master-plan canvas.freshness:trades integrity evaluator intermittently died with out of shared memory — increase max_locks_per_transaction — root-caused to unbounded MAX(time) planning that locks EVERY hypertable chunk + its indexes in one transaction (measured: 7,079 locks for trades' 1,010 chunks × 7 relations vs a 6,400-entry shared lock table); fixed BOTH sides — evaluators now bound their scan (30 locks measured) AND max_locks_per_transaction raised 64→512 via the DigitalOcean config API, computed from chunk counts with 18-month growth headroom (Aug 21, 2026; DB parameter APPLIED 07:37 UTC in the overnight window, ~7 s restart, trader untouched)DEPLOYED Aug 21, 2026 07:37 UTCmax_locks_per_transaction × (max_connections + max_prepared_transactions) = 64 × 100 = 6,400 entries. Planning SELECT MAX(time) FROM trades with no WHERE clause opens every chunk AND every chunk index — trades has 1,010 daily chunks × 7 relations (heap + 6 indexes) = 7,079 locks measured live (BEGIN; EXPLAIN ...; SELECT count(*) FROM pg_locks WHERE pid=pg_backend_pid()). A single evaluator run can overflow the whole shared pool by itself; whether it dies depends on what else holds locks at that moment — hence intermittent (psycopg2.errors.OutOfMemory in integrity_monitor.log, observed live Aug 19 17:00 UTC and again in the Aug 21 log tail). The same unbounded pattern sat in the freshness rules for price_snapshots (316 chunks) and orderbook_snapshots (312 chunks)._check_table_freshness gains hypertable=True which bounds the scan with time > NOW() - lookback (lookback = max(4× threshold, 48 h) — can never mask a fresh row). Chunk exclusion then prunes old chunks at PLAN time: the bounded twin measured 30 locks (236× fewer). If the bounded window is empty, a chunk-CATALOG probe (zero chunk locks) distinguishes truly-empty (legacy unknown) from stale (FAIL with the STALE message — strictly beyond threshold, so no semantics lost). Applied to freshness:price_snapshots / freshness:orderbook_snapshots / freshness:trades. Deliberately NOT applied to the three etfstack freshness rules: they partition on signal_date/as_of_date but the rules query the non-partition *_at columns, so a bound cannot exclude chunks — and their footprint is trivial (16–30 chunks).COUNT(*)/MAX(time), the data-coverage aggregation spanning trades+orderbook+price ≈ 9,900 locks today, ≈ 15,600 at 18-month chunk growth at ~1 chunk/day/table). Sized for 3 such transactions concurrent at the 18-month horizon + slack: 512 × 100 connections = 51,200 capacity (~20 MB shared memory on the 4 GB node — negligible; DO range 64–6,400, cannot be lowered once raised, so deliberately not overshot to the max). Applied via doctl databases configuration update <cluster> --engine pg --config-json '{"max_locks_per_transaction": 512}' at 07:37 UTC (03:37 ET, overnight window, market closed, 0 open positions) — DO restarted the single-node cluster in ~7 s (postmaster start 07:37:48); all services rode through it, earnings-trader NRestarts=0 (NOT restarted, per the Friday-session constraint).gap_monitor.py MAX(time) — imported by NOTHING, run by NO unit, left per §C with this citation; (intentional by-design full-range analytics, now within the 512-parameter capacity) app.py db-stats loop lines ~963-984, per-market count subqueries ~1633-1636, coverage CTE ~1186-1194; (manual one-shot scripts, not scheduled) data_correction.py, conviction_source_bakeoff.py, replay/analysis harnesses; (module demo only) conviction_calculator.get_markets_with_conviction — called solely from its __main__.tests/unit/test_finding_373_freshness_lock_bound.py (12 tests): bounded-SQL tripwire for the 3 core rules, UNbounded tripwire for the 3 etfstack rules (so a future sweep can't "fix" them wrong), lookback floor math, stale-vs-empty semantics via catalog probe. Live §G probe post-deploy: SHOW max_locks_per_transaction = 512; integrity_monitor.py --once × 3 consecutive — freshness:trades ok every run; bounded plan re-measured at 30 locks.src/data_tracker/integrity_monitor.py (_check_table_freshness + 3 rule flags + etfstack note), NEW tests/unit/test_finding_373_freshness_lock_bound.py, docs/POSTGRES_OPERATIONS.md (rewritten — the "DO does not allow raising max_locks_per_transaction" claim was outdated), docs/IMPLEMENTATION_PLAN_2026_08.md (OPS-2 rows → CLOSED).MAX(time) are only cheap at EXECUTION — at PLAN time they are O(chunks × indexes) lock acquisitions in one transaction. Any monitor that runs every few minutes must bound its scan to the window it actually reasons about; the DB parameter is headroom for the full-range queries that are full-range on purpose.scripts/analysis_357/a1_benchmark_report.md (re-runnable helpers a1_common.py / a1_census.py / a1_resolution_compare.py / a1_extras.py, read-only on prod).prediction_correct flags." The operator redirected: "We must ALWAYS use whatever benchmark the Polymarket market itself uses — that is our entry/exit signal." We trade Polymarket's question; the resolution that pays the market's holders IS the outcome we predicted.>=); source = company earnings documents → SeekingAlpha fallback → 96h timeout NO; 45-day no-release NO; restatements don't count; 0 revenue conditions; 1 truly ambiguous market (0.08%). Our eps_estimate already IS the strike (slug-parsed at discovery; 1,275 match to the cent) — the corruption is the ACTUAL side (Yahoo/Finnhub street actuals vs Polygon GAAP actuals graded against whichever basis, unmatched) and the comparator.prediction_correct flags contradict resolution (6 wrong→right: STT/XOM/WMT/RCL/PG/MCHP; 6 right→wrong: LOW/TSLA×2/SOFI/KR/BX; vs the old 14: ADBE/CME/HLT drop — their flags rested on crowd-undecidable resolver labels — LOW joins via the wrong crowd); displayed accuracy stays 88.0% (flips cancel). Resolution lag from release: p50 2.9h, 85.3% ≤6h, 94.2% ≤24h; UMA disputes ~0.24%.umaResolutionStatus=resolved + degenerate outcomePrices, fetched closed=true per #365) — terminal, write-once-locked via a #349-style CASE guard + tripwire; backfill all 1,273. Tier 2 (pre-resolution) = grade against the market's stated benchmark captured at discovery (benchmark_basis, strike=eps_estimate, strict >, cent-rounding), with BASIS-MATCHED actuals (non-GAAP markets ← Yahoo/Finnhub street actuals; GAAP markets ← Polygon SEC diluted GAAP); abstain when unparseable. Tier 3 (intraday proxy) = sustained crowd verdict (w357 method). RETIRED as label sources: the pre-event conviction cascade (#341/#342 poison class) and the single-snapshot 0.95/0.05 tier. New actual_beat_source column; Pass 4 becomes corrective (recomputes on tier upgrade instead of fill-NULL-once). Consumer→tier map (resolver, dashboard badges provisional-marked, detection arbiter gains basis, calibration = tier-1 only, #344 rule) in the report §4.actual_earnings_result recompute + repair trade #29's NULL condition_id via ticker+date join) → re-run strategy_performance_analysis.py (all 141 mislabels sit in the calibration population, spread 4–29/month across every era) → resolver rewrite + tripwires → §G probe: re-run a1_resolution_compare.py, expect 0 mislabels / 0 poisoned flags.scripts/analysis_357/a1_benchmark_report.md + a1_common.py + a1_census.py + a1_resolution_compare.py + a1_extras.py; A-1 rows updated in IMPLEMENTATION_PLAN_2026_08.md, STRATEGY_REVALIDATION_2026_08.md §II.6, scripts/strategy_performance_analysis.py (queue texts), AGENT_HANDOFF.md §11.updated_at column, which the hourly earnings-sync timer touches on every sync — unresolved rows were re-locked every hour, the guard never released, and actual_beat label coverage silently DECAYED (76.0% measured Aug 16 vs the documented ~95-99.8%); fixed structurally with a resolver-OWNED resolver_attempted_at column (migration 033) (Aug 21, 2026)git pull; the resolver is timer-driven oneshot — NO trader restart involved). DEPLOYED Aug 21, 2026resolve_earnings_markets (Pass 1) guarded retries with em.updated_at < NOW() - INTERVAL '6 hours'. updated_at is a SHARED column: the hourly polymarket-earnings-sync.timer touches it on every sync, so for any market inside the sync window the guard was re-armed every hour and never released. The daily resolver pass silently skipped those rows forever; a row that failed to resolve on its first attempt (EPS source lag, transient Polygon failure) never got a second chance. Known since #244 (Apr 26) — but #244 shipped the --force OPERATOR WORKAROUND, not a fix; populations.md §1.2/§4 (Aug 16, #357 program) measured the damage: coverage 76.0% vs the documented ~95-99.8%, unresolved rows concentrated Jun (39) / Jul (137) / Aug (114) 2026, and flagged the needed code follow-up. That follow-up is this finding (queued as A-2, Rev §II.6).earnings_markets.resolver_attempted_at TIMESTAMPTZ — resolver-OWNED; no other writer may touch it (column comment says so)._pass1_guard_clause(force_recent, include_today) keys on resolver_attempted_at; --force still bypasses entirely (operator one-shots); the #343 same-day exemption is preserved verbatim (the 30-min intraday pass must not be locked out of today's rows by its own previous attempt).resolver_attempted_at = NOW() alongside updated_at; the exception path now stamps it too (fail-soft) so a poison row cannot absorb Pass-1 budget every run — previously the failure path relied on the sync's updated_at touch to (over-)lock it.--force needed.tests/unit/test_finding_371_resolver_lockout.py (9 tests): guard keys on resolver_attempted_at, tripwire — no updated_at in any guard variant (the exact lockout pattern), --force bypass, #343 same-day exemption preserved, both write paths stamp the marker, migration ships with the code. Existing test_outcome_resolver.py (39 tests) green.src/data_tracker/collectors/outcome_resolver.py (guard builder + UPDATE + failure-path marker + --force help), NEW src/data_tracker/migrations/033_resolver_attempted_at.sql, NEW tests/unit/test_finding_371_resolver_lockout.py, docs (OPERATIONS.md resolver runbook, DATABASE_DIRECTIVES.md coverage note, STRATEGY_ANALYSIS_ARCHITECTURE.md #244 history annotations, populations.md §1.2/§4 fixed-markers, IMPLEMENTATION_PLAN A-2 row, STRATEGY_REVALIDATION §II.6 A-2 row, dashboard Class-A queue row).--force; the fix was always "give the resolver its own column".polymarket-trade-reconcile-full.service failed since Aug 1 with ZERO alerts for 3 weeks — the exit-2 was CORRECT (#251b semantics; 16 real trade issues), but the unit had no EnvironmentFile so Telegram was silently disabled, AND no integrity rule watched it; fixed end-to-end + services:systemd_unit_health dynamic failed-unit sweep closes the meta-hole class (Aug 21, 2026; DEPLOYED Aug 21, 2026 06:56 UTC, first clean full-history run 06:58 UTC, timer re-armed Sep 1)list-timers); daily reconcile timer also healthy (ran clean 06:57 UTC, next Aug 22 00:00 UTC).collection_jobs row: 16 trade issues, 0 position issues, 0 stop issues) and exited 2 exactly as #251b designed. Breakdown (recovered from job_metadata.trade.mismatches): 14 exit-leg missing_exec_id — every one a trade exited Jan 15 – Mar 6, 2026, BEFORE real-time fill persistence + exec linkage became reliable (Phase 4D + the #138/#139/#251 chain; verified: every non-external trade with exit_time >= 2026-03-07 carries both exec IDs). Unrecoverable era: reqExecutions reaches ~7 days back, the Jan–Feb trader logs are rotated away, and the weekly Flex query's window doesn't reach that far. Plus 2 exit-leg side_mismatch FALSE POSITIVES (CBOE#39, MCHP#38): reconciliation expected SELL but ibkr_executions carried IBKR-native SLD — exactly 3 such rows existed (CBOE ×1 Feb 6 + MCHP ×2 Feb 6, source='reconciliation', from a since-fixed Feb-era writer; every current writer stores BUY/SELL).EnvironmentFile — alerts.py reads os.environ only, found no TELEGRAM_* vars, and AlertManager disabled Telegram without a sound (enabled = bool(token and chat_id)). The unit still reached the DB because src/db/connection.py has a private .env-file fallback for DATABASE_URL — that asymmetry is WHY the run worked but its scream didn't. The daily polymarket-trade-reconcile.service had the same defect (its alerts have been equally dark; it just never had issues to report in its 7-day window).BATCH_SERVICES is a hand-maintained list and the -full unit was never added (neither was polymarket-flex-reconcile.service — §E). A unit nobody registers is a unit nobody watches.EnvironmentFile=[hidden]/.env added to BOTH trade-reconcile units; both units are now also managed by deploy_from_github.sh (they were hand-installed — the fix would have rotted on the next rebuild).EXEC_LINKAGE_EPOCH_UTC = 2026-03-07): pre-epoch missing exec IDs are classified as legacy gaps — named per-trade in the log + job_metadata.trade.legacy_gap_details, but NOT status-flipping (the unit can't sit failed forever over unrecoverable Jan–Feb data). Everything else — missing exec ID after the epoch, dangling exec ID, symbol/side/order/exchange mismatch at ANY age — remains a hard error. The auto-fix pass still attempts recovery for every trade regardless of age, so a gap that ever becomes recoverable self-heals out of the legacy list. Per-trade evaluation extracted to pure _evaluate_trade_linkage() for tests._normalize_side() (BOT→BUY, SLD→SELL) in the reconciler comparison + documented one-time DB repair: UPDATE ibkr_executions SET side='SELL' WHERE UPPER(side)='SLD' — exactly 3 rows (the Feb-6 CBOE/MCHP trio), 0 SLD/BOT rows remain. The repair was REQUIRED, not cosmetic: reconciliation_manager._lookup_exit_execution_in_ledger filters UPPER(side) = 'SELL', so those rows were also invisible to the exit-recovery fallback.services:systemd_unit_health integrity rule (CRITICAL): dynamic systemctl list-units --state=failed sweep across ALL owned patterns (polymarket-*, earnings-trader*, etfstack-*, ibgateway-*, leverage-*) — no list to maintain, new units covered the moment they're installed. Complements (not replaces) the systemd_last_run:* rules: failed is transient (next timer fire clears it), so per-unit last-run rules stay the durable watchers → both reconcile-full and flex-reconcile added to BATCH_SERVICES + TIMERS (monthly 32d+6h slack; weekly 8d).polymarket-ibc.service, failed since Feb 3, 2026 (6.5 months), unit file long deleted (LoadState=not-found) but the failed state still in systemd memory; Telegram transition fired (monitor log: "1 Telegram-eligible", Rule: services:systemd_unit_health); cleared with systemctl reset-failed; next cycle 63 rules / 62 OK / 0 FAIL / 1 pre-existing WARN (storage:orderbook_snapshots).deploy/systemd/polymarket-trade-reconcile{,-full}.service (EnvironmentFile), deploy/deploy_from_github.sh (manage both units), src/data_tracker/collectors/trade_reconciler.py (epoch + _normalize_side + _evaluate_trade_linkage + legacy reporting; also the #369 bounded snapshot), src/data_tracker/integrity_monitor.py (_check_owned_units_not_failed + rule + BATCH_SERVICES/TIMERS additions), NEW tests/unit/test_finding_370_reconcile_full.py (20 tests incl. unit-file/registration tripwires), docs.Result), its alert path (env present?), and its watcher (integrity rule) all failed INDEPENDENTLY here. The dynamic failed-unit sweep is the list-free backstop for the whole class; and any unit that alerts MUST have EnvironmentFile (the db-layer's private .env fallback hides the gap for everything except alerting).reqAllOpenOrders + 3 qualifyContracts + 1 reqContractDetails sites the #354 sweep MISSED) hung the main loop on the half-dead socket left by the nightly IBKR server reset (~00:13 ET) / IBC 06:00 UTC Gateway AutoRestart; the 120s watchdog then force-killed the process. All sites bounded + needrestart trading-stack exclusions + trader:restart_tripwire integrity rule (Aug 21, 2026; DEPLOYED Aug 21, 2026 06:56 UTC)NRestarts baseline 0 as of the 06:50 UTC deploy restart). Needrestart xvfb/x11vnc exclusions AMENDED by #443 (xvfb-*/x11vnc-* missed the live xvfb.service/x11vnc.service).status=1/FAILURE with memory peak ~148 MB at death; zero oom-kill traces in journalctl -k/dmesg across all nights.apt-daily-upgrade.timer DOES fire ~04:15 UTC but is an aggravator, not the killer (see 4).🚨 MAIN LOOP STALL DETECTED: no progress for ~124s (stage=..., timeout=120s). Forcing process restart. The stalls cluster in two nightly windows and each follows an IBKR connectivity event by 90s–2min: ~04:15 UTC (stage=write_ibkr_snapshot) right after the nightly IBKR server reset (~00:13 ET → error 1100 → reconnect ~04:14), and ~06:06 UTC (stage=run_scheduled_cycles) right after the IBC AutoRestartTime=06:00 UTC Gateway restart (reconnect 06:04, stall 06:06). Same-morning proof (Aug 21, post-#366-deploy, so NOT introduced by #366): 04:15:43 stall 90s after the 04:14:12 "IBKR RECONNECTED and verified healthy", and 06:06:46 stall 2min after the 06:04:37 post-reconnection reconciliation.isConnected() true) while the backend isn't serving; the next unbounded synchronous request-response call blocks forever. The #354 sweep bounded qualifyContracts/reqExecutions on SOME paths but missed eleven ib.reqAllOpenOrders() sites, three qualifyContracts sites, and one reqContractDetails — write_ibkr_snapshot hits the snapshot's reqAllOpenOrders; run_scheduled_cycles hits update_market_data_for_breakers → get_ibkr_index_quote → raw qualifyContracts (runs EVERY minute, which is why the 06:00 window killed so reliably). The watchdog did its job correctly; the bug was the unbounded calls it had to shoot.ibgateway-ibc.service mid-session when libraries updated (observed in the apt/needrestart journal) — an EXTRA Gateway disconnect-reconnect cycle feeding mechanism (4) on library-update nights.IBKRClient.refresh_open_orders_bounded() — bounded replacement (via the #354 bounded_request deadline machinery) for the raw "request all open orders + ib.sleep(n)" pattern; reqAllOpenOrdersAsync resolves after openOrderEnd, so the openTrades() cache is fresh on success and the old sleeps are gone too. Returns None on timeout — callers MUST degrade, never iterate a stale cache. All 11 sites converted (orchestrator snapshot/cancel-sweep/repopulate/orphan-cleanup/external-enrichment, position_manager ×4-mirror, reconciliation_manager stop summary, trade_reconciler stop snapshot) — each with an explicit degrade path: skip enrichment + tag stop_orders_error, keep tracking state for a later cancel pass, skip orphan cleanup (never cancel from a possibly-stale view), and the reconciler raises so stop-autofix can't act on a stale book.qualifyContracts sites (trading_utils + orchestrator + market_regime_provider index quotes) → existing qualify_contract_bounded; timeout → yfinance fallback. 1 raw reqContractDetails (get_market_session) → bounded_request; timeout → "no session info".deploy/needrestart/polymarket.conf → /etc/needrestart/conf.d/, deploy-managed): never auto-restart earnings-trader, ibgateway-*, ibkr-monitor, xvfb-*, x11vnc-*, polymarket-websocket, polymarket-collector. AMENDED by #417: also polymarket-live-trades + polymarket-integrity-monitor (Aug 26 apt clean-restarted the trades poller). needrestart still LISTS pending restarts (visible, deliberate, in safe windows).trader:restart_tripwire integrity rule (CRITICAL): NRestarts > 0 on earnings-trader.service → FAIL → Telegram within one 5-min cycle. NRestarts counts only automatic Restart=on-failure restarts and resets on every manual/deploy restart — so any non-zero value is BY DEFINITION a death outside a deploy. This converts the failure mode that ran silent for 6+ nights into a paged event.tests/unit/test_finding_369_bounded_ibkr_calls.py (11 tests): bounded routing + None-on-timeout + timeout forwarding; source tripwires — no raw .reqAllOpenOrders() / sync .qualifyContracts( may reappear in the swept files (the tripwire caught its own docstring during development — it works); tripwire rule state machine (0→ok, >0→fail with #369 tag, unavailable→unknown). Full unit suite 1371 passed (python3.11). Live: deploy restarted the trader 06:50 UTC, active, NRestarts=0, integrity 63/62-OK/0-FAIL.src/ibkr/client.py (new helper + bounded reqContractDetails), src/earnings/trading_orchestrator.py (5 sites + index qualify), src/earnings/position_manager.py (4 sites), src/earnings/trading_utils.py, src/earnings/market_regime_provider.py, src/earnings/reconciliation_manager.py, src/data_tracker/collectors/trade_reconciler.py (bounded + raise), src/data_tracker/integrity_monitor.py (_check_trader_restart_tripwire + rule), NEW deploy/needrestart/polymarket.conf, deploy/deploy_from_github.sh (needrestart install), NEW tests/unit/test_finding_369_bounded_ibkr_calls.py, docs.services:systemd_unit_health make the outcome observable either way (silent success or a paged failure; nothing in between). If a stall recurs at a DIFFERENT stage, the same playbook applies: find the unbounded call behind that stage (the #354/#369 class), bound it, and extend the source tripwire.' — a SyntaxError that killed the ONE main script block, so showTab was never defined (Aug 21, 2026; dashboard-only fix, targeted dashboard-service restart — trader NOT restarted)DEPLOYED Aug 21, 2026'testing_prod': {{ "'🚀 Phase 1'" if phase1_active else "'🧪 Testing Prod'" }},. Starlette's Jinja2Templates autoescapes expression output in .html templates, so the live page rendered 'testing_prod': '🚀 Phase 1', — a JavaScript SyntaxError. dashboard.html has one main <script> block containing every function including showTab(); a SyntaxError prevents the entire block from executing, so every tab click threw ReferenceError: showTab is not defined. Verified on the live rendered HTML (the ' line was the ONLY escaped entity in script context on the whole 2 MB page).'testing_prod': {% if phase1_active %}'🚀 Phase 1'{% else %}'🧪 Testing Prod'{% endif %},. §E sweep: enumerated ALL 7 Jinja tags inside <script> blocks across the full template set (dashboard.html + etfstack_tab.html + btc5m_tab.html) — the other 6 are safe ('true'/'false' literals ×2, a numeric config value, | tojson which is script-safe by design, and {% if %} control tags whose quoted text is literal). All 8 showTab targets have matching id="tab-..." elements.tests/unit/test_finding_368_script_autoescape.py, 5 tests, style per the #367 template tripwires): (1) whole-template compile — Jinja must PARSE every template in the rendered set (catches any TemplateSyntaxError, which 503s the live dashboard, with zero render context); (2) render tripwire — the extracted modeMap region rendered under autoescape=True both sides of phase1_active must contain the literal quoted JS string and NO HTML entities (fails on the pre-fix template, verified); (3) class tripwire — NO {{ }} expression inside any script block may contain a string literal with HTML-escapable characters unless piped through |tojson/|safe; (4) tab integrity — every showTab('X') target has a matching id="tab-X" element.20bc492) 503'd the live dashboard on deploy — its own explanatory JS comment contained a literal brace pair, which Jinja parses as an empty print expression → TemplateSyntaxError: Expected an expression, got 'end of print statement' at template COMPILE time (journalctl -u polymarket-dashboard: "Template render of dashboard.html FAILED"). Root cause of the miss: no test compiled the FULL template — the render tripwire only rendered the extracted modeMap snippet. Fixed by rewording the comment (no braces) + the whole-template compile test above (verified: it catches the 20bc492 template, passes on the fixed one).earnings_dashboard/templates/dashboard.html (1 line + comment), NEW tests/unit/test_finding_368_script_autoescape.py, docs/FINDINGS.md.deploy_from_github.sh restarts earnings-trader.service, so this dashboard-template-only fix used the targeted procedure instead (server git pull --ff-only + systemctl restart polymarket-dashboard) — the trader was NOT restarted mid-session.{{ }} expression inside a <script> block — autoescape WILL entity-encode them. Use {% if %} literal text, | tojson, or server-provided booleans. The class tripwire now enforces this template-wide.PHASE1_START_ET); History-tab ROI re-cut to three stacked windows (All-time / Testing in Production / Phase 1); "Levrage" header typo fixed (Aug 21, 2026; records/dashboard only — NO trading-behavior change; DEPLOYED Aug 21, 2026 04:34 UTC riding the #366 deploy)PHASE1_START_ET verified at deploy: NO .env override; the deploy day (Aug 21, 2026 ET) equals the 2026-08-21 code default, so the boundary is exact. Post-deploy probes ALL PASS: header pill "Phase 1", "Trades: 0", no "Test Progress", no "Levrage", testing window frozen at 193 closed (2026-01-07 → 2026-08-21), phase1_n_closed 0, testing+phase1 ≡ alltime (193+0=193), three ROI windows rendering (all-time/testing 1.93% total, phase1 0.0%).2026-08-21 and set/verified by the deployer at deploy time.Config.PHASE1_START_ET (env-overridable, default 2026-08-21; Config.phase1_start() is the only parser — ET-aware, midnight for date-only values). EVERY phase-scoped surface derives from it; no other date literal exists in code (tripwire-tested). Trades are assigned to a phase by entry_time (the phase in which the trade was opened — the same column the all-time ROI window has always anchored its day span on), compared timezone-correctly (boundary passed to SQL as UTC; naive DB timestamps normalized as UTC per the established equity-curve convention).badge-phase1, informational accent-blue per the dashboard palette — non-warning; yellow = warning class, green would collide with the reserved LIVE mode badge). Renders when mode == testing_prod AND the boundary has passed (phase1_active, derived server-side from the constant). The System-tab JS mode map mirrors it ("🚀 Phase 1").entry_time >= PHASE1_START_ET), from zero — no target, no bar. DB-derived via a COUNT(*) FILTER in the existing _get_weighted_slippage_stats_from_db query. The testing-era counter is frozen by construction (count of trades entered before the boundary — no new trade can land there): shown in the System-tab Phase-1 card ("Testing-Era Trades (frozen)") and implicit in the History-tab testing ROI window's "N closed". NO data deleted.entry_time < boundary (today equals all-time); (bottom) PHASE 1 (⟨cutover date⟩ – ) — same fields over entry_time >= boundary. All three run through ONE pure function (compute_phase_roi_windows) + the shared compute_roi_triplet (moved from a closure to module level — §M one definition); the all-time aggregate SQL was replaced by a per-row fetch feeding that function. Invariant: testing + phase1 counts/P&L sum exactly to all-time (tripwire-tested).testing_prod_paused, /api/testing-prod/*) is retained per #347; the dashboard no longer renders a progress percentage (risk-state passthrough deleted).dashboard.html (incl. the slippage-milestones line), STRATEGY.md §4.0 (retitled "TESTING_PROD Mode → Phase 1"; the Transition Process marked CLOSED-historical — the #357 revalidation served as the milestone review and #366/#367 as the transition; §4.1 gained a "Phase 1" naming note), OPERATIONS.md (services + mode tables), CONTEXT.md (current-state block + Aug-21 bullet), risk_manager.py mode-transition alert strings (the "50% sizing, first 100 trades" fossil). Historical mentions (FINDINGS #347 narrative, milestone history, changelog entries) stay historical per the operator's instruction. TESTING_PROD_MAX_TRADES=150 and get_testing_prod_status() remain in risk_manager.py as the #347 milestone-marker machinery (trader-side, display-retired).2026-08-21 default + deployer verify step; (b) phase assignment column → entry_time (spec-suggested; established all-time anchor; equivalent to exit_time in practice because the deploy checklist requires a no-open-positions window); (c) pill color → accent-blue (yellow = warning, green reserved for LIVE); (d) frozen-193 placement → derived (never hardcoded) and surfaced in the System-tab card + the History testing window's "N closed"; (e) the ROI panel is now fully filter-independent (the removed PERIOD window was the only filter-coupled part).src/earnings/config.py (PHASE1_START_ET + phase1_start()), earnings_dashboard/app.py (phase helpers + compute_phase_roi_windows + compute_roi_triplet + counter split + state fields + _empty_summary per the #286 lesson), earnings_dashboard/templates/dashboard.html (header/badge/CSS, quick-stat removal, System card, ROI windows, JS updater + mode map, Leverage typo), src/earnings/risk_manager.py (alert strings), src/earnings/strategy_changelog.py (entry #367 + "Phase 1 start" chart marker), NEW tests/unit/test_finding_367_phase1_transition.py, docs: STRATEGY.md, OPERATIONS.md, CONTEXT.md, AGENT_HANDOFF.md §11, IMPLEMENTATION_PLAN_2026_08.md (deploy checklist), IMPLEMENTATION_PLAN_P1_SURFACES.md (census drift note).testing_prod_max_trades, no "Levrage", no x/150 counter in the template). Full unit suite green (python3.11). §G live probes are deploy-checklist steps (nothing deployed)./root/deploy_snapshots/pre_366_20260821T042834Z) → migrations 031+032 applied BEFORE the trader restart (an in-flight dashboard analytics SELECT held the trading_positions lock ~4 min; cancelled — read-only — and the ALTERs completed) → manual prod .env: PDT_RESERVE_CAPITAL 25000→17450 AND the stale SIGNAL_LONG_MAX_THRESHOLD=0.95 override fixed to 0.90 (exactly the hazard the checklist flagged — without it the GUARD_REGISTRY would have tripped at startup) → deploy_from_github.sh to 3465087 (all services restarted; close-persist contract check PASS in-script; report regenerated). Post-deploy §G probes ALL PASS: trading capital $10,054.38 (NetLiq $27,504.38 − 17,450; ≥$25k PDT floor intact), A-6 day-P&L anchor line from DB ($+0.00), no consecutive-loss init and no consecutive_losses key in /api/state, integrity 56 OK / 1 pre-existing WARN (storage:orderbook_snapshots) / 0 FAIL with consistency:b5_pilot_auto_revert OK and consistency:open_position_release_window OK, DB↔IBKR reconciliation "in sync", Phase-1 surfaces per #367. Monitors M-C1/K-S2C/K-B4/K-RES/K-B5 ARMED; D-6/D-7/D-9 RETIRED.test_finding_366_* test file):affordability.is_unexecutable_at_size helper (replicates the calculate_fractional_shares whole-share+57%-flex math); skip written to the opportunity log as skip_stage='min_share_size'; the greedy fill re-offers the slot in the same pass. Fail-soft when no price is available.PDT_RESERVE_CAPITAL 25000 → 17450 across EVERY repo encoding from the P1_SURFACES §3 census — config default, the 5 env-default strings (N15), the app.py History-ROI hardcode (N14 — History-tab ROI now follows the config), 3 fallback literals, 2 Jinja defaults, .env.example, GUARD_REGISTRY floor. The prod .env line is the deploy-time manual step (NOT faked). Tripwire test: no 25000 literal outside history/docs.S2C_MIN_ACTIVATION_CAPITAL, buffer-aware per N23, GUARD_REGISTRY-policed); below the gate flat-25 (#314.f) stands unchanged. Arms K-S2C at deploy.batch_order_manager score→size multiplier FLATTENED to 1.0 (documented decision) along with its stale pre-flat-25 mcap weights; all hardcoded /10 dashboard surfaces swept to /3; the 3 stale test files rewritten; 90–95-rank-last tripwire comment placed. Arms K-B4 at deploy.SIGNAL_LONG_MAX_THRESHOLD default 0.9501 → 0.90 (config + priority + all 12 C-1 surfaces + .env guidance); #333/#334 changelog entries flipped SUPERSEDED. M-C1 shadow monitor: would-have-been 0.90–0.95 LONGs tagged at admission via the skip-stage rows, zero trading impact.min(45 + 5×(n−6), 90)s with batch size (BATCH-1); the execution-quality slippage breaker counts per-BATCH, not per-trade (EQ-1); MSP-1 verified record-only — MAX_SHORT_POSITIONS=2 unchanged.RiskState serialization + the unconsumed API key removed), dead CONSECUTIVE_LOSS_LIMIT knob deleted. Size reduction re-keyed to daily-loss tiers (ELEVATED ≤−2.5% day → ×0.50, HIGH ≤−4% → ×0.25, per §15). The daily-loss breaker (5% of capital) is the SOLE loss brake: state persists across restarts (A-6, session-stop derive-from-DB pattern), day P&L DB-derived, resume at true ET market open (the 14:30-UTC hardcode fixed). DAILY_LOSS_LIMIT + tier knobs in GUARD_REGISTRY; the 2 alert-template + 5 dashboard surfaces from the #364.b re-audit updated. D-9 retires AT DEPLOY (the live breaker runs pre-#366 code until then).B5_PILOT_ENABLED switch (false = full #207 revert, no code change); ≤2 concurrent pilot positions (DB-derived count, enforced in all 4 admission loops); every pilot entry tagged pilot_tag='b5_smallcap_long' in trading_positions + trade_audit_log (migration 031); pilot cells rank 1 in the B-4 table (never 2); ≥0.95 stays SKIP-forever. Auto-revert conditions ENCODED as integrity rule consistency:b5_pilot_auto_revert (HIGH): pilot P&L ≤$0 ex-top-2 at re-score · >3 blind-spot gap-throughs · pilot supplies 2+ stops to a breaker firing. Re-score at 25 pilot fills or Dec 15, 2026 (promise register). Arms K-B5 at deploy. (#392 amendment, Aug 21 evening: the rule became the alert-only contract reminder contract:k_b5_revert_decision — same conditions verbatim, WARN + 📋 telegram, the operator decides the revert; the "auto-revert" framing is retired. The rule only ever alerted — no code ever flipped B5_PILOT_ENABLED.)src/earnings/v_blend.py) computed at admission for every evaluated signal and written to polymarket_opportunity_log.v_blend_score (migration 032). Fail-soft; no new Polygon fetches; ZERO trading impact (tripwire test: no trading-decision module reads it).strategy_changelog — ONE package entry (#366, chart marker) + separate B-5 entry (#366.b5) + supersession flips #313/#333/#334/#346 → SUPERSEDED, #207 → AMENDED (by #366.b5), #314.f/#335 → AMENDED, all pointing at the ship entries per #338; dashboard decision rows/queue rows/action plan/monitors flipped to IMPLEMENTED-PENDING-DEPLOY across all encodings (build_revalidation_357 + build_action_queue + template statics), the #361.b status-vs-description tripwire green over the flipped rows; DEPLOY CHECKLIST added to IMPLEMENTATION_PLAN_2026_08.md; STRATEGY.md sizing/table/band/reservation/breakers/pilot sections rewritten (per-item commits); AGENT_HANDOFF §11 + CONTEXT.md Aug-20 bullets; memory.md checklist lines; both canvases updated.test_finding_366_* unit-test files (a7_affordability, capital_step, s2c_sizing, b4_c1_priority, reservation, wavert, clb1_a6, b5_pilot, mvol_vblend + the revalidation-pin updates) + every stale test from the surfaces doc updated; FULL unit suite green with python3.11. scripts/close_persist_contract_check.py could NOT be run at ship — it writes synthetic rows (__SQLCHK__) and the only reachable DB is production, read-only by the operator's instruction; it is a REQUIRED step on the deploy checklist (run on the server against the live schema right after migrations, before the trader restart). Production untouched: no deploys, no prod DB writes, no service restarts.031_b5_pilot_tag.sql + 032_v_blend_score.sql; NEW src/earnings/v_blend.py, src/earnings/affordability.py.LEVERAGE_MODE=regt_2x, LEVERAGE_MAX_EFFECTIVE_LEVERAGE=2.0) and the 1.50× total-exposure cap (MAX_DAILY_EXPOSURE=1.50) unchanged.equity_base = net_liquidation − PDT_RESERVE_CAPITAL (risk_manager.py:601), the same trading-capital definition the exposure budget uses (MAX_DAILY_EXPOSURE × total_capital, risk_manager.py capital docstring/get_actual_available_capital), and the enforced block fires at projected_effective_leverage > 2.0 (risk_manager.py:1162). Since both are ratios over the SAME base, the 1.50× cap binds strictly before the 2.0× margin gate — the Reg-T gate is a 0.50×-headroom backstop, never the binding constraint. Consistent with the standing evidence: E-002 (raising the cap to 2.00× gains ~+$6/wk in sim, fails the healthy-era gauntlet, eats the Reg-T buffer) and the #315 sweep (lift peaks near 1.0×, flat-to-declining above 1.4×).IMPLEMENTATION_PLAN_2026_08.md header line; AGENT_HANDOFF.md §11 bullet; CONTEXT.md weekly bullet. No register/queue row ever existed for this question → no dashboard-builder or canvas mirrors to flip (R2 doc-impact map applied and satisfied vacuously). No strategy_changelog entry per #338 — nothing strategy-affecting shipped.closed=true so closed markets return empty); 97-trade class audit clean (93 provably passed the gate; all 43 below-gate-today displays are artifacts); A-3 EXPANDED from a monotonic write guard into a 5-part bundle (Aug 20, 2026; records only — NO code shipped, NO deploys, production DB read-only) (header line reconstructed Aug 21, 2026 — the original was mangled in a rebase; body below is untouched. The 5-part bundle SHIPPED as Finding #375.)SIGNAL_MIN_VOLUME_TOTAL), reading like a gate breach at entry.earnings_dashboard/app.py:2260 — gamma_for_helper = cached_volume_24h) after BOTH cumulative sources failed. Nothing about the trade, the gate, or the stored decision was wrong; the number on screen today is a different metric wearing the cumulative label.markets.volume_total sync write (the market then fell out of the sync's coverage window, below). Tue Aug-19 15:50 ET — admission check reads effective cumulative volume $2,145.74 (opportunity log), gate PASS, entry at 15:58 ET. Aug-20 (post-close, market now closed) — dashboard render: (1) DB volume_total stale at its Aug-16 value; (2) live Gamma cumulative fetch returns EMPTY for the closed market; (3) the price-snapshot fallback substitutes cached_volume_24h = $930 (24h rolling) with data_source='price_snapshot' but no visible stale/rolling marker in the volume cell — rendered as if it were cumulative volume, below the gate.sync_markets_from_gamma (src/data_tracker/collectors/continuous_collector.py:225–238) requests /events/pagination?tag_id=1013&limit=500 (both passes — active AND closed), but the Polymarket API silently caps the response at 100 events and the code has NO offset/pagination loop. During the heavy earnings week BABA fell out of the top-100 window, so its volume_total went stale at the Aug-16 value. (The #229 two-pass fix is unaffected — this is a coverage cap on top of it.)closed=true: the Gamma /markets?condition_ids=... endpoint returns EMPTY for closed markets unless closed=true is passed. Affected: volume_policy.fetch_gamma_cumulative_single (the §M shared trader/dashboard single-shot, src/earnings/volume_policy.py:119–125), the dashboard batch fetch (earnings_dashboard/app.py:1967–1972) and the dashboard per-market retry (app.py:1997–2001). Consequence: for ANY closed market the live cumulative source is structurally dead, so the fallback chain always bottoms out.app.py:2255–2267) passes cached_volume_24h (rolling) into effective_cumulative_volume_usd and the result renders in the cumulative-volume display slot with no marker distinguishing it. The max(db, rolling) is a safe LOWER bound (never inflates), but on a stale-DB + closed-market combination the displayed number is a 24h-rolling snapshot that can sit far below both the true cumulative and the gate — exactly BABA's $930.volume_policy.effective_cumulative_volume_usd / volume_threshold_passes / SignalGenerator.get_market_volume — single shared definition per §M holds). PNC/CFG (Apr 14–15) entered under a config-DISABLED gate (threshold 0 → volume_threshold_passes returns True by design at the time) — not breaches.markets.volume_total) would NOT have prevented this — a monotonic guard keeps the STORED value from going down, but BABA's stored value didn't go down; it went STALE (cause 1) while the live source went empty (cause 2) and the fallback mislabeled a different metric (cause 3). A-3 in IMPLEMENTATION_PLAN_2026_08.md is rewritten into a 5-part bundle: (1) monotonic GREATEST(volume_total, EXCLUDED.volume_total) guard on BOTH volume writers (continuous_collector.py:302, gamma_collector.py:309) — the original A-3; (2) closed=true on the three Gamma cumulative fetches (volume_policy single-shot + dashboard batch + per-market); (3) pagination/offset loop for the capped earnings-feed sync (continuous_collector.py:225–238); (4) display semantics — entered/past rows show the ADMISSION-TIME volume from the opportunity log, with the warning icon distinguishing "below gate NOW" (info) from "below gate AT DECISION" (alarm); (5) the 24h-rolling fallback never stands in for cumulative volume without a visible stale/rolling marker. Priority stays P2 Class A (no capital was ever at risk) — but noted in the row: the class has now cost operator attention twice (W1 re-baseline, today's false alarm).docs/FINDINGS.md (this entry); docs/IMPLEMENTATION_PLAN_2026_08.md (A-3 row rewritten to the 5-part scope + header Updated line); docs/IMPLEMENTATION_PLAN_P1_SURFACES.md checked — contains NO A-3 reference (verified by grep), no change needed; docs/STRATEGY_REVALIDATION_2026_08.md §II.6 A-3 row + the A-002 fix cell annotated with the expansion pointer (rows otherwise preserved per the Part I–IV conventions); scripts/strategy_performance_analysis.py (class-a-fixes-357 queue row live_evidence + build_revalidation_357 class_a_note — the A-3 text expanded in both encodings per #360/#361 conventions); docs/AGENT_HANDOFF.md §11; docs/CONTEXT.md (Aug-20 weekly-log bullet); the implementation-master-plan canvas A-3 row updated in lockstep.\bA-3\b grep — every match categorized (record surfaces updated this commit; queue ID lists, historical FINDINGS/analysis-artifact mentions and the standing W1/A-3 measurement-ban references left intentionally, IDs unchanged). Live probes read-only.[hidden]/logs/ibkr_snapshot.json 19:03:41 UTC — NetLiquidation $27,465.52, risk_state.total_capital $2,465.52 (= 27,465.52 − 25,000), account U2…13; dashboard /api/state 15:05:43 ET — identical numbers, pdt_reserve 25,000 from prod .env:62 (the only operative encoding). Code trace (exhaustive grep PDT_RESERVE_CAPITAL/pdt_reserve/25000/25_000): trading capital is computed live as net_liquidation − PDT_RESERVE_CAPITAL at every read (run_earnings_trader.py:218, risk_manager.py:415 + leverage base 449/454, src/earnings/__init__.py:33–35, trade_reconciler.py:638–639, app.py:4523/4596) — no hardcoded trading-capital constant exists; nothing counts day trades; nothing moves money. The operator's description is correct: resizing the reserve is ALL that "funding" requires. The change: prod .env PDT_RESERVE_CAPITAL 25000 → 17450 → trading capital = 27,465.52 − 17,450 = $10,015.52 (live-recomputed as equity drifts). PDT floor (§14.6): total equity — unaffected by the parameter — holds at $27,465.52 ≥ $25,000 with $2,465.52 margin; do NOT withdraw. S2c activation gate re-specified buffer-aware ~$9,000 (an exact-$10k gate would flap on the first ~−$16 day; ≈$1,000 margin at ship). FUND-1/C-Q1/register rows rewritten in IMPLEMENTATION_PLAN_2026_08.md; P1_SURFACES §3 rewritten with the arithmetic.IMPLEMENTATION_PLAN_P1_SURFACES.md; all census surfaces classified REQUIRED/NO-CHANGE per item): census extended 148 → 161 (§1b: 13 alert/notification surfaces — the original census was dashboard-only, exactly the under-censused class the re-audit was commissioned to catch). Results: CLB-1 + A-6 = 5 dashboard + 2 alert surfaces · WAVE-RT = 0 dashboard + 2 alert · MSP-1 = record-only (0 everywhere, verified) · FUND-1 = 10 dashboard + 1 alert + 1 code hardcode. New flags N14–N23; the material catches the §11a addendum (and §3 v1) had missed: N14 — app.py:3690 hardcodes PDT_RESERVE_USD = 25_000.0 in the History-ROI computation (the env change alone leaves History ROI subtracting $25k; v1 claimed "code touchpoints: none"); N15 — the "25000" env-default is at 5 sites not 3 (adds run_earnings_trader.py:125, app.py:4364; plus 3 fallback literals, 2 Jinja defaults, .env.example missing the key); N16 — dashboard surface 70 (Edge-Cases) carries a SECOND breaker table (2341–2362) §11a missed; N17 — NO consecutive-loss counter renders anywhere today (/api/state.consecutive_losses has zero UI consumers — §11a's "counter display keeps rendering" was wrong; display-only needs an explicit add-or-skip decision); N18 — the surface-16 risk tooltip misstates the VIX/drawdown thresholds vs code TODAY; N19 — CONSECUTIVE_LOSS_LIMIT is a dead knob (zero readers) — delete with CLB-1; N20 — DAILY_LOSS_LIMIT + the new daily-loss tier knobs must join GUARD_REGISTRY when daily-loss becomes the sole brake (today unregistered); N21 — STRATEGY.md L2050 still says "3 stop-exits (#312)" (threshold is 4 since #335.b); N22 — the alert-census extension itself (EQ-1's semantic sites live there); N23 — the buffer-aware activation gate. Also verified: prod .env carries NO breaker keys (CLB-1 = config-defaults + code, no GUARD-OVERRIDE dance); BATCH_PREP_DEADLINE_SEC is already env-keyed (BATCH-1 is a one-site formula change); the manifest and the plan cross-check clean after the FUND-1/C-Q1 rewrites (no other discrepancy found).IMPLEMENTATION_PLAN_2026_08.md (operator instruction): every package item and every Class A commit follows the full memory.md checklist — exhaustive greps, twin encodings, tests-first-class, docs-in-same-commit, §O changelog conventions, integrity/GUARD_REGISTRY, §G/§H deploy verification — no exceptions.docs/IMPLEMENTATION_PLAN_2026_08.md (FUND-1 row, C-Q1 manifest item, register row, calendar row, pointer paragraph, header, execution-discipline note, Aug-19-evening verification bullet); docs/IMPLEMENTATION_PLAN_P1_SURFACES.md (header, §0, §1b NEW, §2 matrix, §3 rewritten, §5a gate, §9 N14–N23, §10 totals, §11/§12 full inventories replacing §11a/b); scripts/strategy_performance_analysis.py (package-row scope text, next_action step 3, revalidation updated + pdt_constraint — mirrors flipped with the docs per §M/§N); docs/FINDINGS.md (this entry + #364 amendment pointer); docs/CONTEXT.md + docs/AGENT_HANDOFF.md §11 (Aug-19 bullets extended); implementation-master-plan canvas (FUND-1/C-Q1/census numbers).test_finding_357_revalidation.py (PDT_RESERVE_CAPITAL et al.) preserved; live probes read-only (ssh cat/curl only)./api/state.consecutive_losses has zero UI consumers). Faced with add-a-display-vs-delete, the operator chose DELETE (option b): the counter, its RiskState serialization, and the unconsumed API key are all removed at the CLB-1 ship — under S2c the count renders one same-wave event as N events, so displaying it would invite exactly the misreading CLB-1 eliminates. Ship-scope updates recorded in IMPLEMENTATION_PLAN_2026_08.md (row 7 + manifest item 8) and IMPLEMENTATION_PLAN_P1_SURFACES.md §11 (N17 → RESOLVED; code touchpoints, A-6 reconstruction, surface 69, memory.md L259, tests, and the post-ship grep all updated to the deletion), plus the revalidation doc's revision table and CLB-1 register row.docs/IMPLEMENTATION_PLAN_2026_08.md: A-7 (first, own commit) → C-Q1 capital step ($10k / PDT_RESERVE_CAPITAL≈17,450; FUND-1 operator action precedes) → S2c + encoded activation gate → B-4 3-tier 12-surface lockstep (incl. the verified IMPLEMENTATION_PLAN_P1_SURFACES.md inventory: 148 surfaces censused, 35 change + 2 NEW, 13 N-flags) → C-1 band close (F-006 triple lockstep) → RES M1+M5 (computed independent of breaker state) + A-10 → WAVE-RT (BATCH-1 + EQ-1 + MSP-1) → CLB-1 + A-6 → B-5 pilot → M-VOL V-BLEND shadow logger → monitors/contracts at ship (arm M-C1 · K-S2C · K-B4 · K-RES · K-B5; retire D-9 · D-6 · D-7) → ship mechanics (ONE strategy_changelog entry per #338 + supersession flips #314.f/#333/#334/#313/#207/#346/#335-amend + #361/#361.b status-text flips + report regen + the manual prod .env step). Four items the operator's communicated manifest missed were flagged and added: the C-Q1 capital-step config change itself (the manifest started at A-7), MSP-1 (keep MAX_SHORT_POSITIONS=2 at ship, revisit at K-S2C — part of WAVE-RT per #363), D-6/D-7 retirement at ship (superseded by K-RES/K-B4), and the changelog/supersession ship mechanics. One re-homing note: reservation-independence-of-breakers is a RES design requirement (it was communicated under WAVE-RT; recorded with RES, cross-referenced from WAVE-RT — same package either way). Nothing else from #357–#363 belongs in the package and is missing.docs/IMPLEMENTATION_PLAN_2026_08.md): FUND-1 (operator action; gates S2c activation, precedes the package) · PKG-GO (the explicit go to begin package implementation — the ONLY decision missing for the build to start) · Class A queue A-1/A-2/A-3/A-5/A-9/A-11/A-12/A-13/A-14 with the A-12 fix-or-REMOVE choice flagged (evidence favors REMOVE; the trader-log-rotation half needs re-verify per plan contradiction 3) · OPS-1 retention decision (DUE — WARN firing since Jul 27) · OPS-2 max_locks_per_transaction prod-DB change (needs explicit ok) · OPS-3 server stash cleanup (needs ok) · C-Q2 deferred ~Sep 13, 2026 · C-Q3 before ~Oct 12, 2026. One correction to the communicated register: the Class A queue is NOT post-package — per the plan's calendar it is per-commit operator-reviewed work available NOW ("no package go needed"; order A-1 first, then by §II.6 effort/risk). Consistency notes: A-4/A-8 are done, A-6 is elevated into CLB-1, A-7 is P1, A-10 is folded into RES — correctly absent from the queue list.docs/IMPLEMENTATION_PLAN_2026_08.md (CLB-1 row → APPROVED; WAVE-RT row → confirmed-in-scope; NEW manifest + register sections; calendar/A-6/D-9 rows updated) + docs/IMPLEMENTATION_PLAN_P1_SURFACES.md (verified final; header note + §11 CLB-1/A-6 + WAVE-RT change-inventory addendum); docs/STRATEGY_REVALIDATION_2026_08.md (revision row, Part I re-headed Aug 16–19 + §I.2 CLB-1 decision row, §II.7 D-9, §II.8 flipped to APPROVED, App §15 approval annotations); docs/FINDINGS.md (this entry + #363 approval pointers); docs/AGENT_HANDOFF.md §11; docs/CONTEXT.md (Aug-19 bullet); scripts/analysis_357/rb_breaker_audit_report.md (approval annotation); scripts/strategy_performance_analysis.py (breaker-retire-363 + wave-scale-retunes-363 → APPROVED-AWAITING-IMPLEMENTATION, CLB-1 decision row flipped, package-10k-s2c-b4-357 scope text = the full manifest, D-9/updated/action-plan text) — statuses AND description texts flipped together per the #361.b lesson; all four dashboard template encodings already carry the APPROVED-AWAITING-IMPLEMENTATION vocabulary (#361), so no template change was needed; both canvases (implementation-master-plan + strategy-revalidation-357) updated in lockstep.tests/unit/test_finding_357_revalidation.py #363 pins updated to the approved state + new #364 pins (manifest tokens on the package row, D-9 approval wording, plan-doc sections exist); the #361.b status-vs-description coupling tripwire passes over the flipped rows; full unit suite green (python3.11). Sweep: zero remaining "awaiting operator / pending operator" markers for CLB-1 across the repo and both canvases.tests/unit/test_finding_357_revalidation.py.scripts/analysis_357/rb_replay.py, path-dependent idealized-persistent replay on the §14 pkg_common harness; PKG book = $10k+S2c+3-tier+C-1-closed+B-3, FLAT control = flat-25 at real capital):rb_part2_probe.py + code sweep of config/risk_manager/capital_manager/trading_orchestrator): 24 SAFE · 6 NEEDS-RETUNE · 3 REDESIGN. Highlights:min(45+5×(n−6), 90)s or parallelize; ships with PKG-357) · execution-quality breaker (3 consecutive ≥1% slippage fills → 1h pause; a 12-order wave batch prints 3 correlated readings in seconds — count per-batch) · MAX_SHORT_POSITIONS=2 (package book max concurrent shorts = 3 — binds; recommend keep 2 at ship, revisit at K-S2C) · daily-loss resume-time + persistence mechanics (above) · RES × breakers design note: reservation builders call can_open_position per signal, so an active breaker at the 15:50 check silently ZEROES tomorrow's BMO reservation (a 24h pause spanning the evening check releases capital that AMC then eats post-resume) — RES (M1+M5) must compute reservations independently of breaker state.IMPLEMENTATION_PLAN_2026_08.md P1 gains CLB-1/BATCH-1/EQ-1/RES-note rows with A-6 elevated; dashboard build_revalidation_357 gains the CLB-1 decision row (PENDING-OPERATOR-APPROVAL) + D-9 monitor row, build_action_queue gains breaker-retire-363 + wave-scale-retunes-363; tests extended per the #360/#361 tripwire patterns.scripts/analysis_357/rb_data/.scripts/analysis_357/rb_replay.py, rb_part2_probe.py, rb_probe_today*.py, rb_breaker_audit_report.md, rb_data/*; docs/STRATEGY_REVALIDATION_2026_08.md (§II.7, §II.8, Appendix §15); docs/IMPLEMENTATION_PLAN_2026_08.md; scripts/strategy_performance_analysis.py; tests/unit/test_finding_357_revalidation.py; docs/FINDINGS.md (this entry); both canvases.2026-08-17 19:58 and the JS re-read it without timezone conversion; the actual fill was Mon 15:58 ET, a normal batch slot.)earnings_date=2026-08-19, timing BMO, date_confidence='confirmed' (#349 multi-source consensus: NASDAQ + Finnhub + yfinance agree), external calendars concur. The market's Polymarket end_time was STALE: Tue Aug-18 20:00 UTC (never moved when the date was corrected Aug-14). HD (the control, same batch) reports Tue Aug-18 BMO — its Monday entry was correct.get_earnings_markets selects on earnings_date OR end_time within ±1 day. Monday's BMO cycle queried "tomorrow=Aug-18"; VIK's earnings_date (Aug-19) did NOT match, but its stale end_time (Aug-18) did → selected into Monday's batch window.resolve_schedule_basis let the CONFIRMED Aug-19 date win over end_time → the signal was built with entry_datetime = Tue Aug-18 15:58 ET, exit = Wed Aug-19 10:32 ET. The signal was RIGHT; it was in the WRONG day's batch.exit_schedule_is_actionable (#349) also passed (exit far in the future). NOTHING asked "is this signal's entry day TODAY?".run_bmo_cycle stored ONE batch at signals[0].entry_datetime ("All BMO have same entry time") — HD's Monday 15:58. At execution the whole batch filled, VIK included, a day early.scheduled_exit_time is CORRECT (Wed 10:32 ET — exit machinery armed for the right window); earnings detector monitoring since Monday (catches the Wed BMO release); Tuesday's cycle re-selects VIK but the already-have-position check blocks re-entry. Operator decision: LEAVE OPEN (closing burns commissions + re-entry risk for one extra night of stop-protected exposure).earnings_markets.earnings_date drifted LATER post-event (pre-verification-era sync writers). The historical rows are LEFT AS-IS (repairing them would falsify the as-traded record); instead this class is now a standing measurement caveat: any analysis joining historical trades to earnings_markets.earnings_date must treat pre-#349 (Jul 29, 2026) rows as unverified-at-trade-time and cross-check trade_audit_log entry dates against earnings_date_audit before classifying an entry as wrong-dated (recorded in docs/STRATEGY_PLAYBOOK.md §5 date-drift rule).wrong_entry_date): NEW pure guard entry_is_for_today(entry_dt, now) in earnings_date_verifier.py (ET calendar-day equality — the EARLY twin of #359's lateness guard; no config knob, a date-equality check has nothing to tune, hence no GUARD_REGISTRY entry per #356 which polices knobs). Wired into ALL FOUR admission loops after the #359 check and BEFORE can_open_position (orchestrator check_bmo_signals/check_amc_signals with _log_exposure_skip(skip_stage='admission'); capital_manager lockstep twins §E). The "All BMO/AMC have same entry time" assumption is DEAD: NEW _filter_batch_signals_for_today runs at batch scheduling in run_bmo_cycle/run_amc_cycle (drops wrong-day signals, skip_stage='batch_schedule'; post-filter one shared entry time is an ENFORCED invariant — same ET day + timing ⇒ same slot), and a LAST-line guard in _execute_batch_entry (+ capital_manager twin) refuses to place orders for a wrong-day signal (skip_stage='batch_execution'). Legitimate flows untouched: BMO 15:50→15:58 and AMC 10:30→10:34 admissions are same-day; the multi-day reservation path (future-dated, capital planning only) deliberately does NOT run this guard (test-pinned).schedule_outside_window): NEW pure schedule_within_window + a re-check in _get_signals_for_timing immediately after resolve_schedule_basis: if the RESOLVED schedule day falls outside the selecting window, skip (skip_stage='schedule_guard') — the market's true day re-selects it via the earnings_date branch. Closes the #333↔#349 seam at the source; Fix 1 remains belt-and-braces.consistency:open_position_release_window (HIGH): daily cross-check of every OPEN position — (a) the release day implied by its own scheduled_exit_time (BMO: exit day; AMC: prev trading day of exit) must equal the CURRENT earnings_markets.earnings_date (catches post-entry date moves — the SBUX class); (b) the actual entry day (ET) must be the expected entry day for that date (BMO: prev trading day; AMC: same day — the VIK class). Pure core assess_open_position_schedule unit-tested; KNOWN_WRONG_ENTRY_POSITIONS allowlist keyed (symbol, entry ET date) turns the adjudicated VIK Aug-17 incident into an OK-with-annotation instead of a daily false alarm — any NEW divergence (other symbol/date, or a date move on VIK) still FAILS by name.dedupe_reservation_signals in both multi-day BMO reservation builders (orchestrator + capital_manager twin §E) — a signal whose own exit day ≠ the reservation date is dropped (mislabeled by the seam), and a market already reserved under an earlier date is never counted again. Pre-fix VIK's capital was reserved under Aug-18 AND Aug-19.entry_time/scheduled_exit_time/actual_earnings_time in ET via the existing _format_trade_time_et (§M shared definition; was raw naive-UTC), the JS renders them verbatim with an explicit "ET" suffix (fmtEtServerTime, no browser-local reparse — the old new Date() path and the +' UTC' double-conversion are gone), the timeline's "Exit:" line says ET, and a NEW entry_date_mismatch flag (server-computed: actual entry ET day vs prev-trading-day-of-scheduled-exit, both timings) renders a red WRONG-ENTRY-DAY badge on the timeline label and a red-highlighted entry cell in Active Trades.can_open_position rejects it as an existing position — NOT re-entered. Nothing force-exits it: the fixes touch admission/scheduling/reservation/display only; its Wednesday 10:32 ET exit and live stop are untouched. The integrity rule annotates it as the known incident (test-pinned).tests/unit/test_finding_362_wrong_entry_date.py (44 tests: invariant matrix incl. ET-vs-UTC calendar boundary + the legitimate BMO/AMC/prior-evening flows, window re-check semantics + inclusive bounds, reservation dedup, wired-before-can_open_position tripwires on all 4 loops, batch-scheduling + execution-moment tripwires, the reservation-path-has-NO-guard pin, integrity-rule shapes incl. VIK annotation + HD clean + AMC/weekend cases, dashboard encoding pins); 1058 unit tests green (python3.11; was 1014).src/earnings/earnings_date_verifier.py (2 skip reasons + 3 pure functions), src/earnings/trading_orchestrator.py (2 admission loops, _filter_batch_signals_for_today, both cycle schedulers, _execute_batch_entry guard, reservation dedup), src/earnings/capital_manager.py (lockstep twins ×4 §E), src/earnings/signal_generator.py (window re-check), src/data_tracker/integrity_monitor.py (rule + pure core + allowlist), earnings_dashboard/app.py (ET times + mismatch flag), earnings_dashboard/templates/dashboard.html (ET rendering + red highlight), NEW tests/unit/test_finding_362_wrong_entry_date.py, src/earnings/strategy_changelog.py, docs/STRATEGY.md (admission-guard list), docs/STRATEGY_PLAYBOOK.md (§5 date-drift rule), docs/AGENT_HANDOFF.md, docs/CONTEXT.md.[PENDING-OPERATOR-APPROVAL] C-1/RES/B-5 rows on the LIVE dashboard ("why is this not updated? what else did you miss?" — the #334 missed-surfaces lesson repeating); full stale-state re-sweep + status-vs-description tripwire + deploy (Aug 17, 2026; records/display only — NO trading-behavior change)[PENDING-OPERATOR-APPROVAL] badges the morning AFTER approving all three.819a2bb) was committed + pushed but NOT deployed (its own record says "rides the next operator-triggered deploy"). The server still ran b7a162c; its regenerated scripts/strategy_analysis_report.json (rebuilt server-side by the deploy pipeline + weekly timer) therefore still carried the #360-era PENDING-OPERATOR-APPROVAL statuses, rendered as literal [status] badges by the decision-record renderer. Waiting for "the next deploy" while the operator reads the dashboard as the source of truth was the wrong call for a records-only change with zero trading impact.PENDING-OPERATOR-APPROVAL + siblings pending operator / awaiting approval / PENDING-APPROVAL / operator approval, case-insensitive, plus a decision-state staleness pass over all #357-program vocabulary). Missed-surface ledger, all fixed this commit:docs/CONTEXT.md (the AI-bootstrap doc): had ZERO #359/#360/#361 entries — the "What changed this week" log ended at #358, so a future agent would bootstrap into the pre-decision world; the #357 bullet still listed C-1/C-2/$10k/detection/Oct-capacity as "Operator decisions pending" with no resolution pointers; the relax-202-9095 (Jul 10) and sizing-up-2026-07 (Jul 5) historical bullets carried bare PENDING-OPERATOR-APPROVAL markers with no resolution pointers. Fixed: three new dated bullets (#359, #360, #361 + this), week-range header extended to Aug 17, resolution parentheticals on all three historical mentions.docs/STRATEGY_PLAYBOOK.md READ-THIS-FIRST banner (§N forward-plan source of truth): still said "OPEN operator decisions: C-1 … M1+M5 reservation sign-off · B-5 …" and "ships once C-1 + the M1+M5 reservation sign-off land". #361 did not touch the playbook at all (not in its Files list — #360 did). Fixed: banner re-headed Aug 16–17 + #361; the three moved to an "APPROVED Aug 17, 2026 (#361) — awaiting implementation" bullet; package decision set marked COMPLETE; C-Q2/C-Q3 kept as the only open items.docs/STRATEGY_REVALIDATION_2026_08.md §10: "A-8: done, pending deploy" — stale since the Aug-16 20:22 UTC deploy (a b7a162c miss; that commit claimed "all deploy-pending markers updated"). Fixed to DEPLOYED with the deploy evidence.strategy-revalidation-357 canvas: three stale deploy claims ("the deploy is the operator's call", the "#358 status: … the deploy is yours" callout, "#358/#359 are committed and deploy-pending" in the footer) + the §11 outlier-census table labeling C-1 bare "pending" + the §13 appendix lacking the approval pointer its doc twin has. All fixed (#361 claimed the canvas deploy-pending markers were corrected — three survived).docs/STRATEGY.md: documents the LIVE 0.95 cap (correct — the relax stays live until the package ships) but carried no pointer that the re-close is approved; a future agent could re-litigate C-1. Fixed: approval pointers on the header bullet + the §3 lockstep warning.docs/AGENT_HANDOFF.md §11 heading accretion: two bare historical PENDING-OPERATOR-APPROVAL claims (sizing-up-2026-07, "#314.f") without resolution pointers. Fixed with historical annotations.earnings_dashboard/templates/dashboard.html: the #357 section's static comment/intro still described the plan as "decide-now" with only "the operator's Aug-16 decisions folded in (#360)". Fixed (FINALIZED plan, Aug 16–17, #361). The four status encodings + decStatusColor were already correct from #361.build_revalidation_357()/build_action_queue() row's text matches its status (verified programmatically); the 7 in-file generator matches are live code vocabulary (pending_approval counter, cockpit/memo eligibility sets) + the genuinely-open C-Q2/C-Q3 rows; the template's 8 matches are the four parallel status encodings, the cockpit empty-state message and the historical #313 narrative; test_finding_357_revalidation.py / test_top_5_decisions.py matches are the legitimate C-Q2/C-Q3 assertions and synthetic fixtures — neither test file asserted stale C-1/RES/B-5 state (both asserted the APPROVED state since #361; verified by running them); FINDINGS #360/#361 narrative mentions are historical/definitional; STRATEGY_PLAYBOOK.md "PENDING-OPERATOR-APPROVAL — RESOLVED May 30" is a resolved historical section; REVALIDATION Part IV appendix "pending decision" phrasing sits under the established "Preserved verbatim … see Parts I–II" banners.TestFinding361bStatusTextCoupling in tests/unit/test_finding_357_revalidation.py — (a) for EVERY decision row in build_revalidation_357() and EVERY item in build_action_queue(): if the canonical status is NOT PENDING-OPERATOR-APPROVAL, the row's serialized TEXT must not contain any pending/approval-needed marker (PENDING-OPERATOR-APPROVAL, PENDING-APPROVAL, "pending operator", "awaiting …approval", case-insensitive) — description text is now tied to the status field and cannot diverge again; (b) C-1/RES/B-5 pinned APPROVED with clean text on both builders; (c) template-source assertion: no dashboard.html line ties a C-1/RES/B-5 identifier to a pending marker. Follows the #334/#360 parallel-encoding tripwire pattern.deploy/deploy_from_github.sh per OPERATIONS.md; pre-market Monday, verified no open positions/active trading first). §G probe: services active post-deploy; the served report's C-1/RES/B-5 rows carry APPROVED-AWAITING-IMPLEMENTATION (Aug 17, 2026) and the live tab HTML/JSON contains zero stale pending markers for the trio.docs/CONTEXT.md, docs/STRATEGY_PLAYBOOK.md, docs/STRATEGY_REVALIDATION_2026_08.md, docs/STRATEGY.md, docs/AGENT_HANDOFF.md, earnings_dashboard/templates/dashboard.html, tests/unit/test_finding_357_revalidation.py, docs/FINDINGS.md (this + the #361 correction pointer), the canvas.APPROVED-AWAITING-IMPLEMENTATION recorded across every encoding, the #360 action plan FINALIZED, and the operator-requested plain-language reservation explainer added to doc + dashboard (Aug 17, 2026; records only — NOTHING implemented, NOTHING deployed)docs/STRATEGY_REVALIDATION_2026_08.md put forward: C-1 (re-close the LONG conviction cap at 0.90, reverting #333/#334; recommendation CLOSE accepted as-is; M-C1 shadow monitor arms when the re-close ships), RES (the M1+M5 reservation mechanism — observed-set BMO reservation + rank-3 AMC carve-out, multi-day component dropped; K-RES arms at ship), B-5 (the small-cap LONG 0.80–0.95 pilot with the K-B5 contract, §12.5 auto-revert terms unchanged; K-B5 arms at ship). With these, EVERY #357-program decision is made (C-Q1/S2c/B-4 were decided Aug 16 — #360); the remaining open items (C-Q2/C-Q3) are strategic/time-gated, not part of the package.APPROVED-AWAITING-IMPLEMENTATION — a formerly-OPEN Part II decision the operator has approved, with nothing shipped. Deliberately distinct from PENDING-OPERATOR-APPROVAL (no decision yet), DECIDED-NOT-IMPLEMENTED (the Aug-16 Part I decisions, kept as-is for lineage) and IMPLEMENTED/APPROVED-IMPLEMENTED (shipped). Wired into ALL FOUR dashboard.html parallel encodings per the #334 tripwire — AQ_STATUS_META (☑️ indigo), the filter checkbox, the summary chip (summary.approved_awaiting_implementation), the sort order (after FAILED-REGAUNTLET, before DECIDED-NOT-IMPLEMENTED) — plus decStatusColor in the decision-record renderer, where the APPROVED-AWAITING branch is checked BEFORE the IMPLEMENTED substring match so the status can never render green.docs/STRATEGY_REVALIDATION_2026_08.md: revision table + reading guide note the Aug-17 approvals; Part I retitled "decided … August 16–17" and §I.2 gains three rows (C-1/RES/B-5 — APPROVED Aug 17, 2026 — awaiting implementation) + §I.3 updated; Part II heading + §II.1/§II.2/§II.3 headings and preserved queue rows flipped from OPEN/recommended to APPROVED (original recommendation text kept in (was: …) form); §II.7 M-C1/K-B5/K-RES parentheticals carry the approval + arms-at-ship state; Part III B-005/B-006/E-008 action cells + the retired-table multi-day-component and band-reopening rows annotated; the §13 appendix header note points to the approval and the new explainer.build_revalidation_357(): C-1/RES/B-5 operator_decisions entries → APPROVED-AWAITING-IMPLEMENTATION (Aug 17, 2026) with approval-annotated titles/details; updated + ship_note extended (the #361 records ride the NEXT operator-triggered deploy); action_plan FINALIZED — new finalized banner ("every #357-program decision is made; nothing ships without the operator's explicit go"), decide_now → decisions_done (the three Aug-17 approvals, recorded as done), implementation queue re-sequenced: (1) #358+#359+#360 deploy DONE Aug 16 20:22 UTC / #361 rides the next deploy → (2) capital/reserve operational step (C-Q1, PDT) → (3) the implementation package awaiting the operator's EXPLICIT go (S2c + B-4 + M1/M5 + the C-1 re-close in ONE reviewed ship; B-5 rides along or ships separately; A-7 prerequisite first; arms K-S2C/K-B4/K-RES/M-C1(+K-B5)) → (4) Class A fixes. Future work unchanged.build_action_queue(): reservation-m1m5-357 + smallcap-long-b5-357 → APPROVED-AWAITING-IMPLEMENTATION with approval-prefixed titles and next_action = awaiting implementation; NEW summary count approved_awaiting_implementation (2); relax-202-9095 (stays FAILED-REGAUNTLET — the ledger of the failed reopening) annotated: C-1 APPROVED Aug 17, the re-close awaits implementation, the relax stays LIVE until that ship lands; package-10k-s2c-b4-357 SEQUENCE updated — the decision-confirmation step is DONE, the package's decision set is COMPLETE. NEW optional AQ field plain_language on the RES row (the concise explainer twin), rendered by _renderAQItem as its own "🗣️ In plain language" block when present.docs/AGENT_HANDOFF.md §11 current-state re-headed to #361 + a new Recently-completed block; the strategy-revalidation-357 canvas updated in lockstep (header, Part I table +3 approved rows, Part II pills → APPROVED Aug 17 — AWAITING IMPLEMENTATION, a condensed plain-language paragraph on the RES card, monitor/retired-table parentheticals, stale "deploy pending" markers corrected to the Aug-16 deploy).tests/unit/test_finding_357_revalidation.py extended for #361 (decision statuses, finalized action plan, the two APPROVED queue rows + summary count, the plain-language field + renderer block, and the four-encoding parity tripwire now covering APPROVED-AWAITING-IMPLEMENTATION); full unit suite green (python3.11).[PENDING-OPERATOR-APPROVAL] rows on the live dashboard. See #361.b for the full ledger + the status-vs-description tripwire.docs/STRATEGY_REVALIDATION_2026_08.md, scripts/strategy_performance_analysis.py, earnings_dashboard/templates/dashboard.html, tests/unit/test_finding_357_revalidation.py, docs/FINDINGS.md (this), docs/AGENT_HANDOFF.md, the canvas.docs/STRATEGY_REVALIDATION_2026_08.md (Parts I–IV, commit 258f892) and the operator's decisions (C-Q1 capital → $10,000 under the HARD PDT constraint; S2c sizing; B-4 3-tier table; B-3 approved+shipped as #359; #358 implemented), the strategy tab still showed the morning-snapshot state: every decision PENDING, C-2 as a live proposal, D-2/VIX as an armed trigger, the trade-#150 audit as open, and no ordered action plan or consolidated monitor list.scripts/strategy_performance_analysis.py + earnings_dashboard/templates/dashboard.html + tests):build_revalidation_357() decision record: operator_decisions now carries the Part I/II states — C-Q1/S2c/B-4 DECIDED-NOT-IMPLEMENTED; B-3 APPROVED-IMPLEMENTED (#359, deploy pending); A-8 IMPLEMENTED (#358, deploy pending); C-1/RES(M1+M5)/B-5/C-Q2/C-Q3 PENDING-OPERATOR-APPROVAL with the Part II recommendations (C-1: CLOSE, settled by §14); C-2 SUPERSEDED (folded into B-4). (The B-3/A-8 status strings were updated to "deployed Aug 16, 2026" after the Aug 16 20:22 UTC deploy — see the Deploy bullet below.) New pdt_constraint field renders a red callout: equity ≥$25k at ALL times, reserve ≈$17,450 NOT withdrawn, no day-trade counting exists in code. Verdict counts updated 52/8→51/9 (B-006 HOLDS→WEAKENED per §12).action_plan) rendered on the tab: (a) decide now — C-1, M1+M5 sign-off, B-5; (b) implementation queue — deploy #358+#359+#360 → capital/reserve step (PDT) → the S2c+B-4+M1/M5 package (A-7 prerequisite first) → Class A fixes each in its own reviewed commit; (c) future/postponed — C-Q2 (post-clean-prints), C-Q3, D-5 SHORT half, D-1 re-price, old v3 D/E candidates gated on K-B4/D-5 evidence.monitors, 16 rows) rendered as a table: KILL, D-1…D-8 (D-2 discharged→shadow; D-6/D-7 superseded in form by K-RES/K-B4), M-C1, M-VOL, K-S2C/K-B4/K-B5/K-RES, G-RS — each with criterion + source.relax-202-9095 stays FAILED-REGAUNTLET, annotated with the §14 settlement (recommendation CLOSE) + the §11 objection/answer; drop-5k-volume-bump-357 → RETRACTED (superseded by decided B-4); vix-2025-truesim-357 → FAILED-GAUNTLET (D-2 discharged — replay ran, all caps fail era-consistency; shadow monitor armed); gate-2k-reprice-357 +§11 third vote; Audit re-validation → IMPLEMENTED (executed as #357 at n=183; re-arms ~#300); v3 D → folded into B-4 (no per-cell skip ships; K-B4 evidence can re-open); v3 E → still-open, criterion refreshed to D-5 SHORT ≥40 / B-008 ≥20; Sim RuleB +D-004 re-confirmation + A-12 harness-blind note; v2 #6 +D-005 HOLDS + D-4 armed; sizing-up-2026-07 +E-001 contractual PASS (+$17.4/wk vs +$7.4 promised) + S2c successor note; PR 3.b #314.e SKIP candidates RETIRED (F-001/F-002 refuted their conditioning; superseded by B-4). NEW rows: package-10k-s2c-b4-357 (rank 1, new status DECIDED-NOT-IMPLEMENTED), class-a-fixes-357 (rank 2, IN-QUEUE, A-7 first; A-4/A-8 done, A-13/A-14 added), reservation-m1m5-357 + smallcap-long-b5-357 (PENDING-OPERATOR-APPROVAL).DECIDED-NOT-IMPLEMENTED status encoded in ALL FOUR template surfaces (AQ_STATUS_META 📌, filter checkbox, summary chip via summary.decided_not_implemented, sort order — the #334 parallel-encoding tripwire); renderer extended with the PDT callout, plan lists and monitor table (reval357-pdt/-plan/-monitors containers), decision badges colored by state.tests/unit/test_finding_357_revalidation.py extended 17→25 tests (decision states per #360, PDT visibility on the capital decision, action-plan ordering, all-16 monitors, new queue rows, status parity tripwires) — all pass; full unit suite green.deploy_from_github.sh run at commit efebe45, together with #358 + #359). strategy_analysis_report.json regenerated in the same deploy run (Phase ε.1 post-deploy regen); verified live: revalidation_357.pdt_constraint + action_plan + 16-row monitors present in the served report, package-10k-s2c-b4-357 rank-1 row with status DECIDED-NOT-IMPLEMENTED, and GET / serves the "Decided (not implemented)" filter + PDT callout wiring.scripts/strategy_performance_analysis.py (build_revalidation_357 + build_action_queue + summary), earnings_dashboard/templates/dashboard.html (section + renderer + status wiring), tests/unit/test_finding_357_revalidation.py, docs/FINDINGS.md (this) + docs/AGENT_HANDOFF.md + docs/STRATEGY_PLAYBOOK.md.born_dead_schedule BEFORE it consumes an admission slot or capital; the W6 Bug-B _log_exposure_skip date-field bug fixed in passing (same code path) (Aug 16, 2026)deploy_from_github.sh run at commit efebe45, together with #358 + #360)exit_schedule_is_actionable, _build_trading_signal) and nothing at ADMISSION. W6 (§5.2, scripts/analysis_357/w6_report.md): a mis-scheduled SBUX leftover (sched entry Jul-27, already traded and exited) stayed in the generator list, was RE-ADMITTED on Jul-28 with a conviction tie-break over HUM, consumed an admission slot, entered at 19:58:30 UTC, and was killed 15 s later by the #349 entry-side guard (−$0.06 + commissions). The slot was wasted either way — the check fired one stage too late.entry_schedule_is_admissible(entry_dt, now, max_late_minutes) in earnings_date_verifier.py (beside its #349 sibling) + skip-reason constant SKIP_BORN_DEAD_SCHEDULE = "born_dead_schedule". Strict "more than": exactly 5 min late still passes.Config.STALE_SIGNAL_MAX_ENTRY_LATE_MINUTES (default 5.0, env-overridable) + registered in GUARD_REGISTRY (integrity_monitor.py, per the #356 rule: ≤60 min or the override fails integrity by name).can_open_position/slot consumption: trading_orchestrator.check_bmo_signals + check_amc_signals (live path; rejection logged to polymarket_opportunity_log via _log_exposure_skip with new skip_stage='admission', reason born_dead_schedule) and the capital_manager lockstep twins (§E; off the live hot path, log-line only, as established there).signal.entry_datetime (aware ET, built by calculate_entry_exit_times) against aware now; future entries pass trivially (test-pinned, incl. a cross-timezone case)._log_exposure_skip passed signal.entry_datetime where earnings_datetime belongs (BOTH the payload key and the _emit_pm_opportunity_log kwarg that feeds calculate_entry_exit_times), so every exposure_cap/BMO-reservation skip row carried earnings_datetime/entry_datetime_scheduled/exit_datetime_scheduled back-shifted (a full day on BMO rows) — this poisoned the W6 audit's first pass and any retrospective join. Now passes signal.earnings_datetime in both places. Declared here per the fix-only-if-same-path rule: #359's rejection logging goes through this exact function. The A-4 queue row in STRATEGY_REVALIDATION_2026_08.md was deliberately NOT edited (sibling workers own other queue rows); this entry is the canonical record that A-4 is done.tests/unit/test_finding_359_stale_signal_guard.py (19 tests: stale/fresh/prior-evening-BMO/exact-5-min-boundary/one-second-past semantics, cross-timezone comparison, skip-reason string, config default, GUARD_REGISTRY predicate, wired-before-can_open_position tripwires on all 4 loops, Bug-B regression pins on _log_exposure_skip); 995 unit tests green (python3.11; was 976).Config.STALE_SIGNAL_MAX_ENTRY_LATE_MINUTES=5.0 live, knob present in GUARD_REGISTRY, integrity consistency:guard_overrides clean; ZERO guard firings post-deploy (0 born_dead_schedule opp-log rows, 0 "REJECTED at admission" log lines — Sunday, market closed, no signals in flight → no misfires on legitimate schedules).src/earnings/config.py (knob), src/earnings/earnings_date_verifier.py (pure guard + constant), src/earnings/trading_orchestrator.py (2 admission loops + _log_exposure_skip stage param + Bug-B fix), src/earnings/capital_manager.py (2 lockstep twins), src/data_tracker/integrity_monitor.py (GUARD_REGISTRY), NEW tests/unit/test_finding_359_stale_signal_guard.py, src/earnings/strategy_changelog.py, scripts/strategy_performance_analysis.py (B-3 decision-row status), docs/STRATEGY_REVALIDATION_2026_08.md (B-3 row only), STRATEGY.md (§ admission guards), AGENT_HANDOFF.md.price_snapshots + detection latency/correctness) — fixes are code-complete + tested; took effect Aug 16, 2026 20:22 UTC (deploy/deploy_from_github.sh from the server at commit efebe45)🧹 Reaped 1288 stale 'open' markets in collector.log (open rows 2,093 → 805 — the predicted ~1,288 exactly); WS collector unit installed from deploy/systemd/ + restarted for the first time ever, resubscribed 1,386 tokens and connected (log 20:22:07); per-market print cadence ~291s pre-deploy → ~105s in the first 10 min post-deploy (trending further down as the 3-day reap grace window drains the remaining recently-synced closed rows); detector fresh-window knobs live (CONTENT_CROWD_MAX_AGE_SEC=900, CONTENT_CROWD_MIN_FRESH_PRINTS=3, select_fresh_median on the primary path)sync_markets_from_gamma (continuous collector) only UPSERTS markets that Gamma still reports (active pass + closed-unarchived pass, #229). Once Polymarket archives a market it vanishes from both passes — its row stays status='open' forever. By Aug 16: 2,093 'open' rows, 1,288 (61.5%) untouched by sync for >3 days (i.e. long gone from Gamma). The collector's cycle sweeps EVERY open market with multiple HTTP calls each, so cycle time grew from the intended ~1s pacing to 5–9 minutes — and per-market print cadence IS the cycle time. W7's "37 of 43 recent markets at 49–117 prints/day" ≈ one print per 12–29 min, exactly this arithmetic (cycle time + error retries). Worst during earnings season, when the dead-market pile grows fastest.deploy/systemd/) and was absent from the deploy restart list, so deploys never restarted it and code fixes never reached it. Monthly WS-signature rows tell the story: Jan 2.60M → Feb 1.10M → Mar 475k → Apr 58k → May 4.2k → Jun 669k → Jul 706k → Aug 158k (bursts = crash/reboot resubscribes catching whatever was open that day); last WS row Aug 7.save_price_update had two bugs: (a) the INSERT block was dedented OUT of the for pc in price_changes loop, so only the last entry of each batch was ever considered; (b) it stored the raw price field — the order-book LEVEL at which an order was placed/cancelled, on EITHER the YES or the NO token, NO prices uninverted — as yes_price. Proof: of 86,671 WS rows on Jul 14, only 0.7% match the stored (best_bid+best_ask)/2 within 0.02 (the real market price, present in every event, never used). JPM Jul-14 11:00–13:00 UTC (post-BEAT, market pinned ~0.99): 17 prints ≤0.15 interleaved with 28 prints ≥0.90 in the same yes_price column.volume_24h/liquidity NULL, best_bid/best_ask set) in price_snapshots since the WS collector shipped is an order-level print, not a market price. #336's "5–6% of prints ≥0.99 while the market sat at 93%" and #339's "0.86 bid prints mixed with 0.99 asks every few seconds" — the very phenomena the all-of-5 and median-of-5 designs were built to defend against — were largely THIS bug, i.e. partially self-inflicted data corruption, not market microstructure. W7's replay and the #341 parameter tuning consumed the same rows on dense markets. A clean backfill is impossible (each row's stored bid/ask orientation — YES or NO book — was not recorded); the safe treatment is to EXCLUDE WS-signature rows from historical analyses (they are cheaply filterable by the NULL/NOT-NULL signature). Bounding: markets with >100 WS prints on their earnings day number ~70 across the year, vs ~650 adjudicated markets — most #357 adjudications ran on clean poll prints; the dense-market subset (JPM/WFC/GS/BAC Jul-14 class) should be re-checked as part of the queued A-1 re-adjudication._check_polymarket_price — the PRIMARY detection path — took the median of the last 5 prints of ANY age. That is the exact #351 Hyatt geometry (stale prints outvoting fresh ones), fixed on the crowd WITNESS in July but left alive on the primary path. At the collapsed cadence the "median of 5" spanned 25–45 minutes of mixed-era data; the 300s/60s confirmation windows (#341, replay-tuned on dense — and partly poisoned — streams) measured wall time against data that barely changed between checks. The #351 crowd witness itself (3 fresh prints within 900s) almost never forms on a 5–9-min cadence, so the live-Gamma fallback has been carrying the arbiter — by design, and correctly, but the primary path had NO such fallback.continuous_collector.reap_stale_open_markets() — marks 'open' rows CLOSED when sync hasn't touched them in 3 days (STALE_OPEN_REAP_DAYS); runs at startup + every 15-min sync. Cuts the sweep from ~2,100 to the few dozen genuinely live markets → restores per-market cadence toward the intended ~1s pacing without new API load (LESS load: dead markets stop being polled).websocket_collector — token list rebuilt on EVERY (re)connect (refresh_token_map) + a 15-min watcher closes the socket on set changes to force resubscription.websocket_collector.save_price_update — full-batch processing (dedent fixed), YES-orientation lookup per token, stores the bid/ask midpoint (NO books inverted: YES bid = 1−NO ask), drops entries with missing/crossed books, dedups per market on the computed price (extract_yes_quotes pure function). Book events: only YES-token books persist to orderbook_snapshots.deploy/systemd/polymarket-websocket.service NEW in repo (was server-only) + installed/enabled by deploy_from_github.sh BEFORE the restart loop + added to the restart list — the repo is now the source of truth and deploys reach the service.earnings_detector._check_polymarket_price — median now formed by select_fresh_median (#351's pure core: ≤CONTENT_CROWD_MAX_AGE_SEC=900s, ≥CONTENT_CROWD_MIN_FRESH_PRINTS=3) with a live-Gamma fallback when no fresh median forms — the primary path now has the same freshness discipline + fallback as the witness. #336's noise-robustness survives via the median on dense streams and the #341 sustained window (repeated independent reads) on sparse ones._live_gamma_yes_price(persist=True) — fallback reads are persisted into price_snapshots (signature: volume/liquidity/bid/ask all NULL) so sparse release windows leave a replayable trail.tests/unit/test_finding_358_print_density.py (14 tests: NO-inversion, midpoint-not-level, whole-batch, unknown/missing/crossed drops, dedup, frozen-list regression, reaper cutoff + fail-soft, stale-prints-cannot-form-median, fresh-median confirmation arming, Hyatt-geometry Gamma fallback on the primary path, data-gap≠flip); 976 unit tests green (python3.11).deploy/deploy_from_github.sh on the server; the reaper's first run will close ~1,288 stale rows (UPDATE by the production service, not by the agent).src/data_tracker/collectors/continuous_collector.py (reaper), src/data_tracker/collectors/websocket_collector.py (rewrite), src/earnings/earnings_detector.py (fresh-window primary median + persist), NEW deploy/systemd/polymarket-websocket.service, deploy/deploy_from_github.sh, NEW tests/unit/test_finding_358_print_density.py, docs/STRATEGY_REVALIDATION_2026_08.md (G-002/3/4 + A-8 rewritten, NEW §10 deep-dive), scripts/analysis_357/w7_report.md (correction header), src/earnings/strategy_changelog.py, OPERATIONS.md, AGENT_HANDOFF.md, CONTEXT.md.scripts/analysis_357/assumption_register.md — self-labeled "76" but its theme counts sum to 80); Phases 1–2 ran nine workstreams (W1 selection … W9 open-ended, all under scripts/analysis_357/); Phase 3 (this finding) reconciled cross-workstream conflicts, adjudicated all 80 rows, and sorted every candidate into four classes. Full report: docs/STRATEGY_REVALIDATION_2026_08.md.prediction_correct — §F repair queued (Class A-1).markets.volume_total final store is rewritten DOWNWARD on 44% of markets — the March "collapse" was largely measurement artifact. On entry-time volumes the $1k-gate pass rate ran 51–83% all year (Jul 72% / Aug 83% among the BEST); the real drought was May–Jun, seasonal + shallow. Era labels keep P&L meaning only (healthy −$156.50 / collapse +$322.41 / post_jul1 −$45.11). Oct season: slots bind, not universe._log_exposure_skip date bug; A-5 admitted-stage opp-log regression (since April); A-6 breaker-state persistence (−$58 realized leak); A-7 LLY-class unexecutable-admission handling + slot backfill (≈+$3–8/wk); A-8 PM snapshot density around releases; A-9 PRNewswire → corroboration-only + BusinessWire disable; A-10 dead 0.95-override branch cleanup; A-11 PV-1…PV-10 hygiene + Phase-3 loader folded-conviction filter; A-12 RuleB dry-run harness fix-or-remove + log rotation.build_revalidation_357() in scripts/strategy_performance_analysis.py, static content: headlines + verdict counts + Class B/C decision list at PENDING-OPERATOR). Action-queue updates: relax-202-9095 → FAILED-REGAUNTLET (new status, amber/red); NEW rows for C-2, the $2k-gate re-price trigger, and the VIX-zone replay.docs/STRATEGY_REVALIDATION_2026_08.md, scripts/analysis_357/w357_recon_truth.py + w357_recon_w9.py (reconciliation, re-runnable), scripts/strategy_performance_analysis.py (builder + queue rows), earnings_dashboard/templates/dashboard.html (section + AQ_STATUS_META), NEW tests/unit/test_finding_357_revalidation.py, AGENT_HANDOFF.md §11, CONTEXT.md..env guard overrides FAIL integrity by name (Aug 9, 2026).env line that weakens a registered guard MUST carry # GUARD-OVERRIDE-UNTIL: YYYY-MM-DD (same line or the line above). Expiry is inclusive through end-of-day UTC.GUARD_REGISTRY (integrity_monitor): env key → safe-value predicate + description. Registered now: SESSION_STOP_BREAKER_ENABLED (safe=true), SESSION_STOP_BREAKER_THRESHOLD (≤10 — catches threshold neutering, not just the flag), MAIN_LOOP_STALL_TIMEOUT_SEC (≤600), RULEB_MODE (known values). Absent key = safe Config default. Unparseable guard value = not provably safe = violation.consistency:guard_overrides (HIGH, 55th rule): OK when all guards safe; OK-with-detail while an override is time-boxed and future-dated (deliberate overrides stay visible, not alarming); FAIL the moment the expiry passes or when an override is undated — naming the key, value, and expiry. The pure core (evaluate_guard_overrides) is unit-tested against the literal Aug-5 override shape (which correctly FAILS: it was undated).tests/unit/test_finding_356_guard_overrides.py — 17 tests (safe/undated/future/past/inclusive-day/malformed-date/unparseable-value/threshold-neutering/quoted/same-line-comment matrix + rule evaluator incl. the literal Aug-5 shape). Suite: 947 passed.integrity_monitor.py (GUARD_REGISTRY + evaluate_guard_overrides + rule 55), OPERATIONS.md (convention), memory.md §F (mandatory convention line), NEW test file.pause_hours: 0 display bug + COMPLETE-STATE readiness rule (Aug 5, 2026)risk_state.active_breakers or the session stop count. The operator caught it from the Telegram breaker alert. Directive recorded: "when I'm asking you to check something you must always check everything! no shortcuts!" → NEW memory.md section "COMPLETE-STATE CHECKS" (the 5-point gate chain that "ready" must mean)._check_consecutive_loss_breaker passes resume_at (leaving pause_hours=None) and the alert path rendered pause_hours or 0. Now derives the true hours from resume_at. Test added (TestBreakerAlertPauseHoursFinding355).SESSION_STOP_BREAKER_ENABLED=false in prod .env (with dated comment; .env.bak-355 kept) + trader restart via deploy. The circuit breaker needed no override — it self-expired at 12:57:48 ET, before the 15:50 ET BMO check; the restart clears it early as a side effect. Today's 10:30 AM AMC wave is NOT recoverable (entry window passed while the brakes were active — APP/MET/ATO/OXY/FOXA/FTDR/RL all skipped). RE-ENABLE REQUIRED: the session breaker must be turned back on after tonight's session (the .env comment + this finding + AGENT_HANDOFF flag it; leaving it off silently removes a gauntlet-validated defense).risk_manager.py (alert hours derivation), .cursor/memory.md (COMPLETE-STATE CHECKS section), prod .env (override), tests/unit/test_risk_manager.py (+1 test).ib.qualifyContracts() runs the ib_insync event loop until the gateway answers, with NO timeout; on a half-dead socket it blocks forever. The main-loop watchdog (120s) forced a restart at 20:00:03; the re-derived signals hit the (correct) market-hours gate at 16:00:47 ET. Zero entries, no orders leaked. A second stall of the same class hit at 06:06 UTC during the gateway's daily auto-restart (different call site, same mechanism).qualifyContracts, reqExecutions, reqAllOpenOrders) have no deadline; every call site in the codebase trusted them to return. The batch prep loop additionally had no deadline of its own, so one hung symbol consumed the entire 2-minute entry window. The 120s watchdog is a process-level backstop — necessarily slower than the window it needs to protect.IBKRClient.bounded_request(awaitable, what, timeout_sec) — runs any ib_insync request through util.run(..., timeout=) (asyncio.wait_for; cancels the request on expiry; verified available on prod's ib_insync 0.9.86). Timeout/error → None + an honest ⏱️ log; callers degrade (skip symbol / abort order), never hang. Default IBKR_REQUEST_TIMEOUT_SEC=10 (env-tunable). qualify_contract_bounded() wraps the worst offender.qualifyContracts in client.py (get_positions loop, fractional-eligibility check, get_quote, and the four order-placement paths — an unqualifiable contract now ABORTS the order instead of hanging the loop), reqExecutions (async variant, post-sleep removed), 2 qualifyContracts + 4 sync reqAllOpenOrders in order_executor.py (stop-management + #352 zombie checks). Tripwire tests assert no unbounded site comes back.BATCH_PREP_DEADLINE_SEC=45): execute_batch_entries' prep loop stops preparing when the deadline expires and submits whatever is ready — partial entry beats zero entry. Worst case with per-request timeouts, 6 symbols cannot exceed it unless something is genuinely wrong.get_account_info's connection guard passed the literal string "place_market_order" (copy-paste), making routine account polls during disconnects log as blocked ORDER attempts — it misled this incident's review. Fixed + tripwired.tests/unit/test_finding_354_ibkr_request_timeouts.py — 14 tests: bounded_request fast/timeout/exception paths, qualify timeout → [], get_quote skip-on-timeout (the literal Aug-3 site), batch deadline matrix against the REAL execute_batch_entries (zero/partial/all prepared via a fake clock), §E tripwires. Suite: 929 passed.src/ibkr/client.py (bounded_request + qualify_contract_bounded + 7 sites + reqExecutions + guard label), src/earnings/order_executor.py (6 sites), src/earnings/batch_order_manager.py (prep deadline), config.py (BATCH_PREP_DEADLINE_SEC), NEW test file, changelog #354.trading_errors) + SBUX #349 reclassification + twin-table repair + 5 accounting/doc corrections (Jul 31, 2026)trading_errors ledger with exactly 1 row (VIRT −$237.09). A research pass inventoried ALL past error losses; the operator approved five decisions:trading_errors (both were already excluded from stats — V via the reconciliation:% exit_reason, XOM via the "Added via reconciliation" note).trading_errors now 6 rows, total −$248.79: VIRT #352 −$237.09 · V #83 −$8.08 (LONG 2 @ 328.52→324.48, Jan 29-30) · XOM #89 −$2.84 (SHORT 4 @ 137.18→137.89, Jan 30) · SBUX #349 −$0.78 (LONG 6 @ 103.11→103.10, gross −$0.06 + $0.7169 commissions, Jul 28) · ON #178 0.00 unquantified · PFE #109 0.00 unquantified.TRADE_METRICS_FILTER_SQL/_T (earnings_dashboard/app.py) now also exclude exit_reason LIKE 'error:%'. §M sweep of twin filters: scripts/strategy_performance_analysis.py fetch_all_trades() (its own %%-escaped copy — updated), scripts/deep_strategy_analysis.py + scripts/stop_loss_deep_dive.py (frozen-JSON ad-hoc scripts — startswith("error:") exclusion added). The recon-side NOT ILIKE 'reconciliation:%' filters in reconciliation_manager.py/trade_reconciler.py are operational checks, not stats — deliberately untouched. archive/scripts/fix_database.py is archived history — untouched.trading_positions id 203 + trade_audit_log id 170 exit_reason scheduled_exit → error:born_dead_schedule_349 (original preserved in the audit-row note; trading_positions has no notes column).trading_positions id 45 said reconciliation:not_in_ibkr / $0.00 / exit_price NULL while trade_audit_log id 11 (the stats source) says stop_loss_software_backup / −$15.07 / $102.76 — id 45 aligned to the audit row (exit_reason/exit_price/realized_pnl/realized_pnl_pct); old values preserved in the id-11 audit note. This trade STAYS counted (real strategy trade; stats unchanged since they read the audit log, which was already correct).get_trade_history() now runs ONE row-level query (SELECT ... FROM trading_errors ORDER BY occurred_at DESC), derives error_loss_total/error_loss_count in Python from those rows (the old SUM/COUNT query is gone — still exactly one FROM trading_errors literal, #352 tripwire green), and returns the serialized rows as error_log. dashboard.html: new collapsed <details> section between the P&L Summary cards and the P&L Progression chart — "⚠ Error Log — N operational errors, −$X all-time (excluded from all statistics)" with Date/Symbol/Finding/Side-Shares/Open→Close/Loss/Commissions/Description columns (tooltip carries full description). Jinja-only, no JS; fail-soft if the table is missing; the #352 summary card stays.strategy_changelog.py #349 entry (append-only correction note style).trade_audit_log via TRADE_METRICS_FILTER_SQL; the invariant that holds is "no stats code reads trading_errors". All 7 surfaces corrected with correction notes.tests/unit/test_finding_353_error_log.py — 8 tests: filter SQL excludes error:% (both variants + the analysis-script twin), get_trade_history returns error_log rows with totals derived from the SAME rows (fake engine; VIRT/SBUX/ON shapes incl. unquantified NULLs + ET formatting), empty-ledger + engine-down fail-soft, single-query + template wiring tripwires. #352 tripwire unchanged and green. Suite: 915 passed (was 907).earnings_dashboard/app.py (filter + row-level error_log), earnings_dashboard/templates/dashboard.html (Error Log section + tooltip fix), scripts/strategy_performance_analysis.py + scripts/deep_strategy_analysis.py + scripts/stop_loss_deep_dive.py (twin filters), src/data_tracker/migrations/030_trading_errors.sql (comment correction), src/earnings/strategy_changelog.py (#349 correction note), tests/unit/test_finding_352_exit_order_stacking.py (comment), NEW test file, FINDINGS (#178/#179B/#349/#352 corrections + this entry), CONTEXT.md, AGENT_HANDOFF.md §11, memory.md §F line. Prod DB: 5 trading_errors INSERTs + 4 UPDATEs (ids 203/170/45/11), all audited.place_instant_exit cancels the protective stop, places SELL 11 LIMIT — IBKR replies Error 399 "your order will not be placed at the exchange until 09:30 ET". That is a WARNING: the order stays ALIVE, queued. The 5s timeout then placed a SELL 11 MARKET — which ALSO queues (IBKR MARKET orders never fill in extended hours).EXTERNAL POSITION: VIRT SHORT 77.0 — MANUAL FIX REQUIRED (CRITICAL) every 5 min from 13:34 — but nothing was structurally required to LOOK at it; the operator's eyeball caught it ~2h later._get_working_exit_orders(symbol, side) — sums ACTIVE non-stop exit-side orders (PendingSubmit/ApiPending/PreSubmitted/Submitted, remaining qty) via ib.openTrades(). place_instant_exit now refuses to place when working orders already cover the position (returns exit_orders_already_working, unverified — exit_manager keeps the position open and waits), tops up only the uncovered difference otherwise. Same guard in place_scheduled_exit (§E twin).consistency:external_ibkr_position (HIGH, 54th rule) — any nonzero IBKR position without an open trading_positions row is a named FAIL (compares the trader's ibkr_snapshot.json portfolio vs DB; skips when snapshot stale — trader-down covered by artifact/systemd rules). The reconciler's CRITICAL log line is no longer the only witness.trading_errors ledger (migration 030) + §F insert of the VIRT error (SHORT 77, open $54.4556 avg, close $57.53, realized_loss −$237.09). Strategy statistics are excluded by construction — no stats query changed, none needed. (Corrected #353: this entry originally said the stats "derive from trading_positions ONLY" — they actually derive from trade_audit_log via TRADE_METRICS_FILTER_SQL (earnings_dashboard/app.py); the invariant that holds is "no stats code reads trading_errors", which the #352 tripwire enforces.) The dashboard History tab shows a dedicated "⚠ Error Losses (excluded from stats)" card so the account-level picture stays honest. The legitimate VIRT LONG round-trip (−$26.73, a CORRECT miss exit) remains a normal strategy trade.tests/unit/test_finding_352_exit_order_stacking.py — 17 tests: the literal 7×11 queued-order stack, stop/side/status/partial-fill filters, fail-open, stacking refusal (no orders placed, stop untouched), already-flat zombie cleanup, extended-hours no-MARKET (both fallback sites; stop restored on placement failure), cancel-before-restore ordering tripwire, migration + dashboard-exclusion tripwires, external-position rule matrix. Suite: 890 passed.order_executor.py (guards + helpers), integrity_monitor.py (rule 55), NEW migrations/030_trading_errors.sql (+ deploy hook generalized to a migrations list), earnings_dashboard/app.py + dashboard.html (error-loss card), NEW test file, changelog #352, memory.md §F.outsideRth=True, GTC, live at IBKR and able to trigger extended-hours. The real gap was the instant-exit falling-market scenario: pre-market earnings-miss exit fires → stop cancelled → marketable SELL LIMIT placed at quote−1% → price KEEPS FALLING below the limit → the limit is now unmarketable (sits above the market, unfilled) and nothing protects the position (stop deliberately not restored per #352 fix 3).place_scheduled_exit also returned exit_orders_already_working). The position would only exit if price came back up to touch the stale limit. Pre-#352 the accidental "guarantee" was the queued MARKET filling at the open; #352 removed it without a deliberate replacement._working_exit_orders_marketable(symbol, side, tolerance_pct) in order_executor.py (+ _get_working_exit_order_details variant carrying order type and limit price): SELL exit LIMIT is marketable iff limit_price <= bid × (1 + tol); BUY exit iff limit_price >= ask × (1 − tol); tol = NEW Config EXIT_ORDER_MARKETABILITY_TOL_PCT (env-tunable, default 0.002). A working MARKET order in RTH is inherently marketable; outside RTH a queued MARKET must not exist post-#352 — if found it is treated as NOT acceptable and cancelled (belt-and-braces + honest CRITICAL log). Quote unavailable/invalid → FAIL-SAFE: treat as marketable (defer — never churn orders blind).place_instant_exit + place_scheduled_exit): working orders covering the position AND marketable → defer as before (exit_orders_already_working, anti-stacking preserved). NOT marketable → _cancel_working_exit_orders then PROCEED with the normal flow — a fresh marketable limit at the current quote (instant), or place_guaranteed_fill with its RTH limit→market ladder (scheduled). Outside RTH this creates a bounded "limit chase": the instant-exit loop re-fires every ~5 min (#141 stale-clear), each cycle cancels-and-replaces ONE order — never stacking (dedup still guarantees ≤1 working exit order).tests/unit/test_finding_352b_exit_marketability.py — 17 tests: marketability rule matrix (SELL/BUY, marketable/stale, inclusive tolerance edge, explicit tolerance override, quote-unavailable fail-safe, MKT in/out of RTH), the operator's scenario end-to-end (stale SELL limit above bid → cancelled + fresh limit at quote−1%, NO market order in extended hours), marketable-defer preserved (both paths), scheduled stale → cancelled + place_guaranteed_fill, queued MARKET outside RTH → cancelled, details/tuple contract. 2 existing #352 guard tests pinned to the marketable branch. Suite: 907 passed (was 890).order_executor.py (marketability helper + details variant + both guards), config.py (EXIT_ORDER_MARKETABILITY_TOL_PCT + bounds), NEW test file, changelog #352.b, CONTEXT.md, AGENT_HANDOFF.md §11, memory.md §F line._live_polymarket_median took the median of the last 5 prints REGARDLESS of time spread, and H's market is sparse (~11 snapshots/hour vs VIRT's 712): one fresh 0.9995 was outvoted by four 23-40-min-old pre-release prints (0.77-0.795) → median 0.79 → "neutral" → no veto.earnings_outcome='miss', actual_eps=0.38, prediction_wrong=true (the LONG was actually CORRECT).consensus_eps, which loads as 0.0 when trading_positions.consensus_eps was NULL (the open-persist never writes it; it is only written at close). _determine_outcome($0.38, $0) = BEAT → logged "beat admitted (informational)" every few seconds while the monitor loop (consensus $0.91 via the #339 registration fallback) said MISS. Harmless for LONGs (same-side claims exit nothing) but it masks true misses on that path for LONGs and fabricates contra BEAT claims for SHORTs — a live false-exit risk, same witness blindness.EarningsDetector.select_fresh_median): the median counts only prints younger than CONTENT_CROWD_MAX_AGE_SEC (900s); fewer than CONTENT_CROWD_MIN_FRESH_PRINTS (new Config knob, default 3) in-window → the DB median is not trusted._live_gamma_yes_price): when the DB stream is too sparse for a fresh median, the witness reads the ticker's newest market's live Gamma outcomePrices — the #325 bake-off's best source (78% acc) and the same one the dashboard displays. Unlike the conviction path (#333), resolved prints (0.0/1.0) are ACCEPTED — post-release resolution is exactly the signal the arbiter needs. With this, H would have been VETOED at 11:04:45.check_position_for_exit: zero/None consensus now recovers from the monitored-position registry (which went through the #339 earnings_markets.eps_estimate recovery) before checking sources.trading_positions id=210 + trade_audit_log id=177 → instant_exit:false_detection (the #344 honest-labels taxonomy), outcome → beat, actual_eps → 1.12, prediction flags corrected; original values preserved in the audit note. earnings_markets.actual_beat was never touched (#342's resolver-only rule held); the #344 exit_label_vs_resolution rule would also have self-detected this once the market resolved._check_polymarket_price's own median-of-5 (the PM detection source). Its failure mode on a sparse market is a MISSED exit (fail-safe), not a false one, and #341's 300s+grace semantics were tuned on dense streams — widening scope here without replay evidence would be a #330-class unvalidated change.tests/unit/test_finding_351_sparse_crowd_witness.py — 12 tests: the literal H print pattern (must return None, not "neutral 0.79"), dense-stream unchanged, window edge, min-prints boundary, naive-timestamp handling, arbiter VETO with the fresh witness + real-miss control, sparse-DB→Gamma fallback, consensus-fallback matrix. Suite: 873 passed.earnings_detector.py (select_fresh_median + _live_polymarket_median rewrite + _live_gamma_yes_price + check_position_for_exit fallback), config.py (CONTENT_CROWD_MIN_FRESH_PRINTS), NEW test file, §F repair, changelog #351.systemd_last_run:etfstack-signal.service FAIL (exit 3). Deep dive found the whole account throttled: 371 resolver 429s + 52 equity-bars 429s that day; etfstack was simply the only consumer whose failure was LOUD.etfstack_signals row for Jul-29 (paper account held the Jul-28 risk_off stance; low practical impact).src/utils/polygon_rate_limiter.py — cross-process token bucket (the #348 "Tier B" consciously deferred; its trigger condition arrived). flock'd JSON state file (POLYGON_BUCKET_PATH, default /var/tmp/polymarket_polygon_bucket.json); combined budget POLYGON_MAX_RPS (default 10/s = half the single-consumer rate observed safe) + POLYGON_BURST (default 10). FAIL-OPEN everywhere: any limiter error degrades to no-limiting with one warning — the limiter can never be why a fetch fails. Wired into every scheduled/live consumer: counterfactual_pricing.fetch_polygon_minute_bars (covers both resolvers + equity-bars + compute_counterfactual_pnl), etfstack data_provider (GUARDED import — subtree stays standalone), outcome_resolver financials call, dashboard trade-chart (2 sites), caching.py mcap fallback, weekly strategy_performance_analysis. Deliberately NOT wired: manual one-off analysis scripts (operator-run, never concurrent with timers) and the dormant resolution_sniper copies (no systemd unit).classify_quota_downgrade() recognizes the free-tier 429 body; consumers write a flag artifact (logs/polygon_quota_downgrade.json); NEW integrity rule consistency:polygon_plan_downgrade (HIGH, 53rd rule) FAILs on the flag with "POLYGON PLAN DOWNGRADED? … check subscription/billing". Self-heals: the next successful Polygon request clears the flag. A plan lapse now names itself within one scheduled run instead of masquerading as five unrelated service failures over two days.signal_runner.run_daily now passes allow_etf_yfinance_fallback=True — signal continuity beats single-source purity for daily EOD bars on a paper account. Every fallback WARNING-logs; per-symbol series stay single-source (no splicing). Backfill path already had it.tests/unit/test_finding_350_polygon_limiter.py — 21 tests: bucket math (burst/refill/corrupt-state/fail-open/acquire), downgrade classifier (exact prod body), flag round-trip + self-clear, integrity evaluator both ways, tripwires (backoff escapes 60s; live fallback enabled; all 6 scheduled consumers wired; classifier wired in shared fetch).src/utils/polygon_rate_limiter.py, NEW tests/unit/test_finding_350_polygon_limiter.py; counterfactual_pricing.py, strategies/etfstack/src/data_provider.py + signal_runner.py, outcome_resolver.py, earnings_dashboard/app.py, src/utils/caching.py, scripts/strategy_performance_analysis.py, integrity_monitor.py (rule 53), FINDINGS #348 correction, OPERATIONS.md, memory.md §E.trading_errors ledger — exit_reason='error:born_dead_schedule_349' in both tables.)end_time Jul-28 21:00 UTC), and the Polymarket-derived syncs (earnings_market_fetcher, earnings_scraper, earnings_sync) wrote that wrong date into earnings_markets.earnings_date — unconditionally, on every hourly pass.verify_earnings_dates) cross-checked against ONE source (NASDAQ calendar, which was RIGHT) and auto-corrected Jul-28 → Jul-29 four times over two days (Jul-27 14:30, Jul-27 19:50, Jul-28 14:30, Jul-28 19:50:02). Each correction was clobbered back to Jul-28 by the next hourly sync. Neither writer had any memory of verification — last writer always won.earnings_time) was SELECTED for the "tomorrow BMO" wave. But the entry/exit SCHEDULE was derived from the market's end_time (Jul-28) at _get_signals_for_timing — a selection/schedule split. BMO schedule from Jul-28 ⇒ exit 10:32 ET Jul-28 = 14:32 UTC, already 5.5 hours in the past at the 19:58 UTC entry. The exit manager saw an overdue exit on its next tick and sold.src/earnings/earnings_date_verifier.py + rewritten SignalGenerator.verify_earnings_dates): every voice gets one vote — the DB row (Polymarket-derived), NASDAQ calendar, Finnhub earnings calendar (FINNHUB_API_KEY, already on prod), yfinance (on-demand tiebreak only). A unique ≥2-vote winner ⇒ date_confidence='confirmed' (correcting the date + earnings_time on timing consensus first if needed); external voices but no majority ⇒ 'disputed'; no external voice ⇒ untouched. Single-source auto-correction is gone — the old verifier was right about SBUX but structurally unsafe (one wrong source could move a right date).earnings_market_fetcher.py, earnings_scraper.py, and earnings_sync.py now carry a CASE guard — a date_confidence='confirmed' row's earnings_date/earnings_time cannot be overwritten by Polymarket-derived data. This is what actually ends the ping-pong; the verifier (consensus) can still move dates.earnings_date_audit table records every date/timing change (verifier + operator). NEW integrity rules: consistency:earnings_dates_disputed (WARNING — upcoming disputed rows surfaced before their window passes) and consistency:earnings_date_churn (HIGH — any ticker with ≥3 date changes in 7 days = a writer is fighting the verifier again; the SBUX war pattern is now self-detecting).src/data_tracker/migrations/029_earnings_date_confidence.sql (columns date_confidence, date_verified_at + audit table; idempotent).resolve_schedule_basis in _get_signals_for_timing): date_confidence='disputed' ⇒ skip earnings_date_disputed; earnings_date ≠ end_time::date and NOT confirmed ⇒ skip earnings_schedule_mismatch; confirmed-and-differing ⇒ the verified date wins — the schedule is built on the verified day (keeping end_time's hour convention), which is the correct-trade path SBUX should have taken. Both skips logged to polymarket_opportunity_log (skip_stage='schedule_guard').exit_schedule_is_actionable in _build_trading_signal): any signal whose computed exit_datetime is not ≥30 min in the future is refused (scheduled_exit_not_future) — the last line of defense regardless of WHICH upstream data error produced a born-dead schedule. 30 min is deliberately loose (shortest real hold ≈ 18.5h); it only catches impossible schedules.date_confidence field) — trader and dashboard agree on tradeability.date_confidence='confirmed' per official IR + audit row (source operator_349). (The mis-scheduled position was already closed; the loss stands as a real loss caused by the bug — corrected #353: −$0.06 gross / −$0.78 net incl. commissions, not −$0.67; since #353 it is carried in the trading_errors ledger, excluded from strategy stats.)earnings_time may now ALSO be corrected by a ≥2-vote calendar consensus (NASDAQ/Finnhub/yfinance voting) — a strictly stronger evidence standard than the single NASDAQ-explicit voice #220 already trusted. Single-source web/yfinance timing still cannot write to the DB.tests/unit/test_finding_349_earnings_date_war.py — 26 tests: consensus voting (incl. the exact SBUX 2-vs-DB shape and the 2-2 tie), timing consensus, schedule-basis matrix (disputed/mismatch/confirmed-wins), entry invariant with the literal SBUX timestamps, §M tripwires (confirmed-lock present in all 3 sync writers; no bare EXCLUDED.earnings_date assignment; guards wired in signal_generator). Plus 5 pre-existing test fixtures updated (stubbed exits were "now", which the new invariant correctly refuses). Suite: 840 passed.src/earnings/earnings_date_verifier.py, NEW src/data_tracker/migrations/029_earnings_date_confidence.sql, NEW tests/unit/test_finding_349_earnings_date_war.py; signal_generator.py (verifier rewrite + guards + date_confidence in market query), earnings_market_fetcher.py + earnings_scraper.py + earnings_sync.py (write locks), integrity_monitor.py (2 rules), earnings_dashboard/app.py + templates/dashboard.html (DATE? badge), 3 test fixtures.RiskManager.close_position incremented an in-memory completed_trades; at ≥TESTING_PROD_MAX_TRADES=150 it set testing_prod_paused=True, and calculate_position_size (line ~985) returns $0 for every new position while that flag is set — a genuine trading stop. BUT the counter is in-memory only and resets to 0 on every trader restart (deploys happen multiple times weekly) — live state at check time: completed_trades: 0, paused: False while the DB held 150+ real closes. Flagged in #335 as "never fires as documented"; the residual risk was the opposite tail: a long uptime stretch could have silently zeroed all sizing mid-earnings-week.close_position logs the milestone (info) and continues; testing_prod_paused machinery is retained for the manual set_trading_mode/confirm_testing_prod_complete paths only. TESTING_PROD_MAX_TRADES=150 is now a milestone marker feeding the dashboard progress display.risk_manager.py (auto-pause removed + comments), tests/unit/test_risk_manager.py (NEW guard test: crossing 150 does NOT set the pause), STRATEGY.md ×2, dashboard.html subtitle, changelog §O entry.capital_manager._bmo_reservation_threshold to 0.95 but MISSED the trading_orchestrator twin — the copy check_amc_signals actually reads (Jul 22, 2026)trading_orchestrator.py:183 still had _bmo_reservation_threshold = 0.90; capital_manager.py:72 (the copy #333 fixed) is 0.95. The orchestrator copy is the one the LIVE AMC admission path executes (log lines match orchestrator:3518).priority.py miss.test_finding_346_bmo_threshold.py asserting BOTH twins exist exactly once and carry the same 0.95 value (fails on any future divergence).trading_orchestrator.py (threshold + comment), NEW tests/unit/test_finding_346_bmo_threshold.py, changelog §O entry.systemd_last_run:polymarket-backup.service FAIL (exit 1), 7 consecutive daily failures Jul 16-22./tmp/polymarket-backups under the #275 policy (keep 7 days + most recent). (2) The DB grew to ~3.4 GB/day compressed → 7-day retention = ~24 GB steady-state on a 48 GB root disk → disk hit 94% (3.0 GB free < one backup). (3) Ordering bug: main() ran create_backup() BEFORE cleanup_local() — pg_dump hit [Errno 28] No space left on device → sys.exit(1) → cleanup never ran → the 7 stale backups (Jul 8-15, 27 GB) were never deleted → every subsequent run failed identically. A wedged state that could only worsen.cleanup_local() now runs BEFORE create_backup() (pre-flight, so a full disk self-heals on the next run) and retention cut 7 → 2 days (~7 GB steady-state; keep_at_least_one=True still guarantees yesterday's copy survives until today's dump succeeds). (3) Re-ran the service — backup completed, integrity rule green.deploy/backup_database.py (cleanup-first + retention), §F disk cleanup (one-shot).instant_exit:earnings_miss/beat — corrupting every future "how many PM predictions missed" statistic; operator caught that the #342 repair deliberately KEPT PNC's wrong label ("historical fact" + note) violating the #328 honest-labels precedent → 5 rows relabeled instant_exit:false_detection, 2 poisoned actual_beat values corrected, and a self-detecting integrity rule added (Jul 15, 2026)exit_reason feeds aggregations (ILIKE '%earnings_miss%' class predicates) where a notes column never will; (b) Finding #328 (KR) already established the honest-labels rule — the #342 §F decision to keep instant_exit:earnings_miss "as the historical fact of why we exited" with only a note appended was an agent judgment call that violated that precedent. Owned and reversed.earnings_miss/earnings_beat/wrong_prediction) cross-checked against earnings_markets.actual_beat AND independently against the market's own RESOLVED price (last snapshot ≥12h post-earnings — necessary because actual_beat itself was detector-writable pre-#342) AND external EPS ground truth (web) where PM data was ambiguous. Verdicts:instant_exit:false_detection (both tables per §F, audit notes appended with the original label): ON Feb-10 (PRNewswire $0.06 vs real $0.64, PM pinned 0.9995 BEAT — the #264-era incident), ZM Feb-25 (beat-exit at 10:59 ET, hours PRE-release for an AMC name), IONQ Feb-25 (miss-exit at 15:51 ET, pre-release; IONQ actually beat, adj EPS −$0.20 vs −$0.48), VZ Apr-27 (Yahoo GAAP scrape, PM resolved 0.9995 BEAT — Finding #264), PNC Jul-15 (dividend-as-EPS parse, Finding #342).actual_beat values corrected to the market's RESOLVED outcome (what we trade is the market's criterion, not an EPS heuristic): AXP Jan-30 True→False (market resolved NO at 0.0005; the resolver's EPS-tie 3.53 >= 3.53 heuristic said beat) and XOM Jan-30 False→True (market resolved YES at 0.9995; GAAP $1.54 vs $1.69 said miss). Their miss/beat exits were therefore GENUINE (labels stay) but both PREDICTIONS were wrong — flags corrected.trade_audit_log.prediction_correct + trading_positions.prediction_wrong): ON/ZM/IONQ/VZ → correct=True (positions were RIGHT; the exits were the bug), AXP/XOM → correct=False. Necessary because resolver Pass 4 only fills NULL flags — stale values never self-heal.consistency:exit_label_vs_resolution (_check_exit_label_vs_resolution, severity HIGH) — FAILS whenever any instant_exit:earnings_miss/beat row's market resolved the opposite way, naming the offenders and the required relabel. The class is now self-detecting on every integrity cycle instead of waiting for an operator to eyeball a history row.instant_exit:false_detection renders as "Instant Exit (False Detection)" via normalize_exit_reason's generic instant-exit branch (no code change needed); stop-rate analytics (%stop%) never matched these rows; miss/beat statistics (%earnings_miss%) now exclude them; instant-exit aggregates (instant_exit%) still include them (honest — an instant exit DID execute). The strategy_analysis_report.json/trade_data_full.json snapshots regenerate on deploy. The Feb-era "🐛 BUG-EXIT" display convention (notes BUG_EXIT: prefix) is unrelated and untouched.integrity_monitor.py (new evaluator + rule), NEW tests/unit/test_finding_344_exit_labels.py (4 tests: rule pass/fail + display label + aggregation-predicate exclusion), §F repair script (run + deleted), FINDINGS #342 §F line amended, changelog §O entry.trading_positions.earnings_outcome + actual_earnings_time, rendered as "📢 time BEAT") was previously fed by same-side PM "resolution" detections, i.e. exactly the signal class that poisoned WFC/WDFC/OKTA/JPM. Killing the poison killed the feed.actual_beat writer — but its timer ran ONCE daily at 06:00 ET, which is BEFORE same-day BMO releases (Jul-15: ran 06:01, JNJ released ~06:45). Same-day outcomes would settle ~24h late; the Upcoming table's display-only PM-extreme fallback still worked, position rows did not.polymarket-resolver-intraday.{service,timer} — light pass (--earnings-limit 50 --skipped-limit 0 --opportunity-limit 0 --lookback-days 3 --polygon-sleep 0.05 --include-today, 10-min timeout) every 30 min during release windows: 06..10:15,45:00 + 16..18:15,45:00 America/New_York (:15/:45 offsets clear the 06:00 daily full pass, which stays as the completeness backstop). Wired into deploy_from_github.sh (install + enable, same pattern as the daily unit). Calendar strings validated with systemd-analyze calendar on prod. Resolver gained --include-today / ResolverConfig.include_today — Pass 1's earnings_date < CURRENT_DATE floor had excluded same-day rows entirely (found on the first live run: JNJ/CTAS "no rows pending"). Same-day rows resolve via the EPS-surprise tier ONLY (reported EPS is inherently post-release); the post-event PM-snapshot tier (midnight+12h floor would admit PRE-release prints same-day) and the pre-event conviction cascade (a prediction, not an outcome — the #341/#342 poison class) stay next-day. Same-day rows are also exempt from the 6-hour updated_at guard (the hourly earnings-sync would otherwise lock the intraday pass out). Same-day actual_beat now settles as soon as Yahoo/Finnhub/Polygon publish reported EPS (~30-60 min after release) → gold "BEAT" badges, trade_audit_log.prediction_correct, and analytics all fill same-day.pm_outcome_display/pm_outcome_yes_price for open positions with NULL earnings_outcome by feeding the latest price_snapshots print through the SAME _compute_outcome_status used by the Upcoming table (fresh ≤12h + past release window + ≥0.99/≤0.01) — no parallel threshold logic. Rendered in BOTH the Jinja desktop row and the JS mobile row as "📊 BEAT · PM"/"📊 MISS · PM" with a tooltip stating it is a crowd read, not settled truth (#342), superseded when actual_earnings_time/earnings_outcome or the resolver's verdict lands. Touches display only — zero detection/authority code.stop_loss:hardware) on a correct BEAT — sell-the-news price action, JPM Jul-14 pattern; prediction flags correct, no action.deploy/systemd/polymarket-resolver-intraday.{service,timer}, deploy/deploy_from_github.sh (install block), earnings_dashboard/app.py (positions crowd-read fields), earnings_dashboard/templates/dashboard.html (Jinja + JS badge branches), docs/OPERATIONS.md (resolver runbook + topology), changelog §O entry."$X per share" pattern matched the dividend. $2.00 vs $4.41 → MISS → contra our LONG → instant exit filled $245.86 in thin pre-market at 6:34:31 (−$11.14; PNC traded ~$253 pre-market an hour later ≈ $14-15 avoidable), actual_beat=False written (WRONG), monitoring stopped. The crowd said BEAT the entire time (median 0.91 at claim time; flipped to 0.995 five minutes later) — nobody asked it.check_earnings was first-to-detect-wins, so whichever source spoke first held unilateral power to exit, settle ground truth, and kill monitoring. Every incident in the detector's history is this one defect wearing a different source's clothes: ON (PRNewswire $0.06 vs real $0.64), VZ #264 (Yahoo GAAP scrape), JPM #337 (pre-release price pin), WFC/WDFC/OKTA #341 (whipsaw/flipped pins), PNC #342 (dividend parse). Operator: "there must be a single integrated mechanism for this... we need something bulletproof."_arbitrate_content_claim): every content-source CONTRA claim is cross-examined against the always-on second witness, the live PM crowd median (_live_polymarket_median, median-of-5, fresh ≤CONTENT_CROWD_MAX_AGE_SEC=900s): crowd agrees (≤0.25 MISS-side / ≥0.90 BEAT-side) → admit immediately (two witnesses — FASTER than before for genuine misses); crowd neutral/stale/missing → corroboration required (2nd independent source with the same claim, or persistence ≥CONTENT_EXIT_CORROBORATION_SEC=300s un-vetoed); crowd contradicts (≥CONTENT_CROWD_VETO_BEAT_THRESHOLD=0.90 vs a MISS claim / ≤0.10 vs BEAT) → VETO + alert, and the veto resets the persistence clock so a recurring bad parse can never time its way in. Same-side claims are informational (they exit nothing and no longer settle anything). PM price source unchanged from #341 (contra-only, 300s sustained + 60s grace, #337 window gate). The Feb-10 _confirmed_polymarket sticky cache and #264 layers (a)/(b) are replaced by the arbiter (one policy, all sources; live-computed → self-correcting after crowd flips).earnings_markets.actual_beat (removed from BOTH _persist_earnings_detection writers: trading_orchestrator + position_manager, §E). The EPS resolver (#227, outcome_resolver.py) settles the record post-hoc. Detection stays fully audited on the position row._check_all_positions no longer removes positions on detection (any source); only the exit flow does. Callbacks dedup per outcome (MonitoredPosition.last_reported_outcome, carried across the reconciler's ~5-min re-registrations and seeded across restarts via already_reported_outcome) and RE-FIRE on a flip — a later witness contradicting an earlier one is exactly what continued monitoring exists to catch. Both reconciler registration sites now register even when an outcome exists (closes the #337 gap where "already has an outcome" left JPM without instant-exit protection after a restart)._extract_eps_from_text rebuilt as two tiers — TIER 1 explicit EPS labels incl. the number-first "$4.81 Diluted EPS" shape (the exact PNC headline); TIER 2 bare "$X per share" only when the preceding 80 chars contain no dividend/buyback/repurchase/book-value/offering context. Negative shapes (−$0.25, ($0.12), ($0.12) per share) all parse; -$→$- normalization added.tests/unit/test_finding_342_exit_arbiter.py — 22 tests replaying PNC (dividend parse vetoed; real headline parses $4.81), VZ (GAAP scrape vetoed at 0.9995), ON ($0.06 parse vetoed), crowd-agree fast path, 2-source admit, persistence admit, veto-resets-clock, stale-crowd-no-witness, flip-restarts-pending, monitoring immortality + callback dedup + flip re-fire + re-registration dedup, and a source-scan tripwire asserting NO detection module contains SET actual_beat. 803 unit tests green.earnings_markets.actual_beat False→True, trading_positions/trade_audit_log actual_eps $2.00→$4.81, earnings_outcome miss→beat, prediction_wrong→False/prediction_correct→True. ~~The exit reason string instant_exit:earnings_miss kept as the historical fact of why we exited, note appended~~ — SUPERSEDED same day by Finding #344 (operator-caught): keeping the wrong label corrupts miss/beat statistics (notes never enter aggregations, #328 honest-labels precedent); relabeled instant_exit:false_detection along with 4 more historical false-detection exits.earnings_detector.py (arbiter + crowd witness + monitoring lifecycle + parser + docstrings), trading_orchestrator.py + position_manager.py (actual_beat writers removed), reconciliation_manager.py + trading_orchestrator.py (register-even-with-outcome + dedup seed), config.py (4 new knobs), NEW tests/unit/test_finding_342_exit_arbiter.py, 2 #341 tests re-pinned, changelog §O entry, STRATEGY.md detection section rewritten.scripts/detection_latency_replay.py --truth market --positions) over every resolved market with ≥200 prints since Jan (13 markets, incl. the WFC/JPM incident day) scoring signals against actual market resolution AND against what they would DO to a held position (saves vs false exits).actual_beat AND stopped monitoring — the exact #337 poisoning class, from a signal that exits nothing.direction the source emits nothing. Resolution grade = median ≤0.02/≥0.98 from window open (#337 gate); decision grade = median ≤0.25/≥0.90 after the typical release (#339).POLYMARKET_DECISION_CONFIRMATION_SEC=300), with a 60s grace (POLYMARKET_CONFIRMATION_GRACE_SEC): a brief pop back across the threshold (interleaved bid/ask prints nudging the median) doesn't reset the clock unless it lasts >60s. Grace matters: without it the sustained window kept resetting on print noise and median save latency ballooned 22m → 73m._persist_earnings_detection skips the earnings_markets.actual_beat write for source='polymarket' (any grade) — the EPS resolver (#227) owns ground truth. The monitor loop no longer removes the position on a PM signal (the instant-exit flow removes it when the position actually closes; if the exit doesn't happen, monitoring continues and catches a crowd flip)._confirmed_polymarket is no longer armed by exit signals; the PRNewswire/BusinessWire/Yahoo shield now uses the LIVE median extreme (_live_polymarket_extreme_outcome, #264 layer-b) computed at wire-claim time — self-correcting when the crowd flips back (the static cache would have blocked the true verdict after a WFC-class flip).earnings_detector.py (contra-only emission, per-grade→uniform sustained confirmation + grace, 4-tuple confirm state, shield decoupling, monitor-loop keep, EarningsResult.grade field, check_earnings(direction=)), trading_orchestrator.py (actual_beat gate on source), config.py (2 new knobs), scripts/detection_latency_replay.py (final-mode semantics + position lens + grace model), tests/unit/test_earnings_detector.py (19 tests: 4 NEW #341 + all PM tests re-pinned to contra-only), changelog §O entry.null_rate:trade_audit_log:conviction_at_exit_post_fix (26.1% = 6/23 > 10%) and hold_duration_post_fix (13.0% = 3/23 > 5%).::timestamptz mis-parse) and #339 (COALESCE bare-0 integer inference) are the same class, ~3 months apart, both discovered by a live close failing. Fixed structurally: NEW scripts/close_persist_contract_check.py — runs the REAL PositionManager.persist_position_close_with_exit_details end-to-end against production PostgreSQL on EVERY deploy, using a synthetic __SQLCHK__ row with a FRACTIONAL commission (the exact #339 trigger shape), asserts the PRIMARY path succeeded (fallback marks reconciliation_status='fallback_close_fix') and the critical fields round-tripped, then deletes the synthetic rows. Wired into deploy_from_github.sh (fail-loud, non-blocking; the 🚨 line is part of the §H deploy-output review).position_manager.py (fallback widened), NEW scripts/close_persist_contract_check.py, deploy/deploy_from_github.sh (hook), §F backfill scripts (run + deleted), tests/unit/test_position_manager_fallback.py (NEW — fallback field coverage).POLYMARKET_POST_RELEASE_* env-tunable; 30s confirmation + #337 gate still in front). Safety: for held positions a same-direction signal is benign (no exit) — decisiveness is only needed to exit a WRONG position, and pre-release prices are by construction same-direction (we entered on them). Replay: fires on WFC's post-crash mix ~8:40 ET, ~47 min before the stop (~$7-10 of the -$20.58 was avoidable bleed).earnings_markets.eps_estimate held real values (WFC $1.71) — the entry-path yfinance calendar lookup failed and nothing fell back to our own DB; _determine_outcome(actual, 0) reads any positive EPS as BEAT → for LONGs Yahoo could NEVER trigger an instant exit. Fix: add_monitored_position falls back to earnings_markets.eps_estimate (single point covering entry, restart, and reconciler registration paths). Also noted: PRNewswire RSS timed out all morning (chronic; separate infra item, not actioned).total_commission = COALESCE(entry_commission, 0) + COALESCE(:exit_commission_for_total, 0) — the bare 0 made PostgreSQL resolve the untyped bind param to INTEGER, so the FIRST fractional Tiered commission ($0.3555/$0.3652 — correctly captured from IBKR commissionReport!) raised 22P02 invalid input syntax for type integer → the ENTIRE primary close-persist failed → the fallback path (per #263, minimal fields) persisted the close WITHOUT commissions/gross_pnl/hold_duration/conviction_at_exit. Invisible under the Fixed plan ($1.00 parses as integer) and in mock-DB unit tests; Jul-14 were the first live Tiered stop closes. Fix: explicit 0::numeric + CAST(... AS numeric) in both writers (position_manager + the reconciliation writer in trading_orchestrator — §E same-pattern grep found exactly these two). §F backfill: JPM/WFC rows corrected from the exact logged commissionReport values (exit 0.3555/0.3652, totals 0.7062/0.7169, gross_pnl, hold_duration) with audit note.earnings_detector.py (median statistic + _typical_release_passed + _get_release_meta refactor + consensus_eps DB fallback), config.py (2 new thresholds), position_manager.py + trading_orchestrator.py (numeric casts), strategy_changelog.py §O entry, 6 NEW tests (incident replay: WFC collapse detected post-release / dormant pre-release / mid-band never signals / BEAT side / consensus recovery), §F backfill (run + deleted).src/earnings/strategy_changelog.py — 26-entry chronological record from launch (Jan 7) through #337 (Jul 14), each entry in a fixed schema: date / finding / category / title / change (before→after with exact values) / why (evidence with numbers) / expected_impact / verification / monitor / status / superseded_by / marker_label. Supersession chains encoded (e.g. #191 → #211 → #314.f sizing; #222 → #311 gate; #202 → #333 cap; #313 → #334 priority; #317 → #335 exposure; #312 → #335.b breaker; #305 → #330 RuleB CANCELLED).app.py's hardcoded STRATEGY_CHANGE_MARKERS (History-chart) replaced by derivation from the module (get_history_chart_markers()); verified byte-identical to the previous 7 markers (dates/labels/finding strings — Jul-6 merged-marker convention preserved via marker_finding).tests/unit/test_strategy_changelog.py (9 tests: schema completeness, no unknown fields, chronology, category/status enums, content-depth minimums so the detail level can't degrade, supersession pointers resolve, marker continuity). NEW memory.md §O rule: every shipped strategy-affecting change MUST add one standardized entry in the same commit. Format documented in STRATEGY_ANALYSIS_ARCHITECTURE.md §0.14.earnings_markets.actual_beat=True, and STOPPED monitoring the position — the missing dimension was the CLOCK, now added as a release-window gate on the price-based source (Jul 14, 2026)_persist_earnings_detection wrote trading_positions.earnings_outcome='beat' + earnings_markets.actual_beat=True → "Stopped monitoring JPM". The book REALLY printed 0.997: 6,974 prints ≥0.98 between 22:00Z and 05:58Z (sustained multi-minute stretches, e.g. 00:25:55 = 0.997 → 00:28:50 = 0.988) — a speculative pre-release surge, not resolution. #336's all-of-5-prints check and the 30s confirmation both passed legitimately — price robustness cannot distinguish a sustained pre-release spike from resolution. Only the clock can: JPM's BMO release was ~10.5h away.earnings_outcome, the reconciler's re-registration filter (if not position.earnings_outcome) skipped it after the overnight trader restarts (2× asyncio "cannot enter context" crash during IBKR reconnect at ~04:14Z — separate pre-existing issue, noted below) — C/WFC/GS/BAC were re-registered for release monitoring, JPM was not. A real MISS this morning would NOT have instant-exited (only the -3% hardware stop + 10:32 scheduled exit protected it). Direction risk of the false detection itself was benign (LONG + "beat" = no exit) but the wrong actual_beat=True also poisoned the ground truth feeding the dashboard gold path (the badge the operator saw), the resolver, and every band-validation dataset._release_window_open) on the PRICE-based source: _check_polymarket_price now returns not-released (and clears any pending confirmation) until the earliest-believable release window opens: BMO → 4:00 AM ET on the earnings date; AMC → 3:30 PM ET; unknown timing → the earlier BMO bound. Window derived from the symbol's nearest earnings_markets row (DB, cached 10 min), FAIL-OPEN on any error/missing row so a data gap can never suppress real detection. The 4:00 AM bound preserves the KR early-release class (6:52 AM detection, #328). Content-based sources (PRNewswire/BusinessWire/Yahoo — they read actual EPS numbers) are deliberately NOT gated, so a true early release is still caught. signal.earnings_datetime could NOT be used as the gate (BMO signals carry date-at-midnight-UTC = 8 PM ET the prior evening — would not have blocked this incident); nor could MonitoredPosition.expected_release_time (the reconciler re-registers with now(), degrading it on every restart — noted, not changed).earnings_markets.actual_beat NULL-ed (1 row, guarded eps_actual IS NULL) + trading_positions detection fields NULL-ed for the open JPM row (1 row) so the real release records cleanly AND the reconciler re-registers JPM on the post-deploy restart. The append-only audit_logger earnings_detected event is KEPT as the historical record of the error.RuntimeError: cannot enter context ... already entered (asyncio/ib_insync reconnect path, ~04:14Z; systemd auto-restarted within 30s; positions reconciled clean) — pre-existing, needs its own RCA; (2) reconciliation re-registration passes expected_release_time=datetime.now() — harmless now that the gate derives its own window from the DB, but the field is misleading; (3) consensus_eps registered as $0.00 at entry (log shows "consensus: $0.00") — the detector's EPS comparison paths run with consensus 0.src/earnings/earnings_detector.py (_release_window_open + gate wiring + window cache), tests/unit/test_earnings_detector.py (RoutingEngine dual-query dummy + 4 NEW #337 tests: pre-window sustained extreme blocked, in-window detected, fail-open, AMC bound), one-off DB correction (run + deleted per §F)._compute_outcome_status (Finding #280) treats a FRESH (≤12h) price_snapshots.yes_price ≥0.99 as "released BEAT" — but it never checked whether the release window had passed. The snapshot stream for thin pre-event books is noisy microstructure: JPM printed 621 of 11,137 prints ≥0.99 in the prior 12h (5.6%), GS 568/10,217 — interleaved with 0.75/0.85 prints within the same second (the stream mixes bid/ask/trade-side prints; live Gamma outcomePrices sat at 0.933/0.940 — which is what the conviction column shows, hence the visible contradiction). The badge fired whenever the single most recent print happened to be one of the ~5% extremes — a per-render coin flip, which is why the badge appeared on some rows/renders and not others (BAC at 17/2,861 extreme prints rarely hit it).volume_ok AND conviction_ok AND depth_quality_ok — no outcome input; (2) the trader's conviction path uses the windowed MEDIAN over up to 10k snapshots (#325/#327) → robust to 5% noise prints (0.93, not 0.99 → no #333 band skip); (3) actual_beat was NULL for all five banks (EPS resolver untouched pre-event); (4) priority/sizing/reservations don't read outcome. So tonight's JPM/GS/BAC/WFC/C entries proceed on their real convictions._check_polymarket_price (0.98 threshold + 30s confirmation) and _live_polymarket_extreme_outcome (0.99, the Yahoo contradiction shield) both read the SINGLE latest snapshot. Failure chain if noise sustained through the 30s window (requires every ~5s poll to land on an extreme print — low probability but nonzero on a thin overnight book, and positions ARE monitored from entry 15:58 through release): false BEAT on a LONG → no instant exit (direction agrees — benign) BUT (a) _confirmed_polymarket[symbol]='beat' poisons the contradiction shield against a REAL miss detection next morning, and (b) _persist_earnings_detection writes earnings_markets.actual_beat → permanent wrong ground truth that then feeds the dashboard gold path, resolver analytics, and band-validation datasets. Historical scan: no such false detection has fired to date (the noise rarely survives 30s) — this is a latent-risk hardening, not an incident remediation._compute_outcome_status PM-extreme branch now ALSO requires _is_past_release_time(...) — pre-event extreme prints can never badge (EPS-resolver gold path and released_pending unchanged); (2) detector: both PM reads now require ALL of the last POLYMARKET_EXTREME_MIN_PRINTS (default 5, env-tunable) prints at the extreme — a resolved market pins every print within seconds, while JPM-class noise interleaves normal prints in the same second, so all-of-5 separates signal from noise with negligible detection delay; the 30s confirmation window stays on top (defense in depth). NO trading-rule change; detection on genuinely resolved markets is delayed by ~seconds at most.price_snapshots.yes_price stream mixing ask-side prints at 0.99 with bid-side at 0.75 for the same market-second is a collector-side characteristic worth a look if it ever feeds anything less robust than a median.earnings_dashboard/app.py (_compute_outcome_status gate + Outcome tooltip), src/earnings/earnings_detector.py (both PM reads → last-N all-extreme + class constant), src/earnings/config.py (POLYMARKET_EXTREME_MIN_PRINTS=5), NEW tests/unit/test_outcome_status.py (5 tests), tests/unit/test_earnings_detector.py (fetchall-aware dummies + 4 NEW #336 tests incl. the exact JPM print pattern).exposure_cap skips ever logged).can_open_position at the 10:30 AMC check evaluated exposure INCLUDING the overnight BMO positions that exit at 10:32 — two minutes before the 10:34 AMC entry. The Jan-28-2026 freed-capital fix (Finding #72 era) covered batch SIZING only (capital_freed_from_exits → calculate_batch_position_sizes), never ADMISSION. Consequence: with a full overnight book, EVERY AMC signal was auto-rejected ("would exceed cap") — in the Jul-14 week that would have silently killed UAL (Wed) and NFLX (Thu) after 5-position nights. Same gap on the 15:50 BMO check vs prior-day AMC positions exiting 15:57. Fix: can_open_position(capital_freed_from_exits=...) nets the about-to-exit value from current exposure; wired in check_amc_signals (300s window helper) + check_bmo_signals (600s helper) + the capital_manager extracted twins (new _capital_freed_from_imminent_exits helper there; module not on the live hot path but kept lockstep per §E).get_actual_available_capital returned raw IBKR buying_power (~4× net_liq ≈ $110k on a $2.7k trading capital) minus the BMO reservation → the AMC-vs-BMO reservation check NEVER bound at admission; the reservation only worked indirectly via batch sizing (which correctly used exposure math). The ≥0.95-conviction AMC override was additionally unreachable for LONGs post-#333 (≥0.95 = SKIP). Fix: the method now returns the strategy budget (MAX_DAILY_EXPOSURE × total_capital − effective exposure, with the new capital_freed_from_exits param) bounded above by IBKR buying power (real margin availability still wins if lower), minus the reservation. Side effect: the dashboard available_capital field (via get_risk_state) now shows the strategy-relevant figure (~$4k) instead of the misleading $110k.MAX_TOTAL_EXPOSURE_PCT re-aligned 1.40→1.50 in lockstep (still unwired per #315). build_phase3_sim_capacity now resolves the cap from Config.MAX_DAILY_EXPOSURE at build time (was hardcoded 1.40 — would have silently desynced the "always live-accurate" dashboard sweeps).SESSION_STOP_BREAKER_THRESHOLD 3 → 4 — with up to 6 concurrent positions post-#335 and correlated bank-sector stops, a 3-stop morning becomes likely enough to wrongly kill that evening's BMO wave; 4 keeps the tail guard while tolerating one more correlated stop. Historically only 4 of 9 breaker days reached 4+ stops. The session_3_stop_breaker skip-reason string is KEPT for opp_log analytics continuity.config.py (MAX_DAILY_EXPOSURE 1.50 + MAX_TOTAL_EXPOSURE_PCT lockstep), risk_manager.py (can_open_position + get_actual_available_capital + stale 30/22/15 docstrings fixed), trading_orchestrator.py (check_amc/check_bmo wiring + stale check_bmo docstring), capital_manager.py (lockstep twins + new helper), scripts/strategy_performance_analysis.py (cap from Config + #315 row supersession + Action Queue row busy-week-capital-335 + milestone), replay_exposure_cap_sweep.py (1.50 in DEFAULT_CAPS + LIVE marker), replay_regime_v2.py (LIVE_EXPOSURE_CAP), dashboard (System exposure row, Strategy tab Live Config Sizing row + as-of + #315/#316 headers + capacity notes, History marker), STRATEGY.md (×10 incl. the Jan-28 fix completion note + reservation flow + quick-ref), tests (test_config 1.50, exposure-limits test, NEW TestBusyWeekCapitalFixesFinding335 ×5, cap-sweep test).priority_filter despite the relaxed cap (Jul 11, 2026)priority.py:_calculate_base_score still had its own independent conviction >= 0.90 → return 0 encoding of the #202 cap plus a PRIORITY_TABLE with no 90-95 rows. A 0.92-conviction LONG would have passed the SIGNAL_LONG_MAX_THRESHOLD gate (relaxed by #333) but scored priority 0 → is_tradeable() False → dropped in prioritize_signals with skip_reason=priority_not_tradeable. The #333 relax was effectively inert for live entries until this fix. Root cause of the miss: the §E sweep grepped for 0.90/≥90% config-style encodings but priority.py encodes the cap as a bare 0.90 comparison inside the base-score function and was not on the #202-surfaces list; the memory.md §M lockstep note existed for STRATEGY.md↔priority.py but the cap itself lives in TWO places (config + priority) — now called out in both files._calculate_base_score).MAX_PRIORITY_SCORE 8 → 10 (MAX_BASE_PRIORITY_SCORE 6 → 8); #313 volume bump (+1 @ $2k / +2 @ $5k) unchanged on top; labels now N/10. New LONG_MAX_CONVICTION = 0.95 module constant replaces the bare 0.90 literal (lockstep note → Config.SIGNAL_LONG_MAX_THRESHOLD).signal_generator (dataclass docs + prioritize_signals docstring — sort logic itself is scale-agnostic), batch_order_manager (PRIORITY_BOOST normalizes by MAX_PRIORITY_SCORE — scale-agnostic, comment updated), trading_orchestrator (log line was hardcoded /6 — pre-#313 leftover, now /{MAX_PRIORITY_SCORE}; stale check_amc_signals docstring rewritten), app.py (upcoming-earnings scorer comment; active-positions label bands rescaled ★HIGH≥9/HIGH≥6/NORMAL≥3/LOW≥1), phase3 simulator (replay_exposure_tiering._priority imports production calculate_priority_score — auto-lockstep, no change needed).NEW #334 with hover rationale) + chips re-colored on the 0-10 palette; timeline Jinja clamp 8→10 + /8→/10 fallbacks (both active-positions and pending-opportunities paths); upcoming-earnings Priority column clamp/label; JS mobile path clamp (was displaying /6 — a second pre-#313 leftover found by the line-by-line pass) → /10; Priority-column tooltip; "Min Required" tooltip (was still "LONG 70-89% (90%+ skipped)"); SKIP legend + Entry/Exit footer skip-list (were still "LONG ≥ 90%"/"LONG 90%+"); AMC-threshold bullet (was "70-89%"); conviction scorecard annotation; Strategy-tab Live Config Selection + Priority rows + as-of line; History-tab Jul-11 marker label extended to mention the re-tier.test_priority_score_volume_bump_finding_313 base 6→8, new test_priority_score_90_95_band_finding_334 covering both new tiers + ordering + 0.90/0.95 boundaries + small-cap/SHORT exclusions + max=10, legacy system_health_check/comprehensive_system_test expected values corrected — several were stale since BEFORE #202).priority_score values in trades/polymarket_opportunity_log rows written before Jul 11, 2026 are on the old scales (0-6 pre-May-30, 0-8 to Jul-11) — they are point-in-time records and are NOT rewritten (consistent with the #313 precedent).SIGNAL_LONG_MAX_THRESHOLD 0.90 → 0.95 (config default + prod .env override; conviction_calculator.is_high_conviction lockstep; simulator rules.py DirectionPolicySpec default + baseline spec → prob_threshold_70_30_with_95_skip; capital_manager._bmo_reservation_threshold 0.90 → 0.95 in lockstep — at 0.90 the reopened band would have silently overridden every BMO capital reservation; dashboard §M triple-surface: app.py conviction_ok <95, Jinja badges ✗95+/70-94%/conv_ok<95, JS mobile path maxConv 95 + stale $2k volume fallback → $1k per #311; Live Config Direction row; P&L-progression marker Jul-11; STRATEGY.md ×8 incl. quick-ref + AMC-reservation threshold rows; Action Queue relax-202-9095 → IMPLEMENTED + Mar 9 #202 row updated + conviction-band-revalidation supersession note; 2 guard tests re-pinned). The ≥0.95 skip REMAINS (fails everything). ⚠️ Completed by Finding #334 (same day): this rollout MISSED priority.py, which independently zeroed ≥0.90 LONGs — without #334 the reopened band would never have traded. Priority now: Large 90-95 = base 6, Mid 90-95 = base 3 (lowest per mcap group), scale 0-10. Monitor: first ~20 real fills in the reopened 0.90-0.95 band vs the +$6.19/trade counterfactual; re-gauntlet on real fills at trade #150 or 20 band-fills, whichever first. Prior context: Jul-6 exclusion audit (chat) found every §K-validated exclusion correct but flagged these leaks + the ≥0.90 question.earnings_markets.earnings_date sits one day LATE on the day itself (sliding/TBD dates corrected after the fact), the day-of channel's earnings_date window misses it while the eve channel sees it as "tomorrow" and (correctly) mismatch-skips — nobody ever evaluates it. PXLW (May 12, $1,018 — above gate) was lost this way. Fix: get_earnings_markets now ALSO matches on markets.end_time::date within the window, guarded to ABS(end_time::date − earnings_date) <= 1 (end_time matches earnings day for 1042/1058 historical markets; the guard keeps the 15 far-divergent markets out so nothing can be evaluated/traded on the wrong day). Canary: WDFC (AMC Jul-9) self-resolved pre-fix because its earnings_date was correct by the day — the AMC channel evaluated it Jul-9 and excluded it on volume ($752 < $1k), which is the funnel working; the fix covers the stale-date case that WDFC dodged.ConvictionCalculator._get_conviction_from_gamma_live — last-resort live-Gamma outcomePrices fetch (the #325 bake-off's BEST source, 78% acc; same source the dashboard displays), wired into (a) get_conviction_at_time when the windowed lookups are empty AND the target is ≤24h old (guard prevents look-ahead contamination of point-in-time replays like #319), and (b) _get_latest_conviction (inherently live). Rejects resolved 0/1 prints; fail-soft on any error; method='gamma_live_fallback', is_reliable=True, depth_quality=0.75.determine_earnings_timing — infer BMO/AMC from the Polymarket market's own end_time hour (≤14Z → BMO, ≥20Z → AMC; deterministic convention on 1042/1058 markets), before the web fallback. Runtime inference only, NOT persisted (per #220). 8 of 22 conviction/timing losses were above the $1k gate.scripts/analyze_90_95_bucket.py, run on prod): replays every #202-skipped market (deduped, n=112 with schedules; bars cache-first from equity_bars_1m) EXACTLY as the bot would trade it: entry at the scheduled entry bar, per-tier 3/4/5% stop active 24/7 with gap-aware fills (first bar after a ≥10-min gap that opens through the stop fills at the OPEN — the #330 lesson), scheduled-exit close, flat-25% sizing ($625), net of commissions (both Fixed $1/leg and Tiered $0.50/leg reported).relax-202-9095.src/earnings/signal_generator.py (end_time OR-window + PRIORITY 4.5 + call-site), src/earnings/conviction_calculator.py (gamma_live_fallback + 2 wire-ins), NEW scripts/analyze_90_95_bucket.py, NEW tests/unit/test_finding_333_fallbacks.py (8 tests), scripts/strategy_performance_analysis.py (Action Queue row), docs (FINDINGS/PLAYBOOK/CONTEXT/AGENT_HANDOFF).commission grep across src/ + scripts/ + earnings_dashboard/, every match categorized):client._trade_to_result sums commissionReport.commission across ALL fills (partial-fill-correct under both plans; IBKR allocates the per-order minimum across executions); wait_for_commission (already is not None-based summing); entry-side persist keeps full precision (NUMERIC(10,4) — DB shows $1.0001 entries); weekly Flex reconcile (#329) gap-fills exact charged values; fee_calculator.py already models TIERED ($0.0035/sh min $0.35 + SEC/FINRA/exchange) — it becomes MORE accurate post-switch; all dashboard commission displays format cents; PNL_EFFECTIVE_SQL (#331) plan-agnostic; PROJECTIONS["commission_per_trade"]=1.00 is a frozen epoch-1 benchmark (actuals dropping below it post-Tiered is the desired direction, lower_is_better).position_manager.persist_position_close_with_exit_details ran int(round(exit_commission)) before persisting into the NUMERIC(10,4) column. Invisible under Fixed ($1.00→1) but corrupts Tiered ($0.42→0, $1.42→1). Now persists round(float(x), 4). (Explains why 99 of 106 historical exit_commission values are whole-dollar; the 7 exceptions came from the #329 Flex backfill. Historical error ≤ ~$0.0001-level — immaterial; the weekly Flex reconcile remains the exactness safety net.)client.py (if report.commission else None, total if total > 0 else None) mapped a legitimate $0.00 (possible under Tiered) to NULL. Now keyed on report presence / is not None.trade_audit_log writers disagreed on gross_pnl semantics — position_manager wrote realized_pnl + exit_commission while the reconciliation writer wrote realized_pnl. Since realized_pnl IS the gross price P&L (exit_manager computes price-delta × shares; verified numerically), the +commission version double-counted. Standardized both writers on gross_pnl = realized_pnl; corrected the 97 inflated historical rows (gross_pnl = realized_pnl, note appended). No consumer read gross_pnl (column was select-only in the report), so no display/analysis impact.commission > 0 gap-fill guards treat 0 as missing (correct for those sources' semantics); the ABS() convention (#329, for statement_csv debit-sign rows) would flip a true rebate-NEGATIVE commission — not expected for our marketable SMART orders, revisit only if a negative ever appears; the stop-fill inspector (exit_manager._find_recent_stop_fill) records the latest exec's commission only — multi-fill stops under-record, but our 1-30-share orders fill in one exec.STRATEGY_CHANGE_MARKERS kept in sync with PLAYBOOK §2), and a "🧭 Performance by rule era" table (trades / NET P&L / NET-per-trade / win rate per interval between changes; labeled direction-not-precision per §K). Server-built in get_trade_history() as equity_curve (all-time, period-independent); cold-start fallback key guaranteed per #286 lesson.src/earnings/position_manager.py (rounding fix + gross_pnl ×2), src/ibkr/client.py (falsy fixes ×2), earnings_dashboard/app.py (STRATEGY_CHANGE_MARKERS + gross_pnl_total + equity_curve builder + fallbacks), earnings_dashboard/templates/dashboard.html (2 cards + chart section + era table + JS renderer + period-refresh), DB one-off /tmp/fix_gross_pnl.py (97 rows). 733 unit tests pass.realized_pnl is gross price P&L; net_pnl_after_financing subtracts only financing (margin interest + borrow, migration 020); commissions lived in total_commission (per-leg IBKR ground truth since #329) but NO display subtracted them. The display-side "effective P&L" definition COALESCE(net_pnl_after_financing, realized_pnl) existed in app.py as PNL_EFFECTIVE_SQL + 2 INLINE duplicates (§M violation — all-time TC-ROI query + weighted-slippage stats query).PNL_EFFECTIVE_SQL/_T → (COALESCE(net_pnl_after_financing, realized_pnl) - COALESCE(total_commission, 0)); the 2 inline duplicates replaced with the constants (all-time TC-ROI at app.py:3560; slippage-stats total_effective_pnl at app.py:3944 + new total_commissions field).get_trade_history() period summary: total_pnl = Σ per-trade effective pnl − Σ total_commission (per-trade pnl is already financing-adjusted via COALESCE — subtracting financing again would double-count; INFERENCE-DISCIPLINE-checked against the row builder at app.py:3276) + new commissions_total key (+ _empty_summary lockstep per #286).{PNL_EFFECTIVE_SQL} SUM/AVG), _merge_trade_history_with_state.estimate_roi() now ALSO emits current_pnl_net / current_roi_net / total_commissions, rendered as a companion "NET after $X commissions" line under the Total P&L (gross) metric card.realized_pnl in the trade-history summary is the state's effective (net) figure — tooltips renamed "Net realized" accordingly./api + rendered dashboard verified: header Total P&L (NET) ≈ +$190 (net) vs old +$421 (gross); History card + tooltip show commissions; Strategy tab shows the NET companion line (see commit deploy note for exact live values).earnings_dashboard/app.py (PNL_EFFECTIVE_SQL/_T + 2 inline-dup replacements + trade-history summary + _empty_summary + slippage-stats field), earnings_dashboard/templates/dashboard.html (header quick-stat, History card, JS tooltip, Strategy net line), scripts/strategy_performance_analysis.py (estimate_roi net fields), docs (FINDINGS/CONTEXT/AGENT_HANDOFF; #329 cross-ref updated).config.py POSITION_SIZE_PCT_LARGE/MID/SMALL 0.30/0.22/0.15 → 0.25/0.25/0.25; simulator lockstep per §M (scripts/phase3_simulator/rules.py sizing spec jul6_flat25 + production_strategy_jul6() canonical factory); dashboard Live Config Sizing row + Position Sizing Analysis labels + P&L-progression marker; STRATEGY.md §4.0/§4.1/quick-ref; .cursor/memory.md account section. Action Queue row sizing-up-2026-07 → IMPLEMENTED. Monitor: small-cap positions now size 25% in the widest-stop (5%) tier (mitigated by #207 LONG-70-80-only) — watch small-cap stop-outs over the next ~20 trades; re-validate at trade #150+.scripts/phase3_simulator/replay_sizing_variants.py --exposure-cap 1.40 --trading-capital 2500 on prod — the existing #314.c size-aware capital simulator, on FRESH data (578 signals = 134 live + 444 counterfactual, 2025-09-16 → 2026-06-17 = 39.0 weeks, June trades + #321 backfills included), at the LIVE cap per #315.src/earnings/config.py POSITION_SIZE_PCT_* + tests + dashboard Live Config Sizing row + STRATEGY.md §4.replay_sizing_variants.py; results in scripts/phase3_sizing_variants_results.json on prod + Action Queue row + Playbook §2 block.RULEB_MODE=active, per #305/#322.e) was gauntlet-PASS only because of a simulator artifact; flipping it active would have systematically converted contained stop losses into larger held-through-the-window losses.simulate_window_only found the bug in the code, not the model.scripts/analyze_finding_a_full.py:simulate_window_only silenced the stop inside release±90min, then for any LATER bar with low<=stop (LONG) returned an exit at the stop price. When the price crosses the stop during the silent window and is still beyond it at window end, the as-shipped #305 mechanism re-places the STP-LMT, which triggers immediately and fills near market — a stop-price fill is unobtainable. simulate_finding_a_hard already modeled exactly this (rearm_gap_open exits at the bar OPEN — the arm that retracted FA_hard in #228 at −$1,545/100); the RuleB arm never got the same handling. The May-28 PR-1 §10.1 cross-check could not catch it (both sides used the same rule math).simulate_window_only is now gap-aware — tracks silenced_prev; on the first post-window bar, if the OPEN is already beyond the stop → exit at the OPEN (mirrors FA_hard). Function signature unchanged (8 params) so the reference_adapter CHANGE-CONTRACT guard + all phase3_simulator delegation keep working. simulate_combined_window_atr (RuleG) inherits the fix. 6 NEW unit tests (tests/unit/phase3_simulator/test_reference_ruleb_gap_aware.py): gap-through→open-fill, in-window recovery preserved, pre-window stop unchanged, post-window intra-bar cross at stop price, SHORT symmetric, RuleG delegation.build_action_queue() + dashboard Strategy tab): Sim RuleB row → FAILED-GAUNTLET, Phase-2 active flip CANCELLED (do NOT re-propose stop-silencing windows without a gap-aware TRUE simulator run, memory §K); v3 D + v3 E "ON-HOLD pending RuleB" → RuleB dependency REMOVED (re-evaluate as pure selection rules at trade #150+ / SHORT n≥30); #323.b's "0.92-0.99 conditional on RuleB active" lever is dead (strengthens the #202 KEPT verdict); May-28 live_baseline_gauntlet PASS/SHIP record kept as historical with SUPERSEDED annotation; Live Config Stops row updated.decide_ruleb_action returned CANCEL every ~2s main-loop cycle for the entire window → 8,026 duplicate RuleB[DRY-RUN]: would CANCEL lines for just 3 positions (Jun 4-17). Both dry-run branches now dedupe via _ruleb_log_once keyed by (symbol, action, position entry-time) → one line per position per action. Also explains why "would RE-PLACE" never appeared: with no real cancel, hardware_stop_active stays True and the state machine never reaches REARM in dry-run — expected Phase-1 behavior, now documented.run_trade → TradeSim.run_all → simulate_window_only(...) (analyze_finding_a_full.py:443, gap check at the first post-window bar) → per-rule pnl_d → _aggregate/gauntlet output + finding_a_full.csv → strategy_performance_analysis.build_finding_a_simulator() → dashboard 🔬 Finding-A Simulator section + 🎯 Action Queue row.scripts/analyze_finding_a_full.py (gap-aware simulate_window_only), src/earnings/trading_orchestrator.py (dry-run log dedupe ×2 branches), scripts/strategy_performance_analysis.py (verdict_summary + _verdict_for_rule + Sim RuleB / v3 D / v3 E / Mar-9-stops / #323-note rows + live_baseline_gauntlet supersession), earnings_dashboard/templates/dashboard.html (Finding-A note + Stop Loss Analysis banner + Universe cross-link + scorecard JS + Live Config Stops row), tests/unit/phase3_simulator/test_reference_ruleb_gap_aware.py (NEW, 6 tests), docs (FINDINGS/PLAYBOOK/AGENT_HANDOFF/CONTEXT).commissionReport covers most trades, but the Flex token had EXPIRED (only Jan captured) → refreshed token + new 365-day query + weekly FAIL-SOFT flex-reconcile timer; backfilled recoverable gaps + fixed a total_commission staleness ($206→$229) (Jul 5, 2026; data-integrity + ops — full-history backfill pending IBKR query propagation)total_commission staleness fixed. Full-history Flex backfill PENDING IBKR new-query propagation (transient 1001); runs via a detached retry probe + the new weekly timer.commissionReport path (ibkr_executions source=live/live_stop_fill, 205 rows) IS IBKR's actual charged commission, captured at fill time; plus 46 flex execs (Jan 7-29). Gaps = ~11 commission-less recovered execs (log_backfill/manual_recovery) + a few early no-exec trades (trade_audit_log had 31 exit + 5 entry NULL commission).IBKR_FLEX_TOKEN had expired (err 1012) — Flex ran once at setup (Jan) then lapsed AND was never scheduled, so no ongoing ground-truth reconcile since January; (2) some recovered/synthetic fills were captured with commission = NULL.source='statement_csv' rows store commission NEGATIVE (debit-sign convention; 2 XOM rows, duplicates of the positive flex/reconciliation rows) → all backfill uses ABS(). ibkr_executions also holds multi-source duplicate execs of the same fills (flex/reconciliation/statement_csv).ibkr_executions (ABS, order-id preferred): +$7. Recomputed total_commission for all rows → fixed a separate staleness where total_commission summed to $206.37 while the entry+exit columns summed to $222.43; now consistent at $229.44.yyyyMMdd/HHmmss/; — matches the parser).scripts/run_flex_reconcile.sh + polymarket-flex-reconcile.{service,timer} (Sun 13:30 UTC) + deploy install block. Pulls the official IBKR statement → gap-fills exact-charged commissions (ABS) into trade_audit_log + upserts ibkr_executions (source='flex'). FAIL-SOFT (exit 0 on any token/statement hiccup) so it never cry-wolfs the integrity monitor (per #297/#326).--start 2026-01-01 --commit): pending IBKR new-query propagation (fresh Flex queries return transient 1001 for ~15-60 min); a detached retry probe + the weekly timer complete it once IBKR is ready.commissionReport (primary, at fill time) + weekly Flex reconcile (authoritative safety net for late/recovered fills).scripts/run_flex_reconcile.sh, deploy/systemd/polymarket-flex-reconcile.{service,timer}; deploy/deploy_from_github.sh; existing scripts/backfill_trade_audit_from_flex.py (the ground-truth tool); DB: trade_audit_log commission backfill + total_commission recompute; .env Flex token/query refresh.self.position_manager typo before persisting → orphaned → reconciliation blanket-labeled it scheduled_exit:reconciled (RESOLVED Jun 18, 2026; PRODUCTION FIX — crash + honest labels + durable logs)exit 1, multiple times/day) on EVERY instant-exit persist; it corrupted the exit-reason audit trail (a real instant_exit:earnings_miss recorded as a scheduled exit) and left positions orphaned for reconciliation to recover.instant_exit:earnings_miss → SELL order 1122823 filled 9 sh @ $60.02 at 10:52:23 UTC (P&L −$16.83). 13 s later the trader crashed persisting the close, so the DB was never updated → KR left open at the broker-closed state → on restart, reconciliation found it orphaned, recovered the fill from the execution ledger, and (because the exit order ≠ the stop order) blanket-labeled it scheduled_exit:reconciled. NOT a stop ($60.02 was above the $59.41 stop) and NOT scheduled (10:32 schedule)._persist_position_close_with_exit_details referenced self.position_manager, but the orchestrator attribute is self.position_mgr (set in __init__) → AttributeError on every instant-exit persist. Fixed → self.position_mgr. This is THE recurring exit 1.tests/unit/test_close_path_routing.py built its mock with orch.position_manager = _PM() — the SAME wrong name — so the test passed against the buggy code while prod crashed. Test corrected to the real attribute position_mgr (now guards the bug class).order_executor.py imported get_engine from the non-existent src.db.database (correct module: src.db.connection) → exit-execution recording silently failed. Fixed.trading_orchestrator._reconcile_positions_with_ibkr and reconciliation_manager.reconcile_positions_with_ibkr, §E — duplicated logic) blanket-labeled every non-stop recovered exit scheduled_exit:reconciled. Now timing-aware: if the fill is materially (>5 min) BEFORE the position's scheduled_exit_time it is instant_exit:reconciled, else scheduled_exit:reconciled (added scheduled_exit_time to both reconcile queries). §M: the dashboard already renders instant_exit:* → "Instant Exit (…)"; §E: stop-rate analyses key on %stop%, which neither label matches, so stop counting is unaffected.earnings_trader_error.log) but buried under ib_insync INFO spam (362 MB). Quieted ib_insync→WARNING; added sys.excepthook + threading.excepthook that log a FULL flushed traceback for any uncaught main-OR-thread exception before exit; the fatal handler now uses logger.exception (→ both log files, timestamped) instead of raw print_exc.trade_audit_log id=137 + trading_positions (§F — both tables) exit_reason scheduled_exit:reconciled→instant_exit:earnings_miss, exit_time 10:53:14 (reconcile)→10:52:23 UTC (actual fill); a correction note appended. P&L (−$16.83) + exit price ($60.02) were already correct (reconciliation recovered them).__init__ can codify the very bug it claims to test (§B/§G — assert against the REAL attribute).src/earnings/trading_orchestrator.py, src/earnings/order_executor.py, src/earnings/reconciliation_manager.py, run_earnings_trader.py, tests/unit/test_close_path_routing.py; docs FINDINGS.md, CONTEXT.md, AGENT_HANDOFF.md; + KR DB record corrected._get_latest_from_price_snapshots; verified on prod data (KR 0.885→0.92, ACN 0.895→0.905, both now → #202 SKIP); committed + deployed + §G.conviction_at_entry=0.885 → traded as Mid LONG 80-90%. But EVERY source at entry = 0.92: live price_snapshots (stable 0.92 across ±5/15/30min), last_trade 0.90, prob_24h 0.92, and the deployed calc's TIME-WINDOWED path = 0.92. At 0.92 it's a Mid LONG ≥0.90 → should be #202-SKIPPED.method=latest_price_snapshot_robust — the _get_latest_from_price_snapshots path, hit when the orderbook is one-sided (KR's was 0.01/0.99) AND when the entry target is slightly in the future (BMO signal generated ~3:50 PM for the 3:58 PM entry → target_time > now → _get_latest_conviction). That query is ORDER BY time DESC LIMIT :limit_n with NO time window. #325 raised the SHARED PRICE_SNAPSHOT_ROBUST_LIMIT 9→10000 to make the time-windowed path dense — but the "latest" path has no window, so 10000 = the market's ENTIRE history. KR medianed 4021 snapshots spanning 0.50→0.95 → 0.885 (lifetime median, not current). Pre-#325 (LIMIT 9) it was "latest ~9 snapshots" = recent ≈ 0.92, so the bug only appeared once #325 raised the cap._get_latest_from_price_snapshots now constrains to the last LATEST_SNAPSHOT_WINDOW_MINUTES=60 of AVAILABLE snapshots (anchored to max(time) via a CTE, so it also works for stale markets) before the robust median; the dense LIMIT then caps within that recent window. §E: this was the ONLY "latest-N-without-time-window" path (the orderbook-latest query is LIMIT 1; the _get_conviction_from_price_snapshots path is already ±window_minutes time-bound)._get_latest_conviction with the fix — KR 0.885→0.9200 (window [0.92,0.92], n=14), ACN 0.895→0.9050 (window [0.895,0.99], n=30) — both reliable, both ≥0.90 → #202 SKIP. 3 unit tests pass; §G probe post-deploy.src/earnings/conviction_calculator.py; docs FINDINGS.md (this + #325 annotation), CONTEXT.md, AGENT_HANDOFF.md.src/data_tracker/collectors/api_health_checker.py; committed + deployed; service re-run + §G verified green.Integrity Monitor: 1 rule failing — systemd_last_run:polymarket-api-health.service (last run failed, Result=exit-code, status=1), ongoing >1 day.Polymarket Website API: ERROR — "Could not find buildId in page"; Gamma / CLOB / data-api / price-accuracy all HEALTHY. That single ERROR drove overall_status='error' → exit(1) → systemd failed → integrity red."buildId", __NEXT_DATA__, or a /_next/data/<buildId>/finance.json endpoint (all Pages-Router constructs); the finance data is now streamed inline via self.__next_f RSC chunks (the finance-markets + dehydratedState markers ARE still present). So the #293 buildId-extract + secondary-JSON-fetch approach is structurally dead. (Exactly the recurrence the #289.a note predicted: "build-ID changes when Polymarket redeploys… likely needs warn-not-fail or auto-discovery.")EarningsMarketFetcher), yet it escalated to a hard exit(1) service failure. An informational canary must never fail the service when the trading-critical APIs are healthy (cry-wolf, #297).check_polymarket_website_api for the App Router — GET /finance, verify 200 + finance-markets + dehydratedState markers present (shape intact), dropped the dead buildId/_next/data path; (A) every failure path now returns 'warning' not 'error' → exit(0) per #297, so a future Polymarket site change flags the canary WITHOUT failing the service. Removed the now-unused re import (§C).systemctl start polymarket-api-health.service → success → integrity rule green. Trading-critical checks (Gamma/CLOB/data-api/price) unchanged — they still correctly drive error/exit-1._next/data scraper exists only in check_polymarket_website_api; no other check uses it (re import was its only consumer). Supersedes the #293 buildId mechanism (annotated).src/data_tracker/collectors/api_health_checker.py; docs FINDINGS.md (this + #293/#289.a annotations), CONTEXT.md, AGENT_HANDOFF.md.PRICE_SNAPSHOT_ROBUST_LIMIT 9→10000. That was correct for the time-windowed path but broke the no-time-window _get_latest_from_price_snapshots "latest conviction" path (used by live BMO/AMC entries via _get_latest_conviction): with limit 10000 it medianed over a market's WHOLE history instead of recent data, so KR + ACN (Jun 17) were traded at ~0.885/0.895 when their true recent conviction was ≥0.90 (#202 should have skipped). Fixed in #327 by adding a 60-min recent window to that path.src/earnings/conviction_calculator.py; unit tests + before/after + prod §G probe pass; committed + deployed.14:34 UTC = 10:34 ET, the normal AMC time — NOT an anomaly.)_get_conviction_from_price_snapshots / _build_robust_price_snapshot_result): the trader computed conviction = 0.789 vs true ~0.95 due to TWO compounding defects: (1) the filter yes_price > 0.02 AND < 0.99 (3 sites) deleted the genuine high-conviction prints — ADBE's single most-common reading was 0.999 — biasing high-conviction markets DOWN; (2) PRICE_SNAPSHOT_ROBUST_LIMIT = 9 took only 9 of ~1,700 snapshots in ±5 min, so the median was noise-dominated. 0.789 landed in the 70–90% tradeable band → the #202 ≥90% skip never fired → trade entered. The reliability gate didn't catch it either (9-sample min-max range 0.267 < the 0.35 threshold).earnings_dashboard/app.py:2074) shows the latest single yes_price (live Gamma) = 0.975 (correct); the trader uses ConvictionCalculator's buggy fallback. The trade-DECISION path was broken; the display path was always correct. (Display path left as-is — correct in observed cases; optional follow-up to share a robust helper.)actual_beat): Gamma yes_price is the best source (single-read directional accuracy 78.2%, Brier 0.190; ≈ dense median; single-vs-dense diverge only 4%). Gamma is NOT faulty — the bug was the calculator mangling good Gamma data. CLOB last_trade adds nothing on divergences (tied 18–19). Conclusion: trust Gamma, estimate it robustly.conviction_calculator.py): (1) drop the 0.02/0.99 filter → keep all live prices 0 < v < 1 (builder line 548 + the 2 SQL queries); (2) PRICE_SNAPSHOT_ROBUST_LIMIT 9 → 10000 so the median spans the full ±window_minutes window (the Gamma stream runs ~3-6 snapshots/sec, so a small cap collapses to ~±2min and re-introduces noise — see verification); (3) reliability gate switched from min-max RANGE > 0.35 to robust IQR (middle-50% spread) > 0.25 (MAX_PRICE_SNAPSHOT_RANGE → MAX_PRICE_SNAPSHOT_IQR) — a flickering-but-stable market keeps its correct median instead of being skipped, per the operator's constraint to recover the real conviction, not skip flickering markets.tests/unit/test_conviction_robustness.py — 3 pass incl. NEW test_price_snapshot_flicker_tails_stay_reliable (wide tails, IQR-stable → reliable + correct median); (b) before/after on 378 resolved markets: is_reliable=False 24 → 19 (FEWER skips), 0 new tradeable-band skips, conviction-band corrections (incl. SNOW 0.88→0.90); (c) §G prod probe caught a real gap and drove a fix-to-the-fix: at the first deployed value (LIMIT 600) ADBE still computed 0.863 (tradeable) because 600-closest collapsed to ~±2min on the high-frequency stream; raising the cap to 10000 makes the median span the full ±30min window → ADBE 0.912, reliable → #202 SKIP, with the is_reliable count (1) and the ≥0.90 band membership (27/82) identical at 600 vs 10000 (surgical — corrects bimodal cases without broadly shifting outcomes or adding skips).resolution_sniper.py min(conv+0.02,0.99) is an unrelated clamp; archive/* is historical. Refines Finding #197 (the original robust-sampling gate: range→IQR + dense). Interacts with the #283/#319 is_reliable gate (now IQR-based + dense → fires less, on genuinely-uncertain markets only).src/earnings/conviction_calculator.py, tests/unit/test_conviction_robustness.py; docs FINDINGS.md (this), STRATEGY.md §7.35, CONTEXT.md, AGENT_HANDOFF.md. Offline analysis tool: scripts/conviction_source_bakeoff.py.earnings_dashboard/templates/dashboard.html).0-6 / /6, 2,000 / else 2000, rare-day 60, 2.0×, 75% position, Dome, Phase 0/1/2, 25/18/10). Each candidate judged stale-vs-intentional-historical-context; live code/service files verified before changing (e.g. long-weekend multiplier confirmed 0.5 in signal_generator.py:1364; backfill confirmed 6-pass in polymarket-data-backfill.service).6/6→6/8 … 3/6→3/8) + 2 Jinja clamps (>6→6 → >8→8) + 2 labels (/6→/8) + priority-column {{p_score}}/6→/8 + 3 comments (Finding #313). Volume gate $2,000 → $1,000 (Min-Volume tooltip + 3 Jinja fallbacks else 2000→1000, Finding #311). Stops legend now notes STOP-LIMIT (#202/#179). AMC weekend-exit comment 3:58 PM → 3:57 PM.long_weekend_size_multiplier=0.5). 2FA action "VNC manual re-auth" → "Approve 2FA push on IBKR mobile app; VNC fallback" (corrects the May-27 inference fix).gate_quarterly_$2000peak variant names, "base 0-6 → 0-8" descriptions (correct), Q3-2025-labelled archive references.<script> blocks pass node --check; jinja2 parses; 0 mojibake; global residual sweep confirms 0 stale /6 priority, 0 else 2000, 0 75% position, 0 Phase 0/1/2, 0 live-Dome (the 6 remaining 0-6 are all the correct "base 0-6" description; remaining $2,000/rare are migration-history or the uniform-cap fix).earnings_dashboard/templates/dashboard.html only. docs/FINDINGS.md (this entry), docs/CONTEXT.md, docs/AGENT_HANDOFF.md §11. NO Python/strategy/trading change./8; Min-Volume tooltip $1,000; System exposure "uniform"; no /6 priority or 75% position strings.dashboard.html's static HTML; the Python that generates the dashboard and all project docs were skipped — a NO-SHORTCUTS violation the operator caught via a screenshot still showing the 0-6 priority system).dashboard.html static HTML only. #322.b = the complete render surface + all documentation: scripts/strategy_performance_analysis.py (4,815 LoC — generates the Strategy tab JSON), earnings_dashboard/app.py (6,252 LoC — SSR + API), dashboard.html (re-verified), and all 26 docs/*.md (~27.6k LoC). Total ~47k lines, every line read (not sampled).strategy_performance_analysis.py: universe-gate $2K→$1K (SQL >=2000→>=1000 so the count reflects the real gate; volume-sweep gains a $1000 point so is_bot_gate matches; all display labels + docstrings; internal JSON keys kept w/ clarifying comment to avoid cross-interface JS breakage); P&L snapshots $167/116 → +$212/~117; cockpit capital $2,450→$2,500 + sizing model 10%/$245 → 22%/$550 mid-tier (#211); exposure narrative → vestigial MAX_TOTAL_EXPOSURE_PCT (#315) + uniform-140% (#317); PR2-refinement banner → RESOLVED (was "🚨 IN-FLIGHT / NO production change until 2.f"); retracted-variant next_actions + action-queue implemented_date/findings metadata; RuleB verdict → "SHIPPED dry-run #305"; stop what-if "Current" row → 3/4/5%; actual_beat 73%→~95-99.8% (#321); BMO/AMC timing expanded to full entry/exit windows.app.py: priority labels 0-6 → 0-8 (removed unreachable ps>=11 bucket); removed dead watchdog_active SSR (#290); max_trades 100→150; version v2.0→v2.1.0; reload=True→False; priority_label UNKNOWN→SKIP; /api/position-sizing flat-10% → 30/22/15% by mcap (#211); capital label $2.7K→~$2.5K; prices backfillable→False (decommissioned #289).dashboard.html: universe $2K→$1K labels (incl. the 🚪 Pass …gate widget); PR2 banner re-styled green/RESOLVED.STRATEGY.md (§3.2 tiered→uniform-140%, §4.3, §8 STOP→STP-LMT + capital, §10.6 +data-api, header), STRATEGY_PLAYBOOK.md (bundle CLOSED, RuleB shipped, PR2-5 done, ~117/+$212, 9 sessions), STRATEGY_ANALYSIS_ARCHITECTURE.md + PHASE_3_SIMULATOR_DESIGN.md + STRATEGY_AUDIT_v3.md (Phase 3 PR1-5 + Phase 4 COMPLETE; gates/mermaid/roadmaps marked shipped; 75%-weekend conflict resolved→50%), OPERATIONS.md + DATA_ARCHITECTURE.md + STATE_MACHINES.md (service/timer topology incl. strategy-analysis + api-health + backup 05:00; equity-bars 47k/21→1.9M/419; integrity 34/37/40→~47; watchdog→integrity-monitor; stops 2/3/4→3/4/5; sizing 25/18/10→30/22/15; exposure 70%→140%; Dome diagram→data-api), DATA_INTEGRITY.md + API_REFERENCE.md + DATABASE_DIRECTIVES.md ($2k→$1k; rules→~47; coverage→~95-99.8%; cron→timer; log paths), IBKR_SETUP.md + IBKR_CONTINUITY.md (Xvfb :99/x11vnc, IBC gatewaystart.sh -inline not ibc_smart_restart.sh, inline credentials redacted), HOLISTIC_REVIEW_PROMPT.md + PAPER_TRADING_TEST_PLAN.md + BMO_AMC_TIMING_DEEP_DIVE.md + EDGE_CASES.md (capital/stops/sizing/conviction/gate), FINANCIAL_PROJECTIONS_REVISED_87_TRADES.md (SUPERSEDED banner). Clean/no-change: HYPERLIQUID_API.md, OPS_TIMING_VERIFICATION.md, POSTGRES_OPERATIONS.md, DATABASE_AUDIT_2026_04_27.md (frozen snapshot), AUDIT_FIX_QUEUE.md + TESTING_FINDINGS_PENDING.md (correct redirect stubs).py_compile clean (both Python files); jinja2.parse clean; doc diffs reviewed (no history deleted; no fabricated numbers).scripts/strategy_performance_analysis.py, earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html, 18 × docs/*.md, plus this docs/FINDINGS.md entry + docs/CONTEXT.md + docs/AGENT_HANDOFF.md §11. NO strategy/signal/sizing/order-path change./8).get_dashboard_state() previously hardcoded risk_level/active_breakers/consecutive_losses/testing_prod_paused/opportunity_count (never populated). Wired via the trader's logs/ibkr_snapshot.json (single source of truth, no breaker-logic duplication per §M): trading_orchestrator._build_ibkr_snapshot() attaches risk_state = risk_manager.get_risk_state().to_dict() (+ daily_opportunity_count/opportunity_level) near the top so it is present even when IBKR is disconnected; app.get_dashboard_state() reads it. opportunity_level is observability-only post-#317. READ-ONLY serialization of existing state — no trading behavior change.check_dependencies() now health-probes data-api.polymarket.com/trades (live/closed trades source that replaced Dome, #289); added the System-tab "Polymarket data-api" row.MAX_POSITION_SIZE_PCT — confirmed VESTIGIAL / no live consumer (grep: only config def/range + tests + docs; production Large already sizes 30% with NO clamp). Re-aligned 0.15 → 0.30 to match the operative Large tier (POSITION_SIZE_PCT_LARGE, #211) + documented vestigial, mirroring #315's MAX_TOTAL_EXPOSURE_PCT treatment. Zero trading impact (unwired).py_compile clean; test_config.py (now asserts 0.30) + test_risk_manager.py = 27 passed; 3 test_dashboard_connectivity_state.py tests pass in isolation (a pre-existing in-file cache-pollution flake on test_dashboard_state_requests_passive_gateway_status is unrelated — it fails on the stashed baseline too).systemctl restart earnings-trader (Sunday, markets closed) the snapshot now carries risk_state (risk_level=normal, active_breakers=[], consecutive_losses=0, testing_prod_paused=False); trader reconnected clean (connected=True, net_liq=$27,548.36, no reconnect error).src/earnings/trading_orchestrator.py, earnings_dashboard/app.py, earnings_dashboard/templates/dashboard.html, src/earnings/config.py, tests/unit/test_config.py; lockstep docs/FINDINGS.md (this entry) + docs/CONTEXT.md + docs/AGENT_HANDOFF.md. NO signal/sizing/order-path change.PROJECTIONS (frozen) → Epoch 2 87-trade revised (frozen) → Epoch 3 current ~117-trade live + simulator — side-by-side per metric, plus net-new metrics that didn't exist in the old epochs (5-KPI engine, capacity-aware lift, §K outlier dependence, per-tier economics), plus a hybrid forward projection showing the live and simulator anchors side-by-side (never blended).PROJECTIONS_87TRADE constant (Epoch 2 frozen) + build_roi_rebaseline(clean_metrics, strategy_kpis, capacity, roi_estimate) in strategy_performance_analysis.py (reuses the already-computed KPI + capacity-sweep results — the expensive sweep is NOT recomputed); wired into run_analysis() report as roi_rebaseline. NEW dashboard Strategy-tab section "📊 ROI Re-baseline — 3 epochs" + renderRoiRebaseline() JS (caution banner → ladder → hybrid cards → net-new). NEW doc docs/FINANCIAL_PROJECTIONS_2026_05.md (the canonical write-up; old FINANCIAL_PROJECTIONS_REVISED_87_TRADES.md stays frozen as Epoch 2). Old pre-launch PROJECTIONS constant unchanged (frozen Epoch 1).py_compile clean; isolated unit-test of build_roi_rebaseline (live +25.6% / sim +37.7% / outlier alarm render correct); jinja2.parse + node --check (3 JS blocks) clean; post-deploy report regen + dashboard §G.scripts/strategy_performance_analysis.py, earnings_dashboard/templates/dashboard.html, docs/FINANCIAL_PROJECTIONS_2026_05.md; lockstep docs/FINDINGS.md + docs/CONTEXT.md + docs/AGENT_HANDOFF.md. NO trading/strategy change.RULEB_ENABLED default was False and prod .env had no RULEB_*, so the main-loop hook (trading_orchestrator.py:5389 if Config.RULEB_ENABLED and active_positions) never fired — zero RuleB log lines in 7 days. The Phase-2 "flip to active after reviewing dry-run logs" gate could not have been satisfied (no logs existed). Operator chose (via AskQuestion) dry-run-first (not straight-to-active) + tracked config default (not .env).src/earnings/config.py RULEB_ENABLED default False → True; RULEB_MODE stays dry_run. In dry-run mode _ruleb_fire_cancel/_ruleb_fire_rearm only LOG the cancel/rearm decisions (trading_orchestrator.py:4637/4687 if mode == "dry_run") — NO IBKR side effects, NO live stop-order changes. So this starts the observation the plan always intended; it is NOT yet the Phase-2 active flip.earnings_release_dt (graceful SKIP_NO_EARNINGS_DT, position_manager.py:1155), clock-skew/order-state on the cancel→rearm path. Phase 2 (active) is a SEPARATE operator-confirmed ship after dry-run logs are reviewed over a few earnings windows.py_compile clean; test_config.py + test_ruleb_window_gate.py + phase3_simulator/test_rules.py = 68 passed; effective config confirmed RULEB_ENABLED=True, RULEB_MODE=dry_run. Stale RULEB_ENABLED=false in prod comments in test_rules.py updated.git pull + systemctl restart earnings-trader (Sunday, markets closed). §G: trader reconnects clean; RuleB now executes per-cycle on open positions in dry-run (logs only). At the current signal drought (~0 open earnings positions) it will log when a position is next held through its ±90min window.src/earnings/config.py, tests/unit/phase3_simulator/test_rules.py; lockstep docs/FINDINGS.md + docs/CONTEXT.md + docs/AGENT_HANDOFF.md. NO live trade-execution change (dry-run logs only). Next: operator reviews dry-run logs → Phase 2 (RULEB_MODE=active) is a separate confirmed ship.active.test_top_5_decisions.py — caused by #322.b's deliberate cockpit-scale change (real_position_usd 245→550, scale 0.0245→0.055). Tests still asserted the old values. (Process gap: #322.b didn't run the full suite — only targeted tests.) Fixed: updated scale_factor 0.0245→0.055, real-lift 0.735→1.65 / 1.84→4.12, worsening-score −149.265→−148.35, + comments/docstring.test_position_sizing_2x_rollout.py::test_opportunity_exposure_limits_match_140pct_target — asserted rare-day 0.60, but #317 made it uniform 1.40 (shipped, correct). Stale test never updated when #317 shipped. Fixed → expects 1.40 uniform.test_dashboard_connectivity_state.py::test_dashboard_state_requests_passive_gateway_status — pre-existing cache-pollution flake (get_dashboard_state memoizes into _state_cache; an earlier test's cached state was returned, so gateway_status was never re-invoked). Fixed with an autouse fixture resetting the state cache per-test.tests/unit/test_top_5_decisions.py, tests/unit/test_position_sizing_2x_rollout.py, tests/unit/test_dashboard_connectivity_state.py; lockstep docs. NO src/ or prod behavior change (prod config/code already verified correct above).RuleB[dry_run]); if clean, decide the Phase-2 RULEB_MODE=active flip (#322.e). Tracked in the agent todo list; a true scheduled Cursor Automation must be created from the Agents Window.polymarket_opportunity_log counterfactual P&L: 1,288 tradeable rows priced, 497 price_data_unavailable — of which 472 were PAST real gaps (82 symbols; Apr 249 + May 223), only 25 future (June, legitimately not-yet-priceable). The equity_bars_1m data for the 472 already existed → the original compute_counterfactual_pnl.py runs had failed transiently (rate-limit / no key / ran before bars were cached) and marked the rows done (counterfactual_computed_at set) so they were never retried.earnings_markets outcomes/returns: ~48 past markets missing actual_beat/stock_return_* (34 May + ~14 scattered), all with tickers.equity_bars_1m: healthy (1.9M bars / 419 symbols / Sep 2025–May 2026).counterfactual_computed_at on the 472 past price_data_unavailable rows + re-ran compute_counterfactual_pnl.py → counterfactual P&L 1,288 → 1,755 priced (467 closed; 30 remain none = 25 future + 5 genuinely-unfetchable/delisted tickers).outcome_resolver.py --force --lookback-days 300 → earnings_markets.stock_return_* now 959/961 past (99.8%), actual_beat 948/961 (13 very-recent May earnings awaiting Yahoo/Finnhub EPS publication — not a Polygon gap)._CF_SQL loader is filter-limited by design — of 1,769 priced tradeable rows, volume≥$1k drops 713, then market_cap/conviction/NOT (conv≥0.90)/small-cap-skips reduce to 590 raw → 441 after de-dup vs live trades. So completeness improved accuracy/power without changing universe size or conclusions; the binding constraint is the legitimate production gates, not data availability.analyze_universe_view.py") was inaccurate — analyze_universe_hit_rate() is called in run_analysis() and regenerates the Universe View on every report build from the (now-complete) earnings_markets.stock_return_*. Relabeled in dashboard.html to reflect that.compute_counterfactual_pnl.py + outcome_resolver.py; all prod-DB data writes). earnings_dashboard/templates/dashboard.html (Universe View label correction). docs/FINDINGS.md (this entry), docs/CONTEXT.md, docs/AGENT_HANDOFF.md §11.price_data_unavailable gaps (rows are never retried once counterfactual_computed_at is set). outcome_resolver.py Pass 3 (the daily timer) now self-heals: a new _opp_log_select_sql(retry_unavailable=True) (default on; --no-retry-unavailable to disable) ALSO re-selects past price_data_unavailable_v1 rows whose equity_bars_1m now cover the entry day, bounded by the EXISTS-bars gate (never retries genuinely-unfetchable/delisted tickers) + a 3-day re-attempt cooldown; never-computed rows are prioritised first. 2 new unit tests (test_outcome_resolver.py; 39/39 PASS). So a transient failure now reprices automatically once the bars are cached, instead of leaving a permanent hole in the simulator universe.STRATEGY_ANALYSIS_ARCHITECTURE.md §0.7 / §0.11.2), which was gated on the Phase 3 simulator existing. Phase 3 (PR 1-5) completed today (#310-#319) → un-gated. Phase 4 partial (dead-UI removal + label disambiguation + phase banner) had shipped Apr 26 as Finding #248.dashboard.html). Root motivation (§0.0): strategy analysis was fragmented across 23 sections answering different questions on different populations (live-traded / universe / Q3-2025-archive), none canonical — "I'm not sure if Universe View is a trade simulator — it isn't, but neither is Finding-A, and there's no single canonical engine." Phase 3's simulator is now that canonical engine (triangulates live + counterfactual universe + capacity-aware).earnings_dashboard/templates/dashboard.html):#unified-simulator-section) — one canonical header framing the engine + its arms: live stop-loss (Finding-A), capacity-aware (Phase 3 Capacity-Aware Sweeps + Gate Retune), selection-rule research (regime/depth), universe signal-quality (Universe View). Each arm relabeled with its population so "which number do I trust?" is unambiguous.analyze_universe_view.py; current-universe P&L is already covered live by the Capacity-Aware arm).#phase3-selection-research-section) for PR 4 regime (#318) + PR 5 depth (#319).strategy_backtest_constrained.py, strategy_backtest_bmo_amc.py) + their JSON retained for reference; renderBacktestTable / renderCohortTimingTable dispatcher calls removed.renderScorecard dispatcher call removed.renderScorecard / renderBacktestTable / renderCohortTimingTable JS function defs are now orphaned (never called — verified by grep; the only references to the removed DOM ids scorecard-* / backtest-table-body / cohort-timing-* are inside these dead defs, so no runtime error). Safe to delete in a follow-up cleanup; left to bound this diff. → Tail CLOSED by Finding #387 (Aug 21, 2026): all 3 function bodies + their 7 dead DOM-id lookups deleted in the whole-dashboard census clean-up.<script> blocks (227,588 + 17,611 chars) pass node --check.jinja2.Environment().parse() clean (won't 500).<div>/</div> change is balanced (HEAD −1 / working −1; the −1 is a pre-existing </div> inside a JS string, unchanged by this commit).earnings_dashboard/templates/dashboard.html (all changes). docs/STRATEGY_ANALYSIS_ARCHITECTURE.md §0.11 (Phase 4 remainder marked shipped). docs/FINDINGS.md (this entry). docs/CONTEXT.md, docs/AGENT_HANDOFF.md §11. NO Python / strategy / trading change.git pull + systemctl restart polymarket-dashboard — load http://[hidden]:8080/#strategy: (1) browser console 0 errors; (2) 🧪 Unified Simulator umbrella + selection-research cards present; (3) Backtest / BMO-AMC / Live Performance Snapshot sections ABSENT; (4) curl -s http://127.0.0.1:8080/ | grep -c "Unified Simulator" ≥1 and grep -c "Backtest Scenario Comparison" = 0.conviction_calculator / signal_generator depth check unchanged).is_reliable depth/spread gate. Closes Finding #283 (depth-gate v2). NO production behavior change. Completes PR 5 → the Phase 3 simulator (design doc §12, PR 1-5) is now COMPLETE.conviction_calculator._calculate_depth_quality + is_reliable = depth_quality >= 0.5, hard skip at signal_generator.py:~1710/~2153, skip_reason='unreliable_orderbook') — does it net save or cost us, at a proper sample size?scripts/phase3_simulator/depth_gate_variants.py — pure-Python decomposition (decompose splits the score into min-depth / spread / asymmetry factors + dominant; thresholds imported from ConvictionCalculator per memory §M) + variant predicates (remove_all / carveout_real_trade [#283 hyp-#3: drop the gate only when conviction is real-trade-derived, keep for midpoint] / spread_gate_only / depth_gate_only) + is_real_trade_conviction.scripts/phase3_simulator/replay_depth_gate_v2.py — widened reconstruction: replays the REAL ConvictionCalculator.get_conviction_at_entry_time point-in-time over the full 915-market universe (no hand-approximation), finds BINDING depth kills (gate fired AND market clears volume ≥$1k + the #202 ≥0.90 / small-cap-≥0.80 + #207 small-cap-SHORT skips — depth fires BEFORE the volume gate so a low-volume kill isn't binding), recovers the raw orderbook from orderbook_snapshots + reliability_reason to attribute the kill cause, and prices counterfactual equity P&L via the production run_counterfactual_for_row (Polygon 1m, mcap-tiered stop). Per-market (one decision/market — no repeat-eval inflation).| count | counterfactual P&L (real $, per-tier scaled) | |
|---|---|---|
| losers BLOCKED (gate saved us) | 13 | −$142.56 avoided |
| winners KILLED (gate cost us) | 9 | +$165.09 |
| net effect of removing the gate | 22 | +$22.54 over ~7 months (≈ +$1/trade, noise) |
snapshot_reliability (hollow 1¢/99¢ book → conviction fell back to price-snapshots → those were too thin/volatile: insufficient_samples: 1<3 or unstable_window: range>0.35 = implied prob swinging 40–90 pts), 1 pure spread (ACM), 3 no-orderbook. So it's a low-information-signal filter, screening markets where we genuinely lack a trustworthy read.remove_all +$22.54; carveout_real_trade +$22.78 (≈ identical — 21/22 are price-snapshot-derived so the midpoint carve-out is nearly moot at scale); spread/depth-only variants not meaningful (kills aren't spread-vs-depth).src/earnings/conviction_calculator.py + signal_generator.py depth check UNCHANGED.scripts/phase3_simulator/depth_gate_variants.py (NEW), scripts/phase3_simulator/replay_depth_gate_v2.py (NEW).tests/unit/phase3_simulator/test_depth_gate_variants.py (NEW, 20 tests).scripts/strategy_performance_analysis.py:build_action_queue (depth-gate-v2 row DEFERRED-POST-PHASE-3 → KEPT).docs/FINDINGS.md (this entry + Finding #283 status → RESOLVED/KEEP), docs/CONTEXT.md, docs/AGENT_HANDOFF.md §11, docs/STRATEGY_PLAYBOOK.md (banner + §7 close).tests/unit/phase3_simulator/ PASS (+20) + 22 risk_manager + 5 config = 395 PASS on Python 3.11/3.12. Re-runnable on prod: python3 scripts/phase3_simulator/replay_depth_gate_v2.py (read-only; result JSON regenerated-per-run, not committed). NO integrity change, NO migration, NO deploy (offline analysis; trader not restarted).compound_v1 classifier are unchanged).compound_v1. The objective for Rule #1 is ROI, and the faster detectors add no robust ROI; the one positive signal (below_ema20 +$70 on live-only) does NOT survive the capacity-aware cross-check. Closes Finding #278 (regime detection v2) as ANALYSIS-DONE / KEEP. NO production behavior change. Advances PR 4 of the Phase 3 simulator (design doc §12).scripts/phase3_simulator/regime_classifiers.py — pure-Python (no pandas/yfinance/DB) point-in-time SpyDailyHistory (MA20/50/200 + EMA20 + 60d-high, prior-completed-bar lookup) + 6 classifiers (compound_v1 baseline mirroring market_regime_provider._classify; compound_or_dd5 = #278 option B; below_ema20; below_ma50; dd_3pct; compound_symmetric = adds close<MA200 to fix the #278 false-positive asymmetry) + build_regime_variants (wraps each into a SizingVariant whose only difference from production is the Rule #1 skip predicate — sizing/gate held at production) + diagnostics.scripts/phase3_simulator/replay_regime_v2.py — fetches SPY daily bars (yfinance), re-classifies every signal point-in-time, re-applies Rule #1, recomputes P&L via the existing run_sizing_sweep engine + apply_sizing_gauntlet (candidate-vs-compound_v1 delta over the union of admits; sizing held at production so credited == raw realized P&L). Runs BOTH universes; emits phase3_sim_regime.v1 JSON. Live cap 1.40 (#315/#317 — NOT 2.0).below_ema20 labels ~40% of LONG opportunities "downtrend" vs compound_v1's ~17%) it ALSO skips WINNERS whenever SPY merely dips below its 20-day average (common even in bull markets — whipsaw). Net P&L = (losers correctly cut) − (winners whipsawed out). On the actually-traded set those happened to net out positive; across the full opportunity set they cancel.| classifier | LIVE-ONLY (primary) Δ vs compound_v1 | CAPACITY-AWARE 1.40 (cross-check) Δ |
|---|---|---|
compound_or_dd5 (#278 rec.) |
+$0.00 PASS (inert) | +$13.58 FAIL-EX-TOP-2 |
below_ema20 (fastest) |
+$70.31 (+$3.5/wk) PASS (ex-top-2 +$136/100 strengthens) | −$17.02 FAIL-EX-TOP-2 (headline −$5.6/100, ex-top-2 flips +$35) |
below_ma50 |
+$1.01 PASS (inert) | −$24.90 FAIL-EX-TOP-2 |
dd_3pct |
−$40.39 FAIL-SPILLOVER | −$82.05 FAIL-EX-TOP-2 |
compound_symmetric |
−$54.47 FAIL-SPILLOVER (skips fewer → keeps losers) | +$0.00 PASS (inert) |
below_ema20 is the only candidate with a real positive signal, and only on the live-traded set (+$70.31 total, maxDD −$157.66→−$114.92, 5p worst-week −$68.47→−$31.51, WR 38.2%→41.0%, downtrend-LONG cell n=44 / −$159 / ex-top-2 −$277 robustly negative). But across the full 568-signal universe at the live 1.40 cap the gain evaporates to −$17.02 and is itself outlier-driven (FAIL-EX-TOP-2: headline −$5.6/100, ex-top-2 +$35.2/100; maxDD identical −$233.20). The live-only win is selection luck concentrated in the 127 actually-traded outcomes — it does NOT generalize. The one thing that survives both universes is a better worst-week tail (5p −$98.60→−$57.08) at ~breakeven P&L — a downside/tail trade-off, not a money-maker. Operator objective for Rule #1 is ROI → KEEP.compound_v1 reproduces the LIVE recorded regime_at_entry on 95.3% of 106 LONG trades (PIT flags 17 downtrends, all a subset of the 22 recorded; the 5-trade gap is the live intraday 3:58 PM read vs the prior completed close — a conservative, look-ahead-free difference per design §11). Confirms the replay is faithful; the n=17 PIT baseline cell vs Finding #304's n=22 recorded cell is consistent.apply_sizing_gauntlet (union-delta over admitted, 5 checks). Full-population spillover is the primary decider (it counts both losers-removed and winners-whipsawed). The verdict-divergence between universes (PASS live-only vs FAIL capacity-aware for below_ema20) is exactly why the operator chose BOTH universes — the same "don't look in isolation" lesson as Finding #315 (where the capacity-aware re-run at the live cap reversed a same-day sizing conclusion).compound_v1 (no classifier swap). No regime classifier v2 is robustly ship-eligible across both universes; faster detection buys no robust ROI and risks whipsaw (giving up profitable LONGs). Separately, #278 re-test trigger #2 (Rule #1 needs ≥30 live downtrend firings before a classifier swap) is unmet — Rule #1 has had ~0 firings since May 28 in the current low-volume regime. Closes Finding #278 / the regime-detection-v2 Action Queue row as ANALYSIS-DONE.scripts/phase3_simulator/regime_classifiers.py (NEW), scripts/phase3_simulator/replay_regime_v2.py (NEW).tests/unit/phase3_simulator/test_regime_classifiers.py (NEW, 18 tests), tests/unit/phase3_simulator/test_replay_regime_v2.py (NEW, 4 tests).scripts/strategy_performance_analysis.py:build_action_queue (regime-detection-v2 row status DEFERRED-POST-PHASE-3 → KEPT; live_evidence + next_action refreshed with the #318 result).docs/FINDINGS.md (this entry + Finding #278 status → RESOLVED/KEEP), docs/STRATEGY_PLAYBOOK.md (banner + §7 deferred-bundle close), docs/AGENT_HANDOFF.md §11, docs/CONTEXT.md.src/earnings/market_regime_provider.py UNCHANGED (no swap shipped).tests/unit/phase3_simulator/ PASS (+22 new) + 22/22 test_risk_manager.py + 5/5 test_config.py = 375 PASS on Python 3.11/3.12. The PR-4 sweep is re-runnable on prod: python3 scripts/phase3_simulator/replay_regime_v2.py (read-only; result JSON regenerated-per-run, not committed). NO integrity-monitor rule change, NO migration, NO deploy (offline analysis; trader not restarted).RiskManager.OPPORTUNITY_EXPOSURE_LIMITS to a single uniform cap = MAX_DAILY_EXPOSURE (140%). The rare tier was MAX_DAILY_EXPOSURE × 3/7 (60%); now all of rare/normal/busy = 140%. Removed the _RARE_EXPOSURE_RATIO constant; the 3 buckets are kept only so set_daily_opportunity_count still labels days rare/normal/busy for observability logging (exposure value identical for all). get_adjusted_max_exposure docstring updated (was the stale §10.3 "30%/50-70%/70%").TestUniformExposureCapFinding317 (asserts all 3 tiers map to MAX_DAILY_EXPOSURE); 22/22 test_risk_manager.py PASS local Python 3.11.StrReplace tool corrupted risk_manager.py's UTF-8 encoding (re-wrote existing em-dashes/arrows as cp1252 bytes — surfaced when the new test first imported the module). Caught via import failure, reverted via git checkout, and re-applied the patch with a UTF-8-safe Python script + ASCII-only replacement text. Verified clean UTF-8 before commit. No corruption shipped.get_adjusted_max_exposure returns 1.40 for opportunity counts 1/3/6; pytest test_risk_manager.py 22/22.src/earnings/risk_manager.py (OPPORTUNITY_EXPOSURE_LIMITS uniform + docstring) + tests/unit/test_risk_manager.py (+1 test) + docs/FINDINGS.md (this entry) + docs/CONTEXT.md + docs/AGENT_HANDOFF.md + docs/STRATEGY_PLAYBOOK.md.#285 Phase B exposure-cap sub-item of the deferred sizing-priority-v2 bundle).OPPORTUNITY_EXPOSURE_LIMITS tiering (rare 1-2 opps → 60%, normal 3-5 → 140%, busy 6+ → 140%). Loosening the rare-day cap does not help and fails the gauntlet.daily_opportunity_count), on the live-cap (1.40) universe.scripts/phase3_simulator/replay_exposure_tiering.py — a PER-DAY ENTRY-BUDGET model faithful to the #285 greedy-fill (distinct from capital_simulator's concurrent-gross model): groups signals by ET entry-day, classifies the day by opportunity count → tier budget, admits top-priority-first until the day's cumulative new exposure hits the budget. Emits a CapacitySimResult so the 5-check gauntlet (apply_sizing_gauntlet) works unchanged._tier_of boundaries + per-day budget bind/no-bind + uniform-loosening admits more + day distribution + CapacitySimResult shape for gauntlet reuse). 326/326 phase3_simulator PASS local Python 3.11 + prod Python 3.12 (was 317; +9).scripts/phase3_simulator/replay_exposure_tiering.py (NEW, commit 9df31fa) + tests/unit/phase3_simulator/test_replay_exposure_tiering.py (NEW, +9) + scripts/phase3_exposure_tiering_results.json (regen on prod, not tracked) + docs (this entry + CONTEXT + AGENT_HANDOFF + PLAYBOOK §7 #285 sub-item closed).MAX_TOTAL_EXPOSURE_PCT has NO live consumer → Finding #313's capital-cap raise is inert in production (FLAGGED May 31, 2026; capital-relevant; awaiting operator decision; NO production change made)exposure_cap=2.0). NOT an active capital-loss risk (the bot is more conservative than #313 claims, not less). Surfaced via the Priority 4 docs audit.MAX_TOTAL_EXPOSURE_PCT returns only: src/earnings/config.py:138 (definition, =2.00), config.py:413 (validation bound), tests/unit/test_config.py + tests/conftest.py (value assertions), and the 4 docs that #313 updated (FINDINGS/CONTEXT/AGENT_HANDOFF/STRATEGY_PLAYBOOK). There is NO consumer in the live trading path (risk_manager.py, capital_manager.py, order_executor.py, signal_generator.py — none read it).RiskManager.get_adjusted_max_exposure() (risk_manager.py:587-605) → OPPORTUNITY_EXPOSURE_LIMITS (risk_manager.py:274-278) → MAX_DAILY_EXPOSURE (config.py:153, = 1.40, UNCHANGED by #313). The per-day caps are: rare (1-2 opps) 60%, normal (3-5) 140%, busy (6+) 140%. There is no code path that enforces a 200% / MAX_TOTAL_EXPOSURE_PCT ceiling.MAX_TOTAL_EXPOSURE_PCT 1.40→2.00 → $5,000 max gross / +43% capacity" is inert in live trading. The effective live gross cap remains ~$3,500 (1.40 × $2,500) on normal/busy days, $1,500 (0.60×) on rare days. (The #313 priority restructure + the $1k gate #311 DID take effect — only the cap-raise is unwired.)exposure_cap=2.0. They therefore OVERSTATE live capacity; in reality capacity is more binding (the "binds at 99% of $5,000" in #314.b would bind sooner at the live $3,500). The directional conclusions (keep $1k gate; keep 30/22/15; hybrid reverts #311; 2 SKIP rules + combined gauntlet-PASS) are expected to hold or strengthen, but the absolute per-week $ figures would shift if re-run at 1.40.EARNINGS_TRADING_STRATEGY.md §11.2 + §12.1; rare/normal/busy 30%/50-70%/70% was a heuristic). Finding #191 (Feb 23, 2026) "2x rollout" doubled the profile → set BOTH MAX_DAILY_EXPOSURE=1.40 AND MAX_TOTAL_EXPOSURE_PCT=1.40 (in sync) + tiers rare 0.60 / normal·busy 1.40. Finding #313 raised only MAX_TOTAL_EXPOSURE_PCT→2.00, desyncing them; that var has no consumer so it did nothing.Empirical re-validation (NEW scripts/phase3_simulator/replay_exposure_cap_sweep.py, run on prod, 568 signals / 36 wk): lift-vs-cap curve for production_post_313:
| cap | max gross | admits | cap-skips | total $ | $/wk | maxDD |
|---|---|---|---|---|---|---|
| 0.60 (rare) | $1,500 | 246 | 221 | +$353.20 | +$9.81 | −$197.52 |
| 1.00 | $2,500 | 325 | 142 | +$658.24 | +$18.28 (peak) | −$207.26 |
| 1.40 (LIVE / #191) | $3,500 | 370 | 97 | +$561.82 | +$15.61 | −$233.20 |
| 1.70 | $4,250 | 394 | 73 | +$578.42 | +$16.07 | −$277.28 |
| 2.00 (#313 intent) | $5,000 | 409 | 58 | +$607.06 | +$16.86 | −$274.61 |
| 2.50 | $6,250 | 427 | 40 | +$554.03 | +$15.39 | −$291.67 |
| 3.00 | $7,500 | 438 | 29 | +$553.07 | +$15.36 | −$291.67 |
Key result: lift is NON-monotonic — it peaks near 1.0× (no leverage) and is flat-to-declining above 1.4×. Higher caps admit more trades but they are lower-EV and deepen drawdown. The 1.40 → 2.00 raise (#313's intent) buys only +$1.25/wk (+$45 total) for −$41 worse maxDD — marginal. So even if MAX_TOTAL_EXPOSURE_PCT were wired, it would NOT meaningfully help.
MAX_DAILY_EXPOSURE=1.40 cap (do NOT wire MAX_TOTAL_EXPOSURE_PCT=2.0 — it's not worth +$1.25/wk for deeper DD). Re-scope Finding #313's "+43% capacity" cap-raise claim as inert-and-not-worth-wiring (the priority restructure + $1k gate parts of #313 DID take effect and ARE validated; only the cap-raise was both unwired and unhelpful). Optionally drop the now-misleading MAX_TOTAL_EXPOSURE_PCT var or align it to 1.40 to remove the desync (cosmetic; needs operator approval as it touches config).MAX_TOTAL_EXPOSURE_PCT var.MAX_TOTAL_EXPOSURE_PCT as a gross ceiling anyway for forward Reg-T headroom (real-money change) — but the data says marginal.docs/FINDINGS.md (this entry) + scripts/phase3_simulator/replay_exposure_cap_sweep.py (NEW, commit 530ff93) + tests/unit/phase3_simulator/test_replay_exposure_cap_sweep.py (NEW, +4 tests) + audit doc updates. NO production code change.exposure_cap=1.40 per Finding #315): skip Large+LONG+≥$10k +$2.53/wk PASS (was +$2.04); skip Mid+LONG+$1k-$2k +$0.06/wk PASS (was +$1.12 — capacity-freeing benefit shrinks at the tighter cap, now marginal); skip Large+SHORT+$2k-$5k FAIL (n=3); skip Large+LONG+$1k-$2k −$0.82/wk FAIL (cell positive); combined skip-all-4 +$4.28/wk PASS (was +$5.42). Conclusions hold (skip-≥$10k + combined remain the robust candidates; skip-Mid weakens to ~0).scripts/phase3_simulator/replay_skip_rules.py (~310 lines) — 4 named cell predicates + 4 individual SKIP variants + combined + baseline; a real-scaled per-cell diagnostic; reuses replay_sizing_variants.run_sizing_sweep (capacity-aware sweep + 5-check gauntlet per variant vs baseline). SKIP-rule gauntlet delta = variant-credited − baseline-credited over the union of admitted trades (removing a net-loser → positive robust delta; removing winners → FAIL-SPILLOVER).| Cell | Hypothetical (Finding #313) | Real-scaled |
|---|---|---|
| Large + LONG[0.80,0.90) + vol≥$10k | n=16, 25% WR, −$124 avg | n=15, 26.7% WR, −$6.00 avg, total −$90.03 |
| Large + SHORT + vol[$2k,$5k) | n=12, 0% WR | n=3, 0% WR, −$10.74 avg, total −$32.23 |
| Mid + LONG[0.80,0.90) + vol[$1k,$2k) | n=62, 19% WR, −$27 avg | n=64, 20.3% WR, −$0.90 avg, total −$57.32 |
| Large + LONG[0.80,0.90) + vol[$1k,$2k) | n=20, 10% WR, −$20 avg | n=22, 18.2% WR, +$0.93 avg (POSITIVE), total +$20.39 |
Two cells materially changed under real scaling (validating the operator's correction): Large+SHORT+$2k-$5k is only n=3 on real data (not 12 — below the gauntlet floor), and Large+LONG+$1k-$2k is actually NET-POSITIVE real (the hypothetical's −$20 sign was wrong). - Capacity-aware sweep + gauntlet (568 signals / 36 wk / $2,500 × 2.0 = $5,000 max gross; baseline production_post_313 = +$607.06 / +$16.86/wk / maxDD −$274.61):
| SKIP variant | admits | total $ | Δ/wk | maxDD | gauntlet | ship-eligible? |
|---|---|---|---|---|---|---|
| skip Large+LONG[.80,.90)+≥$10k | 396 | +$680.28 | +$2.04 | −$245.17 | PASS | YES |
| skip Mid+LONG[.80,.90)+$1k-$2k | 373 | +$647.36 | +$1.12 | −$274.61 | PASS | YES |
| skip Large+SHORT+$2k-$5k | 408 | +$648.59 | +$1.16 | −$249.20 | FAIL-EX-REPEAT-TICKERS | NO (n=3, single-name) |
| skip Large+LONG[.80,.90)+$1k-$2k | 400 | +$577.39 | −$0.82 | −$274.61 | FAIL-EX-TOP-2 | NO (cell is positive — skipping hurts) |
| skip ALL 4 combined | 345 | +$802.17 | +$5.42 | −$219.76 | PASS | YES (best total + best maxDD) |
signal_generator._build_trading_signal (like Finding #304/#312) + bias-gauntlet lock-in, with the standard memory.md §B/§G discipline + 30+-live-trade monitoring.scripts/phase3_simulator/replay_skip_rules.py (NEW; commit d7d7cc0)tests/unit/phase3_simulator/test_replay_skip_rules.py (NEW, +10 tests; commit d7d7cc0)scripts/phase3_skip_rules_results.json (regenerated on prod each run; not tracked)docs/FINDINGS.md (this entry) + docs/CONTEXT.md (header) + docs/AGENT_HANDOFF.md (block) + docs/STRATEGY_PLAYBOOK.md (Action Queue note)earnings_markets ⋈ markets (977 markets, Sep 2025 → Jun 2026) confirmed the academic quarterly peak-week classifier (classify_earnings_season_segment, peak weeks 4/17/30/43 ±2; locked via the Finding #310 amendment) TRACKS real Polymarket density: peak windows hold 57.7% of all earnings markets and 60% of the ≥$1k-volume markets. Operator's premise also confirmed at the cumulative-volume level: peak-week markets ≥$2k = 419 vs only 31 in [$1k,$2k). Caveat (noted, not disqualifying): the late-Jan/Feb Q4-reporting cluster (ISO weeks 7–9, ~125 markets) sits ~2–3 weeks after academic week 4, so it's classified in_season/off_season — the classifier slightly under-captures the Q4 season. Operator pre-locked the academic cut, and it broadly tracks → implemented A.earnings_markets diagnostic counted 31 peak-week markets in [$1k,$2k) using cumulative markets.volume_total. The capital-simulator universe (568 signals = 127 live + 441 counterfactual, using polymarket_opportunity_log.volume_total / live entry_volume_24h) shows 144 peak-week signals in [$1k,$2k). Different volume fields + different populations. The simulator number (144) is what drives the P&L sweep — so the hybrid is NOT a near-no-op as the 31 figure suggested; it is a substantial change. The pre-build estimate under-counted the footprint.scripts/phase3_simulator/replay_gate_hybrid.py (~290 lines) — 3 gate variants. The hybrid maps cleanly onto existing machinery as the uniform $1k gate + a peak-week sub-$2k skip predicate (_is_peak_week_sub_2k reuses classify_earnings_season_segment). All 3 variants keep production 30/22/15 sizing, so it reuses the general replay_sizing_variants.run_sizing_sweep() (per-variant KPIs + per-period + per-volume-bucket + bias gauntlet vs baseline + Pareto). Prints a footprint diagnostic.earnings_markets SQL diagnostic to size real footprint.| Variant | admits | rule-skips | cap-skips | total $ | per-wk | E[wk] | maxDD | WR | ex-5-outlier | Δ/wk vs base | gauntlet |
|---|---|---|---|---|---|---|---|---|---|---|---|
| uniform_$1k (baseline) | 409 | 101 | 58 | +$607.06 | +$16.86 | +$22.48 | −$274.61 | 36.4% | +$544.30 | — | (baseline) |
| hybrid_$2k_peak_$1k_off | 318 | 245 | 5 | +$290.80 | +$8.08 | +$11.18 | −$314.67 | 38.4% | +$228.04 | −$8.78 | FAIL-SPILLOVER |
| uniform_$2k (anchor) | 295 | 268 | 5 | +$241.22 | +$6.70 | +$10.05 | −$314.67 | 38.6% | +$178.46 | −$10.16 | FAIL-SPILLOVER |
exposure_cap=1.40 per Finding #315): hybrid +$10.37/wk vs uniform $1k +$15.61/wk = Δ −$5.24/wk, FAIL-EX-REPEAT-TICKERS (was −$8.78/wk FAIL-SPILLOVER at 2.0). KEEP uniform $1k — conclusion holds; hybrid still clearly worse at the real cap.scripts/phase3_simulator/replay_gate_hybrid.py (NEW; commit a755f45)tests/unit/phase3_simulator/test_replay_gate_hybrid.py (NEW, +7 tests; commit a755f45)scripts/phase3_gate_hybrid_results.json (regenerated on prod each run; not tracked)docs/FINDINGS.md (this entry) + docs/CONTEXT.md (header) + docs/AGENT_HANDOFF.md (block) + docs/STRATEGY_PLAYBOOK.md (verdict note)capital_simulator.simulate_one_variant() gained a scale_pnl_with_size flag (default False, so Finding #314.b + the SKIP-rule sweeps stay byte-identical). When True, the credited P&L scales with each variant's position size (design doc §12 PR 3: "re-compute P&L = realized_pnl_pct × new_position_value") — a smaller position earns proportionally less, a larger one more. AdmissionDecision gained realized_pnl_credited. This was a required correctness fix: the pre-existing simulator credited the FULL production-sized realized_pnl_real regardless of variant sizing, which is fine when all variants share 30/22/15 (gate/SKIP sweeps) but wrong for sizing variants. (INFERENCE DISCIPLINE catch, confirmed against design doc §12 line 759.)scripts/phase3_simulator/replay_sizing_variants.py (~395 lines) — DB-free run_sizing_sweep() + DB-backed main(); reuses the exact #314.b universe loaders so populations are identical.gauntlet.apply_sizing_gauntlet() — per-trade delta = variant-credited − baseline-credited over the UNION of admitted trades (captures both "earns less per trade" and "fits more trades"); reuses the 5-check memory §K helpers.| Variant | admits | cap-skips | total $ | per-wk | E[wk] | maxDD | 5p | ex-5-outlier | Δ/wk vs base | gauntlet |
|---|---|---|---|---|---|---|---|---|---|---|
| 30/22/15 (baseline) | 409 | 58 | +$607.06 | +$16.86 | +$22.48 | −$274.61 | −$98.60 | +$544.30 | — | (baseline) |
| 25/18/12 | 424 | 43 | +$443.21 | +$12.31 | +$16.41 | −$238.47 | −$81.48 | +$391.21 | −$4.55 | FAIL-SPILLOVER |
| 20/15/10 | 437 | 30 | +$362.06 | +$10.06 | +$13.41 | −$196.68 | −$66.43 | +$319.91 | −$6.80 | FAIL-SPILLOVER |
| 22/16/11 | 432 | 35 | +$397.75 | +$11.05 | +$14.73 | −$212.99 | −$72.03 | +$351.84 | −$5.81 | FAIL-SPILLOVER |
| flat 25/25/25 | 405 | 62 | +$657.74 | +$18.27 | +$24.36 | −$330.27 | −$98.60 | +$599.37 | +$1.41 | FAIL-EX-REPEAT-TICKERS |
| flat 18/18/18 | 428 | 39 | +$457.43 | +$12.71 | +$16.94 | −$249.48 | −$70.99 | +$415.40 | −$4.15 | FAIL-SPILLOVER |
| 33/22/15 | 407 | 60 | +$581.29 | +$16.15 | +$21.53 | −$314.58 | −$103.90 | +$514.24 | −$0.71 | FAIL-EX-REPEAT-TICKERS |
| 35/25/18 | 397 | 70 | +$634.08 | +$17.61 | +$23.48 | −$339.55 | −$113.65 | +$561.46 | +$0.75 | WARN-OUTLIER-DOMINATED |
exposure_cap=1.40 per Finding #315): the original run above used exposure_cap=2.0, but the live cap is MAX_DAILY_EXPOSURE=1.40. Re-running at 1.40 partially REVERSES the conclusion — at the tighter (real) cap, capacity binds harder so concentrating capital into bigger positions wins:MAX_POSITION_SIZE_PCT=0.15 doc cap, and bigger small-cap positions carry wider 5% stops + squeeze risk — needs operator review + §B/§G.src/earnings/ references it.scripts/phase3_simulator/capital_simulator.py (scale_pnl_with_size flag + realized_pnl_credited; commit c5fba22)scripts/phase3_simulator/gauntlet.py (apply_sizing_gauntlet; commit c5fba22)scripts/phase3_simulator/replay_sizing_variants.py (NEW; commit c5fba22)tests/unit/phase3_simulator/test_capital_simulator.py (+6 tests; commit c5fba22)tests/unit/phase3_simulator/test_replay_sizing_variants.py (NEW, +8 tests; commit c5fba22)scripts/phase3_sizing_variants_results.json (regenerated on prod each run; not tracked, matching phase3_capacity_aware_results.json)docs/FINDINGS.md (this entry) + docs/CONTEXT.md (header) + docs/AGENT_HANDOFF.md (block) + docs/STRATEGY_PLAYBOOK.md (verdict note)scripts/phase3_simulator/replay_capacity_aware.py (~410 lines) — orchestration script that loads full universe (127 live trades + 441 counterfactual eligible signals from opp_log post $1k gate + production-wide SKIP filters), de-dupes against live by condition_id, runs capital_simulator.simulate_one_variant() against TWO variants (current production_post_313 + historical pre_311_baseline_2k), and writes phase3_capacity_aware_results.json (schema phase3_sim_capacity.v1).exposure_cap=1.40 per Finding #315): the original run used 2.0 (= the unwired MAX_TOTAL_EXPOSURE_PCT); the live cap is MAX_DAILY_EXPOSURE=1.40. Re-run at 1.40: post-#311 ($1k) admits 370 / +$561.82 / +$15.61/wk; pre-#311 ($2k) admits 272 / +$323.82 / +$9.00/wk → Δ +$6.61/wk (was +$10.16/wk at 2.0). max_capital_used $3,464 of $3,500 (99%, still binding). KEEP $1k gate — conclusion holds; the lift is +$6.61/wk at the real cap.scripts/phase3_simulator/replay_capacity_aware.py (NEW, ~410 lines — already shipped previous commit 47aaa33)scripts/phase3_capacity_aware_results.json (regenerated on prod each run)docs/FINDINGS.md (this entry + Finding #311 re-validation note updated with capacity-aware findings)docs/CONTEXT.md (Last-Updated header)docs/AGENT_HANDOFF.md (Recently completed block)CapitalSimSignal — frozen dataclass; signal input shape covering both live trades and counterfactual markets. Fields: condition_id, symbol, entry_datetime_scheduled, exit_datetime_scheduled, market_cap, direction, conviction, volume_total, realized_pnl_real, intended_position_value_real, hypothetical_stop_hit, source ("live"|"counterfactual").SizingVariant — frozen dataclass; declarative spec for ONE variant under test. Fields: id, description, mcap_pct (per-tier %), flex_max (default 1.57), gate_volume_min (default $1,000 per Finding #311), extra_skip_predicates (tuple of (name, callable) for variants like the 4 deferred SKIP candidates). Method: is_skipped_by_rules(signal) -> (bool, str|None).AdmissionDecision — audit record per-signal. Fields: signal, admitted, skip_reason (None if admitted; e.g. "below_gate", "capacity_full", "CapacitySimResult — aggregate. Fields: variant_id, n_total_signals, n_admitted, n_skipped_rule, n_skipped_capacity, total_pnl_real, avg_pnl_per_admit, win_rate, max_concurrent_positions, max_total_capital_used_real, decisions[]. Methods: admitted_decisions(), to_summary_dict().simulate_one_variant(variant, signals, trading_capital, exposure_cap) — main entry point. Per-event heap-based simulator.signal_from_live_trade(...) — convenience constructor for closed trading_positions rows (no scaling — fields are already real-$).signal_from_counterfactual_market(market) — convenience constructor for CounterfactualMarket rows (applies per-tier empirical scaling per Audit Fix v3 via lazy-imported EMPIRICAL_POSITION_BY_TIER).entry_datetime_scheduled.heapq min-heap of (exit_datetime, capital_reserved) for open positions; earliest-expiring popped first.CapacitySimResult with full decision audit trail.src/earnings/ or src/data_tracker/ references it.scripts/phase3_simulator/capital_simulator.py (NEW, ~365 lines)tests/unit/phase3_simulator/test_capital_simulator.py (NEW, 30 tests)docs/FINDINGS.md (this entry)docs/CONTEXT.md (Last-Updated header + What-changed bullet)docs/AGENT_HANDOFF.md (Recently completed block)src/earnings/config.py:132: MAX_TOTAL_EXPOSURE_PCT default 1.40 → 2.00. Intended to match the Reg T overnight ceiling (50% initial margin = 2× max). 🔁 RE-SCOPED + REVERTED by Finding #315 (May 31, 2026): this variable has NO live consumer — the operative gross cap is MAX_DAILY_EXPOSURE=1.40 via RiskManager.OPPORTUNITY_EXPOSURE_LIMITS, so this 1.40→2.00 change had NO effect in production. The exposure-cap sweep further showed raising the cap to 2.0 would buy only ~+$1.25/wk for worse drawdown. MAX_TOTAL_EXPOSURE_PCT was re-aligned 2.00 → 1.40 (operator-confirmed May 31). The other two #313 changes (priority 0-8 volume-bump + NYSE dead-code cleanup) DID take effect and remain validated — only this cap-raise was inert.src/earnings/priority.py (rewrite): scale extended 0-6 → 0-8. Existing Mar 9 mcap×conviction ordering preserved (Large 70-80% = base 6, Mid 70-80% = base 4, etc.). NEW volume-tier bump: +1 if volume_total ≥ $2,000; +2 if volume_total ≥ $5,000. SKIP rules unchanged. Dead exchange parameter retained as deprecated kw arg (already-ignored per Finding #185 / Migration 019; kept for backwards compat). New constants: MAX_PRIORITY_SCORE=8, VOLUME_BUMP_TIER_1_USD=2000, VOLUME_BUMP_TIER_2_USD=5000.src/earnings/signal_generator.py: pass volume_total=self.volume_total to calculate_priority_score. Updated get_gap_risk_category docstring to mark DEPRECATED (returns LOW/MEDIUM regardless of exchange post Finding #185).src/earnings/order_executor.py:1700-1710: dropped dead is_nyse = primary_exchange in [...] branch (was computed but never referenced — order placement uses identical STP-LMT settings regardless of exchange post Finding #185). primary_exchange still captured for audit-log decoration (kept for retrospective analysis even though 126/127 live trades have primary_exchange=NULL — column was barely populated historically).earnings_dashboard/app.py: pass volume_total to calculate_priority_score for upcoming-qualifying-markets dashboard.earnings_dashboard/templates/dashboard.html: priority scale legend updated to 0-8 (was 0-6); JS clamp Math.min(6, ...) → Math.min(8, ...); Jinja clamp > 6 → > 8; Operator Cockpit Live Config row updated with NEW Priority + updated Sizing entry (max gross 200% per Finding #313).docs/STRATEGY.md §5.5 (Updated Feb 15, 2026 post Finding #185) the order types + buffers + outsideRth flags are IDENTICAL across exchanges in BOTH regular AND extended hours. NYSE_INSTANT_EXIT_BUFFER + NYSE_STOP_LIMIT_BUFFER are aliases of the same canonical constants. is_nyse variable in _place_stop_order_locked was computed but never referenced (dead code).Mid+L80-90+$1k-$2k vol (n=62 hypothetical, 19% WR, -$27 avg), Large+L80-90+$1k-$2k vol (n=20, 10% WR, -$20 avg), Large+L80-90+$10k+ vol (n=16, 25% WR, -$124 avg), Large+SHORT+$2k-$5k vol (n=12, 0% WR — zero winners). All cells have negative or zero-win evidence on solid samples but operator wants simulator-based gauntlet validation before any SKIP rule ships.entry_volume_24h analysis (n=10 trades, avg -$0.77, WR 30%, total -$7.66). The May 30 #311 ship explicitly admits this bucket going forward.signal_generator.TradingSignal.__post_init__ calls calculate_priority_score(exchange=primary_exchange, market_cap, conviction, direction, volume_total=self.volume_total). priority.py applies base 0-6 from PRIORITY_TABLE then volume bump (+1 at $2k, +2 at $5k). Score propagates to opp_log + position_manager + batch_order_manager via existing infrastructure. Capital-cap change (MAX_TOTAL_EXPOSURE_PCT 2.00) read by risk_manager exposure-gate calculations.test_priority_score_volume_bump_finding_313 + test_priority_score_max_score_bounds + test_priority_score_is_exchange_independent PASS local Python 3.11.src/earnings/config.py:132 (capital cap), src/earnings/priority.py:1-260 (rewrite + new constants), src/earnings/signal_generator.py:200-211 (TradingSignal.post_init pass volume_total), src/earnings/order_executor.py:830-835 + 1700-1710 (dead-code drop).if base_score == 0: return 0 short-circuits before bump. SKIP cells (over-confident, small SHORTs, etc.) cannot be elevated by high volume.test_dashboard_connectivity_state.py unrelated to this change (passes in isolation).Config.MAX_TOTAL_EXPOSURE_PCT == 2.0 AND MAX_PRIORITY_SCORE == 8.calculate_priority_score(None, "large", 0.75, "LONG", volume_total=10000) == 8.priority_score column already supports any int value.MAX_TOTAL_EXPOSURE_PCT is unwired; the live gross cap stayed at MAX_DAILY_EXPOSURE=1.40. This bullet was the inert part of #313; re-aligned 2.00→1.40 May 31.src/earnings/config.py (1 line + extensive comment)src/earnings/priority.py (full rewrite, ~260 lines)src/earnings/signal_generator.py (call site + gap_risk_category docstring)src/earnings/order_executor.py (drop is_nyse, update comment)earnings_dashboard/app.py (call site)earnings_dashboard/templates/dashboard.html (legend + clamp + Live Config row)tests/test_reconciliation_and_orders.py (updated test_priority_score_is_exchange_independent + 2 new tests)tests/unit/test_config.py (sync stale assertions)tests/unit/test_position_sizing_2x_rollout.py (sync stale large-cap sizing assertion)docs/FINDINGS.md (this entry)docs/STRATEGY_PLAYBOOK.md (verdict + Action Queue + IMPLEMENTED table)docs/AGENT_HANDOFF.md (header + new block)docs/CONTEXT.md (Last-Updated header + What-changed bullet)rules.py + dashboard.src/earnings/session_stop_breaker.py (~155 lines): pure-decision module with SessionBreakerAction 3-value enum (ALLOW / SKIP / DISABLED), SessionBreakerDecision frozen dataclass, decide_session_stop_breaker(enabled, threshold, stop_count_today) pure function, COUNT_STOPS_TODAY_SQL constant.src/earnings/signal_generator.py: import the new module, NEW _count_todays_stops() helper (~25 lines, fail-soft on DB error → returns 0), insert skip block in _build_trading_signal after Queue Rule #1 long_downtrend (~25 lines).src/earnings/config.py: 2 new env vars: SESSION_STOP_BREAKER_ENABLED (default True), SESSION_STOP_BREAKER_THRESHOLD (default 3).polymarket_opportunity_log via existing skip_stage='generate_signal' infrastructure (no new schema needed).scripts/phase3_simulator/rules.py:DirectionPolicySpec defaults updated: long_threshold 0.55 → 0.70, short_threshold 0.45 → 0.30, NEW field long_max_threshold default 0.90 (Finding #202 over-confident skip), conviction_fn "abs_distance_x2" → "raw_prob" (matches production: ConvictionCalculator returns BEAT-prob directly, not |prob-0.5|×2).production_strategy_may30() factory updated to use the corrected values + descriptive id "prob_threshold_70_30_with_90_skip".earnings_dashboard/templates/dashboard.html Live Config "Direction" row updated: "BEAT-prob conviction ≥0.70 AND <0.90 → LONG · ≤0.30 → SHORT · 0.30<prob<0.70 → SKIP (deadband) · ≥0.90 → SKIP (over-confident, Finding #202 Mar 9). Small-cap LONGs further restricted to 0.70-0.80 (Finding #207)." Was incorrect "Polymarket prob ≥0.55 → LONG · ≤0.45 → SHORT · 0.45-0.55 → SKIP (deadband)".signal_generator.py:_build_trading_signal which actually uses Config.SIGNAL_LONG_THRESHOLD=0.70 / SIGNAL_SHORT_THRESHOLD=0.30 / SIGNAL_LONG_MAX_THRESHOLD=0.90. INFERENCE DISCIPLINE catch (operator-flagged via screenshot).SELECT DATE(exit_time AT TIME ZONE 'America/New_York') AS d, COUNT(*) FROM trading_positions WHERE exit_reason ILIKE '%stop%' GROUP BY 1 HAVING COUNT(*) >= 3 returned 9 sessions: 2026-02-03 (3), 2026-02-05 (3), 2026-02-10 (4), 2026-02-19 (5), 2026-02-26 (5), 2026-03-03 (3), 2026-03-05 (4), 2026-04-22 (3), 2026-04-29 (3).exit_reason ILIKE '%stop%' per Finding #233: covers stop_loss:hardware (44), stop_loss:hardware_reconciled (8), instant_exit:stop_loss_backup (5) = 57 stop-exits total.SignalGenerator._build_trading_signal checks Config.SESSION_STOP_BREAKER_ENABLED → calls _count_todays_stops() → calls decide_session_stop_breaker() → if SKIP returns (None, "session_3_stop_breaker") → caller emits opp_log with skip_stage="generate_signal" + skip_reason="session_3_stop_breaker".src/earnings/session_stop_breaker.py:1-160, src/earnings/signal_generator.py:1110-1162 (_count_todays_stops), src/earnings/signal_generator.py:1281-1311 (skip block in _build_trading_signal), src/earnings/config.py:251-252.trading_positions.exit_reason or exit_time → SQL fails → fail-soft path. Detected via post-deploy dashboard regen.python3 -c "from src.earnings.config import Config; print(Config.SESSION_STOP_BREAKER_ENABLED, Config.SESSION_STOP_BREAKER_THRESHOLD)" → True 3.production_strategy_may30().direction_policy.long_threshold == 0.70 AND production_strategy_may30().selection_rules_active.includes("session_3_stop_breaker").src/earnings/session_stop_breaker.py (NEW, 155 lines)src/earnings/signal_generator.py (helper + skip block + import)src/earnings/config.py (2 new env vars)scripts/phase3_simulator/rules.py (DirectionPolicySpec defaults + production factory)tests/unit/test_session_stop_breaker.py (NEW, 22 unit tests)tests/unit/test_signal_session_stop_breaker.py (NEW, 8 integration tests)tests/unit/test_signal_regime_gate.py (stub _count_todays_stops in fixture)tests/unit/test_signal_volume_threshold.py (stub _count_todays_stops where reachable)tests/unit/phase3_simulator/test_rules.py (direction-policy lock-in tests)scripts/strategy_performance_analysis.py (Action Queue: v2 #6 IN-QUEUE → IMPLEMENTED)earnings_dashboard/templates/dashboard.html (Live Config rows: Direction + Selection + as-of header)docs/FINDINGS.md (this entry)docs/STRATEGY_PLAYBOOK.md (verdict + Action Queue + IMPLEMENTED table)docs/AGENT_HANDOFF.md (header + new block)docs/CONTEXT.md (Last-Updated + What-changed)Re-validation Note (May 30, 2026 LATE LATE evening — PR 3.b.2 capacity-aware sweep update): Operator-requested re-run of PR 2 analysis. Two updates:
Methodology audit (operator-led correction): Initial re-validation conflated Polymarket platform volume with IBKR equity-execution slippage — these are independent. PM volume signals prediction-market efficiency; IBKR slippage depends on the underlying stock's liquidity (NYSE/NASDAQ spread). The hypothetical_pnl methodology already captures prediction-quality differences via per-bucket WR (e.g., $1k-$2k WR 27.4% vs $5k+ WR 40.9%). Equity slippage is roughly constant across PM volume buckets (depends on stock not PM market). The "$2.64/trade slippage on $1k-$2k" framing was incorrect.
Capacity-aware re-validation (PR 3.b.2 / Finding #314.b ran on prod May 30): Using the new capital simulator (Finding #314 / PR 3.b.1) on the FULL universe (127 live + 441 counterfactual = 568 signals over 36 weeks):
- Pre-#311 ($2k gate): 295 admits, total +$241 → +$6.70/wk baseline.
- Post-#311 ($1k gate): 409 admits, total +$607 → +$16.86/wk.
- Δ vs $2k baseline = +$10.16/wk (about HALF the optimistic +$21/wk Phase γ+δ projection — gap is capacity contention: 58 capacity-rejected signals at $1k vs 5 at $2k).
- $1k-$2k bucket admitted (n=132): avg +$3.20, WR 31.8%, total +$423 → POSITIVE contribution. Operator's earlier concern (based on live n=10) is NOT confirmed at scale.
- max_total_capital_used = $4,949.70 (99% of $5,000 cap). Capacity is binding.
Decision: KEEP $1k uniform gate as shipped (Finding #311). The capacity-aware re-validation confirms it's a real +$10/wk lift on $2.5k capital (~21% return-on-capital boost annualized). Less than the original +$21/wk projection but still material. NO CODE CHANGE.
src/earnings/config.py line 239: SIGNAL_MIN_VOLUME_TOTAL default 2000.0 → 1000.0. Existing .env overrides still work; default just lowered.scripts/strategy_performance_analysis.py line 2907: BOT_VOLUME_GATE_USD constant 2000.0 → 1000.0 (kept in lock-step per memory.md §M).scripts/phase3_simulator/rules.py:baseline_ruleset() (Phase α factory): gate_threshold.id "current_2k" → "current_1k", min_volume_total=2000.0 → 1000.0. New production_strategy_may30() factory; production_strategy_may29() kept as backward-compat alias (returns same content as may30).gate_uniform_$1000 candidate (per-tier empirical scaling from 127 live trades; Large $537 / Mid $339 / Small $205): E[wk P&L] +$36.51 (Δ +$20.65/wk), max DD -$212 (Δ -$46; still within -$735 target), outlier ex-top-5 +$582 (alarm fixed), capital efficiency 9.6% (was 16.4% — admits more lower-position markets, drops avg).SIGNAL_MIN_VOLUME_TOTAL from config. volume_policy.py:effective_cumulative_volume_usd() returns max(db_volume_total, gamma_volume) per Finding #282. Gate check: effective_volume >= config.SIGNAL_MIN_VOLUME_TOTAL. New default 1000.0 → markets with effective volume ≥ $1k now pass; was ≥ $2k before.src/earnings/config.py:239, scripts/strategy_performance_analysis.py:2907, scripts/phase3_simulator/rules.py:191-194 (gate_threshold spec).test_volume_gate_is_2000 would fail if updated tests aren't shipped together — fixed with test_volume_gate_is_1k rename.systemctl is-active (11 services) → all active.curl /api/integrity → 47/47 OK preserved.pytest tests/unit/phase3_simulator/ tests/unit/test_*.py on prod Python 3.12 → expect all PASS.grep 'SIGNAL_MIN_VOLUME_TOTAL' src/earnings/config.py → 1000.0.python3 -c 'from scripts.phase3_simulator.rules import production_strategy_may30; print(production_strategy_may30().gate_threshold.min_volume_total)' → 1000.0.gate_uniform_$1000gate_quarterly_$2000peak_$1000other, gate_quarterly_$1000peak_$500other (operator picked simplest single-axis change; quarterly variants would have required day-of-year branch in signal_generator.py — extra complexity for ~$0/wk vs $20/wk extra lift not worth code surface).src/earnings/config.py (1 line: SIGNAL_MIN_VOLUME_TOTAL default)scripts/strategy_performance_analysis.py (1 const: BOT_VOLUME_GATE_USD)scripts/phase3_simulator/rules.py (factory + new production_strategy_may30 alias)tests/unit/phase3_simulator/test_rules.py (lock-in tests updated)docs/FINDINGS.md (this entry)docs/STRATEGY_PLAYBOOK.md (verdict banner refresh + Action Queue update)docs/AGENT_HANDOFF.md (header + new block)docs/CONTEXT.md (Last-Updated header + What-changed bullet)docs/STRATEGY.md (gate doc)earnings_dashboard/templates/dashboard.html (Live Config row updated)--run-gate-retune-sweep flag + dashboard render + 3-condition conservative-ship eligibility check per Finding #310 Q3 (C). After this commit, PR 2 is COMPLETE: PR 2.a (gate_variants + segmenter + classifier) + PR 2.b (counterfactual_admission loader + per-variant filter) + PR 2.c (this; sweep + gauntlet + dashboard) all shipped.scripts/phase3_simulator/gate_retune_sweep.py (~280 lines): the orchestrator. ShipEligibility frozen dataclass (eligible / full_pop_verdict / full_pop_ex_top_2_per_100 / n_admitted_collapse / reasons[]) + VariantSweepResult (per-variant aggregate + per-period gauntlet + ship eligibility) + SweepResult (full sweep with per-segment universe counts + eligible_variants). evaluate_conservative_ship_eligibility(full_pop_gauntlet, n_admitted_collapse) -> ShipEligibility — Finding #310 Q3 (C) 3-condition check (verdict==PASS + n_collapse≥20 + ex_top_2_per_100≥0). run_gate_retune_sweep_on_universe(variants, markets) -> SweepResult deterministic in-memory orchestrator (used by tests). run_gate_retune_sweep(engine, limit, variants) -> SweepResult loads universe + delegates.scripts/phase3_simulator/gauntlet.py extension: ADMISSION_BASELINE_RULE_ID = "gate_status_quo_$2k" constant + apply_admission_gauntlet(variant_id, admission_results, period_filter) -> GauntletResult new entry point. Reuses existing _aggregate / _check_* helpers via duck-typed AdmissionResult (the @property accessors .symbol and .entry_time added to AdmissionResult mirror the ReplayResult interface those helpers depend on). Restricts to admitted-only subset — rejected markets contribute 0 status-quo delta and don't materially affect per-100 metrics.scripts/phase3_simulator/counterfactual_admission.py extension: added @property symbol + @property entry_time to AdmissionResult so the gauntlet helpers work via duck-typing without a parallel set of admission-specific helpers.scripts/phase3_simulator/output.py extension: write_sweep_results_json(sweep_result, path, code_git_sha) — schema = phase3_sim_gate_retune.v1; deterministic output (sort_keys + indent=2 + trailing newline). Includes boundary_date ("2026-03-01") + admission_baseline_rule + per-variant + eligible_variants list.scripts/phase3_simulator/runner.py extension: --run-gate-retune-sweep flag + --sweep-output path arg + new _run_gate_retune_sweep_mode(args) orchestration function. Loads universe + iterates variants + writes JSON + prints per-variant summary table + per-eligible-variant detail. Wall time ~1-3s on the 262-row prod universe.scripts/strategy_performance_analysis.py extension: build_phase3_sim_gate_retune() helper that loads scripts/phase3_simulator_gate_retune_sweep.json + falls back to status='not_run' placeholder if missing. Integrated into run_analysis() report dict as phase3_sim_gate_retune alongside phase3_simulator_status.earnings_dashboard/templates/dashboard.html extension: new 🧪 Phase 3 — Gate Retune Sweep section under Strategy Analysis tab (placed below the existing 🧪 Phase 3 Simulator Status section per design doc §7.4). Shows: status header (boundary date + universe + eligible-count) + empty-state with runbook for "not_run" path + per-variant table (sorted: ship-eligible first, then by per-100 delta) with verdict color-coding + per-variant collapsible details with ship-eligibility reasons + per-period gauntlet table + eligible-variants summary list. NEW renderPhase3SimGateRetune(s) JS function (~150 lines) + render-loop integration.tests/unit/phase3_simulator/test_gate_retune_sweep.py (~440 lines, 25 tests in 6 classes):.symbol + .entry_time properties match the ReplayResult interface gauntlet helpers consume.phase3_sim_gate_retune.v1 schema + sorted keys + 2 runs = 0-byte diff after masking + trailing newline.runner.py --run-gate-retune-sweep → argparse → DATABASE_URL check → gate_retune_sweep.run_gate_retune_sweep(engine, limit) → load_counterfactual_markets() → for each variant in gate_retune_sweep_variants(): evaluate_variant_admissions(variant, markets) → for each period in {full, healthy, collapse}: apply_admission_gauntlet(variant.id, admissions, period_filter=period) → evaluate_conservative_ship_eligibility(full_g, n_collapse) → VariantSweepResult packaged → write_sweep_results_json(sweep, path) → print summary table + ship-eligible list → exit 0.scripts/phase3_simulator/gate_retune_sweep.py:118 evaluate_conservative_ship_eligibility; :175 _evaluate_variant; :235 run_gate_retune_sweep_on_universe; :265 run_gate_retune_sweep. scripts/phase3_simulator/gauntlet.py:259 apply_admission_gauntlet. scripts/phase3_simulator/output.py:208 write_sweep_results_json. scripts/phase3_simulator/runner.py:_run_gate_retune_sweep_mode.test_empty_universe_yields_zero_admissions_per_varianttest_outlier_dominated_variant_fails_eligibilitytest_two_runs_produce_zero_byte_diff catches ✅pytest tests/unit/phase3_simulator/test_gate_retune_sweep.py -v → 25/25 PASS local Python 3.11.runner.py --run-gate-retune-sweep on prod produces JSON; strategy_performance_analysis.py re-run picks up new section; dashboard renders new tab content.from scripts.phase3_simulator.gate_retune_sweep → 1 caller (runner.py via lazy import in _run_gate_retune_sweep_mode) + 1 self-import in test. No other callers.apply_admission_gauntlet → 2 sites: definition in gauntlet.py + lazy-import in gate_retune_sweep.py. Plus 1 test reference. No drift.phase3_sim_gate_retune → 5 sites: scripts/strategy_performance_analysis.py (build_phase3_sim_gate_retune() + run_analysis() integration) + earnings_dashboard/templates/dashboard.html (HTML section + render call + JS function name). All intentional.phase3_simulator_gate_retune_sweep.json → 4 sites: gate_retune_sweep / runner / strategy_performance_analysis / dashboard runbook string. All intentional + consistent.systemctl is-active (11 services) → all active.curl /api/integrity → 47/47 OK preserved (no integrity rule changes).pytest tests/unit/phase3_simulator/ tests/unit/test_pm_v2_conditional_label.py tests/unit/test_ruleb_window_gate.py tests/unit/test_signal_regime_gate.py tests/unit/test_signal_volume_threshold.py tests/unit/test_signal_reliability_gate.py -q → expect 250/250 PASS on prod Python 3.12.cd [hidden] && source .env && export DATABASE_URL && venv/bin/python3 scripts/phase3_simulator/runner.py --run-gate-retune-sweep --log-level INFO → expect ~1-3s wall time, JSON written to scripts/phase3_simulator_gate_retune_sweep.json, per-variant summary table printed, eligible-variants count printed.venv/bin/python3 scripts/strategy_performance_analysis.py → expect phase3_sim_gate_retune section in strategy_analysis_report.json with status='loaded'.curl -s http://127.0.0.1:8080/api/strategy-analysis | python3 -c '...' → expect phase3_sim_gate_retune.status == "loaded" + eligible_variants list populated.sizing_policies.py + capital_simulator.py + per-direction SKIP rule revalidation + position×regime interaction + phase3_sim_sizing dashboard section.scripts/phase3_simulator/gate_retune_sweep.py (NEW, ~280 lines).scripts/phase3_simulator/gauntlet.py (UPDATED: +ADMISSION_BASELINE_RULE_ID constant + apply_admission_gauntlet entry point, ~80 lines added).scripts/phase3_simulator/counterfactual_admission.py (UPDATED: +@property symbol + @property entry_time on AdmissionResult, ~14 lines).scripts/phase3_simulator/output.py (UPDATED: +write_sweep_results_json + schema constant, ~50 lines).scripts/phase3_simulator/runner.py (UPDATED: +--run-gate-retune-sweep flag + --sweep-output arg + _run_gate_retune_sweep_mode helper, ~80 lines).scripts/strategy_performance_analysis.py (UPDATED: +build_phase3_sim_gate_retune helper + run_analysis integration, ~30 lines).earnings_dashboard/templates/dashboard.html (UPDATED: NEW 🧪 Phase 3 — Gate Retune Sweep section + renderPhase3SimGateRetune JS function + render-loop integration, ~280 lines).tests/unit/phase3_simulator/test_gate_retune_sweep.py (NEW, ~440 lines, 25 tests).docs/PHASE_3_SIMULATOR_DESIGN.md (§12 PR 2 — SHIPPED marker + PR 3 NEXT documented).docs/STRATEGY_PLAYBOOK.md §1 (verdict banner refresh for PR 2 COMPLETE).docs/FINDINGS.md (this entry).docs/AGENT_HANDOFF.md §11 (header + new block).docs/CONTEXT.md (Last-Updated header + What-changed bullet).hypothetical_entry_price + hypothetical_exit_price + hypothetical_stop_hit + hypothetical_pnl + hypothetical_pnl_pct + intended_position_value + intended_shares + intended_stop_price fields populated by scripts/compute_counterfactual_pnl.py (re-run in Finding #308). Massive scope reduction — PR 2.b becomes a 230-line loader + 20-test suite instead of a multi-module replay extension. But the schema check also surfaced a critical nuance: 278 of 540 skip_reason='low_volume' rows have hypothetical_prices_source='none' (Polygon fetch failed; hypothetical_pnl=$0.00 placeholder, NOT a real outcome). Without filtering these out we'd bias the gauntlet downward by counting "ghost markets" as observed neutral outcomes.hypothetical_prices_source='polygon.io_1m_close'); 278 unusable (source='none').gate_uniform_$2000 admits 0 (confirms no usable counterfactual crosses the current production gate). At lower thresholds: $1k admits 96; $500 admits 146; $250 admits 181; $100 admits 196; $0 admits 262.scripts/phase3_simulator/counterfactual_admission.py (~230 lines):CounterfactualMarket frozen dataclass — 22 fields including identity (opp_log_id / symbol / condition_id / earnings_date / evaluated_at), trader context (direction / timing / market_cap / conviction / volume_total_usd / scheduled_entry & exit), pre-computed outcome (hypothetical_entry/exit/stop_hit/pnl/pnl_pct/source), intended sizing (intended_position_value/shares/stop_price), and regime_at_entry.load_counterfactual_markets(engine, limit, usable_sources) — SQL loader filtering skip_reason='low_volume' AND hypothetical_prices_source IN ('polygon.io_1m_close',) AND direction_would_be IN ('LONG', 'SHORT') AND volume_total IS NOT NULL AND earnings_datetime IS NOT NULL AND entry_datetime_scheduled IS NOT NULL AND exit_datetime_scheduled IS NOT NULL. Per-row construction with WARN-on-parse-failure (matches population.py pattern). CHANGE-CONTRACT note: any field added to CounterfactualMarket MUST update the SQL columns in lock-step.USABLE_PRICES_SOURCES = ('polygon.io_1m_close',) constant — regression-locked; expanding it requires a Finding entry + AskQuestion.evaluate_admission(variant, market) -> bool — per-variant admission via variant.admits(market.earnings_date, market.volume_total_usd). The opp_log's volume_total is the effective max(DB, Gamma) value (Finding #282) recorded by the trader at evaluation time, NOT the raw DB column.AdmissionResult frozen dataclass — (variant_id, market, admitted, delta_vs_status_quo_dollars). delta_vs_status_quo_dollars = market.hypothetical_pnl_dollars when admitted (because current production rejects → $0 status-quo baseline); 0 when rejected. .period property delegates to gate_variants.classify_period for Finding #310 segment classification.evaluate_variant_admissions(variant, markets) -> List[AdmissionResult] — one result per market.admitted_only(results) — convenience filter.summary_dict(variant, results) — quick per-variant aggregate (n_universe / n_admitted / n_admitted_by_period / total_pnl_dollars / per_100_delta / n_winners / n_losers). Heavy-weight gauntlet aggregation (5-check + ex-top-2 + ex-repeat-tickers + period segmentation) lands in PR 2.c.tests/unit/phase3_simulator/test_counterfactual_admission.py (~270 lines, 20 tests in 6 classes):TestCounterfactualMarket (3) — construct with required fields + frozen guard + USABLE_PRICES_SOURCES regression-lock.TestEvaluateAdmission (5) — admits above threshold + rejects below + admits at exact threshold + seasonal off-peak admits with lower volume + seasonal peak rejects with off-peak volume.TestEvaluateVariantAdmissions (4) — one result per market + admitted delta = hypothetical_pnl + rejected delta = 0 + variant_id propagation.TestAdmittedOnly (1) — filter to admitted results.TestAdmissionResultPeriod (2) — .period property returns healthy / collapse correctly.TestSummaryDict (5) — zero admitted + all admitted positive + mixed winners/losers + by_period aggregation + variant metadata.load_counterfactual_markets(engine, limit) → SQL → 262-row List[CounterfactualMarket] → for each variant in gate_retune_sweep_variants(), call evaluate_variant_admissions(variant, markets) → 262 AdmissionResult → filter via admitted_only(results) → aggregate via PR 2.c gauntlet aggregator (per-period × per-variant). PR 2.b itself fires only at SQL-load + admission-decision time; no replay execution.scripts/phase3_simulator/counterfactual_admission.py:118 load_counterfactual_markets; :170 evaluate_admission; :206 evaluate_variant_admissions; :230 summary_dict.test_zero_admitted_zero_pnltest_seasonal_off_peak_admits_with_lower_volumetest_by_period_aggregationtest_usable_prices_sources_constant regression-locks ✅pytest tests/unit/phase3_simulator/test_counterfactual_admission.py -v → 20/20 PASS local Python 3.11.load_counterfactual_markets(engine, limit=5) returns 5 real CounterfactualMarket rows.from scripts.phase3_simulator.counterfactual_admission → no callers yet (PR 2.c adds them); 1 self-import in test file.polymarket_opportunity_log.*skip_reason='low_volume' → 2 sites: this module's SQL + scripts/compute_counterfactual_pnl.py (the original writer; expected). No drift.volume_total field semantic matches Finding #282 max(DB, Gamma) — the opp_log row stores the effective value the trader's gate check used, NOT the raw DB column. Same input semantics as variant.admits consumes.hypothetical_pnl vs realized_pnl), different stop-loss semantics (intended_stop_price was never placed). Keeping them as distinct types prevents the gauntlet from accidentally treating one as the other.systemctl is-active (11 services) → all active.curl /api/integrity → 47/47 OK preserved.pytest tests/unit/phase3_simulator/ tests/unit/test_pm_v2_conditional_label.py tests/unit/test_ruleb_window_gate.py tests/unit/test_signal_regime_gate.py tests/unit/test_signal_volume_threshold.py tests/unit/test_signal_reliability_gate.py -q → expect 225/225 PASS on prod Python 3.12.python3 -c "from sqlalchemy import create_engine; import os; from scripts.phase3_simulator.counterfactual_admission import load_counterfactual_markets, summary_dict; from scripts.phase3_simulator.gate_variants import gate_retune_sweep_variants; from scripts.phase3_simulator.counterfactual_admission import evaluate_variant_admissions; e=create_engine(os.environ['DATABASE_URL']); markets = load_counterfactual_markets(e, limit=1000); print(len(markets), 'usable counterfactual markets'); v_uni_500 = next(v for v in gate_retune_sweep_variants() if v.id=='gate_uniform_$500'); print(summary_dict(v_uni_500, evaluate_variant_admissions(v_uni_500, markets)))" → expect 262 usable markets + summary dict with n_admitted=146, total_pnl_dollars ≈ positive few hundred.scripts/phase3_simulator/counterfactual_admission.py (NEW, ~230 lines).tests/unit/phase3_simulator/test_counterfactual_admission.py (NEW, ~270 lines, 20 tests).docs/PHASE_3_SIMULATOR_DESIGN.md (§12 PR 2 status — PR 2.b SHIPPED marker).docs/FINDINGS.md (this Finding #310.b sub-entry).docs/AGENT_HANDOFF.md §11 (header + new block).docs/CONTEXT.md (Last-Updated header + What-changed bullet).classify_seasonal_tier function classified day-of-month 1-14 as "peak weeks" and 15-31 as "off-peak weeks". Raw earnings_markets evidence shows 477 day-1-14 reports vs 485 day-15-31 reports = ratio 0.98 — indistinguishable from coin-flip. There is NO day-of-month seasonality in our raw data.Q1-2026-Earnings-Kick-Off) + SEC filing data + ScienceDirect 247K-firm study (1985-2019): peak weeks 4/17/30/43 of year; ~32% of firms in single peak week; ~54% in top-2 weeks; ~96% in 7 weeks centered on peak. The 2026 Q1 reports peak was Apr 27 - May 15 per Wall Street Horizon. Off-season periods are mid-quarter (Mar / Jun / Sep / Dec).2cdb42c): replaced day-of-month classifier with classify_earnings_season_segment(d) → "peak" | "in_season" | "off_season" per academic peak weeks 4/17/30/43 ± window. NEW constants ACADEMIC_PEAK_WEEKS_OF_YEAR=(4,17,30,43), PEAK_WINDOW_WEEKS=2, IN_SEASON_WINDOW_WEEKS=3. NEW seasonal_quarterly mode in GateVariantSpec. Deprecated classify_seasonal_tier now delegates to new classifier (backward-compat shim). 12 new tests; 262/262 PASS.069fb14 + fix 57211e7): NEW scripts/backfill_opp_log_pre_apr22.py reconstructs synthetic opp_log entries for pre-Apr-22 markets via Polymarket trades-table volume reconstruction + earnings_markets outcomes + Polygon bars. 39 new tests; 301/301 PASS.compute_counterfactual_pnl.py is currently populating hypothetical_pnl for all 554 rows via Polygon 1m bars (~10-15 min; 240/554 priced as of May-29 08:30 UTC).gate_uniform_$1000 / gate_seasonal_$2000peak_$1000offpeak / gate_seasonal_$1000peak_$500offpeak) cannot be approved until PR 2.f (cross-season sweep re-run on expanded 447-market low-volume universe with proper quarterly classifier) signs off.earnings_markets LEFT JOIN markets BEFORE writing any PR 2 code to decompose the observed "volume drought" into seasonal (expected) vs structural (real decline) components.healthy (earnings_date < 2026-03-01), collapse (≥ 2026-03-01), full (all data). Boundary date 2026-03-01 per the SQL evidence — the discontinuity is at the Mar-Feb boundary.$0/$0 (peak/off-peak both 0 → 1 unique) → 11 variants. Thresholds: 2000 / 1000 / 500 / 250 / 100 / 0. Modes: uniform (gate = threshold for every market) + seasonal_week (gate = threshold for wk1+wk2 markets; gate = threshold × 0.5 for wk3+ markets). The 0.5 factor approximates the healthy-regime wk1+wk2 vs wk3+ median-volume ratio.gate_variants.py (declarative GateVariantSpec dataclass + 11 variants + period segmenter + week-of-month classifier + constants regression-locked to operator AskQuestion defaults) + 51 unit tests + doc updates (this Finding + STRATEGY_PLAYBOOK + AGENT_HANDOFF + CONTEXT + design doc §12).counterfactual_admission.py — synthesize a Trade dataclass from a polymarket_opportunity_log row with skip_reason='low_volume' (entry_price from equity_bars_1m cache at scheduled entry; stop_price = entry × (1 - mcap_tier_stop_pct); delegate to existing replay.replay_trade) + unit tests with mocked bars. THIS is the technically-novel piece — first time the simulator extends beyond replaying existing closed trades.output.py schema extension + runner.py --variant-set gate_retune_sweep flag + 3-condition conservative-ship check per Q3 (C) + new phase3_sim_gate_retune dashboard section in strategy_performance_analysis.py + render in dashboard.html + run on prod + operator AskQuestion per qualifying variant.scripts/phase3_simulator/gate_variants.py (~205 lines): GateVariantSpec frozen dataclass with threshold_for_market(date) -> float + admits(date, effective_volume) -> bool methods; gate_retune_sweep_variants() factory returning 11 variants; classify_period(date) segment classifier; classify_seasonal_tier(date) week-of-month classifier; constants (COLLAPSE_BOUNDARY_DATE / OFF_PEAK_FACTOR / PEAK_WEEK_LAST_DAY_OF_MONTH / MIN_ADMITTED_MARKETS_COLLAPSE_SEGMENT / SHIP_CONSERVATIVE_MIN_EX_TOP_2_PER_100) all operator-AskQuestion-locked (do NOT change without re-evidencing).tests/unit/phase3_simulator/test_gate_variants.py (~220 lines, 51 tests in 6 classes): TestConstants (5 — regression-lock all 5 constants); TestThresholdForMarket (10 — uniform mode parametrize + seasonal_week parametrize at day-14 boundary + zero-threshold edge + unknown-mode raises); TestAdmits (4 — at-threshold + above + below + seasonal off-peak admits with lower volume); TestGateRetuneSweepVariants (7 — count = 11 + unique ids + 6 uniform thresholds + 5 seasonal thresholds after dedupe + id format + description content); TestClassifyPeriod (9 parametrize — healthy days + collapse days + Mar-1 boundary); TestSupportedPeriods (1); TestClassifySeasonalTier (8 parametrize — peak days + off-peak days + day-14 boundary).gate_retune_sweep_variants() → for each variant, for each (replay_result, opp_log_row), call variant.admits(earnings_date, effective_volume_usd) to decide inclusion → for each (variant, period) bucket use classify_period(earnings_date) to route → for seasonal-tier breakouts call classify_seasonal_tier(earnings_date). None of this fires in PR 2.a.scripts/phase3_simulator/gate_variants.py:60 GateVariantSpec.threshold_for_market; :122 gate_retune_sweep_variants; :175 classify_period; :215 classify_seasonal_tier.test_seasonal_week_mode_zero_threshold_stays_zeropytest tests/unit/phase3_simulator/test_gate_variants.py -v → 51/51 PASS local Python 3.11.from scripts.phase3_simulator.gate_variants → no callers yet (PR 2.b + 2.c add them); 1 self-import in test_gate_variants.py.COLLAPSE_BOUNDARY_DATE | OFF_PEAK_FACTOR | gate_retune_sweep_variants → only the module + tests.effective_volume_usd semantic matches Finding #282 max(volume_total, Gamma cumulative) — the same input the live trader's gate check uses. PR 2.b's counterfactual-admission will compute this same way.systemctl is-active (11 services) → all active.curl /api/integrity → 47/47 OK preserved (no integrity rule changes).pytest tests/unit/phase3_simulator/ tests/unit/test_pm_v2_conditional_label.py tests/unit/test_ruleb_window_gate.py tests/unit/test_signal_regime_gate.py tests/unit/test_signal_volume_threshold.py tests/unit/test_signal_reliability_gate.py -q → expect 205/205 PASS on prod Python 3.12.python3 -c "from scripts.phase3_simulator.gate_variants import gate_retune_sweep_variants; print(len(gate_retune_sweep_variants()), 'variants')" → expect "11 variants" on prod.scripts/phase3_simulator/gate_variants.py (NEW, ~205 lines).tests/unit/phase3_simulator/test_gate_variants.py (NEW, ~220 lines, 51 tests).docs/PHASE_3_SIMULATOR_DESIGN.md (§12 PR 2 status — IN FLIGHT; PR 2.a scope documented).docs/STRATEGY_PLAYBOOK.md (§1 verdict banner refresh + §7 deferred-bundle: sizing-priority-v2 row update with Finding #310 cross-ref).docs/FINDINGS.md (this entry).docs/AGENT_HANDOFF.md §11 (header + new block).docs/CONTEXT.md (Last-Updated header + What-changed bullet).runner.py --run-gauntlet flag for the end-to-end pipeline. After this commit, PR 1 is COMPLETE: foundation skeleton (#306.a) + replay + cost-model + cross-check (#306.b) + gauntlet + H-rule self-validation + reproducibility + JSON output + #273 conditional-label fix (#307) + Bug #6 hyp_pnl re-run (#308) all shipped. PR 2 (volume-gate retune sweep) can begin next session per design doc §12.test_h_rule_synthetic_returns_fail_ex_top_2) rather than a runtime assertion that would require actually implementing the H-rule variant (PR 4/5 work). The synthetic fixture (10 trades × delta=-1 + 2 trades × delta=+20 = headline +$30/+$250/100, ex-top-2 drops the +20s → -$10/-$100/100 → sign flip → FAIL-EX-TOP-2) proves the gauntlet machinery works without coupling to variant-replay code.test_two_runs_produce_zero_byte_diff using _make_run_output() fixture → write_results_json → mask_volatile_fields → diff. Fast (<1s) and conclusive. The real-data reproducibility check can be re-run on prod via runner.py --run-gauntlet twice and diff the JSONs (with generated_at field zeroed); operator-runnable §G probe.strategy_performance_analysis.py phase3_sim_* sections explicitly ship WITH the respective PRs that produce real variant data (PR 2 = gate-retune, PR 3 = sizing, etc.). #306.c only ships the JSON schema that PR 2+ will consume; no strategy_performance_analysis.py change needed.--run-gauntlet flag that runs the full baseline pipeline (replay → gauntlet → JSON). The default --population/--limit mode still does load-and-print without invoking the gauntlet (cheap smoke).gauntlet.py (~340 lines) — Implements all 5 checks per memory.md §K:n_adequate (n ≥ N_FLOOR=12 hard / N_IDEAL=20 ideal-with-notes)ex_top_2 (drop 2 trades with largest |delta|, re-aggregate; PASS if sign survives)ex_repeat_tickers (dedupe by symbol most-recent-first; PASS if sign survives)full_population_spillover (variant per-100 must be ≥ SPILLOVER_FAIL_THRESHOLD_PER_100=-5.0)five_outlier (drop GPRO/DELL/SWBI/TXN/STUB; PASS if sign survives — FAIL flips to WARN-OUTLIER-DOMINATED per memory §K)compute_per_trade_delta(result, baseline_rule, variant_rule) -> Optional[float] (None-aware so RuleD/RuleG no-ATR rows drop cleanly).VariantStats + CheckResult + GauntletResult frozen dataclasses with to_dict JSON serializers._verdict() dispatcher in memory §K priority order: FAIL-N-TOO-LOW → FAIL-EX-TOP-2 → FAIL-EX-REPEAT-TICKERS → FAIL-SPILLOVER → WARN-OUTLIER-DOMINATED → PASS.apply_gauntlet(results, baseline_rule, variant_rule) -> GauntletResult public entry point.true_simulator_compliant=True baked into every result — this IS a true simulator per memory §K (replays actual data, not a proxy). The compliance flag persists into the JSON output._aggregate/_ex_top_n/_dedupe_tickers/_split_by helpers operate on TradeSim and are hard-coded to the reference's 7 stop-loss rules. Phase 3 gauntlet must support arbitrary variants for PR 2-5, so it operates on ReplayResult natively. The Q1 HYBRID delegation still holds for stop-loss MATH (via reference_adapter); gauntlet AGGREGATION is independent.output.py (~210 lines) — Per design doc §7.2:DataSnapshot (trade_audit_log_max_id / opportunity_log_max_id / equity_bars_1m_max_ts).RulesetEvaluation (ruleset + replay_results + gauntlet_results_by_rule).ShipRecommendation (ruleset_id / variant_rule / recommendation / verdict / rationale).RunOutput top-level container (generated_at / code_git_sha / data_snapshot / rulesets_evaluated / ship_recommendations).derive_ship_recommendation(ruleset_id, gauntlet) -> ShipRecommendation — verdict→SHIP/DEFER/DROP/WARN mapping per memory §K with human-readable rationale.get_code_git_sha() — defensive subprocess wrapper for git rev-parse; returns 'unknown' if git unavailable (Docker/cron contexts).mask_volatile_fields(payload) — zeros out generated_at for reproducibility diffs.write_results_json(output, path, include_replay_results=True) — deterministic output (sort_keys=True + indent=2 + trailing newline for POSIX). --no-replay-detail strips per-trade detail for summary runs.runner.py UPDATED (~80 lines change) — Removed "#306.a stops here" footer staleness. Added:--run-gauntlet flag: full pipeline (load trades → replay_population → apply_gauntlet for each variant rule → write_results_json → print ship recommendations).--baseline-rule (default A_base) + --variant-rules (nargs+, default RuleB) for the gauntlet's per-trade delta computation.--output (default scripts/phase3_simulator_results.json) + --no-replay-detail + --polygon-sleep.test_gauntlet.py (~340 lines, 17 tests in 7 classes):TestComputePerTradeDelta (3 — basic + None-handling on baseline/variant)TestVerdictPass (1 — simple positive robust variant)TestVerdictNTooLow (3 — below floor, at floor, between floor/ideal-with-notes)TestHRuleSelfValidation (1 — test_h_rule_synthetic_returns_fail_ex_top_2) — the design doc §10.3 PR 1 acceptance gate. Constructs the v2 H-rule shape (12 trades: headline +$30, ex-top-2 -$10 sign flip), asserts verdict==FAIL-EX-TOP-2, with a DO-NOT-SHIP-PR-2 diagnostic if it ever returns PASS.TestVerdictExRepeatTickers (1 — 13×SPAM-early-positive + 1×SPAM-late-negative + 1×SOLO-negative, headline positive but dedupe-most-recent flips sign → FAIL-EX-REPEAT-TICKERS)TestVerdictSpillover (2 — severe negative spillover fails / mild negative passes)TestVerdictFiveOutlier (1 — outlier-dominated WARN per memory §K)TestSerialization (2 — VariantStats + GauntletResult to_dict)TestEdgeCases (3 — empty results / all-None deltas / zero-headline neutral pass)test_output.py (~210 lines, 16 tests in 5 classes):TestRunOutputSchema (4 — top-level keys match §7.2 + DataSnapshot keys + RulesetEvaluation serializes + ShipRecommendation keys)TestReproducibility (5) — the design doc §10.2 PR 1 acceptance gate:test_two_runs_produce_zero_byte_diff — 2 runs with identical inputs produce byte-identical JSON (after masking generated_at).test_json_uses_sorted_keys — top-level keys are alphabetically sorted.test_trailing_newline_for_posix.test_mask_volatile_fields_only_nulls_generated_at.test_no_replay_detail_strips_replay_results.TestDeriveShipRecommendation (5 — PASS→SHIP, WARN→DEFER, FAIL-N-TOO-LOW→DEFER, 3-way parametrize for FAIL-*→DROP).TestGetCodeGitSha (1 — returns non-empty string).runner.py --run-gauntlet → argparse → DATABASE_URL/POLYGON_KEY checks → _load_engine_or_die() + _pop.load_trades() → _replay.replay_population(trades, baseline_ruleset(), ctx) → per-variant-rule loop calling _gauntlet.apply_gauntlet(results, baseline_rule, variant_rule) → _output.RunOutput assembly + _output.write_results_json() → print ship recommendations → exit 0.apply_gauntlet(20-trade fixture, "A_base", "RuleB") → _deltas() extracts 20 (result, delta) pairs → _aggregate() produces VariantStats(n=20, total=20, per_100=+100) → 5 checks each pass → verdict PASS → JSON output round-trip via mask_volatile_fields produces byte-identical text. On real prod data n=121 the same flow executes in ~7 min (cache-dependent; cache populated after #306.b §G probe).scripts/phase3_simulator/gauntlet.py:200 apply_gauntlet; :240 _verdict; scripts/phase3_simulator/output.py:170 write_results_json; scripts/phase3_simulator/output.py:118 derive_ship_recommendation; scripts/phase3_simulator/runner.py:163 _run_full_pipeline.FAIL-N-TOO-LOW ✅ (covered by TestEdgeCases.test_empty_results_returns_fail_n_too_low)FAIL-N-TOO-LOW ✅FAIL-EX-TOP-2 ✅ (the H-rule canonical test)FAIL-EX-REPEAT-TICKERS ✅FAIL-SPILLOVER ✅WARN-OUTLIER-DOMINATED ✅get_code_git_sha() returns 'unknown' gracefully ✅pytest tests/unit/phase3_simulator/ -v → 112/112 PASS local Python 3.11.test_h_rule_synthetic_returns_fail_ex_top_2 PASSES (returns FAIL-EX-TOP-2 as required).test_two_runs_produce_zero_byte_diff PASSES (byte-identical output after masking generated_at).python3 scripts/phase3_simulator/runner.py --skip-db exits 0 with documented RuleSet summary; footer no longer says "#306.a stops here".runner.py --run-gauntlet --limit 200 end-to-end → expect verdict for RuleB (likely PASS or close — current dry-run hasn't fired, but the May-28 reference simulator showed RuleB ex-top-2 +$127/100 STRENGTHENED gauntlet PASS). Also runs runner.py --run-gauntlet twice and diffs the two JSON outputs (after generated_at masking) → expect 0-byte diff.from scripts.phase3_simulator.gauntlet → 2 callers (runner.py + test_output.py) + 1 self-reference (test_gauntlet.py). All intentional.from scripts.phase3_simulator.output → 2 callers (runner.py + test_output.py). All intentional.FIVE_OUTLIER_SYMBOLS → 2 sites: definition + 1 test using it. The 5 outlier names are also documented in .cursor/memory.md §K + docs/STRATEGY_PLAYBOOK.md §1; those are narrative references not code consumers. No drift.analyze_finding_a_full.py:_aggregate / _ex_top_n / _dedupe_tickers / _split_by / print_gauntlet helpers stay UNCHANGED. The Phase 3 gauntlet is a parallel implementation per design doc §5.1 (not a delegation per Q1 HYBRID) because it must support arbitrary variant rule ids for PR 2-5.systemctl is-active (11 services) → all active.curl /api/integrity → 47/47 OK preserved.pytest tests/unit/phase3_simulator/ tests/unit/test_pm_v2_conditional_label.py tests/unit/test_ruleb_window_gate.py tests/unit/test_signal_regime_gate.py tests/unit/test_signal_volume_threshold.py tests/unit/test_signal_reliability_gate.py -q → expect 154/154 PASS on prod Python 3.12.cd [hidden] && source .env && export DATABASE_URL POLYGON_KEY && venv/bin/python3 scripts/phase3_simulator/runner.py --run-gauntlet --limit 200 --output /tmp/p3sim_baseline.json --log-level INFO → expect documented RuleSet summary + replay completion + per-variant gauntlet summary (RuleB likely PASS at +$127/100 ex-top-2 per May-28 reference simulator + matching derive_ship_recommendation rationale) + JSON written. Wall time ~7 min (cache should be hot after #306.b §G probe).runner.py --run-gauntlet --output /tmp/run1.json then runner.py --run-gauntlet --output /tmp/run2.json; python3 -c "import json; from scripts.phase3_simulator.output import mask_volatile_fields; a=mask_volatile_fields(json.load(open('/tmp/run1.json'))); b=mask_volatile_fields(json.load(open('/tmp/run2.json'))); print('IDENTICAL' if a==b else 'DIFFER')" → expect IDENTICAL.test_two_runs_produce_zero_byte_diff), §10.3 H-rule self-validation (FAIL-EX-TOP-2 per test_h_rule_synthetic_returns_fail_ex_top_2), Phase 0 §0.6 Gate 1/4 status (corrected understanding per #308 structural ceiling — signal_emitted 100% is sufficient).gate_variants.py (declarative thresholds $2k→$0) + replay.py extension (counterfactual admission for skip_reason='low_volume' rows) + output.py schema extension + first phase3_sim_gate_retune dashboard section in strategy_performance_analysis.py.scripts/phase3_simulator/gauntlet.py (NEW, ~340 lines).scripts/phase3_simulator/output.py (NEW, ~210 lines).scripts/phase3_simulator/runner.py (UPDATED: --run-gauntlet flag + footer cleanup, ~80 lines change).tests/unit/phase3_simulator/test_gauntlet.py (NEW, ~340 lines, 17 tests incl. H-rule §10.3 acceptance).tests/unit/phase3_simulator/test_output.py (NEW, ~210 lines, 16 tests incl. §10.2 reproducibility acceptance).docs/PHASE_3_SIMULATOR_DESIGN.md (§12 PR 1: #306.c marked SHIPPED + PR 1 COMPLETE marker).docs/FINDINGS.md (this entry).docs/AGENT_HANDOFF.md §11 (header + new block).docs/CONTEXT.md (Last-Updated header + What-changed bullet).Trade.scheduled_exit_dt(cal) was MISSING from scripts/phase3_simulator/population.py — the reference's run_trade calls it, so the delegation in #306.b would AttributeError immediately. My TestReferenceParity cross-check test only validated dataclass FIELDS (via dataclasses.fields()), not METHODS. Fixed both as part of #306.b: added scheduled_exit_dt to population.py + new TestReferenceParity.test_method_names_match_expected test that asserts {release_dt, scheduled_exit_dt, position_value, stop_distance_pct} are present on BOTH dataclasses + TestReferenceParity.test_scheduled_exit_dt_consistency_amc that asserts identical output on identical inputs. Caught BEFORE writing any #306.b code (per NO SHORTCUTS §A pre-coding rule).scripts/phase3_simulator/, all offline-only):cost_model.py (~140 lines) — CostEstimate frozen dataclass (method / spread_pct / entry_cost / exit_cost / total_cost) + estimate_cost(trade, opp_log_pm_fields=None) -> CostEstimate per Q2 FLAT 1% default. The pm_v2_orderbook path fires when ALL 3 fields (pm_best_bid + pm_best_ask + pm_spread) are non-None (matches Finding #307 conditional-label semantic); otherwise the proxy_flat_1pct path fires (50bps each leg = 1% round-trip on position value). Half-spread cost uses entry_price * abs(shares) / 2 per leg with abs(shares) defense against negative-shares SHORT feeds. cost_estimate_to_dict for JSON output schema. Module-level docstring documents the design-doc §4.4 + §9 + §10.1 cross-references + the explicit "cost NOT folded into pnl_d for baseline; held separately for gauntlet aggregation in #306.c" decision.reference_adapter.py (~120 lines) — thin shim per Q1 HYBRID delegation. Re-exports TradeSim / fetch_bars / run_trade / simulate_baseline / simulate_finding_a_hard / simulate_window_only / simulate_combined_window_atr / simulate_no_stop from scripts.analyze_finding_a_full. REFERENCE_RULE_IDS = (A_base, FA_soft, FA_hard, RuleB, RuleD, RuleG, RuleF) kept in lock-step with TradeSim.run_all. _assert_reference_module_stable() fires at import time + checks inspect.signature param counts against _EXPECTED_PARAM_COUNTS dict; raises RuntimeError with memory.md §M-pointing CHANGE-CONTRACT diagnostic on any drift. (Caught a real signature miscount during my own writing: I expected 9 params for simulate_combined_window_atr but actual is 8 — the assertion fired correctly on collection-time import; fixed the expectation.)replay.py (~210 lines) — ReplayContext dataclass (polygon_key + cal + sleep_s + optional opp_log_pm_fields_by_audit_id mapping for PR 2+ cost-model wiring). ReplayResult dataclass (audit_id / symbol / direction / timing / entry_time / exit_time / live_pnl / per_rule_pnl_d Dict[str, Optional[float]] / per_rule_aux Dict[str, Dict[str, Any]] / cost_estimate / halt_suspect / atr_dollars). replay_trade(trade, ruleset, ctx) delegates to _ref_adapter.run_trade, unpacks sim.rules into per_rule_pnl_d + per_rule_aux (pnl_d kept SEPARATE from aux to avoid duplication), computes cost_estimate via estimate_cost(trade, opp_log_pm). replay_population(trades, ruleset, ctx, progress_every=25) loops + logs progress + skips None results with WARNING. replay_result_to_dict for JSON schema output. ruleset arg is currently passed-but-not-consumed for the baseline (production behavior unconditionally) — accepts the arg so #306.c + PR 2-5 can introduce variant branching without breaking the signature.cross_check_baseline.py (~280 lines) — OPERATOR-RUNNABLE SCRIPT (NOT a unit test). Implements design doc §10.1 PR 1 acceptance gate: load N trades via population.load_trades, run replay_population(trades, baseline_ruleset(), ctx), load scripts/finding_a_full.csv (reference's most-recent run output), INNER JOIN on audit_id, per-rule compute abs(ours - theirs) drift, count n_within_tolerance per rule, PASS if every rule has match_pct >= 95% within $1 tolerance (defaults from design doc §10.1), else FAIL with per-trade outlier dump (first 10 per rule). Writes summary to scripts/phase3_simulator_baseline_cross_check.json. Exits 0/1/2. CLI args: --reference-csv / --population / --limit / --tolerance-dollars / --min-match-pct / --polygon-sleep / --output / --log-level. Standalone — does NOT run as part of pytest (would require POLYGON_KEY + DATABASE_URL + 1-7 min wall time per Finding #240 cache hit rate).scripts/phase3_simulator/population.py (NEW lockstep fixes flagged by INFERENCE DISCIPLINE catch above):Trade.scheduled_exit_dt(cal) method matching analyze_finding_a_full.Trade.scheduled_exit_dt (mirrors the BMO/AMC calendar lookup via _scheduled_times_from_timing + fallback to exit_time.astimezone(ET)).sys.path bump + lazy-imports of src.data_tracker.collectors.outcome_resolver._scheduled_times_from_timing and src.earnings.market_calendar.MarketCalendar (same pattern as scripts/analyze_finding_a_full.py:85-95).test_population.py, 37 NEW tests total; 79/79 PASS local Python 3.11):test_cost_model.py (~140 lines, 13 tests in 4 classes): TestConstants (2 = regression-lock PROXY_FLAT_SPREAD_PCT=0.01 + method string ids); TestProxyFlatPath (5 = no-args fires proxy + None fires proxy + empty-dict fires proxy + 3-way partial-fields fires proxy via parametrize + SHORT abs-shares math); TestPmV2OrderbookPath (2 = all-3-populated fires orderbook + spread==0 is NOT treated as NULL); TestSerialization (2 = frozen + dict keys).test_reference_adapter.py (~80 lines, 13 tests in 3 classes): TestReExports (8 = parametrize over all 8 callables); TestRuleIdRegistry (2 = expected ids + source-grep that every id literally appears in TradeSim.run_all source); TestSignatureStabilityAssertion (3 = passes-on-current + fails-on-drift via monkeypatch + fails-on-missing-function via delattr — both with CHANGE-CONTRACT diagnostic check).test_replay.py (~195 lines, 9 tests in 3 classes): TestReplayTradeWithStubbedAdapter (6 = extracts per-rule pnl_d + attaches proxy cost_estimate by default + uses pm_v2_orderbook when opp_log provided + returns None when reference returns None + preserves per_rule_aux without pnl_d duplication + propagates halt_suspect + atr_dollars); TestReplayPopulation (2 = skips None + preserves input order); TestReplayResultSerialization (1 = JSON round-trip via replay_result_to_dict + json.dumps(default=str)).test_population.py: TestReferenceParity.test_method_names_match_expected (catches the #306.b §A pre-coding gap class) + TestReferenceParity.test_scheduled_exit_dt_consistency_amc (output-parity guard).cross_check_baseline.py:main() → argparse → DATABASE_URL/POLYGON_KEY checks → _run_cross_check() → load reference CSV → create_engine + load_trades → replay_population(trades, baseline_ruleset(), ctx) → INNER JOIN by audit_id → per-rule drift breakdown → _verdict() PASS/FAIL → _print_human() + write JSON → exit 0/1. replay_trade(trade, ruleset, ctx) → _ref_adapter.run_trade(trade, polygon_key, sleep_s, cal) → analyze_finding_a_full.run_trade (delegated per Q1 HYBRID) → fetch_bars + halt detection + ATR + TradeSim.run_all → sim.rules[rule_id]['pnl_d'] extracted → estimate_cost(trade, opp_log_pm) → ReplayResult returned.scripts/phase3_simulator/cost_model.py:79 estimate_cost; scripts/phase3_simulator/reference_adapter.py:74 _assert_reference_module_stable; scripts/phase3_simulator/replay.py:121 replay_trade; scripts/phase3_simulator/cross_check_baseline.py:104 _run_cross_check; scripts/phase3_simulator/population.py:128 Trade.scheduled_exit_dt._assert_reference_module_stable raises at import time with §M diagnostic ✅ — covered by TestSignatureStabilityAssertion.test_fails_on_signature_drift + test_fails_on_missing_functionTestReferenceParity.test_method_names_match_expected fails loudly ✅TestProxyFlatPathTestProxyFlatPath.test_partial_pm_fields_falls_back_to_proxy parametrizeTestReplayTradeWithStubbedAdapter.test_returns_none_when_reference_returns_none + TestReplayPopulation.test_skips_none_results_load_reference_csv raises FileNotFoundError with runbook hint ✅pytest tests/unit/phase3_simulator/ -v → 79/79 PASS local Python 3.11.python3 scripts/phase3_simulator/runner.py --skip-db exits 0 with documented baseline RuleSet summary (re-verified post-#306.b).python3 scripts/phase3_simulator/cross_check_baseline.py --limit 200 → expect verdict PASS (>= 95% within $1 on every rule). Wall time ~1-7 min depending on equity_bars_1m cache hit rate (Finding #240 should give mostly cache hits since the reference's last run already populated the same trade-symbols). Output JSON: scripts/phase3_simulator_baseline_cross_check.json (NOT committed; operator-runnable evidence file).from scripts.phase3_simulator → 5 source files (the 4 new + cross-check script) + 7 test files (4 phase3_simulator tests + the new ones) — all intentional.from scripts.analyze_finding_a_full → 2 imports: tests/unit/phase3_simulator/test_population.py:176 (TestReferenceParity cross-check, from #306.a) + tests/unit/phase3_simulator/test_reference_adapter.py:51 (rule-id source-grep, NEW in #306.b). Plus the adapter module's own re-exports (intentional + central per Q1 HYBRID)._scheduled_times_from_timing → 3 sites: outcome_resolver.py (definition) + analyze_finding_a_full.py:93 (reference's import) + phase3_simulator/population.py (NEW lockstep import via #306.b). Same usage pattern; no drift.systemctl is-active (5 services + 5 timers + ibgateway-ibc) → all active (no service-restart changes; sanity check only).curl /api/integrity → 47/47 OK preserved (no integrity rule changes).cd [hidden] && source .env && export DATABASE_URL && venv/bin/python3 -m pytest tests/unit/phase3_simulator/ tests/unit/test_pm_v2_conditional_label.py tests/unit/test_ruleb_window_gate.py tests/unit/test_signal_regime_gate.py tests/unit/test_signal_volume_threshold.py tests/unit/test_signal_reliability_gate.py -q → expect 121/121 PASS on prod Python 3.12.cd [hidden] && source .env && export DATABASE_URL POLYGON_KEY && venv/bin/python3 scripts/phase3_simulator/cross_check_baseline.py --limit 200 --log-level INFO → expect verdict PASS with match_pct >= 95.0% on every rule + max_drift <= $1.00. Wall time estimate: ~1-7 min depending on equity_bars_1m cache hit rate.python3 scripts/phase3_simulator/runner.py --population all-closed --limit 200 → expect documented baseline RuleSet summary + ~120 loaded trades + most-recent sample (id=128 APP as of #306.a §G probe).src/earnings/ or src/data_tracker/ references it).replay.ReplayResult + replay.replay_population and adds gauntlet.py (per design doc §8 + memory §K 4-check + 5-outlier + true-simulator-compliance) + H-rule self-validation (must return FAIL-EX-TOP-2 per §10.3) + reproducibility test (2 runs → 0-byte diff per §10.2) + output.py JSON schema + dashboard surface hook + strategy_performance_analysis.py phase3_sim_* sections.opp_log_pm_fields_by_audit_id plumbing in ReplayContext for pm_v2_orderbook cost-model fallback on counterfactual-admitted markets.scripts/phase3_simulator/cost_model.py (NEW, ~140 lines).scripts/phase3_simulator/reference_adapter.py (NEW, ~120 lines).scripts/phase3_simulator/replay.py (NEW, ~210 lines).scripts/phase3_simulator/cross_check_baseline.py (NEW, ~280 lines, operator-runnable script).scripts/phase3_simulator/population.py (UPDATED: added scheduled_exit_dt method + lockstep imports).tests/unit/phase3_simulator/test_cost_model.py (NEW, ~140 lines, 13 tests).tests/unit/phase3_simulator/test_reference_adapter.py (NEW, ~80 lines, 13 tests).tests/unit/phase3_simulator/test_replay.py (NEW, ~195 lines, 9 tests).tests/unit/phase3_simulator/test_population.py (UPDATED: +2 tests test_method_names_match_expected + test_scheduled_exit_dt_consistency_amc).docs/PHASE_3_SIMULATOR_DESIGN.md (§12 PR 1: #306.b marked SHIPPED).docs/FINDINGS.md (this entry).docs/AGENT_HANDOFF.md §11 (header + new block).docs/CONTEXT.md (Last-Updated header + What-changed bullet).scripts/phase3_simulator/ package + RuleSet dataclasses + CLI stub) (RESOLVED May 28, 2026 — third PR-1 ship; foundation incremental commit #1 of 3)docs/PHASE_3_SIMULATOR_DESIGN.md §12 (operator AskQuestion Q3 = option B INCREMENTAL MILESTONES; rationale: each sub-commit gets full memory.md §B/§E/§G discipline; intermediate states are deployable; easier to course-correct on a multi-day project).scripts/phase3_simulator/).scripts/analyze_finding_a_full.py (952-line Apr-25 reference simulator behind Finding #228 FA retraction). Options: (A) REFACTOR — move simulate_/load_trades/aggregate helpers into shared modules and turn analyze_finding_a_full.py into a thin wrapper / (B) DUPLICATE — port fresh into scripts/phase3_simulator/ and cross-check at PR 1 acceptance / (C) HYBRID — duplicate the minimal Trade + load_trades into population.py but delegate stop-loss simulate via a future reference_adapter.py in #306.b. Operator chose (C) HYBRID — preserves the proven stop-loss math anchored in the reference (zero regression risk on the FA-retraction baseline) while avoiding a duplicate copy of trade-loading logic that would silently drift in a year.cost_model_method='proxy_flat_1pct'; easy to swap in PR 1.5 if Phase 3 simulator dry-run data reveals materially-wrong-vs-live-CLOB-fetch ground-truth. (Lands in #306.b's cost_model.py.)scripts/phase3_simulator/__init__.py (~40 lines) — package marker docstring with design-doc + sub-commit roadmap + Q1/Q2/Q3 decisions.scripts/phase3_simulator/population.py (~200 lines): Trade dataclass + load_trades loader duplicated from scripts.analyze_finding_a_full per Q1 HYBRID. Module docstring includes a CHANGE-CONTRACT (any change to the Trade schema here MUST land in analyze_finding_a_full.py in the same commit and vice versa — same lesson as memory.md §M cross-interface rule). supported_populations() helper returns the four valid filter strings.scripts/phase3_simulator/rules.py (~230 lines): declarative dataclasses per design doc §5.4 — RegimeClassifierSpec, SizingPolicySpec, GateThresholdSpec, DepthGateSpec, RuleSet. All frozen=True (immutable). baseline_ruleset(id_suffix='may28') factory constructs the production-baseline RuleSet matching May 28, 2026 live config (Mar-9 sizing per Finding #211, $2k gate per Finding #282, compound regime classifier, depth threshold 0.50, active selection rules per Findings #304/#207/#202, active stop-loss rule per Finding #305). spec_to_dict/spec_from_dict for JSON round-trip (required for design doc §10.2 reproducibility) via a _kind discriminator pattern.scripts/phase3_simulator/runner.py (~150 lines): CLI stub with --variant-id / --population / --limit / --skip-db / --log-level args. Loads baseline RuleSet + (optionally) trades from DB; prints a summary; exits 0. Does NOT do replay or gauntlet — that lands in #306.b/c. Explicit --skip-db mode for CLI smoke-testing without DATABASE_URL. Returns exit 2 if variant_id is anything other than baseline_may28 (the only id #306.a supports; sweep ids land in PR 2+).tests/unit/phase3_simulator/__init__.py (1-line package marker).tests/unit/phase3_simulator/test_population.py (~250 lines, 18 tests in 4 test classes):TestPositionValue (2): LONG positive shares; SHORT absolute-value guard against negative-shares feeds.TestStopDistancePct (5): LONG below-entry / SHORT above-entry / zero-entry guard / zero-stop guard / direction case-insensitive.TestReleaseDt (5): actual_earnings_time wins / BMO default 07:30 ET / AMC default 16:15 ET / NULL earnings_date falls back to entry-time date / NULL label+timing defaults to BMO.TestReferenceParity (4): field-name set match between our Trade and analyze_finding_a_full.Trade; field-type match; position_value consistency on identical inputs; release_dt consistency on BMO default. These are the CHANGE-CONTRACT guards — break them on either side and tests fail loudly with a memory.md §M-pointing diagnostic message.TestSupportedPopulations (2): all 4 expected filter values present; cross-source-grep verifies the population keys also appear in scripts.analyze_finding_a_full.load_trades source (last-mile drift check).tests/unit/phase3_simulator/test_rules.py (~210 lines, 24 tests):RuleSet minimal construction (1).baseline_ruleset regression-locks May 28 production config across regime / sizing / gate / depth / selection / stop-loss assertions (7 tests).spec_to_dict/spec_from_dict round-trip on every spec class + full RuleSet (5 tests) + unknown-kind raises + missing-kind raises (2).python3 scripts/phase3_simulator/runner.py [args] → argparse → optional unrecognized-id exit 2 → build baseline_ruleset() → print RuleSet summary → if --skip-db exit 0 else lazy-import SQLAlchemy + create_engine + population.load_trades(engine, population, limit) → print loaded count + most-recent sample → exit 0/1.--skip-db produces documented baseline summary in ~50ms. --population all-closed --limit 200 on prod produces ~120 loaded trades (matching reference simulator's load_trades on same DB snapshot).scripts/phase3_simulator/runner.py:103 main(), :108-119 baseline-RuleSet print, :124-129 trade load + sample print. scripts/phase3_simulator/population.py:127 load_trades(). scripts/phase3_simulator/rules.py:130 baseline_ruleset(). scripts/phase3_simulator/__init__.py:1 package marker.--variant-id → stderr message + exit 2 ✅ — covered by integration smoke test (post-deploy)--skip-db → stderr + exit 2 ✅ — covered by --skip-db CLI smokeTestReferenceParity tests fail with memory.md §M-pointing diagnostic ✅TestSerializationRoundTrip.*_roundtrip tests fail ✅python3 -m pytest tests/unit/phase3_simulator/ -v → 42/42 PASS on Python 3.11 (mirror of prod Python 3.12).python3 scripts/phase3_simulator/runner.py --skip-db exits 0 with documented baseline summary.pytest … + DATABASE_URL=… python3 scripts/phase3_simulator/runner.py --population all-closed --limit 200 loads ~120 trades and prints most-recent sample. (#306.a §G probe runway.)scripts/phase3_simulator|phase3_simulator workspace-wide: 6 source files (the 4 new package files + the 2 new test files) + 3 doc references (CONTEXT / AGENT_HANDOFF / PHASE_3_SIMULATOR_DESIGN). No stale references.from scripts.analyze_finding_a_full: 1 import in tests/unit/phase3_simulator/test_population.py:176 (intentional — TestReferenceParity cross-check). No other simulator engines to consolidate.raise SystemExit(main()) matches scripts/analyze_finding_a_full.py:main exit-code convention.systemctl is-active earnings-trader polymarket-dashboard polymarket-collector polymarket-live-trades polymarket-integrity-monitor → all active (no service-restart-relevant changes in this commit; sanity check only).curl /api/integrity → 47/47 OK preserved (no integrity rule changes in this commit; sanity check only).cd [hidden] && source .env && export DATABASE_URL && venv/bin/python3 -m pytest tests/unit/phase3_simulator/ tests/unit/test_pm_v2_conditional_label.py tests/unit/test_ruleb_window_gate.py tests/unit/test_signal_regime_gate.py tests/unit/test_signal_volume_threshold.py tests/unit/test_signal_reliability_gate.py -q → expect 84/84 PASS on prod Python 3.12.cd [hidden] && source .env && export DATABASE_URL && venv/bin/python3 scripts/phase3_simulator/runner.py --population all-closed --limit 200 → expect documented baseline RuleSet summary + ~120 loaded trades + most-recent sample (latest trade as of session start was id=163 HPQ entered May 27 14:34 UTC, still open).cd [hidden] && source .env && export DATABASE_URL && venv/bin/python3 scripts/phase3_simulator/runner.py --skip-db → exits 0 with the baseline RuleSet summary.src/earnings/ or src/data_tracker/ references it).population.Trade + rules.baseline_ruleset() and adds cost_model.py (proxy-flat-1pct per Q2) + replay.py + reference_adapter.py (delegates stop-loss simulate_* to scripts.analyze_finding_a_full per Q1 HYBRID) + baseline cross-check vs analyze_finding_a_full.py (acceptance per design doc §10.1: within $1/trade on ≥95% of trades, gauntlet aggregates within $5/100).strategy_performance_analysis.py phase3_sim_* sections).scripts/phase3_simulator/__init__.py (NEW, ~40 lines).scripts/phase3_simulator/population.py (NEW, ~200 lines).scripts/phase3_simulator/rules.py (NEW, ~230 lines).scripts/phase3_simulator/runner.py (NEW, ~150 lines).tests/unit/phase3_simulator/__init__.py (NEW, 1 line).tests/unit/phase3_simulator/test_population.py (NEW, ~250 lines, 18 tests).tests/unit/phase3_simulator/test_rules.py (NEW, ~210 lines, 24 tests).docs/PHASE_3_SIMULATOR_DESIGN.md (§12 PR 1 status: mark #306.a SHIPPED).docs/FINDINGS.md (this entry).docs/AGENT_HANDOFF.md §11 (header + new block).docs/CONTEXT.md (Last-Updated header + What-changed bullet).docs/PHASE_3_SIMULATOR_DESIGN.md §12) — "compute_counterfactual_pnl.py re-run + backfill generate_signal + depth stages (target ≥75% per Phase 0 gate 4)."counterfactual_computed_at IS NULL (truly pending): 78 rows (54 timing + 24 generate_signal)counterfactual_computed_at IS NOT NULL were already processed and marked counterfactual_version='no_price_simulation_v1'. Direct query revealed ALL 519 such rows have direction_would_be='NONE' — meaning conviction is in deadband (between SHORT_THRESHOLD=0.30 and LONG_THRESHOLD=0.70) so _direction_hypothesis_for_log returns "NONE". The script's _can_simulate_prices correctly refuses to simulate counterfactual P&L when there's no direction (you can't compute LONG/SHORT P&L without knowing direction). These rows are STRUCTURALLY un-simulatable, not a coverage gap.compute_counterfactual_pnl.py --limit 200):generate_signal + depth stages is approximately what we now have (~69% / ~28%), NOT 100% or even 75%. The gap to that 75% Phase 0 target was a framing artifact — Phase 0 audit treated all hyp_pnl IS NULL rows as "should be backfilled" without realizing ~95% of those rows are deadband-conviction signals that have no direction and thus no computable counterfactual P&L. The Phase 3 simulator's primary input is the signal_emitted subset (100% coverage), so this ceiling does NOT block any Phase 3 work.docs/PHASE_3_SIMULATOR_DESIGN.md (§3 Gate 4 status updated with structural ceiling clarification).docs/FINDINGS.md (this entry).docs/AGENT_HANDOFF.md §11 (header + brief block).docs/CONTEXT.md (Last-Updated header + brief What-changed bullet).signal_emitted 100% subset is sufficient for all 4-bundle item analyses.snapshot_schema='pm_v2' conditional-label fix at _emit_pm_opportunity_log (RESOLVED May 28, 2026 evening — first PR-1 ship of the Phase 3 simulator project)docs/PHASE_3_SIMULATOR_DESIGN.md §15 Decision 1 = operator AskQuestion option A "MINIMAL conditional-label fix"). This is the LOCKSTEP fix for the original Finding #273 (deferred to Phase 3 simulator lockstep on Apr 27, 2026 then re-confirmed May 28 afternoon).PHASE_3_SIMULATOR_DESIGN.md §9) specified the fix scope (Option A MINIMAL conditional-label + cost-model proxy fallback) after operator AskQuestion. This commit ships Option A._emit_pm_opportunity_log at src/earnings/signal_generator.py:1041 set snapshot_schema='pm_v2' unconditionally inside the if conviction_result is not None: block, even when conviction_result.best_bid / best_ask / spread were ALL None. Root cause: ConvictionCalculator._get_latest_from_price_snapshots fallback fires for one-sided orderbooks (common in high-conviction markets like NVDA at 97%) and returns ConvictionResult with NULL orderbook fields. The schema label was set anyway because the writer only checked conviction_result is not None, not the field-population state.skip_stage schema n w_bid w_ask w_spread
signal_emitted pm_v2 185 0 0 0 (100% NULL on labeled rows)
generate_signal pm_v2 989 1 1 1 (99.9% NULL)
depth pm_v2 49 48 38 38 (partial: ~78% complete)
Total: 1,055 NULL bid/ask/spread of 1,104 pm_v2-labeled rows = 95.6% mislabel rate.python
# src/earnings/signal_generator.py line 1041+
pm_snapshot = {
"method": conviction_result.method,
"pm_best_bid": conviction_result.best_bid,
...
# snapshot_schema removed from unconditional dict initialization
}
if (
conviction_result.best_bid is not None
and conviction_result.best_ask is not None
and conviction_result.spread is not None
):
pm_snapshot["snapshot_schema"] = "pm_v2"
Conservative gate (ALL THREE fields required, not just bid) → label means "complete orderbook snapshot present". Partial-data rows fall back to implicit v1 (no snapshot_schema key emitted) but ALL OTHER pm_* fields continue to be set (preserves JSONB shape for forward-compat readers). 17 lines added (5 code + 12 context comment).src/data_tracker/integrity_monitor.py:541-557 had rule null_rate:opp_log:pm_v2_post_deploy checking (snapshot->>'snapshot_schema') IS NOT NULL with max_null_pct=20.0. Post-fix, ~95% of signal-stage rows would have NULL schema_label (intentionally), causing integrity FAIL on next 5-min cycle. Updated rule to check (snapshot->>'method') IS NOT NULL instead — same intent ("verify pm enrichment block at line 1019+ actually ran for rows that should have ConvictionResult") but unaffected by the new conditional-label semantic. method is set unconditionally inside the enrichment block at line 1029. Same null-rate threshold (20%) + same WHERE clause preserved. 18 lines updated (3 code + 15 context comment explaining the field-name change + reasoning)._emit_pm_opportunity_log called at 25+ sites in signal_generator.py; 8 of those (BMO + AMC × 4 stages: depth, generate_signal, signal_emitted, signal_rules) pass conviction_result. Inside the function at line 1019, if conviction_result is not None: branch runs → builds pm_snapshot dict → merges into merged['snapshot'] → writes via log_polymarket_opportunity. Post-fix: pm_snapshot["snapshot_schema"] = "pm_v2" only set inside new conditional at line 1057+ that requires all 3 orderbook fields populated._get_latest_from_price_snapshots (one-sided orderbook case) → best_bid=None, best_ask=None, spread=None. Pre-fix: row written with snapshot_schema='pm_v2' + pm_best_bid=None → mislabeled. Post-fix: conditional fails → row written WITHOUT snapshot_schema key + pm_best_bid=None → accurately labeled as implicit v1. Other simulate: signal from a fully-populated orderbook (e.g., $9k NVDA market) → all 3 fields non-None → condition passes → snapshot_schema='pm_v2' written + non-NULL bid/ask/spread → accurate.src/earnings/signal_generator.py:1028-1058 (pm_snapshot dict construction + new conditional). src/data_tracker/integrity_monitor.py:541-577 (rule definition with new col field).test_label_not_set_when_all_three_are_nonetest_label_set_when_all_three_fields_populatedtest_no_pm_snapshot_block_when_conviction_result_is_nonemethod field is always populated when enrichment ran → null-rate stays at ~0% (verified per existing pm_v2_post_deploy baseline) ✅pytest tests/unit/test_pm_v2_conditional_label.py -v → 6/6 PASS on prod Python 3.12 venv.pytest tests/unit/test_pm_v2_conditional_label.py tests/unit/test_ruleb_window_gate.py tests/unit/test_signal_regime_gate.py tests/unit/test_signal_volume_threshold.py tests/unit/test_signal_reliability_gate.py → 42/42 PASS, no regressions.snapshot_schema='pm_v2' IFF all 3 fields populated. SQL: SELECT skip_stage, snapshot->>'snapshot_schema', snapshot->>'pm_best_bid' IS NULL AS bid_null FROM polymarket_opportunity_log WHERE evaluated_at > NOW() - INTERVAL '1 day' AND skip_stage IN ('signal_emitted', 'generate_signal') ORDER BY evaluated_at DESC LIMIT 10.snapshot_schema or write pm_v2. Findings:src/data_tracker/integrity_monitor.py:549 — reader, UPDATED in lockstep this commit (changed to method field).earnings_dashboard/templates/dashboard.html:2791 — purely a UI badge displayed in the strategy-analysis tab ("✓ Phase 2.1 — opp_log pm_v2 (#243)"). It's a static text label, no logic gate. Doesn't need update.docs/STRATEGY_ANALYSIS_ARCHITECTURE.md + docs/DATABASE_AUDIT_2026_04_27.md + docs/STRATEGY_AUDIT_v3.md + docs/DATABASE_DIRECTIVES.md — historical narrative references; no behavior change needed (they describe what was happening at audit time; this finding is a NEW change, not a re-write of history).systemctl is-active earnings-trader polymarket-dashboard polymarket-collector polymarket-live-trades polymarket-integrity-monitor → all active.curl /api/integrity → 47/47 OK preserved (the updated rule should immediately re-check + pass on new criterion).pytest tests/unit/test_pm_v2_conditional_label.py → 6/6 PASS on prod with deployed code.src/earnings/signal_generator.py (17 lines net added: 5 code + 12 context comment).src/data_tracker/integrity_monitor.py (18 lines net updated: 3 code + 15 context comment).tests/unit/test_pm_v2_conditional_label.py (NEW, ~150 lines, 6 tests).docs/PHASE_3_SIMULATOR_DESIGN.md (§3 + §12 + §15 updated with corrected gate-1 understanding from PR 1 §A pre-coding).docs/FINDINGS.md (this entry).docs/AGENT_HANDOFF.md §11 (header + new block).docs/CONTEXT.md (Last-Updated header + What changed bullet).docs/FINDINGS.md; this entry is the SHIP-Phase-1 close. The 1,055 pre-fix rows remain in DB as HISTORICAL data with the original mislabeled snapshot_schema='pm_v2' — they are NOT retroactively unlabeled (would be a destructive backfill that loses the audit trail of "we mislabeled them, then we fixed forward"). Cost model in Phase 3 simulator handles both pre-fix (label-but-NULL) and post-fix (label-iff-populated) rows uniformly via proxy fallback for any row where bid/ask/spread are NULL.docs/STRATEGY_PLAYBOOK.md §2 RuleB was IN-QUEUE rank 2 since Apr 25 evening (PROMOTED from rank 4 via Finding #232 re-validation when corrected stop classification revealed stops are the strategy's biggest leak); after Rule #1 ship earlier today, RuleB became rank 1 IN-QUEUE. Phase 2 active-mode flip pending operator review of dry-run logs over ~few days.scripts/analyze_finding_a_full.py:simulate_window_only (line 443) explicitly skips stop-triggering during the window (if w0 <= b["t"] <= w1: continue # inside window: stop silent). The simulator's +$127/100 ex-top-2 EV REQUIRES the live hardware stop to actually be silent during the window. A soft-gate implementation would deliver $0 EV — exactly the FA_soft failure mode that Finding #228 retracted on Apr 25 (FA_soft was $0/100 because the hardware STP-LMT carries outsideRth=True per Finding #179; the broker fills regardless of bot bookkeeping). The Playbook spec's caveat "works for RuleB only because the window is narrow enough..." was INCORRECT — the broker's hardware fill closes the position at the stop-fired price regardless of window narrowness. Per memory.md NO SHORTCUTS + INFERENCE DISCIPLINE, agent did NOT silently code the broken spec; surfaced the inconsistency via AskQuestion with comparison matrix + recommendation BEFORE writing any code.ruleb_state DB column from the 8-item list is unnecessary — derived-from-current-state (now, hardware_stop_active, release_dt, scheduled_exit_time) is strictly better per memory.md "IBKR is source of truth for live state" + §C surgical-changes. Skipped the migration; flagged for operator override if disagreement.t = release_dt - 90min: cancel hardware STP-LMT via existing IBKRClient.cancel_order(stop_order_id). Stop becomes silent.t = release_dt + 90min: re-place hardware STP-LMT via existing order_executor._place_stop_order(symbol, shares, side, stop_price, primary_exchange). Stop restored at original price.scheduled_exit - 30min, force re-place even if technically still in window (catches stuck-cancelled-past-rearm edge cases).(now, hardware_stop_active per_check_hardware_stop_status(symbol), release_dt, scheduled_exit, entry_time) — no DB column / migration. On bot restart, state re-derives from current world. No race condition between IBKR action and DB persist (which a column would have introduced).| Rule | Headline /100 | Ex-top-2 /100 | Ex-repeat-tickers /100 | Gauntlet verdict |
|---|---|---|---|---|
| RuleB (±90 min window) | +$236 | +$127 | +$239 | ✅ PASS all 4 checks |
| RuleG (window + ATR) | +$242 | +$105 | +$245 | ⚠️ ex-top-2 weaker than RuleB; more invasive |
| RuleD (ATR k=2.0) | +$182 | +$44 | +$183 | ❌ ex-top-2 fragile |
| RuleF (no stop ever) | +$191 | +$53 | +$192 | n/a (reference only) |
| FA_hard (Finding A) | +$13 | +$8 | +$9 | ❌ rearm_gap_open arm n=24 at -$1,545/100 (retraction holds) |
src/earnings/ruleb_window_gate.py (NEW, ~180 lines): pure decision function decide_ruleb_action(), RuleBAction enum (8 values), RuleBDecisionContext dataclass, derive_earnings_release_dt() helper. Module-level docstring explains the simulator alignment + Phase 1 limitations + why derived-state-not-DB.src/earnings/trading_orchestrator.py (4 new methods, ~220 lines): _process_ruleb_window_gate() per-cycle dispatcher; _ruleb_log_once() for once-per-(symbol,action) skip-logging; _ruleb_fire_cancel() for cancel side effect (dry-run logs only, active mode actually cancels); _ruleb_fire_rearm() for re-place side effect (dry-run logs only, active mode actually re-places + alerts on failure); main-loop hook between check_stop_loss_backup and the loop sleep.src/earnings/config.py (4 new env vars, ~25 lines including comment block): RULEB_ENABLED (bool, default False — production default disabled), RULEB_MODE ('dry_run' | 'active', default 'dry_run'), RULEB_WINDOW_MINUTES (int, default 90 — matches simulator), RULEB_HARD_FALLBACK_MIN_BEFORE_EXIT (int, default 30).tests/unit/test_ruleb_window_gate.py (NEW, ~225 lines, 25 tests): covers all 8 RuleBAction return values + sanity-gate skips (no stop_price / no earnings_dt / no scheduled_exit) + late-entry edge case + pre/in/post-window state transitions + hard-fallback safety boundary + derive_earnings_release_dt helper._place_stop_order places hardware STP-LMT → orchestrator main loop calls _process_ruleb_window_gate per cycle → for each open position: decide_ruleb_action(ctx) → dispatch to _ruleb_fire_cancel or _ruleb_fire_rearm (or no-op). At release_dt - 90min: state transitions to CANCEL → ibkr.cancel_order(stop_order_id) (or dry-run log) → next cycle sees hardware_stop_active=False → state NOOP (in-window). At release_dt + 90min: state transitions to REARM_POST_WINDOW → order_executor._place_stop_order(...) (or dry-run log) → next cycle sees hardware_stop_active=True → state NOOP (post-window).src/earnings/ruleb_window_gate.py:decide_ruleb_action (line ~120-180); src/earnings/trading_orchestrator.py:_process_ruleb_window_gate (line ~4490+); main-loop hook (line ~5128).cancel_order returns False → log warning; retry next cycle (stop still active = position protected; only EV opportunity lost — acceptable bias toward safety)._place_stop_order fails → HIGH-severity Telegram alert + retry every cycle (position unprotected until success; alert wakes operator).signal=None (Phase 1 limitation per position_manager.load_persisted_positions:1155) → graceful SKIP_NO_EARNINGS_DT action with once-per-position warning log; rule simply doesn't fire on that position.SKIP_LATE_ENTRY; stop runs naturally through whatever remains of position lifecycle.cancel_target would fire at wildly wrong time. Mitigated by SKIP_LATE_ENTRY check (if cancel_target ≤ entry_time, no cancel scheduled).pytest tests/unit/test_ruleb_window_gate.py -v → 25/25 PASS on prod Python 3.12 venv.test_ruleb_window_gate.py + test_signal_regime_gate.py + test_signal_volume_threshold.py + test_signal_reliability_gate.py — no regressions.RULEB_ENABLED=true env var (Phase 1 keeps RULEB_MODE='dry_run' default); monitor journalctl -u earnings-trader | grep "RuleB\[" for dry-run "would CANCEL" / "would RE-PLACE" entries on the next BMO/AMC trade entry that survives the volume gate.run_bmo_cycle, run_amc_cycle, run_exit_cycle, check_stop_loss_backup) all run on the same main-loop polling pattern. _process_ruleb_window_gate follows the same shape: per-cycle iteration of active_positions, per-position decision, dispatch on action. No new architectural primitive introduced. Existing _check_hardware_stop_status (line ~4340) is reused as the "hardware stop active?" source of truth. IBKRClient.cancel_order + order_executor._place_stop_order are existing well-tested entry points; RuleB just calls them at new times.scripts/strategy_performance_analysis.py:build_action_queue): queue rank-2 row (Sim RuleB after Rule #1 ship) flipped status IN-QUEUE → IMPLEMENTED-DRY-RUN; rank: 2 → rank: None; implemented_date: "May 28, 2026 (Phase 1 DRY-RUN)"; implemented_findings: ["#305"]; live_evidence + bias_check + next_action rewritten with May-28 fresh sim numbers + Phase-1/Phase-2 rollout notes.docs/STRATEGY_PLAYBOOK.md): §2 — RuleB row moved from 🟢 IN-QUEUE table to ✅ IMPLEMENTED table; remaining IN-QUEUE rules renumbered (3-stop breaker 3→1, trade #150+ revalidation 4→2). §3.7 verified-finding entry gets a SHIPPED banner with as-shipped narrative + Phase 1 limitations. §6.7 implementation-notes block REWRITTEN — the original spec described soft-gate (which would deliver $0 EV); the AS-SHIPPED section documents the hardware cancel-and-rearm pattern + all 4 file changes + failure modes + Phase 1→Phase 2 rollout. Original soft-gate spec preserved below as "DO NOT IMPLEMENT" historical context (so future agents debugging "why doesn't the code match the original spec?" find the answer).signal=None per position_manager.load_persisted_positions:1155 → cannot derive earnings_release_dt → RuleB skips them with SKIP_NO_EARNINGS_DT + once-per-position warning log. Phase 2 active-mode flip may need to add a restart-safe lookup if dry-run logs show this happens in practice. Three candidate Phase 2 approaches: (a) add condition_id field to ActivePosition + query earnings_markets table at decision time; (b) add a new column earnings_release_dt_scheduled to trading_positions (migration); (c) back-derive from scheduled_exit_time using earnings_timing.py DEFAULT_TIMINGS per ticker (fragile). Defer per memory §C — Phase 1 dry-run will reveal whether restart-safe lookup is actually needed (most positions don't span bot restarts at current operations cadence).src/earnings/ruleb_window_gate.py (NEW, ~180 lines).src/earnings/trading_orchestrator.py (~220 lines added: 4 new methods + main-loop hook).src/earnings/config.py (~25 lines added: 4 env vars + context comment block).tests/unit/test_ruleb_window_gate.py (NEW, ~225 lines, 25 tests).scripts/strategy_performance_analysis.py (queue rank-2 row update).docs/FINDINGS.md (this entry).docs/STRATEGY_PLAYBOOK.md (§2 IMPLEMENTED table addition + IN-QUEUE renumbering; §3.7 status banner; §6.7 implementation-notes REWRITTEN with as-shipped + original-spec-preserved-as-historical).docs/AGENT_HANDOFF.md §11 (header + new block).docs/CONTEXT.md (Last-Updated header + "What changed this week" bullet).systemctl is-active earnings-trader polymarket-dashboard polymarket-collector polymarket-live-trades polymarket-integrity-monitor → all active.curl /api/integrity → integrity 47/47 OK preserved (no integrity rule changes).pytest tests/unit/test_ruleb_window_gate.py → 25/25 PASS on prod.scripts/strategy_performance_analysis.py regen so dashboard reflects IMPLEMENTED-DRY-RUN status immediately.curl /api/architecture-findings should show Finding #305 (count 213 → 214).RULEB_ENABLED=false default (production behavior unchanged until operator explicitly enables).journalctl -u earnings-trader | grep "RuleB\[" for dry-run "would CANCEL" / "would RE-PLACE" entries on the next BMO/AMC trade entry.docs/STRATEGY_PLAYBOOK.md §2 IN-QUEUE rank-1 since Apr 22, 2026 (v3 audit) — confirmed STRONGER on Apr 25 evening re-validation (Finding #232) — re-confirmed on May 28 fresh data regen. Operator chose ship sequence "Finding #273 deferred (per its lockstep narrative), Rule #1 ships first" via AskQuestion after evidence on #273 surfaced its bug surface was 16× bigger than the FINDINGS.md narrative described AND the fix scope was non-trivial without knowing the Phase 3 simulator's schema needs.AskQuestion calls: (1) "Phase 3 entry path" → "D then A, then re-evaluate" (originally Finding #273 then Rule #1); (2) "Fix #273 scope" → "(E) defer per the original FINDINGS.md lockstep approach" after evidence on the bug surface. Net sequence: Rule #1 ships first (this Finding), Finding #273 stays deferred to lockstep with Phase 3 simulator, then re-evaluate remaining options.trade_audit_log with reconciliation-filter):| regime | direction | n | wins | avg pnl | total pnl |
|---|---|---|---|---|---|
| downtrend | LONG | 22 | 5 (23 %) | −$6.20 | −$136.30 ← Rule #1 target bucket |
| sideways | LONG | 39 | 14 (36 %) | +$2.29 | +$89.28 |
| uptrend | LONG | 41 | 20 (49 %) | +$3.25 | +$133.20 |
| downtrend | SHORT | 3 | 3 (100 %) | +$57.51 | +$172.52 ← preserved by rule (LONG-only) |
| sideways | SHORT | 7 | 1 (14 %) | −$3.94 | −$27.60 |
| uptrend | SHORT | 8 | 1 (12 %) | −$3.28 | −$26.22 |
_build_trading_signal() → conviction check → volume gate → get_market_cap_for_symbol + classify_market_cap → small_cap_short skip → small_cap_long_above_80 skip → NEW Rule #1 block: if direction == LONG: current_regime = self._safe_get_regime(); if current_regime == "downtrend": return (None, "long_downtrend") → callers (BMO loop line 1606 / AMC loop line 2052) see reject=="long_downtrend" → emit _emit_pm_opportunity_log({skip_stage: "generate_signal", skip_reason: "long_downtrend", ...}) → no entry order placed.MarketRegimeProvider reporting regime="downtrend": bot calls _build_trading_signal() → conviction passes → volume passes → mcap=Large → existing skips no-op → Rule #1 fires → logger.info → return (None, "long_downtrend") → caller emits opportunity_log row → continue.src/earnings/signal_generator.py: new _safe_get_regime() helper at line ~1067 (immediately after _regime_fields_for_log, mirrors the same try/except pattern); skip block inserted at line ~1213 (immediately after the existing small_cap_long_above_80 check, before calculate_entry_exit_times). tests/unit/test_signal_regime_gate.py: new file, 7 tests._regime_provider is None (test mocking / production instance built before provider wired in) → _safe_get_regime() returns None → no skip.provider.get_snapshot() raises Exception → caught at DEBUG level (mirrors _regime_fields_for_log pattern) → returns None → no skip.snap.regime == "unknown" (classifier can't determine state — insufficient SPY MA history, stale weekend data, etc.) → does not equal "downtrend" → no skip.snap.regime == None → same code path as "unknown" → no skip.MarketRegimeProvider._CACHE_TTL_SECONDS) → at most 60 s drift between actual SPY state and classifier output → acceptable; the rule is statistical not deterministic.pytest tests/unit/test_signal_regime_gate.py -v — 7/7 PASS on prod's Python 3.12 venv (test names: test_long_in_downtrend_is_skipped, test_long_in_uptrend_is_not_skipped_by_rule1, test_long_in_sideways_is_not_skipped_by_rule1, test_short_in_downtrend_is_not_skipped_by_rule1, test_long_with_unknown_regime_is_not_skipped, test_long_with_no_regime_provider_is_not_skipped, test_long_when_regime_provider_raises_is_not_skipped).pytest tests/unit/test_signal_volume_threshold.py tests/unit/test_signal_reliability_gate.py tests/unit/test_signal_regime_gate.py — 11/11 PASS, no regressions._build_trading_signal to confirm the insertion pattern matches. 6 existing skip-rules: "intraday" (line ~1140), "long_conviction_max_threshold" (line ~1151), "conviction_deadband" (line ~1156), "volume_total_missing" + "low_volume" (lines ~1173, ~1190 — these call log_skipped_signal directly because they have data the upstream caller doesn't), "small_cap_short" (line ~1202), "small_cap_long_above_80" (line ~1206). Rule #1 follows the post-volume-gate pattern (matches small_cap rules; consistent with existing convention per operator AskQuestion choice). It does NOT call log_skipped_signal because the upstream caller (_emit_pm_opportunity_log at line ~1617/2065 with skip_stage="generate_signal", skip_reason=reject) already covers the opportunity-log row emission — no double-logging.STRATEGY_PLAYBOOK.md §7 sizing-priority-v2 re-test triggers) is NOT a Rule #1 ship-precondition — that trigger applies to the deferred bundle's re-evaluation, not to Rule #1 itself.src/earnings/signal_generator.py (new _safe_get_regime helper + Rule #1 skip block — ~40 lines total including comments).tests/unit/test_signal_regime_gate.py (NEW file, 7 unit tests + shared test scaffolding helper, ~185 lines).scripts/strategy_performance_analysis.py:build_action_queue (queue rank-1 row status flipped IN-QUEUE → IMPLEMENTED; implemented_date="May 28, 2026"; implemented_findings=["#304"]; live evidence + bias check text refreshed against May-28 fresh data; per memory §K dashboard mirror).docs/STRATEGY_PLAYBOOK.md §2 (Rule #1 row moved from 🟢 IN-QUEUE table to ✅ IMPLEMENTED table; remaining IN-QUEUE rules renumbered 1→2, 2→3, 3→4 since #1 is now shipped; §3.1 verified-finding entry gets a SHIPPED banner; §6.1 implementation-notes block expanded with AS-SHIPPED line numbers and acceptance-test results).docs/FINDINGS.md (this entry).docs/AGENT_HANDOFF.md §11 (last-updated header + new May-28 afternoon Rule #1 block below the Finding #279 amendment block).docs/CONTEXT.md (Last-Updated header + new "What changed this week" bullet).build_action_queue() once the JSON regen fires on Sun 2026-05-31 13:00 UTC; the next deploy's polymarket-strategy-analysis.timer will pick up the IMPLEMENTED status). For operator-immediate visibility, I will also re-run scripts/strategy_performance_analysis.py on prod as part of the post-deploy probe so the IMPLEMENTED row shows on the dashboard NOW rather than waiting until Sunday.systemctl is-active earnings-trader polymarket-dashboard polymarket-collector polymarket-live-trades polymarket-integrity-monitor → all active.curl /api/integrity | python3 -c '...' → integrity still 47/47 OK (the rule change should not affect any existing integrity rule; if a new FAIL surfaces, INVESTIGATE before declaring done).scripts/strategy_analysis_report.json so dashboard reflects IMPLEMENTED status immediately (vs waiting for Sun timer); commit the regen JSON in a follow-up "chore: refresh report" commit matching today's earlier pattern./api/architecture-findings should include Finding #304 in its 213-row payload (was 212; +1).polymarket_opportunity_log.skip_reason='long_downtrend' rows over the next downtrend regime. SQL: SELECT skip_reason, COUNT(*) FROM polymarket_opportunity_log WHERE evaluated_at > '2026-05-28' AND skip_reason='long_downtrend' GROUP BY skip_reason. Likely zero matches for weeks/months at current volume regime — that's expected.deploy_from_github.sh pre-pull hygiene for recurring working-tree drift (RESOLVED May 28, 2026 LATE morning)script_only scope, agent-recommended divergent handling — stash_continue) via AskQuestion.git pull --ff-only because prod had accumulated 19 blocking files across 3 categories. 3 successive pull-aborts surfaced wave after wave of state. Resolved that day's deploy via §H scp fallback; documented the underlying recurring pattern in #302's handoff entry; operator directed "fix this" via this finding.git pull --ff-only aborts on May 28):error: untracked working tree files would be overwritten by merge).earnings_dashboard/templates/dashboard.html, scripts/equity_bars_collector.py, src/data_tracker/integrity_monitor.py, src/earnings/market_cap_data.py, strategies/pm_earnings_arb/src/data_loader.py). Same root cause as (a) but for already-tracked files: working-tree edits made on prod, then committed elsewhere as the same content, leaving prod with "uncommitted local changes" that match what's incoming.scripts/strategy_analysis_report.json local=271.8KB vs remote=185.4KB; strategies/pm_earnings_arb/reports/h1_longshot_24h.csv same size, different content). These are genuine prod-only edits not yet committed anywhere — must NOT be silently discarded.deploy/deploy_from_github.sh end-to-end; confirmed line 5 is the bare git pull --ff-only with no pre-cleanupdiff -q <(git show origin/master:<f>) <f> returns 0 if identical, nonzero if differsgit stash push -- <files> accepts a path-list to scope the stash to just the divergent files (preserves other working-tree state)git status --porcelain=v1 outputs the canonical ?? path (untracked) and M path (modified-not-staged) prefixes; awk-parseablebash -n deploy/deploy_from_github.sh syntax-clean on the modified scriptgit pull without pre-cleanup. Only deploy/deploy_from_github.sh runs git pull on prod; no other entry-point. Single-point fix.deploy/deploy_from_github.sh lines 5+: new git_hygiene_cleanup() function (~80 lines including ~40 lines of context comments) inserted BEFORE the git pull --ff-only line. Function does:git fetch origin master --quiet so we know what's incoming. If fetch fails (network down), log + return early (pull will also fail, no harm done).rm it (pull re-creates same content via incoming commit).git checkout HEAD -- <file> (case b — pull re-applies same content via incoming commit). Otherwise add to divergent list (case c).git stash push -m "auto-stash before deploy <ISO-timestamp> (Finding #303)" -- <files>, log a WARNING listing each path + restore/drop commands. Operator reviews on next opportunity.removed_untracked=N reset_identical=M stashed_divergent=K.git_hygiene_cleanup || true so a broken hygiene step doesn't block the deploy entirely (subsequent git pull --ff-only will surface any real blocker with its own error).bash deploy/deploy_from_github.sh on prod → cd [hidden] → git_hygiene_cleanup() → git fetch origin master → scan untracked → scan modified → stash divergent → git pull --ff-only → cache clear → service restarts → systemd installs → dashboard smoke-test.removed=0 reset=0 stashed=0. Pull behaves normally. ✓login_attempt_state.json, *.archived_*): function skips them (git cat-file -e returns nonzero). Pull doesn't see them as conflicts. ✓git fetch: function returns early with log message; subsequent git pull also fails; deploy aborts with git's standard error. ✓git stash push fails (e.g., index lock): function returns 2; deploy continues to git pull which surfaces the actual blocker. ✓--porcelain=v1 output format: v1 is documented as stable; this scenario is unlikely. ✓deploy/deploy_from_github.sh lines 5-91 (new function definition + invocation + context comments). Existing git pull --ff-only stays at the (now line 92) position.git fetch and diff check → very unlikely in practice; worst case is a benign no-op or unnecessary stashbash -n syntax check pre-commitgit status line count; typical N < 50; each file is one git show + one diff -q; should complete in <5sbash -n deploy/deploy_from_github.sh returns 0 ✓ (verified pre-commit)bash deploy/deploy_from_github.sh on prod's currently-broken state cleans 17 identical files, stashes 2 divergent files, pulls successfully, completes the rest of the deploybash deploy/deploy_from_github.sh on the now-clean state is a no-op for hygiene (counts = 0/0/0) and behaves like a standard deploygit stash list post-deploy shows the auto-stash entry with timestamped message for operator reviewbash -n deploy/deploy_from_github.sh cleanbash deploy/deploy_from_github.sh on prod with currently-blocked state:git pull --ff-only succeeds, prod advances to e342938 (latest origin/master with #302 + #303)git stash list shows the 2 divergent files preserveddeploy/deploy_from_github.sh (new git_hygiene_cleanup() function ~80 lines including ~40 lines of inline context comments; invocation before existing git pull --ff-only)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 (new LATE-LATE-morning block + last-updated header)polymarket-backup.timer collision with Ubuntu apt-daily-upgrade.timer window (RESOLVED May 28, 2026)AskQuestion (vs B: retry-with-backoff in script / C: both A+B / D: revert apt-daily-upgrade.timer to Ubuntu default).systemd_last_run:polymarket-backup.service flipped STATUS_FAIL on 2026-05-28 04:11:55 UTC; operator surfaced via dashboard banner; agent investigated per memory.md INFERENCE DISCIPLINE rule (verify model against code BEFORE declaring root cause).apt-daily-upgrade.service started its nightly rununattended-upgrade actively processing a systemd-related packagepolymarket-backup.timer fired (RandomizedDelaySec landed 1m53s into the 30-min window)unattended-upgrade triggered systemd-networkd + systemd-resolved + systemd-timesyncd RESTART (consequence of the systemd package update; standard Debian/Ubuntu post-install action)backup_database.py:create_backup called pg_dump -h db-postgresql-nyc2-polymarket-do-user-4668295-0.m.db.ondigitalocean.com ... while systemd-resolved was mid-restart → DNS lookup failed instantly → pg_dump: error: could not translate host name "...digitalocean.com" to address: Temporary failure in name resolution → exit 1polymarket-backup.service recorded Result=exit-code, ExecMainStatus=1, SubState=failed; integrity-monitor's systemd_last_run:polymarket-backup.service rule flipped STATUS_FAILapt-daily-upgrade.timer has been overridden on this droplet to OnCalendar=*-*-* 04:00 RandomizedDelaySec=30m (Ubuntu default is 06:00 + 60m; reason for override not documented here)polymarket-backup.timer was set to OnCalendar=*-*-* 04:00:00 RandomizedDelaySec=1800 (same 30-min window)/var/log/polymarket/backup.log.*.gz found ZERO prior name resolution / could not translate / DNS errors — today was the first instance of all 3 conditions aligning: (1) random delays placed the timers within ~20s of each other, (2) today's upgrade payload included systemd components, (3) the upgrade actually restarted services (not all systemd updates do)/etc/systemd/system/polymarket-backup.service to confirm script path + Type=oneshot semantics/var/log/polymarket/backup.log to extract the exact pg_dump error messagegetent hosts + dig +short — confirms the failure was transient, not a persistent DNS misconfigjournalctl --since 04:00 --until 04:13 to find the trigger event (unattended-upgrade at 04:11:35 → service restarts at 04:11:54)zgrep showed ZERO prior DNS errorssystemctl cat polymarket-backup.timer + systemctl cat apt-daily-upgrade.timerdeploy/backup_database.py end-to-end to confirm no existing retry logic + understand cleanup-local semantics (Findings #260b / #275 — local-only backup is acceptable when SPACES creds absent, keep 7 days + most recent)OnCalendar=*-*-* 04:*:polymarket-data-backfill.timer (existing) — fires hourly, no daily 04:00 conflict per sepolymarket-resolver.timer, polymarket-equity-bars.timer, polymarket-trade-reconcile.timer, polymarket-earnings-sync.timer — checked; none in the 04:00 windowpolymarket-trades-backfill.timer (Finding #269) — weekly Sun 06:00 ET, different cadencepolymarket-api-health.timer (Finding #289.a) — daily 08:00 UTC, different windowetfstack-signal.timer / etfstack-execute.timer (Mon-Fri 21:00 / 13:30 UTC) — different windowspolymarket-backup.timer overlapped with apt-daily-upgrade.timer — single point fix.deploy/systemd/polymarket-backup.timer: changed OnCalendar=*-*-* 04:00:00 → OnCalendar=*-*-* 05:00:00 (kept RandomizedDelaySec=1800 so jitter remains; fires 05:00-05:30 UTC = 30 min after apt-daily-upgrade's 04:00-04:30 window latest possible end)install -m 0644 + systemctl daemon-reload + systemctl restart polymarket-backup.timer (deploy_from_github.sh doesn't auto-install timers per Finding #270's same scope)polymarket-backup.service once post-deploy to fill today's May-28 gap + validate the fix end-to-endbackup_database.py — addresses generic transient network issues but not the systemic timing collision; not needed given the 14-day historical scan showed ZERO other DNS failuresapt-daily-upgrade.timer to Ubuntu default 06:00 — would require understanding why the override was made (not documented); out of scope herepolymarket-backup.timer fires → polymarket-backup.service → python deploy/backup_database.py → DATABASE_URL parsed → pg_dump -h <hostname> ... → DNS lookup → pg_dump SSL handshake → SQL extraction → gzip stream → /tmp/polymarket-backups/*.sql.gz → systemd records Result + ExecMainStatus → integrity-monitor's systemd_last_run:polymarket-backup.service reads on next 5-min cycledeploy/systemd/polymarket-backup.timer lines 5 (OnCalendar) + new 16-line context comment block above the [Timer] sectionsystemctl edit polymarket-backup.timer could mask the file-level change → mitigated by deploy script overwriting the file + daemon-reload/etc/systemd/system/polymarket-backup.timer → mitigated by post-deploy systemctl cat polymarket-backup.timer | grep OnCalendar verificationsystemctl cat polymarket-backup.timer shows new OnCalendar=*-*-* 05:00:00 on prod, (b) systemctl list-timers polymarket-backup.timer shows NEXT as tomorrow 05:00-05:30 UTC, (c) manually-triggered backup completes successfully with exit 0 + creates polymarket_db_20260528_*.sql.gz + systemd_last_run:polymarket-backup.service flips FAIL → OK, (d) integrity-monitor summary returns to baseline (1 less FAIL rule)systemctl cat polymarket-backup.timer | grep OnCalendar confirms new schedule; systemctl list-timers polymarket-backup.timer shows NEXT = next-day 05:00-05:30 UTC; manual systemctl start polymarket-backup.service runs successfully, creates the May-28 backup file, exits 0; curl /api/integrity shows systemd_last_run:polymarket-backup.service flipped STATUS_OK; total integrity-monitor FAIL count drops by 1.deploy/systemd/polymarket-backup.timer (schedule edit + 16-line context comment)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 (new May-28 block + last-updated header)data-api/trades hard 5-year history limit on Polygon-class trade endpoints (DOCUMENTED May 17, 2026)data-api/trades endpoint and its Polygon-class providers enforce a hard 5-year max-window on the trade fetch range. Requests for trades older than ~5 years return empty results regardless of the requested time bound.trades hypertable's effective floor at ~Jan 2024 (when Polymarket's earnings-market dataset became dense enough to trade against, plus the lookback shrinks each quarter as the rolling 5y window advances).MIN(time) is ~Jan 2023 in the live data (some pre-2024 chunks exist from earlier backfills), but contiguous coverage starts mid-2024.polygon-api/trades documentation (external) explicitly states "trades over 5 years are not available via the public endpoint."docs/DATA_ARCHITECTURE.md (caveat to add at the trades-table section — separate doc edit, NOT in this commit)strategies/pm_earnings_arb/docs/DATA_INVENTORY.md already documents this in §2 ("Pre-2024 PM trades")outcome_resolver.py --force --lookback-days 300 run. 184 unresolved-past → 13 (93% recovery; 171 newly resolved). No new code shipped — the existing resolver had everything needed; the operator just needed to bypass the updated_at lockout.earnings_markets had 929 total, 743 resolved (actual_beat IS NOT NULL), 186 unresolved. Of the 186 unresolved, 184 had earnings_date < CURRENT_DATE — their earnings event had already happened but our table never received a beat/miss verdict.eps_actual IS NULL (100% match) — the resolution pipeline gates on eps_actual being populated for the canonical EPS-surprise path.outcome_resolver.py has a 6-hour updated_at lockout (line 528-531) intended to prevent the daily timer from spinning on legitimately-unresolvable rows. The hourly polymarket-earnings-sync.timer ALSO touches updated_at on every run (refreshing market_cap_category, company_name, etc.), which silently locks out the resolver from re-attempting these rows — they look "recently updated" so the resolver skips them.--force CLI flag (already in the resolver) bypasses the lockout. The fix was operational, not code.python -m src.data_tracker.collectors.outcome_resolver --force --lookback-days 300 --earnings-limit 250 (180 sec wall time)./stock/earnings → Polygon /vX/reference/financials → post-event PM yes_price → pre-event PM probability), and updated 191 with stock pricing + actual_beat where the cascade succeeded.prob_24h_before populated but post-event PM didn't converge to 0.95/0.05 (ATO=0.65, ANF=0.98, AVGO=0.72, DNUT=0.99, CSGP=0.46, SUN=0.16, BLSH=0.64) — markets where Polymarket resolved at intermediate prices for various reasons (resolution disputes, ambiguous outcomes, low post-event volume).NULL prob_24h_before AND no EPS source coverage: GT, FRO, AVR, RUM, AZN, IART — typically foreign ADRs, share-class variants (AZN/AZN.L), or thinly-tracked tickers._pm_resolution_actual_beat thresholds to handle 0.85-0.95 markets via voted resolution OR (b) manually classifying these 13 markets via primary-source EPS lookup. Out of scope for a Severity-Medium finding.polymarket-resolver.timer @ 06:00 ET) does eventually catch up over many days as the sync's updated_at touches drift past 6 hours of inactivity — but the hourly sync prevents that drift from happening on actively-tracked tickers, creating a permanent backlog.--force resolver run to a new systemd timer (e.g., polymarket-resolver-force.timer @ Sunday 04:00 ET) that runs outcome_resolver --force --lookback-days 180. Catches the lockout backlog automatically without operator intervention. Or alternatively, change the resolver SELECT criteria to also pick up rows with updated_at < earnings_date + 1 day (i.e., never-properly-resolved rows regardless of recent sync touches). Either path would prevent future 184-row backlogs from accumulating.docs/FINDINGS.md (this entry — updated to RESOLVED)orderbook_snapshots.spread_yes/spread_no capture stub auto-MM quotes ($0.01/$0.99), not real tradeable spreads (RESOLVED May 17, 2026 evening)orderbook_snapshots to trades for realistic spread estimates. Affected: pm_earnings_arb cost-modeling (FINDINGS pm_earnings_arb-001 Phase 1.5 #1 audit had to switch to a trades-based realized-spread method as workaround, embedded in the audit script — not reusable); etfstack's PM-direct cost assumptions (uses Phase 1.5 #1 corrected spreads, so already protected).spread_no from orderbook_snapshots for each H1b-eligible market and found average spread_no = 0.98 ($0.01 bid / $0.99 ask) — clearly nonsensical for a market where the actual mid-price is $0.85.orderbook_snapshots collector captures whatever's at the top of the book. When the only top-of-book is the auto-MM's stub quote, that's what gets recorded — but it doesn't reflect the price a real trader would execute against.prob_24h_before value in earnings_markets comes from a different source (CLOB last-trade or reference price), so the spread_yes/spread_no fields and the prob_*_before values are NOT consistent — they describe different price-discovery layers.orderbook_snapshots rows for ~50 known-active markets where we have heavy trade flow. In each case, spread_yes / spread_no reported the stub-quote pair $0.99-0.01 = 0.98, while the actual realized spread (max trade price - min trade price over the same 24h window) was 0.07-0.12.src/utils/orderbook_spread.py (new module, 230 lines) with three public functions:realistic_spread_from_trades(engine, condition_id, *, start, end, outcome="NO") — single-market, single-side realized spread (max - min over the time window). Returns {n_trades, total_volume_usd, avg_price, min_price, max_price, realized_spread}.realistic_spread_yes_no_from_trades(engine, condition_id, *, start, end) — both sides at once via single GROUP BY query. Returns {"YES": {...}, "NO": {...}}.aggregate_realistic_spread(engine, condition_ids, *, start, end, outcome, min_trades_per_market=2) — cohort-level summary. Returns {n_markets_input, n_markets_with_trades, median_realized_spread, p25/p75, mean_realized_spread, mean_n_trades_per_market, mean_volume_usd_per_market}. Excludes single-trade markets where spread is artificially 0.time bounds for TimescaleDB chunk pruning (avoiding the OOM-by-locking pitfall hit during pm_earnings_arb development).None for fields that can't be computed (zero-trade or single-price windows). Use n_trades to detect thin-data cases.tests/unit/test_orderbook_spread.py (13 tests):realistic_spread_from_trades: empty input, single-trade-zero-spread, max-min math, outcome filtering, time-window filtering, invalid-outcome ValueError.realistic_spread_yes_no_from_trades: both-sides correctness, one-side-empty placeholder.aggregate_realistic_spread: empty input, invalid-outcome ValueError. (The Postgres-specific ANY(:cids) SQL isn't exercised against SQLite; live SQL tested via prod smoke-test below.)_percentile: empty list NaN, basic percentiles, p25/p50/p75 ordering.n_markets_with_trades: 4median_realized_spread: 0.479, p25: 0.329, p75: 0.479mean_n_trades_per_market: 101.0, mean_volume_usd: $27,374orderbook_snapshots.spread_no join):
```python
from src.utils.orderbook_spread import aggregate_realistic_spread
from datetime import timedelta# Cohort-level realistic NO-side spread for H1b-eligible markets
# in the 24h pre-earnings window (matches Phase 1.5 #1 methodology)
result = aggregate_realistic_spread(
engine,
condition_ids=h1b_eligible_cids,
start=actual_earnings_time - timedelta(hours=24),
end=actual_earnings_time - timedelta(hours=1),
outcome="NO",
)
print(f"Median NO-side spread: {result['median_realized_spread']:.3f}")
# Use this for cost_model.DEFAULT_SPREAD_HALF / 2 calibration in pm_earnings_arb
``
- **Future work (operator-decision; NOT in scope for this finding):**
- **Fix-the-capture-pipeline (option b)** is still on the table for if/when the operator wants to makeorderbook_snapshots.spread_*columns trustworthy directly. Would benefit dashboards / live-trading code that doesn't have the per-window flexibility of the trades-based helper. Estimated effort: collector validator + tests + a one-shot historical re-compute job for the 7.79M existing rows.
- **refresh_pm_realized_spreadscron job** could pre-compute per-market median spread for the 24h pre-ER window of every resolved earnings market and store it in a new column. Saves repeated SQL passes for analysis code that wants the same window repeatedly. Defer until usage patterns justify it.
- **Files (this commit):**
-src/utils/orderbook_spread.py(new)
-tests/unit/test_orderbook_spread.py(new, 13 tests, all pass on prod)
-docs/DATA_ARCHITECTURE.md(orderbook_snapshots caveat already shipped May 17 morning; add helper-usage hint in this commit)
-docs/FINDINGS.md` (this entry — updated to RESOLVED)
earnings_markets.market_cap_category contains 4 inconsistent value formats due to two pipeline writers (DOCUMENTED May 17, 2026; canonical helper SHIPPED, source-data migration PROPOSED)010_priority_score.sql which hard-codes lowercase comparisons against capitalized values — that migration likely never matched its intended rows.As of 2026-05-17, earnings_markets.market_cap_category contains 4 distinct value formats:
| format | n_total | n_resolved | source |
|---|---|---|---|
Mid |
305 | 212 | current canonical writer |
Small |
227 | 156 | current canonical writer |
| (NULL) | 160 | 154 | unset / older rows |
Large |
156 | 123 | current canonical writer |
large |
66 | 66 | legacy archived writer |
mega |
23 | 23 | legacy archived writer (S&P-mega tickers) |
mid |
8 | 8 | legacy archived writer |
small |
1 | 1 | legacy archived writer |
Two writers historically populated this column:
src/db/earnings_sync.py.get_market_cap_category()) — uses KNOWN_MCAPS from src/earnings/market_cap_data.py with LARGE_CAP_THRESHOLD=$100B / SMALL_CAP_THRESHOLD=$10B. Returns 3-tier capitalized: 'Large' / 'Mid' / 'Small'.archive/scripts/fix_database.py, no longer running) — used 4-tier lowercase with different thresholds: mega ≥ $200B, large ≥ $10B, mid ≥ $2B, small < $2B. Wrote 'mega' / 'large' / 'mid' / 'small' to old rows.Crucially, the two schemes have DIFFERENT thresholds, not just casing. A ticker like AAPL ($3T) gets 'Large' from the current writer but would be 'mega' under the legacy scheme; a ticker like APO ($60B) gets 'Mid' from the current writer but 'large' under legacy. Sample tickers per legacy bucket (DB inspection):
mega tickers: AAPL, AMD, AMZN, AXP, BRK, BUD, CSCO, CVX, GOOGL, IBM, KO, LLY, MA, MCD, META, MRK, MSFT, PEP, PG, PLTR, TSLA, V, XOM (23 tickers, mostly $100B-$3T range; "mega" applied liberally to famous large-caps not strictly to >$500B).large (legacy lowercase) tickers: ABNB, AFL, ALL, AMAT, APO, APTV, ARES, ARM, BP, CBOE, CFG, CI, CME, CMG, COF, COIN, COP, CVS, DDOG, DIS, etc. (66 tickers, mostly mid-range tech / financials).Phase 1.5 #2 of pm_earnings_arb-001 found these case-distinguished groups behave differently in PM earnings markets:
| category | N | YES hit rate | NO net EV | action |
|---|---|---|---|---|
| Mid | 113 | 72.6% | +110% | deploy NO |
| Large | 83 | 72.3% | +136% | deploy NO |
| mega | 17 | 94.1% | -45% | AVOID NO (correctly priced) |
| large | 35 | 77.1% | +71% | deploy NO |
| Small | 48 | 83.3% | +19% | not deployable |
The case-distinguished groups are NOT just naming variants of the same underlying universe — they correspond to different ticker subsets that systematically respond differently to earnings overpricing. Collapsing 'mega' into 'Large' loses the most-important calibration distinction (Phase 1.5 #2's headline finding).
- Investigation (this commit):
- git grep market_cap_category across the repo identified the two writer paths above. The legacy writer is in archive/scripts/fix_database.py (already moved to archive/).
- Migration 010_priority_score.sql hard-codes lowercase comparisons (market_cap_category = 'large', 'mid', 'small') — would FAIL to match the capitalized 'Large' / 'Mid' / 'Small' values written by the current pipeline. This is a likely-dormant secondary bug.
- Migration 019_priority_unified_exchange_neutral.sql defensively uses LOWER(market_cap_category) = 'large' etc. — handles both cases correctly. The migration 010 bug was caught and worked-around in 019.
- Other downstream readers in src/earnings/trading_orchestrator.py and reconciliation_manager.py use .lower() defensively before mapping to the MarketCap enum — protected.
- Fix shipped (this commit):
- src/earnings/market_cap_data.py adds:
- canonical_mcap_category(raw_or_symbol) — normalizes any of the 4 formats (or a known symbol) to a canonical lowercase 4-tier value: mega / large / mid / small / unknown. Uses CANONICAL_MEGA_CAP_THRESHOLD = $500B, CANONICAL_LARGE_CAP_THRESHOLD = $100B, CANONICAL_MID_CAP_THRESHOLD = $10B for symbol lookups.
- 30-line context comment block documenting the historical drift.
- classify_mcap_value() UNCHANGED (still returns the 3-tier capitalized form for the trading-position write path; modifying it could break trading logic — out of scope).
- The pm_earnings_arb subtree's normalize_cohort() function (in strategies/pm_earnings_arb/src/hypothesis_h3_iv_crush.py) is the equivalent normalizer used in the research subtree. The two implementations agree on outputs for the same inputs. Cross-references each other via comments. The duplication is intentional — pm_earnings_arb is designed to be extractable to a standalone repo without depending on src/earnings/market_cap_data.py.
- Source-data migration PROPOSED (NOT run in this commit):
- (option a — minimal): Add a one-shot SQL migration src/data_tracker/migrations/0NN_normalize_market_cap_category.sql that lowercases the existing capitalized values: UPDATE earnings_markets SET market_cap_category = LOWER(market_cap_category) WHERE market_cap_category IN ('Large', 'Mid', 'Small'). Does NOT touch the 'mega' tier. Risk: tickers currently classified as 'Large' (e.g., AAPL, MSFT) would lose their implicit "this is a current-writer row" marker.
- (option b — full normalization): Re-classify ALL rows using canonical_mcap_category() based on the ticker symbol, ignoring the existing column value. Lifts everything to the canonical 4-tier (mega/large/mid/small/unknown). Best long-term path; requires more validation to ensure tickers map correctly.
- (option c — deferred): Don't touch the source data; rely on canonical_mcap_category() at read-time wherever needed. Already applied in pm_earnings_arb subtree. Low effort but leaves the source-data inconsistency as a permanent gotcha.
- Recommended: start with (c) for May 2026 (already shipped); revisit (b) if/when an operator-approved data cleanup pass is undertaken (e.g., as part of a major schema migration). The migration 010 row-counts are dormant, so there's no urgent action item.
- Files (this commit):
- src/earnings/market_cap_data.py (+ canonical_mcap_category() helper + 30-line context comment)
- docs/FINDINGS.md (this entry)
- Action items: operator-decision on whether to run the source-data migration (option a/b) or accept the read-time normalization approach (option c).
api_health_checker WARN-but-exit-2 cry-wolf antipattern on price_accuracy drift (RESOLVED May 17, 2026 morning)AskQuestion option (b) "WARN-but-exit-0" (vs (a) widen threshold / (c) leave as-is / (d) build age-aware structural fix / defer). Discovered during the §G post-deploy probe of Finding #296 — the integrity-monitor surfaced an unexplained STATUS_FAIL on systemd_last_run:polymarket-api-health.service that turned out to pre-date the #296 deploy by ~22.5 hours.api_health_checker.py:check_price_accuracy compares DB price_snapshots.yes_price vs live Gamma outcomePrices[0] for a sample of markets. A single-price threshold of 5% absolute (abs(db_price - gamma_price) > 0.05, lines 503-504) counts as a mismatch.mismatch_rate > 0.5 → status='error' ("prices differ from live data")0 < mismatch_rate ≤ 0.5 → status='warning' (string literal: "N prices differ - may be normal market movement" — the check's own text acknowledges this is expected behavior)mismatch_rate == 0 → status='healthy'overall_status='warning'.python
if results['overall_status'] == 'error':
exit(1)
elif results['overall_status'] == 'warning':
exit(2) # <-- conflates "informational anomaly" with "system is broken"
else:
exit(0)
Exit 2 → systemd records Result=exit-code, ExecMainStatus=2 → integrity-monitor's systemd_last_run:polymarket-api-health.service rule reads it via _check_systemd_unit_last_result (Finding #268) → STATUS_FAIL (HIGH severity) → AlertManager fires Telegram alert.systemd_last_run rule family (Finding #268) was designed to catch real failures (OOM, network, API contract violation), not informational anomalies — so the false-FAIL is a category error.overall_status: warning, with price_accuracy: { status: warning, issues: ["1 prices differ - may be normal market movement"] } for ONE sampled market. All other 4 sub-checks (polymarket_website / gamma_api / clob_api / polymarket_data_api) STATUS_HEALTHY.systemd_last_run:polymarket-api-health.service STATUS_FAIL since May-16 08:03:35 UTC; consecutive_failures: 271 at May-17 06:40 UTC (× 5-min cycle = 22.5 hours of continuous FAIL).api_health_checker.py end-to-end: only the exit-code mapping at lines 677-683 has the antipattern. check_price_accuracy itself, the per-check classification, and the persistence to data/api_health/latest.json are correctly informational at the WARNING level.data/api_health/latest.json is independently monitored by artifact:api_health_latest_json (Finding #289.a) which checks freshness only, NOT contents. So even after this fix, an operator who wants to audit drift events can: (a) curl data/api_health/latest.json, (b) view the dashboard's api_health surface, (c) read the WARNING log line emitted by the script when overall_status==warning. No information lost.price_snapshots.captured_at lookup, threshold matrix design, simulator testing on historical drift events. Operator chose to defer (d) and ship (b) first; if (b) proves insufficient (e.g., a real broken-pricing event slips through as "warning"), revisit then.grep -E '\bexit\(2\)|sys\.exit\(2\)' returned 2 matches:src/data_tracker/collectors/api_health_checker.py:681 — this finding (FIXED in this commit)deploy/backup_database.py:287 — sys.exit(2) on upload_failed. This is a REAL failure (S3 upload failed → restore impossible) and SHOULD continue to alert via systemd_last_run:polymarket-backup.service. NOT in scope; not the same antipattern. Verified by reading the surrounding context — there is no "warning" intermediate state in backup_database.py.grep -E 'overall_status.*warning|status.*=.*["\\x27]warning["\\x27]' showed only api_health_checker.py mixes warning-classification with exit-code propagation; backup_database.py has the literal string "warning" only inside descriptive print/log statements, no exit-code dependence.integrity_monitor.BATCH_SERVICES (polymarket-backup, polymarket-resolver, polymarket-trade-reconcile, polymarket-equity-bars, polymarket-data-backfill, polymarket-earnings-sync, polymarket-trades-backfill, polymarket-api-health, etfstack-signal, etfstack-execute) — all checked. None has the WARN→exit-non-zero antipattern.api_health_checker.py lines 677-683 (now 678-702 with comment block): exit-code mapping rewritten with 16-line context comment explaining the (Finding #297) decision. warning → exit 2 becomes warning → exit 0, with a new logger.warning("Overall WARNING result — exiting 0 by design (Finding #297). See data/api_health/latest.json for per-check details.") so the next agent reading /var/log/polymarket/api_health.log doesn't get confused about why a WARNING result didn't propagate to a non-zero exit.error → exit 1 and healthy → exit 0 paths UNCHANGED. Real failures still alert via systemd_last_run.latest.json content schema UNCHANGED — overall_status field still reflects warning/error/healthy. Only the exit-code mapping changed.polymarket-api-health.timer → polymarket-api-health.service (Type=oneshot) → bash -c 'source .env && python -m src.data_tracker.collectors.api_health_checker' → main() → checker.run_all_checks() → 5 sub-checks → results['overall_status'] ∈ {healthy, warning, error} → save to data/api_health/latest.json → log to collection_jobs table → exit code via NEW mapping → systemd records Result + ExecMainStatus → integrity-monitor 5-min cycle reads via _check_systemd_unit_last_result(unit) → STATUS_OK (Result=success, exit 0) OR STATUS_FAIL (Result=exit-code, exit 1).Result=success → STATUS_OK ✓ (unchanged)Result=success → STATUS_OK ✓ (false-alarm closed)Result=exit-code, ExecMainStatus=1 → STATUS_FAIL → Telegram alert ✓ (real-error path preserved)results['overall_status']='error' (line 572) → exit 1 → same as Case C ✓src/data_tracker/collectors/api_health_checker.py lines 677-702 (16-line context comment + 1-digit behavior change 2 → 0 + 4-line logger.warning).latest.json + dashboard; new logger.warning emits to /var/log/polymarket/api_health.log and journal. The dashboard reads latest.json so warnings are visible there. An operator who wants Telegram alerts specifically for price_accuracy warnings would need a separate rule reading latest.json contents — out of scope here (could be a future #298 if requested)._check_systemd_unit_last_result). Verified via §E grep — nothing else reads the script's exit code. The collection_jobs table records overall_status text, not exit code; unchanged.overall_status somehow be set to a value other than {healthy, warning, error}? The else branch at line 682 (now 701) handles arbitrary other values as exit 0 — same defensive behavior as before.systemctl start polymarket-api-health.service post-deploy exits 0 even with a price drift WARNING in latest.json; (c) systemd_last_run:polymarket-api-health.service flips FAIL → OK on the integrity-monitor's next 5-min cycle; (d) latest.json continues to record per-check warnings with detail (no info loss).systemctl start polymarket-api-health.service post-deploy → confirm Result=success regardless of whether today's run flags a price-drift warning. curl /api/integrity post-cycle confirms systemd_last_run:polymarket-api-health.service STATUS_OK and total rule count flips from 44/45 OK → 45/45 OK (back to baseline after closing this finding + the May-10 #296 close-out).src/data_tracker/collectors/api_health_checker.py (exit-code mapping rewrite + 16-line context comment + logger.warning)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 (last-updated header + new May-17 morning Recently-completed block + 1-FAIL → 0-FAIL state transition in the May-17 IBKR-reconnect block)docs/CONTEXT.md (Last-Updated header + What changed this week bullet)_check_systemd_unit_last_result false STATUS_OK on never-fired units (RESOLVED May 11, 2026 evening)systemd_last_run:* rule references a non-existent unit name; never swallowed a real alert because all 8 currently-installed batch services have fired multiple times — but did mask correctness when adding new oneshot units)AGENT_HANDOFF.md §11 May-10 final block (the lowest-risk of the three; the other two — Sunday-2FA partial-automation and Sunday-aware artifact:ibkr_snapshot threshold — explicitly deferred per AskQuestion outcome)._check_systemd_unit_last_result in src/data_tracker/integrity_monitor.py (lines 268-337) returned the success branch BEFORE the never-ran branch:
python
if run_result == "success": # systemd's default for never-fired units
return (STATUS_OK, f"{unit}: last run succeeded ({sub_state})", detail)
if not run_result or exit_ts in ("", "n/a", "0"): # never reached for never-ran
return (STATUS_UNKNOWN, f"{unit}: never ran ...", detail)
systemd defaults Result=success for any unit that has never been started, with ExecMainExitTimestamp= empty. The success branch caught these cases first, returning a misleading STATUS_OK with text "last run succeeded (dead)". Same false-OK applied to LoadState=not-found units (e.g., a renamed/deleted unit still referenced by a systemd_last_run:* rule), which is arguably worse — the rule would silently report OK while monitoring nothing.probe-never-ran.service via daemon-reload (no start), then queried systemctl show -p Result -p ExecMainExitTimestamp. Result: Result=success, ExecMainExitTimestamp= (empty). Confirmed the bug shape exactly.systemctl show for a non-existent unit name (polymarket-doesnotexist.service). Result: identical shape (Result=success, ExecMainExitTimestamp=, plus LoadState=not-found). Same false-OK risk.polymarket-api-health.service, fired today at 08:01 UTC). Result: Result=success + ExecMainExitTimestamp=Mon 2026-05-11 08:01:17 UTC (non-empty). Correctly returns STATUS_OK with both old and new ordering.rm + daemon-reload)._check_systemd_unit_last_result is defined ONCE (integrity_monitor.py:268) and used by 8 lambda evaluators in _build_rules() (one per BATCH_SERVICES entry). No external callers — verified via repo-wide grep '_check_systemd_unit_last_result\|systemd_last_run'.tests/ — verified via Glob '**/test_integrity_monitor*'._check_systemd_timer_recent (lines 340-382) does NOT have the same bug — it explicitly checks for empty LastTriggerUSec BEFORE returning OK.freshness, null_rate, artifact, storage) don't depend on systemd defaults. No same-pattern violation found elsewhere._check_systemd_unit_last_result (12-line context comment + 4-line move). Never-ran case (not run_result or exit_ts in ("", "n/a", "0")) now returns STATUS_UNKNOWN BEFORE the success branch can claim STATUS_OK on a default-success-empty-timestamp tuple. Failure branch (signal/oom-kill/exit-code/timeout) unchanged at the bottom._build_rules() → evaluator(engine) → _check_systemd_unit_last_result(unit) → subprocess.run(['systemctl', 'show', ...]) → parse stdout → branch. Result written to integrity_status table + /var/log/polymarket/integrity_status.json snapshot + (HIGH-severity FAIL transitions) Telegram alert via AlertManager.polymarket-api-health.service today 08:01 UTC) → Result=success + ExecMainExitTimestamp=Mon 2026-05-11 08:01:17 UTC → never-ran branch sees exit_ts non-empty → falls through → success branch → STATUS_OK. No regression. (b) Brand-new oneshot installed but not yet fired → Result=success + ExecMainExitTimestamp= → never-ran branch sees empty → STATUS_UNKNOWN with text "never ran (Result=success)". Correct. (c) Failed run → Result=signal + ExecMainStatus=9 + ExecMainExitTimestamp=Mon 2026-05-11 03:00:00 UTC → never-ran branch sees exit_ts non-empty → success branch sees Result≠"success" → falls through → fail branch → STATUS_FAIL. No regression. (d) systemctl show returns non-zero rc (e.g., systemd unavailable) → existing top-level if result.returncode != 0 returns STATUS_UNKNOWN. No regression.src/data_tracker/integrity_monitor.py lines 318-340 (12-line context comment block + branch reorder).Result="" and ExecMainExitTimestamp non-empty? Per systemd docs, Result is set on every state transition and is never empty after a unit has run once. Empty Result only happens before the first start. The combined check not run_result or exit_ts in ("", "n/a", "0") is overly defensive but correct. (b) Could ExecMainExitTimestamp be the literal string "0" for a real run? No — systemd writes Mon YYYY-MM-DD HH:MM:SS UTC for any non-zero exit. The "0" literal handling came from prior defensive code; harmless. (c) Locale/timezone differences could affect the timestamp string — but the new code only checks for empty/n/a/0, doesn't parse the date.curl /api/integrity confirms 8/8 existing systemd_last_run:* rules still STATUS_OK (no regression on real data) and total still 45/45 OK.curl http://127.0.0.1:8080/api/integrity returns 45/45 OK; the 8 systemd_last_run:* rules continue to return STATUS_OK with their fired-recently timestamps; no rule flipped to UNKNOWN (because every currently-installed batch service has fired). Forward observability: the next time a new oneshot unit is added (e.g., a future Sunday-2FA timer per the still-deferred maintenance item #1, or any cron-to-timer migration), the brief pre-first-fire window will now correctly show UNKNOWN instead of falsely OK.LoadState=not-found would be more diagnostic but expands scope beyond the operator-approved item; would also require an additional systemctl show -p LoadState field. Deferred as a possible #297 candidate if/when a misnamed unit actually slips through (currently no systemd_last_run:* rule references a misnamed unit — verified live).src/data_tracker/integrity_monitor.py (_check_systemd_unit_last_result branch reorder + 12-line context comment block citing #296 + #289.a)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 (closes one of the three "observed-but-not-actioned" maintenance items + last-updated header)docs/CONTEXT.md (May 11 changelog entry + last-updated header)backfill_trades.py:save_trades_batch SAVEPOINT-per-trade fix (RESOLVED May 10, 2026 early afternoon)AskQuestion (vs B: one transaction per trade / C: defer until next backfill failure).live_trades_collector.save_trade fix surfaced this latent bug. backfill_trades.py:save_trades_batch had the IDENTICAL try/except INSIDE with engine.begin() block as #289.b's pre-fix code:
python
with self.engine.begin() as conn:
for trade in trades:
try:
# ... INSERT trade_dedupe ...
# ... INSERT trades ...
saved += 1
except Exception as e:
if saved == 0: # Only log first error
logger.error(f"Trade save error: {e}")
Cascade pattern: any single trade's INSERT failure (e.g., #294 VARCHAR(10) overflow before that fix shipped) → connection enters failed-tx state → ALL subsequent trades' INSERTs raise pg8000 InterfaceError (silently caught by inner except — only first error logged) → end-of-batch COMMIT also fails with InterfaceError → engine.begin().__exit__ sees the exception → entire batch's data silently rolled back DESPITE saved counter showing >0.backfill_trades is timer-driven (weekly Sunday 06:00 ET, post-#269)live_trades_collector doesn't use save_trades_batch — it uses single-trade save_trade (which was #289.b's scope)with conn.begin_nested():) is already used in 2 active code paths in the codebase:src/data_tracker/collectors/continuous_collector.py:534src/earnings/trading_orchestrator.py:1641python
saved = 0
skipped = 0
failed = 0 # NEW — per-trade failures isolated via SAVEPOINT
with self.engine.begin() as conn:
for trade in trades:
try:
# ... compute trade_id ...
with conn.begin_nested(): # SAVEPOINT
# INSERT trade_dedupe (with ON CONFLICT DO NOTHING)
# IF dedupe rowcount > 0:
# INSERT trades
# SAVEPOINT committed cleanly here
saved += 1
except Exception as e:
# SAVEPOINT auto-rolled back; outer transaction stays healthy
failed += 1
if failed <= 5:
logger.warning(f"save_trades_batch per-trade error (Finding #295 — ...): trade_id=... {type(e).__name__}: ...")
elif failed == 6:
logger.warning("6+ per-trade errors in this batch — suppressing further per-trade logs ...")
if failed > 0:
logger.warning(f"save_trades_batch SUMMARY: {saved} saved, {skipped} skipped, {failed} failed")
Two structural changes vs pre-fix:with conn.begin_nested(): SAVEPOINT wraps both INSERTs per trade. Failed trades roll back to the SAVEPOINT only; outer transaction continues healthy. Outer COMMIT at end-of-batch succeeds for all VALID trades.trade_id + exception type + message (was: ERROR level, only first error per batch). Capped at 5 per batch + 1 summary line for run-end visibility, so a bad-data burst doesn't flood logs but each unique failure type IS captured.(saved, skipped) so all 4 callers (lines 581/614/624/632) work without changes. The failed counter is logged at WARNING but not returned (matches existing log-only diagnostic convention).polymarket-trades-backfill.service → python -m backfill_trades → for each market, batches of 500 trades → save_trades_batch(batch) → per-trade SAVEPOINT enclosure. Bad trade T1: INSERT raises ProgrammingError/InterfaceError/etc. → SAVEPOINT auto-rolls back → outer except catches → log WARNING + increment failed counter + continue loop. Subsequent trade T2: SAVEPOINT opens fresh → INSERT succeeds → SAVEPOINT commits → saved counter increments. End-of-batch outer COMMIT: succeeds because outer transaction was never in failed-tx state. Net: T1 correctly excluded from DB, T2 correctly persisted.backfill_trades.py:save_trades_batch (rewrite of save loop ~line 298-405; ~30-line context comment in docstring; failed counter + summary at end).trade.get('timestamp') raises) → tid_for_log = locals().get('trade_id', '?') handles safely, no NameError.
(b) conn.begin_nested() raises (e.g., transaction already in error state) → caught by outer except, becomes a failed count. But this should never happen since outer transaction is initiated cleanly by engine.begin().
(c) Burst of 6+ bad trades floods log → suppression after first 5 + summary line caps log volume.
(d) Operator wants to know how many failed per market in run summary → currently only at batch-level (per-market summary not extended; run-level summary in #292's n_truncated/n_failed exists for circuit-breaker tracking but doesn't currently aggregate per-batch failed from save_trades_batch). Acceptable for now — log line per batch is sufficient.
(e) SAVEPOINT in pg8000 — verified pg8000 supports SAVEPOINT (it's standard PostgreSQL DDL); conn.begin_nested() is SQLAlchemy abstraction.backfill_trades.py AST parses cleanly; no lint errors. ✅systemctl start polymarket-trades-backfill.service runs cleanly (no regression on normal trades).systemctl start polymarket-trades-backfill.service post-deploy → service exits cleanly. With #294 ALTER COLUMN already in place, no per-trade failures expected on today's run. Future failures will surface as WARNING logs in /var/log/polymarket/trades_backfill.log for forensic analysis.src/data_tracker/collectors/backfill_trades.py (save_trades_batch rewrite — try/with inversion via SAVEPOINT-per-trade + WARNING-level per-trade logging + failed counter + ~30-line context comment in docstring documenting the structural lesson)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 close-out section + #295 follow-up status updatedocs/CONTEXT.md (May 10 changelog + last-updated header)EarningsMarketSyncer class + cron entry" via AskQuestion (vs option (A) URL fix only / option (B) flip check from fail→warn).systemd_last_run:polymarket-api-health.service STATUS_FAIL fired Telegram alerts because the Polymarket Website check returned ❌ Earnings JSON returned 404. Same broken URL was also used by the SEPARATE src/data_tracker/collectors/sync_earnings_markets.py (EarningsMarketSyncer class) which the cron 0 */6 * * * invoked indirectly — but that cron had also been silently broken for 14 days due to the dash-vs-bash source bug from #289.a (and the EarningsMarketFetcher hourly Gamma path was independently keeping earnings_markets healthy).https://polymarket.com/_next/data/<buildId>/finance/earnings.json?filter=earnings → HTTP 404 with age: 64648 (~18 hours of cached 404 — confirms long-term failure, not transient). Tried alternative URL patterns; https://polymarket.com/_next/data/<buildId>/finance.json (without /earnings segment, without ?filter=earnings query param) → HTTP 200 with the same pageProps.dehydratedState.queries shape including a finance-markets query. Polymarket renamed the Next.js route from /finance/earnings to /finance (and dropped the server-side filter).src/data_tracker/collectors/api_health_checker.py:check_polymarket_website_api (line 88+105) — uses the broken URL. FIXED in this commit (URL update + result key rename earnings_markets → finance_markets + health gate adjusted to check shape integrity rather than count).src/data_tracker/collectors/sync_earnings_markets.py (EarningsMarketSyncer class) — uses the broken URL. DELETED in this commit as dead code (zero active callers).scripts/sync_earnings_markets.py (manual-sync wrapper) — uses healthy src.db.earnings_sync.run_full_sync path which reads from local markets table (NOT the broken Website URL). KEPT per Finding #220 documented manual-sync workflow.scripts/verify_retry_backoff_and_block.py:129+222-226 — only consumer of EarningsMarketSyncer; tests its retry-with-backoff. CLEANUP in this commit (removed import + retry test, with comment block citing this finding — same pattern as #288's removal of dome_client._make_request and prices_backfill_service._get_with_retry retry tests).0 */6 * * * for scripts/sync_earnings_markets.py — REMOVED on prod manually post-deploy (the wrapper script is preserved for on-demand manual invocation; removing only the cron schedule).EarningsMarketFetcher health: earnings_markets.updated_at MAX() is fresh (rows updated within last 9 minutes during investigation); 916 rows updated in last 24h. The hourly Gamma-based path is fully healthy and is the canonical earnings_markets writer. The cron was redundant + silently broken.https://polymarket.com/finance/... URLs:polymarket_sniper/scripts/* — many references (test scripts, debug scripts, scrapers in a separate sub-project). NOT in scope per memory.md §C surgical-changes — they're tooling for the sports/arbitrage sniper bot, not the earnings-trader.src/data_tracker/collectors/earnings_scraper.py — declares EARNINGS_URL = "https://polymarket.com/finance/earnings". Verified: this scraper is not actively imported by any live service per grep. Out of scope (could be a future #290-style cleanup if scoped).python -m src.data_tracker.collectors.api_health_checker → check_polymarket_website_api() → HTML fetch → buildId extract → NEW URL /finance.json (not /finance/earnings.json?filter=earnings) → 200 OK → JSON parse → dehydratedState.queries[finance-markets] shape check → STATUS_OK. systemd_last_run rule sees Result=success → no Telegram noise. systemd_timer rule sees fired-recently → STATUS_OK.latest.json reflects new field name finance_markets (was earnings_markets).api_health_checker.py:check_polymarket_website_api lines 74-153 (URL update, field rename, health-gate logic adjusted). print_report lines 596-602 (dual-field handling for backward compat). verify_retry_backoff_and_block.py lines 128-129 + 216-226 (import removed + retry test removed + comment blocks added). sync_earnings_markets.py (DELETED). Cron entry on prod (REMOVED manually).queryKey → my new health gate detects this via if not queries_intact: STATUS_ERROR. Self-detecting via shape-check.
(b) latest.json from the OLD pre-#293 schema (key earnings_markets) is read by some downstream consumer → the only consumer is print_report which now handles BOTH keys with backward-compat (elif 'earnings_markets' in check).
(c) Some forgotten cron / invocation references the deleted EarningsMarketSyncer → exhaustive grep verified zero callers besides the deleted retry test.
(d) Manual operator runs scripts/sync_earnings_markets.py after delete → preserved; uses healthy run_full_sync path; works fine.systemctl start polymarket-api-health.service:journalctl -u polymarket-api-health.service shows last run succeeded (Result=success, ExecMainStatus=0)cat [hidden]/data/api_health/latest.json shows polymarket_website.status: "healthy" (was "error")curl /api/integrity shows systemd_last_run:polymarket-api-health.service flipped from FAIL → OKcrontab -l | grep sync_earnings_markets returns emptyfind [hidden]/src -name "sync_earnings_markets*" returns ONLY scripts/sync_earnings_markets.py (the wrapper); the deleted class file is gonepython3 scripts/sync_earnings_markets.py still works (uses healthy run_full_sync)src/data_tracker/collectors/sync_earnings_markets.py (12,075 bytes — EarningsMarketSyncer class with broken Polymarket Website URL)src/data_tracker/collectors/api_health_checker.py (URL update from /finance/earnings.json?filter=earnings → /finance.json + 30-line docstring documenting the change + result key rename earnings_markets → finance_markets + health gate adjusted to check shape integrity rather than count + print_report backward-compat handling) [SUPERSEDED by Finding #326, Jun 14 2026 — Polymarket moved Pages Router → App Router, removing buildId + /_next/data entirely; the check now parses the App-Router inline self.__next_f/dehydratedState markers and is warn-not-fail.]scripts/verify_retry_backoff_and_block.py (import removed + retry test removed + comment blocks citing this finding)docs/CONTEXT.md (cron jobs section: SHELL=/bin/bash + comments documenting api_health_checker → systemd-timer migration + sync_earnings_markets cron removal)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 (#293 follow-up status update + last-updated header)0 */6 * * * cd [hidden] && source venv/bin/activate && python3 scripts/sync_earnings_markets.py >> [hidden]/logs/earnings_sync.log 2>&1trades.outcome and trades.side widened from VARCHAR(10) to VARCHAR(64)/VARCHAR(20) (RESOLVED May 10, 2026 LATE morning)AskQuestion (vs option (A) widen only outcome, vs option (C) input-side truncation).save_trade error-path caught all reported pg8000.dbapi.ProgrammingError: value too long for type character varying(10). Per information_schema, trades.outcome and trades.side were both VARCHAR(10), defined in original 001_initial_schema.sql:131-132 under the assumption that all values would be 'yes'/'no'/'BUY'/'SELL' (≤4 chars). However, Polymarket data-api /trades returns the FULL outcome label, which for sports/non-binary markets is the team/player/option name.'Cavaliers' (9), 1631 with 'Clippers' (8), 1602 with 'Warriors' (8), 1161 'Mavericks' (9), 1160 'Grizzlies' (9), 573 'PARIVISION' (10 chars EXACTLY — right at the edge), 499 'Avalanche' (9), and many more team/player names. Sports markets are at the boundary of the limit.trade_dedupe and trades INSERT together; that's correct behavior).trade_dedupe post-investigation — confirms the atomic-rollback theory.side values >4 chars in trade_dedupe: ZERO. Side is currently fine (always BUY or SELL) but operator chose option (B) to widen defensively (10→20) so future upstream API changes don't trip the same path. NEW value too long errors on side would have the same cascade behavior pre-#289.b fix (and post-#289.b, would log WARNING + skip).001_initial_schema.sql:131-132 — original schema definition. NOT changed (migrations are append-only history per project convention; the new state is set by 028).src/data_tracker/models/database.py:107-108 — String(10) declarations. Updated in this commit to String(20) for side and String(64) for outcome — must stay in lockstep with the migration.trading_positions.symbol, earnings_markets.ticker, earnings_time, fiscal_quarter, etc.) are for DIFFERENT tables/concerns — not affected.\[:10\], MAX_OUTCOME, etc.). Safe to widen without other code changes.src/data_tracker/migrations/028_widen_trades_outcome_side.sql (new file):ALTER TABLE trades ALTER COLUMN outcome TYPE VARCHAR(64);ALTER TABLE trades ALTER COLUMN side TYPE VARCHAR(20);trades is a hypertable with 907 chunks as of May 10, 2026. PostgreSQL VARCHAR widening (where the new max length is GREATER than the old) does NOT rewrite the table — the on-disk format for VARCHAR is the same regardless of the declared max length. Only catalog metadata changes. The ALTER propagates instantly across all chunks with no chunk rewrite.side ALTER took ~2m 19s wall (most likely network/connection overhead to DigitalOcean managed Postgres + Timescale catalog updates across 907 chunks); the outcome ALTER took 0.9s when re-run cleanly. Total downtime: zero (table reads/writes continue during ALTER COLUMN since it doesn't take an exclusive lock on this VARCHAR-widening operation).sql.split(';') then skipped statements where s.strip().startswith('--') — but the FIRST split chunk contained the entire ~30-line comment block followed by the first ALTER, so it incorrectly skipped the outcome ALTER. Side widened on first run; outcome ran on second invocation directly via c.execute(text('ALTER TABLE trades ALTER COLUMN outcome TYPE VARCHAR(64)')). Final post-migration information_schema query confirms: outcome VARCHAR(64), side VARCHAR(20). Lesson noted but the parser bug is in a one-shot ssh invocation, not committed code.outcome >10 chars (e.g., 'Maple Leafs' 11, 'Eastern Conference' 18) → live_trades_collector.save_trade → trade_dedupe INSERT (succeeds — JSONB column has no length limit) → trades INSERT — post-fix: succeeds (column is VARCHAR(64)) → trade_dedupe.add(trade_id) → return True → _trades_saved += 1. Pre-fix: would have raised ProgrammingError, post-#289.b would log WARNING + add to seen set + return False (cascade-isolated but trade would NOT be saved).'Toronto Maple Leafs' (19 chars) arrives via /trades?asset_id= polling. Pre-#294: would fail INSERT (overflow) → #289.b WARNING logged + skipped. Post-#294: INSERT succeeds, trade saved.e.begin() transaction; verified manually via post-migration column-width query before declaring done.
(b) Some downstream consumer reads outcome/side and assumes max length → §E grep found zero such consumers.
(c) Future Polymarket trade has outcome >64 chars → would hit the same overflow at the higher boundary; mitigated by #289.b cascade closure (would log WARNING + skip without poisoning connection); long-term mitigation would be widening further OR client-side truncation. 64 is generous enough for any reasonable team/player/option name.information_schema.columns query returns outcome VARCHAR(64) and side VARCHAR(20). ✅polymarket-live-trades.service continues active. ✅live_poll source rows in trade_dedupe continue growing at baseline rate (60 in last 10 min). ✅live_trades.log. ✅src/data_tracker/migrations/028_widen_trades_outcome_side.sql (NEW)src/data_tracker/models/database.py (lines 107-108: String(10) → String(20) for side + String(64) for outcome with inline comment citing this finding)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 close-out section + #294 follow-up status updatedocs/CONTEXT.md (May 10 changelog + last-updated header)/trades offset hard-cap at 3000 (graceful end-of-pagination handling) (RESOLVED May 10, 2026)polymarket-trades-backfill.service after Tier B (#288) shipped. After 7 successful pages of 500 trades for the TSLA Q3 2025 market (tsla-quarterly-earnings-nongaap-eps-2025-10-22-0pt5, condition_id 0x0aaf73677aeda3cda88c7337343177267011f1d6d08fbdc83174e5bee6a48edc), the 8th page request ?market=<cid>&limit=500&offset=3500&takerOnly=true returned HTTP 400 with body {"error":"max historical activity offset of 3000 exceeded"}. The pre-fix script's _fetch_trades_page retried 4× with backoff (1s/2s/4s = 7s wasted per truncation event) then backfill_market set success=False, leaving the market un-marked in completed_markets. Net result pre-fix: TSLA market would be re-attempted on every weekly timer fire forever, always failing the same way, never being added to completed_markets.data-api.polymarket.com/trades):offset=3000 ✅, offset=3300 ❌. With limit=500 we get up to 3500 trades per market (offsets 0/500/1000/1500/2000/2500/3000).{"error":"max historical activity offset of 3000 exceeded"} — direct quote./trades: probed all of before=, after=, since=, until=, start_time=, end_time=, max_timestamp=, startTime=, endTime=, maxTimestamp= — all silently ignored (return the unfiltered first page; same proxyWallet and same first row across every variant).?asset=<token_id> is silently ignored too: when queried with TSLA's YES/NO token IDs, the response showed trades from TOTALLY UNRELATED markets (different conditionId, different proxyWallet). Per-asset pagination is NOT a workaround.takerOnly=false does not bypass the cap: still HTTP 400 at offset 3500./activity requires a user param; /positions requires a user param; /holders works but isn't a trade history; /trades-history and /tradeHistory return 404. /openapi.json and /swagger.json return 404.trade_dedupe post-bootstrap (all polymarket_data_api_backfill source) and ZERO pre-#288 backfill (Dome-era) coverage. Older trades than 2025-10-12 03:44:37 UTC are permanently inaccessible via /trades.backfill source label) — Dome captured them historically without the offset cap. So the historical depth IS preserved for those markets; the post-#288 polymarket_data_api_backfill rows just supplement (the newest 3500) on top of the existing ~25k+ Dome rows. Zero data loss for the 144 high-volume markets.MAX(time) - 1h overlap, which is rarely deeper than offset 3000 unless a market accumulates >3500 trades in a single week._fetch_trades_page HTTP 400 with offset-cap body → raise sentinel _OffsetCapReached → propagates immediately (skipping the inner 4-attempt retry loop AND the outer retry_with_backoff max_attempts=1) → caught in backfill_market's except block → save pending batch, set truncated=True, success=True, break loop → returned in 5-tuple → run() adds market to completed_markets, increments n_truncated counter, logs warning.start_time = MAX(time) - 1h = 2025-10-22T22:40:53 → cutoff_ts = start_time - 3600 = 2025-10-22T21:40:53. Page 1 (offset=0) returns the newest 500 trades, all newer than cutoff → batch saved. Page 2 (offset=500) similar. Continues until either (a) page returns trades older than cutoff (delta complete — clean success) or (b) offset hits 3000 then 3500 → cap triggered → marked truncated. For TSLA Q3 2025 specifically: market is closed (Oct 22, 2025), no new trades since, so warm-delta should hit (a) immediately with empty page = clean success, NOT trigger cap. Cap only triggers on a future --full-resync or new market that opens with >3500 trades.backfill_trades.py line ~104 (new _OffsetCapReached class), line ~387-401 (offset-cap detection in _fetch_trades_page), line ~436 + ~442 (truncated local + return-tuple extension in backfill_market), line ~510-526 (sentinel except handler), line ~611-636 (truncated counter + log + completed-marks in run()), line ~660-665 (run summary new line + truncated_markets bullet)."max historical activity offset" (lowercased). If wording changes to e.g. "max offset 3000 exceeded" the substring match fails and we fall through to the legacy retry-then-fail path. Acceptable — degrades gracefully to old behavior._fetch_trades_page (which only calls /trades)._OffsetCapReached propagates through retry_with_backoff(max_attempts=1) correctly because retry_with_backoff only retries on Exception-subclass exceptions in its exceptions arg AND max_attempts=1 means it makes 1 attempt then re-raises. Verified.completed_markets despite zero data. Acceptable — Polymarket genuinely won't give us more data; logging it as truncated provides visibility.MAX(time) lookup now finds the 3500 saved trades) and complete without triggering cap (delta cutoff filters out everything immediately). n_truncated=0 for the run. Going forward: any cap event logs as Completed-truncated: and is added to completed_markets.polymarket_client.PolymarketClient.get_trades (lines 188-200 of polymarket_client.py) ALSO uses offset-based pagination on /trades and would hit the same cap. HOWEVER: grep confirms PolymarketClient.get_trades has zero active callers (the only .get_trades( reference in the codebase is continuous_collector.py:346 which calls _clob_client.get_trades(params) — that's the CLOB Python SDK client, a different code path). Per memory.md §C surgical-changes, NOT preemptively patching this dead-code library function; would do so when a real caller is added.live_trades_collector.py polls /trades?asset_id=<token> without pagination — never affected.continuous_collector.py CLOB trades path is disabled by default per its own comment (ENABLE_CLOB_TRADES=false).Result=success) → log shows n_truncated=0 for warm-delta runs; TSLA market completes via warm-delta path. Subsequent weekly timer fires never re-attempt TSLA (now in completed_markets). When/if a future market hits the cap, log clearly identifies it as ⚠️ Completed-truncated: with the slug; integrity-monitor's systemd_last_run:polymarket-trades-backfill.service rule continues to pass (was a concern that truncation would falsely fail the rule — verified it doesn't because exit code is 0 unless circuit-breaker tripped).n_truncated counter in run summarytruncated_markets[] list in run summarybackfill rows for those markets instead of relying on polymarket_data_api_backfill only)src/data_tracker/collectors/backfill_trades.py (new _OffsetCapReached sentinel + detection in _fetch_trades_page + truncated flag in backfill_market 5-tuple return + run() truncated counter & summary line)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 (cold-bootstrap close-out section + #292 entry)docs/CONTEXT.md (May 10 changelog: cold-bootstrap-completed result + #292 discovery + 5-label trade_dedupe.source correction below)docs/DATA_ARCHITECTURE.md (added 5th legacy 'backfill' source label to the schema description)trade_dedupe.source discovery (RESOLVED May 10, 2026)Result=success, ExecMainStatus=0, NRestarts=0. Wall time 06:59:28 → 07:28:45 UTC = 29 min (between dry-run "cold" estimate 7.7 min and legacy Dome ~3h cold).completed_markets.trade_dedupe.source='polymarket_data_api_backfill' label. Confirms #288 field-mapping migration is functionally correct.[hidden]/trades_backfill_state.json (65 KB) populated correctly with 928 completed_markets + trades_saved=152101 — matches log summary exactly. (Pre-bootstrap I checked the wrong path data/trades_backfill_state.json and briefly thought state-file persistence was broken; the actual path per STATE_FILE = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'trades_backfill_state.json') resolves to project root, NOT data/. False alarm; doc note added in run-book in case future agent makes the same mistake.)trade_dedupe rows put each market into delta-mode immediately via MAX(time) lookup).polymarket_data_api_backfill 152,101 / live_poll 307 (post-#289) / dome 687,991 (pre-#289 live_trades) / backfill 2,170,662 (pre-#288 — see correction below)..cursor/memory.md, docs/CONTEXT.md, docs/AGENT_HANDOFF.md, docs/FINDINGS.md (#288 + #289), and docs/DATA_ARCHITECTURE.md consistently said "4 labels coexist" — but trade_dedupe actually has FIVE distinct source values:'backfill' (2,170,662 rows) — previously undocumented. All inserted in a single bulk import at 2026-01-31 10:13:24 UTC. Raw_data shape varies (oldest rows Dome-shape, newest Polymarket-shape) suggesting a one-off historical migration script that's no longer in the repo (current 'backfill' literal in code is only conviction_tracker.py writing to a different table). Treat as legacy historical inventory; no current writer.'dome' (687,991 rows) — pre-#289 live_trades_collector.py legacy.'dome_backfill' (0 rows) — pre-#288 backfill_trades.py literal per git history (commit 04b4ca4's diff shows 'source': 'dome_backfill'), but ZERO rows in DB. Likely all ON CONFLICT-skipped against the existing 'backfill' rows from the Jan 31 bulk import. Label is in git history; data never persisted at scale.'live_poll' (307 rows post-bootstrap) — post-#289 live_trades_collector.py.'polymarket_data_api_backfill' (152,101 rows post-bootstrap) — post-#288 backfill_trades.py.docs/FINDINGS.md line 2813 (#289 §B Step 2 simulation), docs/FINDINGS.md line 2870 (#288 source-label change), docs/DATA_ARCHITECTURE.md line 351 (schema description), docs/CONTEXT.md "What changed this week" dome_backfill mentions, docs/AGENT_HANDOFF.md §11 'dome_backfill' mentions, src/data_tracker/verify_schema.py line 10. All 6 surfaces updated in this commit to mention all 5 labels with their row counts as of May 10, 2026 morning.docs/FINDINGS.md (this entry + #292 above; doc-correction inline edits to #288 + #289 surface)docs/AGENT_HANDOFF.md §11 (cold-bootstrap close-out + 5-label correction)docs/CONTEXT.md (May 10 changelog update)docs/DATA_ARCHITECTURE.md (5-label correction at schema description line)src/data_tracker/verify_schema.py (legacy-source comment updated to 5 labels)systemctl enable --now polymarket-trades-backfill.timer. First weekly fire: next Sunday 06:00 ET ± 600s randomized delay.progress:* rules + helper) to a tighter defense that actually closes the §E #287 blind spot for the current architecture.polymarket-orderbook-backfill, polymarket-prices-backfill, polymarket-dome-prices-backfill) had been silently failing for 12 days post-Apr 28 EOL because they were enabled+active+running but doing zero useful work (state files frozen at 5,735/5,735 from before EOL — cycle loop just exits "all done" every hour). Existing integrity-monitor freshness rules catch DATA-side problems (table not being written) but not COMPUTE-side waste (service active but accomplishing nothing) when REDUNDANT writers exist (the #287 case — Polymarket-native consumers were independently writing the same target tables, so freshness rules stayed clean).progress:* rules (polymarket-live-trades, polymarket-collector, polymarket-websocket) attributing writes to specific services via DB-query patterns. After reading integrity_monitor.py end-to-end and verifying the live system, all 3 named daemons are the SOLE writers of their respective tables in the current architecture (CLOB trades disabled per ENABLE_CLOB_TRADES=false; the Dome-era redundant-writer pattern doesn't apply). So the existing freshness:* rules already alarm 10-60 min after any of these would silently die. The proposed progress:* rules would have duplicated existing coverage with attribution noise on illiquid days (no trades happening = no writes regardless of service health). Operator approved re-scope (B) — actual blind-spot closure via state-file mtime defense + documentation + deploy-checklist, skip the progress:* rules. ~30 min vs the original ~45 min estimate.artifact:trades_backfill_state rule in _build_rules() §4 — _check_artifact_mtime on [hidden]/trades_backfill_state.json with 9-day threshold (8-day timer cadence + 1-day slack — matches systemd_timer:polymarket-trades-backfill.timer). Severity HIGH. Catches the §287 frozen-state pattern: state file not advancing despite service appearing active._build_rules() §4 explaining the §E "long-running no-op service" lesson, why state-file mtime is the primary defense, and which services are/aren't in scope (with rationale).deploy/deploy_from_github.sh:services=() array — when a new long-running daemon collector is added, reminder to (a) add a state-file mtime rule if the daemon maintains state, (b) note that SOLE-writer daemons are covered by freshness rules, (c) REDUNDANT-writer daemons MUST have a state-file rule (only defense).docs/DATA_INTEGRITY.md — "Long-running-no-op service defense — state-file mtime rules (Finding #291)". Documents the §287 blind spot, the defense pattern, the in-scope vs out-of-scope decision matrix, and pointer to the deploy-checklist.curl /api/integrity post-deploy: artifact=3, freshness=5, meta=1, null_rate=5, storage=4, systemd=19); artifact count 3 (was 2 before #291); systemd count 19 (=5 always-on + 7 timer + 7 last_run — the +2 vs older 17-claim came from #269 adding systemd_timer + systemd_last_run for polymarket-trades-backfill on May 9, which the §2.2 table had not propagated)._build_rules() returns the new rule → cycle calls evaluator() → _check_artifact_mtime reads os.stat().st_mtime on the state file → returns (STATUS_OK, "...{age}s old", detail) if <9 days, else (STATUS_FAIL, "...{age}h old, threshold 216h", detail) → _upsert_rule_status UPSERTs into integrity_status → AlertManager fires Telegram on ok→fail HIGH transition → dashboard's /api/integrity reflects the new row.save_state calls were broken / no-op'd, mtime would freeze; rule fires within 9 days. (c) Replay timer-disabled scenario: mtime stays at last fire's value; rule fires after 9 days of no fire.integrity_monitor.py ~line 728-755 (40-line comment + new rule append after artifact:strategy_analysis_report); deploy/deploy_from_github.sh ~line 35-50 (checklist comment in services array); docs/DATA_INTEGRITY.md §2.2 table update + new §2.5 ~lines 86-100 + 145-175.backfill_trades.py is refactored → rule silently goes STATUS_FAIL "missing"; mitigated by _check_artifact_mtime returning explicit "missing" message that surfaces in dashboard. The rule also serves as a tripwire for accidental refactor breakage.
(b) State file is touched by some non-script process (e.g., admin manually edits it) → mtime advances spuriously, rule passes when it shouldn't. Acceptable risk — admin manual editing is rare and obvious.
(c) Threshold is too tight (9 days) and Sunday's timer fire fails → false alarm Mon. Mitigated: existing systemd_last_run:polymarket-trades-backfill.service would also fire (HIGH) — cross-confirms.
(d) Threshold is too loose and a 2-week silent failure is missed for too long → 9 days is balanced against the weekly cadence; reducing to 8 days = no slack against Sunday RandomizedDelaySec=600.
(e) _check_artifact_mtime raises (file system error) → caught by Rule.evaluate() wrapper → returns STATUS_UNKNOWN with exception detail; never crashes the monitor cycle.curl http://127.0.0.1:8080/api/integrity shows new check artifact:trades_backfill_state with STATUS_OK and current mtime detail. After this commit, count of artifact checks = 3.login_attempt_state.json — IBKR login retry state, written by trader on connect attempts. Not a long-running-no-op concern.gap_monitor_state.json — would be written by src/data_tracker/collectors/gap_monitor.py BUT gap_monitor.py is dead code (no service, no process, no state file on prod — verified via ps aux | grep gap_monitor returns empty + systemctl status returns "could not be found"). Per memory.md §C surgical-changes, NOT preemptively cleaning up gap_monitor.py here; should be a follow-up cleanup like #290 if/when scoped.dome_prices_backfill_state.json.archived_*, orderbook_backfill_state.json.archived_*, price_backfill_state.json.archived_* — explicit archive files from #287 Tier A; intentionally preserved with .archived_<timestamp> suffix; never written to.ibkr_snapshot.json — already covered by existing artifact:ibkr_snapshot rule (5-min threshold).curl http://127.0.0.1:8080/api/integrity returns the new check artifact:trades_backfill_state with status=ok and detail.age_seconds matching the actual state file mtime (~1h post §G probe of #292 from earlier this morning).src/data_tracker/integrity_monitor.py (new rule + 40-line context comment in _build_rules() §4)deploy/deploy_from_github.sh (CHECKLIST comment in services=() array)docs/DATA_INTEGRITY.md (§2.2 table update + new §2.5 "Long-running-no-op service defense")docs/FINDINGS.md (this entry — flipped DOCUMENTED → RESOLVED)docs/AGENT_HANDOFF.md §11 (#291 close-out section + next-session row update)docs/CONTEXT.md (May 10 changelog entry)watchdog.py + watchdog_v3.py + 2 unit files) + same-pattern stale-reference cleanup (RESOLVED May 10, 2026)[Dd]ome references in src/data_tracker/watchdog.py (# Trades can potentially be backfilled from Dome API) and src/data_tracker/watchdog_v3.py (Dome service display names: "Orderbook Backfill (Dome API)", "Trades Backfill (Dome API)", "Dome Prices Backfill (Historical)"). Per .cursor/memory.md §C "if you notice unrelated dead code, mention it - don't delete it. Don't remove pre-existing dead code unless asked," the agent left them in place. Operator approved deletion at handoff. Now shipped.polymarket-watchdog.service and polymarket-watchdog-v3.service removed from /etc/systemd/system/ on prod; the deploy script's for legacy in polymarket-watchdog.service polymarket-watchdog-v3.service block (lines 127-128 of deploy_from_github.sh) is the idempotent prod-cleanup path that ran daily for ~2 weeks. Replaced by polymarket-integrity-monitor.service (Finding #258, 34 declarative rules).systemctl is-active polymarket-watchdog.service: not-foundsystemctl is-active polymarket-watchdog-v3.service: not-foundps auxf | grep -E "watchdog\.py|watchdog_v3\.py" | grep -v grep: empty (no processes)src/data_tracker/watchdog.py (21,853 bytes — note: original FINDING entry estimated "~1 KB" which was a documentation drift; actual was 22 KB)src/data_tracker/watchdog_v3.py (31,190 bytes)deploy/polymarket-watchdog.service (674 bytes)deploy/polymarket-watchdog-v3.service (719 bytes)deploy/systemd/polymarket-watchdog.service (477 bytes — 5th file, caught by §E re-grep after first deletion sweep; second copy of the unit file in deploy/systemd/ subdir, ExecStart pointed to the now-deleted src/data_tracker/watchdog.py. Original Finding entry only mentioned deploy/polymarket-watchdog.service — caught by exhaustive [Pp]olymarket-watchdog grep post-delete. Lesson: sweep BOTH deploy/ and deploy/systemd/ for systemd unit files; both directories have been used for unit storage at different points in project history.)polymarket_sniper/watchdog.py (12,490 bytes, Dec 7, 2024) — completely DIFFERENT watchdog from the polymarket_sniper/ sub-project (sports/arbitrage trading bot). Process-monitor for the sniper bot, not the earnings-trader. Has nothing to do with data_tracker/watchdog{,_v3}.py. Documented here for the next agent's NO-SHORTCUTS audit transparency.deploy/setup-digitalocean.sh lines 145, 153, 196 — fresh-droplet install steps for the now-deleted unit. Replaced with explanatory comment + polymarket-integrity-monitor.service callout.deploy/install_services.sh lines 15, 30, 39, 54 — install-script enable/start/status calls. Removed; comment cites #290.tests/system_health_check.py line 92 — polymarket-watchdog-v3 in expected-services list. Replaced with polymarket-integrity-monitor.tests/comprehensive_system_test.py line 379 — same. Replaced with polymarket-integrity-monitor.docs/DATA_INTEGRITY.md §6.1 (lines 310-321) — "Watchdog Logs" section with journalctl -u polymarket-watchdog commands. Replaced with §6.1 "Integrity Monitor Logs" + cross-reference block.docs/DATA_INTEGRITY.md §7.1 services-overview table line 368 — polymarket-watchdog | Gap detection & health entry. Replaced with polymarket-integrity-monitor + the also-deleted polymarket-prices-backfill row dropped (it was decommissioned in #287). Plus annotated the trades-backfill row with "(timer-driven Sun 06:00 ET — Finding #269)".docs/DATA_INTEGRITY.md Appendix file table line 480 — src/data_tracker/watchdog_v3.py | Gap detection service. Replaced with src/data_tracker/integrity_monitor.py. Also updated other rows: removed deleted prices_backfill_service.py, replaced with backfill_trades.py (post-#288). Removed deleted watchdog_state.json and price_backfill_state.json references; replaced with trades_backfill_state.json.docs/AGENT_HANDOFF.md: §11 #270 footnote (line 373) updated with forward-pointer; #250 audit baseline historical (line 845)docs/FINDINGS.md: Finding #5 file list (line 81 — 2024-era file list), Finding #270 watchdog rows (lines 2627-2628 — updated with forward-pointer above), Finding #289.v2 watchdog mention (line 2808 — updated with forward-pointer above), Findings #258/#260/#260c/#250 narratives (lines 3487-3830 — frozen historical)docs/DATA_ARCHITECTURE.md line 818 — §7.1 explicit "Integrity Monitor (replaces v2/v3 watchdogs)" headerdocs/STATE_MACHINES.md lines 8-13 — explicit > §8 "Watchdog v3" REPLACED calloutdocs/OPERATIONS.md lines 1207-1208 — historical context with datedocs/STRATEGY.md line 1201 — "NEW Apr 27, 2026 — replaces decommissioned" historical citationdocs/DATABASE_AUDIT_2026_04_27.md — frozen audit baseline, intentionally never editeddocs/archive/* — archive contentearnings_dashboard/app.py:1324 — already has DECOMMISSIONED Finding #260c commentsrc/data_tracker/integrity_monitor.py lines 6, 616 — comparison-with-watchdog commentsdeploy_from_github.sh:127-128 decommission loop runs systemctl stop polymarket-watchdog.service 2>/dev/null || true; systemctl disable ... 2>/dev/null || true; rm -f /etc/systemd/system/polymarket-watchdog.service — idempotent on the now-not-found units. No regression. CI passes (the 2 test files now check polymarket-integrity-monitor which IS active).grep covered all of src/, tests/, deploy/, docs/, earnings_dashboard/ and confirmed only intentional historical/archive refs remain. (b) deploy_from_github.sh loop fails on non-existent units — already uses systemctl ... 2>/dev/null || true pattern (verified). (c) Some .py import I missed — python3 -c 'from src.data_tracker.watchdog import ...' would now ImportError; verified via exhaustive grep that no from src.data_tracker.watchdog exists in the repo.find [hidden]/src -name "watchdog*" returns empty. find [hidden]/deploy -name "polymarket-watchdog*" returns empty. systemctl is-active polymarket-watchdog.service continues to return not-found. systemctl is-active polymarket-integrity-monitor.service returns active.find + systemctl is-active confirmation; deploy script's decommission loop runs without error.src/data_tracker/watchdog.py, src/data_tracker/watchdog_v3.py, deploy/polymarket-watchdog.service, deploy/polymarket-watchdog-v3.service, deploy/systemd/polymarket-watchdog.service (5th caught by §E re-grep)deploy/setup-digitalocean.sh, deploy/install_services.sh, tests/system_health_check.py, tests/comprehensive_system_test.py, docs/DATA_INTEGRITY.md (§6.1 + §7.1 + Appendix), src/data_tracker/integrity_monitor.py (lines 4-6 + line 616 — clarified that the watchdog files referenced are now DELETED), docs/FINDINGS.md (this entry + #270 + #289.v2 forward-pointers), docs/AGENT_HANDOFF.md §11 + #290 row + line 373 footnote, docs/CONTEXT.md changeloglive_trades_collector.py pg8000 InterfaceError failed-tx cascade (RESOLVED May 10, 2026 — root cause was inner-try-swallows-exception fooling engine.begin into failed-tx COMMIT)live_poll source-label growth — but emitted constant error noise: ~12-13k InterfaceErrors/day for at least 10 consecutive days)(pg8000.exceptions.InterfaceError) in failed transaction block errors at ~5-second cadence. CORRECTION to original Finding #289.b entry: the original claim that errors appeared in journalctl -u polymarket-live-trades.service was wrong — journalctl showed ZERO occurrences in the entire 14-day journal history. The errors are in the log file /var/log/polymarket/live_trades.log (which the systemd unit writes to via StandardOutput=append:, NOT journal). Verified counts in current+rotated logs: log.1=2664, log.2.gz=12209, log.3.gz=12657, log.4.gz=13564, log.5.gz=12802, log.6.gz=13912, log.7.gz=11393, log.8.gz=13758, log.9.gz=13856, log.10.gz=12719 — totaling ~118k InterfaceErrors across 10 days. Cascade was firing every ~5s (matches the time.sleep(5) after exception in run() line 387).save_trade (lines 219-293) had this pattern:
python
with self.engine.begin() as conn:
try:
# ... INSERT trade_dedupe ...
# ... INSERT trades ...
self._seen_trade_ids.add(trade_id)
return inserted
except Exception as e:
logger.debug(f"Trade save error: {e}") # DEBUG = invisible at INFO log level
return False
The bug: the try/except is INSIDE the with engine.begin() as conn: block.except Exception as e: logger.debug(...) return False catches it, logs at DEBUG (invisible), returns Falsewith engine.begin() as conn: block exits NORMALLY (no exception propagated to it)engine.begin().__exit__ thinks everything succeeded → tries to COMMITInterfaceError: in failed transaction blockpoll_market → run() → outer except Exception as e: logger.error(f"Cycle error: {e}") at line 385-387_seen_trade_ids.add(trade_id) was only called on SUCCESS path (line 286). On the inner-exception path, the failed trade_id was NEVER added to seen set. So next 5s cycle re-fetches the SAME bad trade from /trades?asset_id= and re-attempts the SAME failing INSERT — forever. ~12-13k/day cascade across 10+ days.src/db/connection.py:53) and DOES eventually mark broken connections invalid via SELECT 1. But the cascade isn't connection-pool persistence — it's the SAME bad TRADE re-arriving every cycle. Even with a fresh clean connection from the pool, the same INSERT on the same trade fails the same way.with engine.begin() as conn: try: ... (inner try INSIDE the with):backfill_trades.py:save_trades_batch lines 275-353 — uses try/except INSIDE with engine.begin() similarly. BUT semantics differ: only logs the FIRST error per batch (if saved == 0) and doesn't add anything to a seen-set. Affects backfill which is timer-driven (weekly), so cascade frequency is bounded. Could still poison the connection for the rest of the batch. Lower-priority follow-up; not in #289.b scope. Documented for future agent.continuous_collector.py: — checked grep, uses with engine.begin() for many writes. Need separate audit. Out of scope.save_trade:
python
try:
with self.engine.begin() as conn:
# ... INSERTs (no inner try) ...
inserted = result.fetchone() is not None
# Clean exit only — engine.begin COMMITed successfully
self._seen_trade_ids.add(trade_id)
if inserted:
self._trades_saved += 1
return inserted
except Exception as e:
# Inner SQL exception OR engine.begin's commit failure now
# propagates HERE — connection was returned to pool clean by
# engine.begin's auto-rollback path.
logger.warning(
f"save_trade error (Finding #289.b — added to seen set, won't retry this session): "
f"trade_id={trade_id[:40]} {type(e).__name__}: {str(e)[:200]}"
)
self._seen_trade_ids.add(trade_id) # CASCADE CLOSURE
return False
Two structural changes: (1) try is OUTSIDE with engine.begin() so SQL exceptions propagate out of with → engine.begin auto-rollbacks → connection returns to pool clean. (2) _seen_trade_ids.add(trade_id) is called on BOTH the success and error paths, so the bad trade is never re-attempted on a 5s cycle.poll_market → fetch_trades_from_polymarket → for each trade → save_trade(trade, condition_id). Inside save_trade: trade_id check → if not seen, enter try → with engine.begin() as conn: opens transaction → INSERTs → if SQL OK, clean exit → COMMIT → trade_id added to seen → return inserted. If SQL fails: with engine.begin() exit catches the exception → ROLLBACK → connection returns to pool clean → exception propagates to outer try → outer except logs WARNING + adds trade_id to seen + returns False.live_trades_collector.py:219-293 (rewrite of save_trade method)./trades polling.
(d) _seen_trade_ids grows unboundedly → memory leak. Already a pre-existing concern (set grows with all successful trades too); not introduced by this fix.grep -c InterfaceError /var/log/polymarket/live_trades.log → expect 0 (or sub-1/hour for genuine transient errors)grep -c "save_trade error (Finding #289.b" /var/log/polymarket/live_trades.log → may show non-zero if bad trades still arriving (now visible vs hidden); each unique trade_id should appear AT MOST ONCElive_poll rows in trade_dedupe continue to grow at the same baseline rate (no regression in trade-write throughput)awk filtering on post-restart timestamps:live_poll source-label rows in trade_dedupe continue growing (service writes baseline-healthy).pg8000.dbapi.ProgrammingError: value too long for type character varying(10). Per information_schema query, columns of type VARCHAR(10) in the trades table are outcome and side. side is BUY/SELL (≤4 chars, fits). outcome is Yes/No/Unknown by convention (≤7 chars, fits) — BUT some Polymarket markets have CUSTOM outcome labels that exceed 10 chars (likely from non-binary markets that snuck through the active-markets filter). Recommended fix path (NOT in #289.b scope): either ALTER COLUMN outcome and side to VARCHAR(64) or VARCHAR(32) so future schema changes from upstream don't trip this, OR add input-side truncation/validation in save_trade. The 3 affected trade_ids are documented in the post-deploy log for follow-up forensic.src/data_tracker/collectors/live_trades_collector.py (save_trade rewrite — try/with inversion + cascade closure via _seen_trade_ids on error path + WARNING-level visibility)docs/FINDINGS.md (this entry — DOCUMENTED → RESOLVED)docs/AGENT_HANDOFF.md §11 close-out section + #289.b row updatedocs/CONTEXT.md (May 10 changelog + last-updated header)backfill_trades.py:save_trades_batch has a similar inner-try-inside-engine.begin() pattern. Not currently affecting prod (timer-driven, runs weekly, batch-saves so cascade bounded to one batch), but structurally same risk. Should be refactored when next touched. NOT in #289.b scope.api_health_checker cron silent failure (RESOLVED May 10, 2026 — systemd-timer migration via operator-approved option (B))AskQuestion surfaced the trade-offs vs (A) crontab patch./var/log/polymarket/api_health.log was 0 bytes since Apr 27, 2026 (verified via stat). Crontab 0 8 * * * cd [hidden] && source venv/bin/activate && python3 -m src.data_tracker.collectors.api_health_checker >> /var/log/polymarket/api_health.log 2>&1 was firing daily — journalctl --since "10 days ago" | grep CRON.*api_health confirmed daily fires May 4-9 — but the log stayed empty./bin/sh: 1: source: not found. Ubuntu's /bin/sh is dash, not bash. source is a bash-ism. The chained cd && source && python && >> log short-circuits at the dash source failure (exit 127), so the python invocation NEVER runs and the >> log 2>&1 redirect (which is part of the chained command) never executes. Mail spool is empty (no MAILTO set in crontab) so the dash error went nowhere. Bug existed for 14 days (Apr 27 → May 10). Reproduction: /bin/sh -c 'cd [hidden] && source venv/bin/activate && python3 -m ...' returns exit 127 with stderr source: not found. Confirming control: /bin/bash -c 'cd ... && source ... && python ...' writes 1010 bytes to the log and exits 1 (script ran, exits non-zero because Polymarket Website API is returning ERROR — separate ongoing issue tracked by the new artifact rule below).0 */6 * * * cd [hidden] && source venv/bin/activate && python3 scripts/sync_earnings_markets.py >> [hidden]/logs/earnings_sync.log 2>&1 also uses source and would also fail under dash. Earnings sync was less impacted because EarningsMarketFetcher (the hourly polymarket-data-backfill.timer service path) was independently keeping earnings_markets updated. Fixed in same commit via the SHELL=/bin/bash crontab change (covers both bugs).deploy/systemd/:polymarket-api-health.timer — OnCalendar=*-*-* 08:00:00 UTC, Persistent=true, RandomizedDelaySec=300 (matches old cron schedule + adds resilience to host downtime).polymarket-api-health.service — Type=oneshot, RemainAfterExit=no, WorkingDirectory=[hidden], ExecStart=/bin/bash -c 'source [hidden]/.env && export DATABASE_URL POLYGON_KEY FINNHUB_API_KEY && [hidden]/venv/bin/python3 -m src.data_tracker.collectors.api_health_checker'. Matches the polymarket-resolver.service convention for env injection. StartLimitIntervalSec + StartLimitBurst in [Unit] per #270 placement. Documentation=file:///... URLs per #269.v2 lesson. Output appended to /var/log/polymarket/api_health.log AND captured by journal via systemd default.deploy_from_github.sh — new install block (~lines 136-155) installs both files with install -m 0644, runs daemon-reload, enable --now ...timer, and restart ...timer (idempotent on subsequent deploys)._build_rules():systemd_timer:polymarket-api-health.timer — daily + 2h slack threshold (60*26 min) — under existing TIMERS listsystemd_last_run:polymarket-api-health.service — under existing BATCH_SERVICES list — catches non-zero exitartifact:api_health_latest_json — [hidden]/data/api_health/latest.json mtime ≤ 26h — belt-and-suspenders for the #287 "service ran but didn't write its file" scenarioSHELL=/bin/bash at the top of root crontab (fixes the latent sync_earnings_markets.py bug + any future cron entry that uses bash-isms)(crontab -l | sed -e '/api_health_checker/d' -e '1i SHELL=/bin/bash') | crontab -polymarket-api-health.timer fires daily 08:00 UTC + 300s randomized delay → triggers polymarket-api-health.service (Type=oneshot) → bash -c 'source .env && python -m api_health_checker' runs → script makes 5 HTTP API checks → writes results to data/api_health/health_check_<date>.json AND data/api_health/latest.json → exits 0 (all OK) or non-zero (any check failed) → systemd records Result + ExecMainStatus → integrity-monitor's systemd_last_run:polymarket-api-health.service rule reads on next 5-min cycle → emits STATUS_OK or STATUS_FAIL → AlertManager fires Telegram on HIGH transition.systemctl start polymarket-api-health.service → service exits non-zero (because Polymarket Website API earnings JSON returns 404 — pre-existing issue) → latest.json mtime updates → all 3 rules return correct status (timer=fired-recently, last_run=fail (script exited non-zero, expected), artifact=ok (latest.json fresh)). The last_run rule firing as STATUS_FAIL is CORRECT — it surfaces the pre-existing Polymarket Website API 404 to Telegram, which has been silently broken for some unknown duration.deploy/systemd/; deploy_from_github.sh ~+10 lines; integrity_monitor.py ~+20 lines (1 entry in TIMERS, 1 in BATCH_SERVICES, 1 new artifact rule). Crontab edit is post-deploy manual..env source fails → bash exits non-zero → service exits non-zero → systemd_last_run:* STATUS_FAIL → alert fires within 5 min. Self-healing observability.
(b) Polymarket Website API earnings JSON returns 404 (current state) → script exits non-zero → systemd_last_run:* STATUS_FAIL daily → operator either fixes the API or adjusts the rule. Acceptable: surfacing the real issue is the goal.
(c) Crontab edit fails / not done → both old cron AND new systemd timer fire daily → latest.json written twice in same day → no harm (idempotent overwrite). Documented in OPERATIONS for next-deploy cleanup if missed.
(d) Timer doesn't fire on host that was down at 08:00 → Persistent=true makes it fire on next boot. Caught by systemd_timer:* rule's 26h threshold.
(e) latest.json symlink/write race during integrity check → _check_artifact_mtime reads os.stat() which is atomic; minor mtime jitter is acceptable.systemctl start polymarket-api-health.service:systemctl is-active polymarket-api-health.timer → activesystemctl list-timers polymarket-api-health.timer shows NEXT for tomorrow 08:00 UTC + delaystat [hidden]/data/api_health/latest.json shows fresh mtimewc -l /var/log/polymarket/api_health.log shows growing line countcurl /api/integrity shows 3 new rules: systemd_timer:polymarket-api-health.timer (UNKNOWN until first fire), systemd_last_run:polymarket-api-health.service (OK or FAIL based on last manual fire), artifact:api_health_latest_json (OK)crontab -l | grep api_health_checker returns empty (cron line removed)crontab -l | head -1 shows SHELL=/bin/bashdeploy/systemd/polymarket-api-health.service (NEW)deploy/systemd/polymarket-api-health.timer (NEW)deploy/deploy_from_github.sh (new install block + comment block)src/data_tracker/integrity_monitor.py (1 entry in TIMERS list + 1 in BATCH_SERVICES list + 1 new artifact:api_health_latest_json rule with comment)docs/FINDINGS.md (this entry — DOCUMENTED → RESOLVED)docs/AGENT_HANDOFF.md §11 close-out section + #289.a row updatedocs/CONTEXT.md (May 10 changelog + last-updated header)docs/OPERATIONS.md (data-collection cron-vs-timer table — replace cron line with timer)docs/DATA_INTEGRITY.md §2.2 rule-categories table update (artifact 3 → 4; systemd 19 → 21)❌ Polymarket Website API: ERROR — Earnings JSON returned 404 — Build ID: build-TfctsWXpff2fKS. The https://polymarket.com/_next/data/<buildID>/... URL returns 404 because the build ID changes when Polymarket redeploys. This has been broken for unknown duration and was hidden by the cron silent-failure. Now visible daily via systemd_last_run:polymarket-api-health.service STATUS_FAIL. Separate finding will need to address (likely needs build-ID auto-discovery via the Polymarket homepage HTML, OR the script should warn-not-fail on this single check). NOT in scope for #289.a — surfaced for operator awareness. [RESOLVED by Finding #326, Jun 14 2026 — Polymarket then migrated to the Next.js App Router (buildId gone entirely); rewrote the check App-Router-aware AND demoted it to warn-not-fail, both fixes this note predicted.][Dd]ome|DOME across the entire repo — 47 files, ~150 refs total. Categorized:Finding #287/#288/#289 citations, the field-mapping doc table in backfill_trades.py:save_trades_batch, etc.) — these tell the story of what changed and SHOULD stay per memory.md §J (canonical migration narrative in code + docs).migrations/*.sql + models/database.py:api_calls_dome (existing DB column) — left untouched (migrations are append-only history; the DB column exists in production and dropping it is a separate migration).watchdog.py + watchdog_v3.py (decommissioned per Finding #260c — services already removed from prod, .py files remain but unused) — left per §C "if you notice unrelated dead code, mention it - don't delete it"; subsequently DELETED in Finding #290 (May 10, 2026) along with the 2 leftover unit files in deploy/ — those Dome refs are no longer in the repo.docs/archive/, archive/, polymarket_sniper/data/api_health/*.json — explicit historical preservation, intentionally never edited.docs/DATA_ARCHITECTURE.md — surgical pass on 5 sections that previously had inline Dome refs as "current truth": §2.1 API table (added Polymarket data-api with rate limits + migration note), §2.2 Third-Party APIs (Dome marked DECOMMISSIONED + added Polygon/Finnhub/SEC EDGAR), §2.4 Data Source Matrix (rewritten with current Polymarket sources + Phase 3 orderbook gap note), §3 schema column descriptions for trade_dedupe.source (lists all 4 source labels coexisting), §4.1 Active Services table (3 decommissioned services struck through), §4.2 architecture diagram boxes, §4.3 Data Source Matrix, §4.4 "Why Dome for Live Trades?" section header renamed + body updated, §5 Backfill Sources tables + priority list. Banner from v1 stays as the "TL;DR" summary above the body content.docs/OPERATIONS.md — surgical pass on data-sources table (§5), collection schedule diagram, backfill status table, price-backfill-details section, backfill-priority-order list, API-health-check curl commands, .env appendix.src/data_tracker/collectors/api_health_checker.py — replaced check_dome_api() (which called dead api.domeapi.io) with check_polymarket_data_api() (calls data-api.polymarket.com/trades?limit=1 and verifies expected fields per Finding #288 mapping). Daily cron now actually monitors the right API. Plus updated check_polymarket_website_api's descriptions and the run_all_checks dispatcher.src/data_tracker/collectors/continuous_collector.py — log message clarification (was "Use Dome live trades collector" → "Use polymarket-live-trades.service (data-api /trades poller)").src/data_tracker/collectors/websocket_collector.py — module docstring + WebSocketCollector class docstring updated to reference Polymarket data-api instead of Dome.src/data_tracker/verify_schema.py — module docstring + sample-data labels updated to reference current source label set.src/data_tracker/integrity_monitor.py — 2 cosmetic comment refs ("CLOB/Dome" → "CLOB/Polymarket data-api"; "Dome outage" → "upstream API outage").src/data_tracker/scripts/install_collector.sh — removed DOME_API_KEY from supervisor env config + .env template + post-install note. Updated post-install backfill instruction to reference the new timer.earnings_dashboard/app.py — services list comment updated (lines 1316-1328): renamed entries with # Live trades from Polymarket data-api etc., struck through 4 decommissioned services with citations to #287/#289. data_freshness['trades']['source'] and backfill_source strings: 'Dome API' → 'Polymarket data-api'. Stale 'Backfill stats - Orderbooks (Dome API)' comment updated.earnings_dashboard/templates/dashboard.html line 2125 — link text "Polymarket, Gamma & Dome API docs" → "Polymarket API docs (Gamma, data-api, CLOB, WSS)".src/data_tracker/collectors/backfill_trades.py — 4 stale comment/log refs cleaned: get_last_trade_time_for_market docstring (Dome start_time reference no longer applicable since Polymarket /trades has no server-side start_time), error log "Failed markets (Dome 504/429...)" generalized, circuit breaker comment + log message generalized, argparse description updated.api_health_checker cron next-fire calls Polymarket data-api instead of dead Dome endpoint. Dashboard data-freshness panel labels Polymarket data-api instead of Dome API.data_freshness['trades']['source'] as a literal 'Dome API' string for branching — verified via grep, only the dashboard template reads it for display. (b) check_polymarket_data_api field-shape check is tighter than check_dome_api was — could false-alarm WARNING if Polymarket changes response shape; matches Finding #284 "data-shape contracts" lesson and is desirable. (c) install_collector.sh env removal — only used at fresh-install time on a clean droplet; existing prod .env already had DOME_API_KEY removed in Tier C operational step.grep -r "Dome\|DOME" src/ earnings_dashboard/ docs/ returns ONLY: (a) intentional migration-history references with explicit citations to Findings #287/#288/#289, (b) historical schema columns in migrations + ORM, (c) decommissioned watchdog files (per §C). Zero remaining "Dome is the current data source" claims in active code.~~strikethrough~~ and a "DECOMMISSIONED Finding #X" pointer).docs/DATA_ARCHITECTURE.md (~6 sections surgically rewritten)docs/OPERATIONS.md (~7 sections updated)src/data_tracker/collectors/api_health_checker.py (replaced check_dome_api)src/data_tracker/collectors/continuous_collector.py (log msg)src/data_tracker/collectors/websocket_collector.py (docstrings)src/data_tracker/collectors/backfill_trades.py (4 stale comment cleanups)src/data_tracker/integrity_monitor.py (2 comments)src/data_tracker/verify_schema.py (docstring + labels)src/data_tracker/scripts/install_collector.sh (env config cleanup)earnings_dashboard/app.py (services list + data_freshness labels)earnings_dashboard/templates/dashboard.html (link text)docs/FINDINGS.md (this entry)live_trades_collector.py, doc surface refresh (RESOLVED May 10, 2026)backfill_trades.py) shipped earlier today.live_trades_collector.py should KEEP and just get cosmetic rename):src/data_tracker/collectors/dome_client.py — DomeAPIClient lib (last consumer migrated in #288)src/data_tracker/collectors/main_collector.py — dead orchestrator with broken imports (from collectors.X — wrong module path), never invokedsrc/data_tracker/collectors/backfill_earnings_trades.py — superseded by backfill_trades.py (Finding #269 smart Option A)src/data_tracker/collectors/backfill_candlesticks.py — candlesticks table is empty (verified in Q3 deep-dive); no active readerssrc/data_tracker/collectors/backfill_prices.py — Dome-candlestick-based price backfill, no readerssrc/data_tracker/collectors/dome_prices_backfill.py — service stopped Tier A, file deadsrc/data_tracker/collectors/orderbook_backfill_service.py — service stopped Tier A, file deadsrc/data_tracker/collectors/prices_backfill_service.py — service stopped Tier A (Gamma /markets/{cid}/prices not in new docs); file deadsrc/data_tracker/collectors/__init__.py — removed from .dome_client import DomeAPIClient and from .main_collector import DataCollector. __all__ now ['PolymarketClient'] (gamma + data-api + clob multi-API client).live_trades_collector.py (Q4 verdict: KEEP — service is working, writes ~3,474 trades/24h):DOME_URL constant → POLYMARKET_DATA_API constant.fetch_trades_from_dome() method → fetch_trades_from_polymarket().trade_dedupe.source label 'dome' → 'live_poll' for new rows. Old rows stay 'dome' for forensic distinguishing of pre/post-migration data.create_trade_id prefix dome_ → lp_ (live poll) for new rows.https://data-api.polymarket.com/trades?asset_id=<token> — just had misleading variable + label names.earnings_dashboard/app.py:1144-1184 — removed dead prices_backfill_service and orderbook_backfill_service process-running checks. Stats keys still set to defaults (False/0) so dashboard template doesn't KeyError, but the underlying services are gone. Verified no template references to those keys (no UI changes needed).gap_monitor.py:check_backfill_processes() — removed backfill_candlesticks process check (file deleted). backfill_trades check kept but description updated to note it's now timer-driven (only runs Sun 06:00 ET, so process-not-running is the expected state most of the time).src/data_tracker/env.example.txt — removed DOME_API_KEY=your_dome_api_key_here line; added migration-note comment.README.md — 3 inline Dome refs updated to mention Polymarket replacement + Finding #289.docs/DATA_INTEGRITY.md — §3.1 "Available Backfill Sources" table rewritten with Polymarket endpoints; §3.2 "Prices Backfill Service" replaced with decommissioning notice.docs/API_REFERENCE.md — TOC entry "Dome API" → "Polymarket data-api"; §4 fully rewritten with new endpoints, rate limits (20 q/s /trades vs Dome 1 q/s), trade-record shape mapping (transactionHash/proxyWallet/conditionId/asset/etc.), and a Polymarket replacement code example. Section 6 rate-limit table updated. Collection-frequency table at §5 expanded to show all 8 active collectors (post-Tier-A surface).docs/OPERATIONS.md — services-array diagram updated to show post-Dome-EOL services + decommissioned list; Trades-Backfill runbook updated (~3h cold bootstrap → ~8min on Polymarket); "Data Sources" table replaced with full Polymarket API surface; collection-schedule diagram updated; Backfill-Status table refreshed; Price-Backfill-Details section replaced with decommissioning notice; final API-health-check curl commands updated; appendix .env example removes DOME_API_KEY.docs/DATA_ARCHITECTURE.md — top-of-file banner added with critical migration table (old Dome endpoint → new Polymarket replacement). Body left intact as historical context (34 inline Dome refs are accurate descriptions of the v1.6 system; v1.7 banner makes the historical-vs-current distinction clear).docs/AGENT_HANDOFF.md §11 (Tier C close-out) + docs/CONTEXT.md "What changed this week".scripts/verify_retry_backoff_and_block.py had dome_client._make_request and prices_backfill_service._get_with_retry retry tests removed in #288 (preemptively, since Tier C deletes the modules). No further action needed in this commit.__init__.py now exports only PolymarketClient (the multi-API replacement). All live consumers (continuous_collector, live_trades_collector, websocket_collector, polymarket_client, gamma_collector, earnings_market_fetcher) have zero residual imports of deleted files (verified via grep).live_trades_collector polls data-api.polymarket.com/trades?asset_id= (same as before #289) but now writes trade_dedupe.source='live_poll' for new rows. backfill_trades.py writes 'polymarket_data_api_backfill' for its new rows. Correction May 10 evening (Finding #288.v3): trade_dedupe actually has 5 source labels in coexistence (not 4 as originally documented): backfill (2.17M rows from one-off Jan 31, 2026 bulk-import script no longer in repo — discovered via cold-bootstrap §G probe), dome (pre-#289 live_trades legacy), dome_backfill (literal in pre-#288 backfill_trades.py per git history but 0 rows in DB — all ON CONFLICT-skipped against the existing 'backfill' rows), live_poll (post-#289 live_trades), polymarket_data_api_backfill (post-#288 backfill_trades). All historical labels preserved for forensic queries.git rm (handled in commit). Cosmetic edits in live_trades_collector.py lines ~5-25 (docstring), 64 (constant), 188-201 (create_trade_id), 219-228 (fetch_trades_from_polymarket), 251 (source label), 272 (taker comment), 305-313 (poll_market call sites). Dashboard cleanup at earnings_dashboard/app.py:1144-1184 (40 lines removed, 12 line stub added). gap_monitor at src/data_tracker/collectors/gap_monitor.py:66-84 (15 lines simplified).verify_retry_backoff_and_block.py) catches at-import errors; +ran AST parse on edited files locally before commit; verified __init__.py only exports PolymarketClient cleanly.
(b) Dashboard breaks because of removed stats keys → verified no Jinja template references to prices_backfill_running / orderbook_backfill_running (grep returned 0 matches in dashboard.html); plus default values kept set so even if reintroduced the dict has the keys.
(c) live_trades_collector cosmetic rename breaks running service mid-deploy → service restart on deploy picks up the new code; the only live state is _seen_trade_ids (in-memory, lost on restart, repopulated within ~5 min); no DB schema change; old trade_dedupe rows with source='dome' remain valid (dedup still works because the trade_id format lp_<hash>_<ts> is distinct from dome_<hash>_<ts>, so old rows can't false-positive against new ones — and even if they did, ON CONFLICT DO NOTHING is idempotent).
(d) Some doc cross-link to a deleted section is now broken → documented inline in the Tier C changes (e.g., DATA_INTEGRITY §3.2 was renamed to "DECOMMISSIONED" but section number stays). Verified TOC consistency where applicable.
(e) gap_monitor.py is invoked somewhere I didn't audit → grep showed no active service or cron entry on prod; per Finding #258/#260 watchdog cleanup, gap_monitor was likely deprecated alongside watchdog v2/v3 (decommissioned) but the file remained. Not deleting it in this commit per §C surgical-changes; just removing the candlesticks check that would always be False post-#289.active post-deploy (systemctl is-active for earnings-trader, polymarket-collector, polymarket-dashboard, polymarket-live-trades, polymarket-websocket, polymarket-integrity-monitor, earnings-conviction-tracker).freshness:trades, freshness:orderbook_snapshots, freshness:price_snapshots integrity rules continue passing (verified live in Tier A + Tier B probes)./ returns HTTP 200; no UndefinedError in journalctl post-restart.python -c "from src.data_tracker.collectors import PolymarketClient" succeeds; from src.data_tracker.collectors import DomeAPIClient raises ImportError as expected.live_trades_collector continues writing trades; new rows have trade_dedupe.source='live_poll'. Verified post-deploy via SELECT source, count(*) FROM trade_dedupe WHERE trade_time > '<deploy-ts>' GROUP BY source.domeapi.io URL in repo? Verified: 0 references in active code (a few in archive/ and docs/archive/ are intentional historical preservation).DomeAPIClient import? Verified: 0 references after __init__.py cleanup + 8 file deletions.DOME_API_KEY reference in active code? Verified: 0 in src/, only in docs/archive/ and a few historical docs (mentioned with migration context).deploy_from_github.sh propagates the file changes. Service restarts (trader, dashboard, collector, live-trades) pick up the renamed live_trades_collector cleanly. No state-file migrations needed (existing trade_dedupe rows valid; new rows use new source labels).DOME_API_KEY removal from prod [hidden]/.env after deploy verification. Per §C surgical scope, this is a separate post-deploy command — not in this commit.api_health_checker cron silent failure (log file 0 bytes since Apr 27; cron fires daily but produces no output). Likely cron-env / .env-not-sourced / module-import issue. Investigate + fix in a follow-up commit.live_trades_collector pg8000 failed-tx cascade (InterfaceError: in failed transaction block every 5s). Service still functional (trades being saved) but emits constant error noise. Investigate root cause (likely a DB error in a prior cycle leaves connection in failed state without explicit ROLLBACK). Investigate + fix in a follow-up commit.src/data_tracker/collectors/{dome_client,main_collector,backfill_earnings_trades,backfill_candlesticks,backfill_prices,dome_prices_backfill,orderbook_backfill_service,prices_backfill_service}.pysrc/data_tracker/collectors/__init__.py (cleaned imports)src/data_tracker/collectors/live_trades_collector.py (cosmetic rename + label change)src/data_tracker/collectors/gap_monitor.py (removed candlesticks check)earnings_dashboard/app.py (removed dead backfill-process checks)src/data_tracker/env.example.txt (removed DOME_API_KEY)README.md (3 Dome refs updated)docs/DATA_INTEGRITY.md (§3.1 + §3.2)docs/API_REFERENCE.md (§4 rewritten + §6 rate limits + §5 collection table)docs/OPERATIONS.md (services diagram + trades-backfill runbook + data sources + backfill status + appendix)docs/DATA_ARCHITECTURE.md (top banner)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 (Tier C close-out)docs/CONTEXT.md (May 10 update)backfill_trades.py from Dome /polymarket/orders → Polymarket data-api/trades (RESOLVED May 10, 2026)backfill_trades.py needs to repoint at the new Polymarket data-api /trades endpoint.https://api.domeapi.io/v1/polymarket/orders | https://data-api.polymarket.com/trades |
| condition_id=<cid> query param | market=<cid> query param |
| response wrapped in {orders: [...]} | top-level array [{...}, ...] |
| start_time server-side filter | NOT supported — implemented client-side (paginate newest-first, stop when crossing MAX(time) - 1h overlap threshold) |
| trade fields: tx_hash, user, condition_id, shares_normalized, token_label, order_hash, taker | trade fields: transactionHash, proxyWallet, conditionId, size, outcome, (no order_hash), (no taker), plus new asset (token_id) field |
| rate limit free tier: 1 q/s + 10 q/10s window | rate limit /trades: 200 req/10s = 20 q/s sustained (20× faster) |MAX(time) - 1h overlap), T2 smart re-fetch policy (active markets + earnings_date within 7 days), T3 earnings-only scope by default, circuit breaker (MAX_CONSECUTIVE_FAILURES=5), CLI flags (--all-markets, --full-resync, --dry-run), state-file resume, success-flag aware completed_markets handling (Finding #269.v2), exit non-zero on circuit-break (Finding #269.v3 + #268 BATCH_SERVICES integrity rule).f"{transactionHash}_{asset[:20]}_{timestamp}" — asset (token_id) included for uniqueness on multi-fill same-tx orders that can hit different tokens.f"{proxyWallet}_{asset[:20]}_{timestamp}_{price}_{size}".'dome_backfill' → 'polymarket_data_api_backfill' for new rows. Old rows stay tagged 'dome_backfill' historically — useful for forensic distinguishing of pre-vs-post-migration data. Live trades collector continues to use 'dome' until #289 cosmetic rename. Post-cold-bootstrap correction (Finding #288.v3, May 10 evening): the actual coexisting label set in trade_dedupe is 5 (not 4): backfill (2.17M rows from a one-off Jan 31, 2026 bulk import — script no longer in repo) is a 5th legacy label that all earlier docs missed; dome_backfill itself has 0 rows in DB despite being in pre-#288 source code (all ON CONFLICT-skipped against the existing backfill rows).--full-resync, ~929 earnings markets): ~9,000 calls × 0.05s/call = ~8 minutes (vs ~3 hours on Dome — 20× faster).polymarket-trades-backfill.timer → polymarket-trades-backfill.service (Type=oneshot) → python -u backfill_trades.py → parse_args → TradesBackfill.__init__ (creates requests.Session to data-api, no DOME_API_KEY needed since /trades is public) → run() → get_markets() (INNER JOIN earnings_markets) → for each market: should_skip_market decides → get_last_trade_time_for_market for delta → backfill_market(start_time=...) → paginated _fetch_trades_page(condition_id, offset, limit) against data-api.polymarket.com/trades?market=<cid>&limit=500&offset=N → client-side cutoff filter (skip trades older than start_time - DELTA_OVERLAP_SECONDS) → save_trades_batch with new field mapping → trade_dedupe ON CONFLICT DO NOTHING → trades INSERT.src/data_tracker/collectors/backfill_trades.py: header + module docstring rewritten; DATA_API_URL + DATA_API_MIN_GAP_SEC=0.05 constants replace Dome-tier rate limit; from src.data_tracker.collectors.dome_client import DomeAPIClient removed; requests import added; TradesBackfill.__init__ swaps DomeAPIClient() for requests.Session + _last_request_ts rate-limit gating; new _fetch_trades_page(condition_id, offset, limit) helper with built-in 4-attempt exponential backoff; backfill_market updated for new endpoint + client-side delta-cutoff semantics (paginate newest-first, stop when trade.timestamp < cutoff); save_trades_batch field mapping rewritten (transactionHash/proxyWallet/conditionId/size/outcome/asset) with new trade_id construction; dry-run budget output now shows 0.05s/call = 20 q/s instead of 1.2s/call.scripts/verify_retry_backoff_and_block.py: removed dome_client._make_request retry test + prices_backfill_service._get_with_retry retry test (services stopped in Tier A; modules deleted in Tier C). Imports tightened./trades returns unexpected shape → _fetch_trades_page validates isinstance(data, list) and returns [] on mismatch with a warning log.
(b) Rate-limit storm (sustained 429) → _fetch_trades_page exponential backoff with 30s cap × 4 attempts; circuit breaker trips at 5 consecutive market failures.
(c) Client-side cutoff over-stops on a partially-old page → cutoff applied per-trade via if ts < cutoff_ts: continue, then break only after the page exits; trade_dedupe handles overlap-window duplicates idempotently.
(d) Trade with no transactionHash AND no proxyWallet → skipped (matches old Dome behavior for trades with no tx_hash AND no order_hash).
(e) Polymarket changes the response shape → save_trades_batch uses .get(field, '') defaults; missing fields produce empty values (acceptable; rows still pass dedup + can be backfilled when shape is corrected).
(f) start_time=None (cold market) → cutoff_ts is None, hit_cutoff stays False, paginate full history (intended for cold bootstrap).
(g) Newest-first ordering assumption → verified via OpenAPI spec: /trades orders by timestamp DESC implicitly. Pagination via offset is stable since prediction-market trades are append-only (no retroactive inserts).python3 src/data_tracker/collectors/backfill_trades.py --dry-run shows expected market count + budget estimate at new ~0.05s/call rate. Manual systemctl start polymarket-trades-backfill.service runs with [delta] markers for markets that have prior trades + [cold] for new markets. State file trades_backfill_state.json updates with new entries. trade_dedupe.source = 'polymarket_data_api_backfill' rows appear within first minute. Existing freshness:trades integrity rule (60-min threshold) continues to pass.live_trades_collector.py already uses data-api.polymarket.com (just with misleading DOME_URL constant — Tier C cosmetic rename in #289). Other consumers (orderbook_backfill_service, dome_prices_backfill, backfill_candlesticks, backfill_earnings_trades, backfill_prices, prices_backfill_service) all stopped in Tier A or being deleted in Tier C — no other migration target.deploy_from_github.sh propagates the script change. Service file polymarket-trades-backfill.service already at Polymarket-naming-correct content from #287; no service file change needed. After deploy, manual --dry-run test on prod, then operator decides when to run cold bootstrap (likely after Tier C ships to avoid mid-migration noise).src/data_tracker/collectors/backfill_trades.py (rewritten module: ~210 lines changed, +~80 / -~50 net)scripts/verify_retry_backoff_and_block.py (removed dome_client + prices_backfill_service retry tests)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 (Tier B close-out)docs/CONTEXT.md (May 10 update: Tier B shipped)backfill_trades.py to data-api) and Tier C (full code/doc cleanup) ship as separate findings #288 and #289.api.domeapi.io (Findings #269.v2 + #269.v3 cold-bootstrap §G probe) weren't transient outages — Dome has been permanently dead for 12 days. Operator commissioned a deep investigation followed by Tier A → B → C migration.dome_client.py (lib), 6 collectors (backfill_trades.py, orderbook_backfill_service.py, dome_prices_backfill.py, backfill_candlesticks.py, backfill_earnings_trades.py, backfill_prices.py), 1 dead orchestrator (main_collector.py — broken imports, never invoked), 1 health checker (api_health_checker.py), 1 CI test (scripts/verify_retry_backoff_and_block.py), 4 docs (API_REFERENCE.md, DATA_ARCHITECTURE.md, OPERATIONS.md, README.md), env.example.txt.polymarket-orderbook-backfill (enabled+active, no-op cycling because state file frozen at 5,735/5,735 from before EOL), polymarket-prices-backfill (active but "Saved: 0 prices" — uses Gamma /markets/{cid}/prices which is also NOT in the new Polymarket API docs; may have been silently broken even before EOL), polymarket-dome-prices-backfill (disabled but Restart=on-failure cycling; OOM-killed daily at 04:21 UTC since at least May 6 — 512M MemoryMax limit hit because prices list accumulates in memory while iterating over 90 days × 24 hours × 5,735 markets).trades last write 2 min ago (2.93M rows), orderbook_snapshots last write 0 min ago (8.6M rows), price_snapshots last write 0 min ago (11.4M rows). All four tables are actively populated by Polymarket-native consumers: polymarket-live-trades.service (data-api /trades?asset_id=), polymarket-collector.service (continuous_collector — gamma + clob via py-clob-client SDK), polymarket-websocket.service (WSS market channel). The 12-day Dome-dead window cost only wasted CPU, not data.candlesticks table is empty (0 rows), no readers in any active code path. Dashboard's "candlestick" references are about stock candlesticks via equity_bars_1m (Polygon-sourced; Finding #240), unrelated to our prediction-market candlesticks table. gap_monitor.py only checks process-running status of backfill_candlesticks (not the table itself).api_health_checker cron silently dead since Apr 27 (separate side-effect bug). Cron fires daily 08:00 UTC but /var/log/polymarket/api_health.log is 0 bytes. Manual run produces output. Plus check_dome_api() would always have returned "skipped: DOME_API_KEY not set" because cron doesn't source .env like systemd services do. Documented for separate fix in Finding #289 cleanup.live_trades_collector.py has a separate pg8000 failed-tx cascade bug (InterfaceError: in failed transaction block every 5 seconds) — but trades ARE still being saved (3,474/24h, 27,597/7d, 46,702/12d all source='dome' in trade_dedupe). Endpoint ?asset_id=X empirically VERIFIED working on the new data-api when token is actively trading (returns valid 2,263-byte JSON response). The misleading DOME_URL constant is just leftover naming — actual URL is https://data-api.polymarket.com. Cosmetic cleanup deferred to Tier C #289./polymarket/orders | data-api.polymarket.com/trades?market=<cid>&limit=N&offset=M | ✅ tested | Tier B backfill_trades.py migration target |
| /polymarket/orderbooks | ❌ NO direct historical endpoint | n/a | Decommission — live collector + WSS market channel cover go-forward |
| /polymarket/candlesticks | clob/prices-history (price-only {t,p}, no OHLC) | not relevant | candlesticks table is empty/unused; deletion in Tier C |
| /polymarket/market-price/{token} historical | clob/prices-history?market=<token>&startTs=... | not tested | Redundant with live collector + #246 backfill |
| /polymarket/activity | data-api/activity | not tested | Not used by earnings strategy |
| /polymarket/wallet/pnl/{wallet} | derive from /positions + /closed-positions | n/a | Not used by earnings strategy |/trades is 200 req/10s = 20 q/s sustained, vs Dome free tier 1 q/s = 20× faster. CLOB /prices-history is 1,000 req/10s. This means the Tier B backfill_trades.py migration drops cold bootstrap from ~3 hours → ~8 minutes, weekly warm delta from ~18 minutes → ~1 minute.polymarket-orderbook-backfill, polymarket-prices-backfill, polymarket-dome-prices-backfill. All is-active=inactive, is-enabled=disabled post-action. (mask attempted but failed because file exists at the path — irrelevant since stop+disable + Restart=on-failure-only-applies-when-running means service can't auto-restart, and we delete the unit files in this commit so future deploys can't reinstall.)orderbook_backfill_state.json, dome_prices_backfill_state.json, price_backfill_state.json → *.archived_20260510_052113.deploy/polymarket-orderbook-backfill.service, deploy/polymarket-prices-backfill.service, deploy/polymarket-dome-prices-backfill.service.deploy/deploy_from_github.sh:services array (lines 25-27) so future deploys don't try to restart them. Replaced with a multi-line comment block citing this finding so the next agent / operator knows what was removed and why.rm /etc/systemd/system/polymarket-{orderbook,prices,dome-prices}-backfill.service + systemctl daemon-reload on prod (single-file hotfix per §H fallback) — performed in the deploy phase below.completed_markets state files were frozen pre-EOL or producing 404/timeout). Stopping them → no observable downstream impact on trader / dashboard / DB writes.integrity_monitor.py:480-515 continue to pass (price_snapshots 0 min, trades 2 min, orderbook_snapshots 0 min — confirmed live). All Polymarket-native consumers continue producing data uninterrupted.deploy/deploy_from_github.sh:20-39 (services array trimmed); deploy/polymarket-{orderbook,prices,dome-prices}-backfill.service deleted; prod /etc/systemd/system/ files removed via post-deploy SSH rm + daemon-reload._check_systemd_unit_last_result returns STATUS_UNKNOWN for unit files that are gone (FileNotFoundError handled), not FAIL. (c) downstream Phase 3 simulator might want historical orderbook data — deferred decision per operator's Q1 answer (accept go-forward WSS streaming only; revisit if Phase 3 surfaces a hard requirement; Dune queries are a fallback option).systemctl is-active polymarket-{orderbook,prices,dome-prices}-backfill.service returns inactive (or not-found after file removal); systemctl daemon-reload clean. Dashboard /api/integrity shows no new failures. Existing freshness rules at integrity_monitor.py:480-515 continue to report STATUS_OK on trades / orderbook_snapshots / price_snapshots (1-2 min recency).polymarket-websocket (WSS — actively receiving), polymarket-live-trades (data-api poller — actively writing 3.5k/24h), polymarket-collector (continuous_collector — actively writing). All produce data per the freshness rules. The 3 stopped services were the only no-op cyclers. Pattern fully covered.backfill_trades.py (Finding #288); Tier C deletes 6 collector files + the lib + updates docs (Finding #289). All identified upfront in the comprehensive investigation report.deploy_from_github.sh propagates the services array change. Manual SSH rm /etc/systemd/system/polymarket-{orderbook,prices,dome-prices}-backfill.service + daemon-reload per §H single-file fallback (deploy script doesn't auto-remove old units when removed from array). Verified post-deploy: 0 zombie processes, 3 services gone from systemctl is-active, freshness rules clean.deploy_from_github.sh:services array but had NO integrity-monitor coverage of "is the service actually doing useful work" — only "is the unit file present and running." A service can be enabled+active+running but doing zero useful work (because internal state file is frozen, or upstream API is dead) without any alert firing. Dome EOL was published Apr 28; we noticed May 10 — 12 days of silent waste. Existing freshness rules on trades/orderbook_snapshots/price_snapshots (Finding #258) were the only thing that would have caught a real impact, and they pass because OTHER Polymarket-native services were producing the data. Future similar pattern: if we add a long-running daemon collector, also add an integrity rule that asserts it's making forward progress (e.g., state['completed_markets'] count grows OR DB write recency from THAT service's source label improves), not just that the systemd unit is active.deploy/deploy_from_github.sh (services array trimmed + comment block)deploy/polymarket-orderbook-backfill.service DELETEDdeploy/polymarket-prices-backfill.service DELETEDdeploy/polymarket-dome-prices-backfill.service DELETEDdocs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 (Tier A close-out + Tier B/C plan)docs/CONTEXT.md "What changed this week" (May 10 — Dome EOL response)backfill_trades.py from Dome /polymarket/orders → data-api /trades. Drop rate limit 1.2s → 0.05s. Preserve T1 delta + T2 smart re-fetch + T3 earnings-only + circuit breaker (Findings #269 + .v2 + .v3).dome_client.py, main_collector.py (dead code), backfill_earnings_trades.py (superseded), backfill_candlesticks.py (no readers), backfill_prices.py (Dome-candlestick consumer, no readers), dome_prices_backfill.py, orderbook_backfill_service.py. Update env.example.txt (remove DOME_API_KEY), API_REFERENCE.md, DATA_ARCHITECTURE.md, OPERATIONS.md, README.md. Cosmetic cleanup of live_trades_collector.py (rename DOME_URL, update docstring, change source='dome' → 'live_poll'). Update verify_retry_backoff_and_block.py CI test. Investigate api_health_checker cron silent-failure + live_trades_collector pg8000 failed-tx cascade as separate sub-findings.DOME_API_KEY removed from [hidden]/.env after Tier C ships.curl /polymarket/markets?limit=1 and curl /polymarket/orders?...&limit=1 BOTH returned HTTP 000 (timeout >30s). Dome API is currently fully down today, not just flaky on a high-volume market.failed_markets[] summary would log them all, but the script would chug for nearly 3 days before completing — well past the next weekly fire (which would stack up via Persistent=true and eventually start while the previous run is still going, except Type=oneshot prevents concurrent runs so the next run would be skipped entirely).MAX_CONSECUTIVE_FAILURES = 5 constant in backfill_trades.py. After 5 markets fail in a row (retries exhausted on each), abort the run early with a clear 🛑 Circuit breaker: log line. Reset counter on any success — only consecutive failures trip it.sys.exit(1) so systemd's exit code is captured. Without this, the script would exit 0 (oneshot success) even after circuit-breaking.polymarket-trades-backfill.service to BATCH_SERVICES list in integrity_monitor.py. Finding #268's systemd_last_run:* rule now watches this service's exit code — circuit-break trips → systemctl Result=exit-code, ExecMainStatus=1 → integrity monitor returns STATUS_FAIL → Telegram alert via existing AlertCategory.DATA_INTEGRITY path. Operator sees the upstream-down event without scraping logs.consecutive_failures += 1 → check >= 5 → log circuit-break + circuit_broken = True + break out of for-loop → run summary fires with circuit-break banner → sys.exit(1). systemd records Result=exit-code ExecMainStatus=1. Next integrity-monitor cycle (5 min) reads systemctl show -p Result,ExecMainStatus → STATUS_FAIL. AlertManager fires Telegram on HIGH/CRITICAL transition.failed_markets[] shows the 5 attempted. State file: completed_markets unchanged (5 attempts, 5 failures, 0 successes). Next weekly fire (or manual retry when Dome is healthy) starts from market 1 again.backfill_trades.py:MAX_CONSECUTIVE_FAILURES constant (top of file with comment), consecutive_failures counter init in run(), increment-on-failure / reset-on-success logic, post-success if consecutive_failures >= MAX_CONSECUTIVE_FAILURES check + circuit_broken=True + break, summary banner, sys.exit(1) on circuit_broken. integrity_monitor.py:BATCH_SERVICES list +1 entry.sys.exit(1) returns even when many markets succeeded before circuit broke → integrity monitor would still show fail → operator sees the alert + checks logs to confirm partial success. Acceptable: better signal than silent partial-success.systemctl start polymarket-trades-backfill.service. With Dome healthy → all 929 markets succeed (or genuinely failed for non-Dome reasons — e.g., 404 on a removed market). Circuit breaker doesn't trip. Exit 0. Integrity monitor systemd_last_run:polymarket-trades-backfill.service flips back to STATUS_OK.polymarket-data-backfill.service (Sun 04:00 ET) calls 4-6 sub-scripts sequentially; if Dome (or yfinance, Polygon, SEC) goes down for one sub-script, the whole timer chain might burn time. But each sub-script has its own retry semantics + the chain is already sequential not retry-exhaust-driven, so the failure mode differs. Decided NOT to extend circuit-breaker pattern to other batch jobs in this commit (per §C surgical scope); revisit if a similar runaway is observed elsewhere.src/data_tracker/collectors/backfill_trades.py (constant + counter + early-abort + exit code)src/data_tracker/integrity_monitor.py (BATCH_SERVICES +1 entry)docs/FINDINGS.md (this entry, plus v2 + v1 below)systemctl start polymarket-trades-backfill.service at 20:23 UTC. After ~3 minutes the §G probe surfaced two issues:run() STILL marked the market as completed — meaning the smart re-fetch policy (T2) would permanently skip it on subsequent runs (NVDA earnings is 172 days ago, well past the 7-day re-fetch window). Real correctness bug introduced by my v1 ship inheriting the original code's "always mark complete" pattern.Documentation= directive in service unit files emitted "Invalid URL, ignoring" warnings on every daemon-reload. systemd expects file:// / http:// / https:// / info: / man: URL formats; bare paths like docs/FINDINGS.md are silently rejected. §E same-pattern check found the bug in BOTH the new polymarket-trades-backfill.service (just shipped in v1) AND the pre-existing polymarket-integrity-monitor.service (introduced by Finding #258). Same kind of log-noise warning as #270.backfill_market() → exception handler catches Dome 504/retry exhaustion → returns (saved, fetched, api_calls) → caller run() does completed.append(cid) unconditionally → state file marks NVDA permanent-complete → next run's should_skip_market(NVDA, completed) → cid in completed AND status=closed AND earnings_date 172d old → SKIP. NVDA never gets re-attempted.backfill_trades.py:backfill_market() returns 4-tuple (saved, fetched, api_calls, success). success=True ONLY on natural end-of-pages break; success=False on retry-exhaustion break. Caller in run() only appends to completed_markets when success=True. Failed markets get a ⚠️ FAILED log line + accumulate into failed_markets[] for end-of-run summary.polymarket-trades-backfill.service: split Documentation= line into separate file://[hidden]/... URLs (one per line per the systemd convention).polymarket-integrity-monitor.service: same Documentation= URL fix (pre-existing #258 bug — §E same-pattern).completed_markets doesn't grow at all → next run repeats from market 0 → eventually catches up. Acceptable. (b) A single market keeps failing across many runs → failed_markets summary log shows it weekly → operator sees and investigates. (c) success=True but fetched=0 (legitimate empty market) → still marked complete, correct (T2 will re-fetch if active or recent earnings_date). (d) Documentation= URL fix changes log-noise only; no service behavior change.⚠️ FAILED — retries exhausted; NOT marking complete; will retry next run AND state['completed_markets'] stays unchanged for that cid. journalctl daemon-reload produces zero Invalid URL, ignoring warnings.deploy/**/*.service: only polymarket-trades-backfill.service (mine, just fixed) and polymarket-integrity-monitor.service (pre-existing #258, also fixed). Other unit files don't use Documentation=. Clean.src/data_tracker/collectors/backfill_trades.py (4-tuple return + run()-side conditional)deploy/systemd/polymarket-trades-backfill.service (Documentation= URLs)deploy/systemd/polymarket-integrity-monitor.service (Documentation= URLs — pre-existing #258 §E fix)docs/FINDINGS.md (this entry, plus #269 v1 entry below for full design context)polymarket-trades-backfill.service had no scheduler — RESOLVED via smart Option A: weekly timer + delta backfill + earnings-only scope (RESOLVED May 9, 2026)trades table TODAY, but Phase 3 unified simulator may want historical trade history; closing the auto-self-healing gap is structurally sound)polymarket-trades-backfill.service had not started in 30 days; no .timer companion, no cron entry. Operator decision pending until May 9..timer companion BUT make the underlying script smarter so it fits within free-tier Dome's 1-query-per-second budget on weekly cadence. Three targeting changes (T1 + T2 + T3) + new timer + integrity rule + cold-bootstrap-then-enable runbook./orders start_time parameter. New get_last_trade_time_for_market(condition_id) returns EXTRACT(EPOCH FROM MAX(time)) for the market's existing trades; subtract a 1-hour overlap window for safety; pass to Dome as start_time. Subsequent runs are O(new trades) not O(history). trade_dedupe ON CONFLICT DO NOTHING makes the overlap idempotent.completed_markets semantics with: (a) never seen → fetch; (b) active markets (status != 'closed') → fetch; (c) closed markets within 7 days of earnings_date → fetch (post-event activity); (d) old closed markets → skip. Closes the gap where collector might miss trades during an earnings window and the backfill couldn't recover them.INNER JOIN earnings_markets reduces market count from ~5,735 → ~929 (verified live: 929/929 match exactly). CLI --all-markets for one-off full sweeps.--all-markets / --earnings-only (default), --full-resync (ignore completed_markets), --dry-run (count + estimate API budget without writing).polymarket-trades-backfill.timer — Sun 06:00 ET, Persistent=true, RandomizedDelaySec=600. Runs 2 hours after polymarket-data-backfill.timer (Sun 04:00 ET) so we don't burst Dome alongside Gamma sync work.deploy_from_github.sh INSTALLS but does NOT enable the timer. Operator runs the cold bootstrap manually (systemctl start polymarket-trades-backfill.service — defaults to earnings-only with delta logic — but on first run trades table has no data for these markets so start_time=None → full history fetched per market). Once cold bootstrap completes and completed_markets state file is populated, operator enables the timer with systemctl enable --now polymarket-trades-backfill.timer. Subsequent timer fires run warm-delta.systemd_timer:polymarket-trades-backfill.timer with 8-day max-age (matches polymarket-data-backfill.timer). Returns STATUS_UNKNOWN (not STATUS_FAIL) for never-fired timers so the install-but-not-enable window doesn't false-alarm.--dry-run on prod):
Cold bootstrap (--full-resync, all 929 earnings markets):
Estimated ~9,000 API calls × 1.2s/call ≈ 3 hours
Warm weekly delta (default --earnings-only, ~929 markets):
Estimated ~900-1,800 API calls × 1.2s/call ≈ 18-36 minutes
(See dome_client.py:_make_request for the rate-limit ladder: 1.2s minimum per-request gap, 8 requests per 10-second window cap with auto-sleep, exponential backoff on 429/5xx with 30s cap. Free-tier published limits are 1 q/s + 10 q/10s; we run conservatively under both.)polymarket-trades-backfill.timer → polymarket-trades-backfill.service (Type=oneshot) → python -u src/data_tracker/collectors/backfill_trades.py → parse_args (defaults: earnings-only=True, full-resync=False, dry-run=False) → TradesBackfill.run() → get_markets() (INNER JOIN earnings_markets) → for each market: should_skip_market() decides fetch vs skip → get_last_trade_time_for_market() for delta → backfill_market(start_time=...) → Dome /polymarket/orders paginated → save_trades_batch() → trade_dedupe ON CONFLICT DO NOTHING → trades INSERT.src/data_tracker/collectors/backfill_trades.py (full rewrite — ~330 lines, +130 from original 208), deploy/systemd/polymarket-trades-backfill.timer (new file, 23 lines), deploy/systemd/polymarket-trades-backfill.service (Documentation= block + smart Option A description, +20 lines), deploy/deploy_from_github.sh (timer install in existing trades-backfill block, +5 lines), src/data_tracker/integrity_monitor.py (new TIMERS entry, +9 lines including comment)._make_request exponential backoff handles it, retries up to 4× with 30s cap. (b) MAX(time) query returns NULL (market has no trades in our DB yet) → start_time=None → full history fetched (this is the cold-bootstrap path; intentional). (c) Dome start_time filter has a bug or off-by-one → 1-hour overlap window catches trades within that boundary; trade_dedupe handles duplicates. (d) Operator forgets to run cold bootstrap before enabling timer → first weekly fire takes ~3 hours instead of ~18 min, but Persistent=true survives interruption + state file resumes; eventual consistency. (e) earnings_markets.earnings_date is date not timestamp → handled in should_skip_market by .date() conversion + isinstance checks. (f) Markets with active live trading mid-run → race condition between MAX(time) lookup and Dome's view → 1-hour overlap window covers it. (g) Concurrent runs (timer fires while manual run still in progress) → Type=oneshot + RemainAfterExit=no means systemd won't start a second instance while the first is running; safe. (h) Dome start_time parameter unit mismatch (seconds vs ms) → verified Dome /orders uses Unix seconds (no *1000 multiplier in dome_client.get_orders, unlike get_orderbook_history which converts to ms).systemctl list-timers --all polymarket-trades-backfill.timer shows the timer file installed (LOAD=loaded, ACTIVE=inactive — disabled per cold-bootstrap policy). python3 -m src.data_tracker.collectors.backfill_trades --dry-run shows expected market count (~929 earnings) and budget estimate (~9k cold / ~900 warm). systemctl start polymarket-trades-backfill.service triggers the cold bootstrap; first 2-3 minutes of journalctl shows [cold] markers per market and steadily-growing trades_saved in the state file. /api/integrity shows systemd_timer:polymarket-trades-backfill.timer as STATUS_UNKNOWN until the timer is enabled (no false alarm).polymarket-data-backfill.timer (Sun 04:00 ET, also weekly, also Persistent=true, also RandomizedDelaySec=600). Same operational shape; smart Option A is consistent with the codebase's existing batch-timer convention.--dry-run: scripts/strategy_performance_analysis.py (regenerates strategy_analysis_report.json), scripts/analyze_finding_a_full.py (Polygon-fetch-heavy; respects similar rate-limit care). Convention is consistent.dome_client.py itself. No parallel rate-limit logic to reconcile.deploy_from_github.sh extended in this commit to install (not enable) the timer. Single deploy fires both the script change and the timer install. systemctl daemon-reload triggered automatically by the deploy script.build_action_queue() — #269 is data-capture infrastructure, not a strategy rule (per §K, the Action Queue tracks strategy decisions; engineering findings live in the Testing Findings tab via /api/architecture-findings). Both surfaces render from docs/FINDINGS.md.docs/OPERATIONS.md.trades table is even the consumer Phase 3 wants vs. data-api /trades direct calls. If Phase 3 doesn't use trades table, this finding could be downgraded back to manual-only and the timer disabled.src/data_tracker/collectors/backfill_trades.py (~330 lines — rewritten with CLI flags, T1 delta, T2 smart re-fetch, T3 earnings-only filter)deploy/systemd/polymarket-trades-backfill.timer (new file)deploy/systemd/polymarket-trades-backfill.service (Documentation= + comment block)deploy/deploy_from_github.sh (timer install in existing block)src/data_tracker/integrity_monitor.py (TIMERS entry)docs/OPERATIONS.md (cold-bootstrap → enable-timer runbook)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 + docs/CONTEXT.md (housekeeping batch close-out)f3e0b12) and Phase 3 (c9ef9ef) revealed:polymarket-backup, polymarket-resolver, polymarket-trade-reconcile, polymarket-equity-bars, polymarket-data-backfill, polymarket-trades-backfill) exit non-zero on real failure.systemd:* (4 always-on services + integrity-monitor self) and systemd_timer:* (timer LAST-FIRE freshness). Neither covered batch service EXIT RESULTS.polymarket-backup.service was OOM-killed at 04:09 UTC on Apr 25, 26, and 27. journalctl shows the failures clearly. Zero Telegram alerts fired across all three._check_systemd_unit rule for polymarket-backup would only see failed SubState briefly; once the next timer fire flips the unit to active(running) then inactive(dead), the failure is hidden._check_systemd_unit_last_result(unit) in integrity_monitor.py reads Result + ExecMainStatus + ExecMainExitTimestamp via systemctl show. A unit's most-recent exit code is monitored even when currently inactive. Returns STATUS_FAIL for Result=signal/oom-kill/exit-code/timeout/..., STATUS_OK for Result=success, STATUS_UNKNOWN if the unit has never run.systemd_last_run:<unit>, severity HIGH:polymarket-backup.servicepolymarket-resolver.servicepolymarket-trade-reconcile.servicepolymarket-equity-bars.servicepolymarket-data-backfill.servicepolymarket-earnings-sync.serviceintegrity_monitor._evaluate_rule() → _check_systemd_unit_last_result(unit) → subprocess.run(systemctl show -p Result,ExecMainStatus,ActiveState,SubState,ExecMainExitTimestamp) → status emit. Telegram alert fires on HIGH+ status transition via existing AlertManager.send(AlertCategory.DATA_INTEGRITY) path.polymarket-backup.service runs, OOM-killed. systemctl shows Result=oom-kill ExecMainStatus=9. Helper returns STATUS_FAIL. Integrity monitor's status_change_handler fires Telegram alert.src/data_tracker/integrity_monitor.py lines 268-330 (helper), 580-595 (rules).Result="" and ExecMainExitTimestamp empty) → STATUS_UNKNOWN, no false positives. (c) systemctl timeout → STATUS_UNKNOWN.psql ... SELECT check_name, status FROM integrity_status WHERE check_name LIKE 'systemd_last_run:%' shows 6 rows. polymarket-backup.service shows fail (because last 04:09 UTC run was OOM-killed). Integrity monitor's next 5-min cycle fires a Telegram alert. Tomorrow's backup run after the streaming fix should clear it to ok.src/data_tracker/integrity_monitor.py (new helper + 6 rules)docs/FINDINGS.md (this entry)/api/architecture-findings parser missed RESOLVED / DOCUMENTED / NOT-A-BUG status keywords → dashboard showed closed findings as open (RESOLVED Apr 27 LATE evening)earnings_dashboard/app.py:get_architecture_findings() Format-B parser only mapped DONE / CODE / VERIFIED / PARTIAL to the done/in_progress states. Enumerated every status keyword actually present in docs/FINDINGS.md headers via grep -oE '^### Finding... \(.+\)$' | sort | uniq -c:
47 (DONE) ← parser recognized
8 (RESOLVED) ← parser MISSED → showed as "pending"
5 (DOCUMENTED ...) ← parser MISSED → showed as "pending"
4 (NO/NOT A BUG) ← parser MISSED → showed as "pending"
1 (UPDATED ...) ← parser MISSED → showed as "pending"
1 (VERIFIED + EXTENDED)← parser recognized via fuzzy "VERIFIED"
1 (IN_PROGRESS) ← parser MISSED (only "PARTIAL"/"CODE")
Net: ~19 closed findings (incl. all of #263-#275) + 1 active rendered as "pending" / "open" on the dashboard.docs/FINDINGS.md on every request (audit_file.read_text() — no in-memory cache). The drift was pure parser logic, not staleness.earnings_dashboard/app.py:get_architecture_findings() Format-B parser now explicitly enumerates every status keyword from the grep | uniq output:
python
if any(kw in st for kw in [
"DONE", "RESOLVED", "VERIFIED", "UPDATED",
"NO CURRENT BUG", "NOT A BUG",
]):
status = "done"
elif "DOCUMENTED" in st:
status = "deferred" # operator-decision pending, not pending review
elif any(kw in st for kw in ["IN_PROGRESS", "IN PROGRESS", "PARTIAL", "CODE"]):
status = "in_progress"
# else: pending (fall through — surfaces new keywords as visibly stale)
Also tightened the regex to handle sub-versioned headers (### Finding 262.v6 — ...) by adding (?:\.\w+)? after the (\d+) group — these previously parsed correctly because the dedup-by-id check skipped them, but explicit handling future-proofs against parser ordering changes.fetch('/api/architecture-findings') → get_architecture_findings() → re-reads FINDINGS.md → emits per-finding {id, title, severity, status, status_icon} → JS renders.pending. Post-fix: same 19 show as done with ✓ icon.earnings_dashboard/app.py lines 4990-5025 (Format-B parser block, rewritten).pending, surfaces as visibly stale on dashboard (intentional). (b) parser regex itself fails → existing try/except returns {"findings": [], "error": ...} (existing behavior).curl /api/architecture-findings | jq '.stats' shows updated done count matching FINDINGS.md.rg "FINDINGS\.md|architecture[-_]findings" earnings_dashboard/ src/ — only this one parser. No other consumer of FINDINGS.md.earnings_dashboard/app.py (parser block 4990-5025)docs/FINDINGS.md (this entry)instant_exit:earnings_miss despite Polymarket resolving YES (RESOLVED Apr 27 LATE evening)prediction_correct and actual_earnings_result were already backfilled to TRUE / 'beat' (Finding #264). But exit_reason='instant_exit:earnings_miss' remained as the historical record of WHAT the trader did, which the dashboard renders as "Instant Exit (Earnings Miss)" in the trade log — visually pollutes future bucket-by-exit-reason analyses.BUG_EXIT (Finding #N): <explanation>... prefix in trade_audit_log.notes. This is the single source of truth for "this exit was triggered by a since-fixed bug; exclude from exit-reason aggregations". Aggregation queries should filter on notes ILIKE 'BUG_EXIT%'.notes = 'BUG_EXIT (Finding #264): Yahoo $1.20 GAAP scrape contradicted Polymarket YES=99.95% (BEAT)...' and lessons_learned = 'Trust Polymarket extreme price (>=99% / <=1%) over Yahoo's GAAP/preliminary scrape when they conflict near earnings...'. Historical exit_reason left intact (audit-trail integrity).earnings_dashboard/app.py trade-history serializer: detects rows where notes starts with "BUG_EXIT", prefixes exit_reason_display with "🐛 BUG-EXIT · " so VZ #120 renders as 🐛 BUG-EXIT · Instant Exit (Earnings Miss) instead of the misleading raw label. Also surfaces a new bug_exit_flagged: bool field on the API response so JS / future analyses can filter without parsing the prefix string.docs/DATABASE_DIRECTIVES.md §"Outcome resolution writers": documented the convention so future similar incidents follow the same pattern./api/trade-history → get_trade_history() → row dict includes exit_reason_display (now BUG-EXIT-prefixed when notes starts with "BUG_EXIT") and bug_exit_flagged. Template renders {{ t.exit_reason_display }} directly.notes starting with "BUG_EXIT (Finding #264)" → bug_exit_flagged=True → exit_reason_display='🐛 BUG-EXIT · Instant Exit (Earnings Miss)'. Other rows unaffected (their notes don't match the prefix).earnings_dashboard/app.py ~3093-3115 (the bug_exit_flagged block + display override).notes is None or empty → bug_exit_flagged=False, normal display (existing behaviour preserved). (b) someone writes BUG_EXIT lowercase → won't match (startswith is case-sensitive). Acceptable: operators following the convention use the documented uppercase prefix. (c) future bug-exits use a different prefix → won't trigger; doc convention is the contract.curl /api/trade-history | jq '.[] | select(.symbol=="VZ") | {exit_reason, exit_reason_display, bug_exit_flagged}' shows the BUG-EXIT prefix and bug_exit_flagged: true. Dashboard trade log row visually distinguishes the entry.trade_audit_log.notes updated — trading_positions doesn't have a notes equivalent and isn't queried for the trade log; no sync needed.rg "BUG_EXIT" [hidden]/ — only the new code in app.py and the SQL annotation on VZ. No other entry-point.earnings_dashboard/app.py (trade-history serializer)docs/DATABASE_DIRECTIVES.md (BUG_EXIT convention documented)docs/FINDINGS.md (this entry)trade_audit_log (one-shot via psql, not committed)cleanup_local() silently wiped local backup when Spaces unconfigured (RESOLVED Apr 27 LATE evening)ok because Result=success, but no file persisted)systemd_last_run:polymarket-backup.service to be fixed. The natural next backup wasn't until Apr 28 04:00 UTC, so we manually triggered systemctl start polymarket-backup.service to prove the Phase 5 streaming fix and clear the FAIL flag now. First manual run succeeded (Result=success, ExecMainStatus=0, 8m 11s, 1.5 GB memory peak, 0B swap) — but /tmp/polymarket-backups/ was empty post-run.deploy/backup_database.py:cleanup_local() deleted ALL *.sql.gz files at the end of every run unconditionally:
python
def cleanup_local():
for f in glob.glob(f"{BACKUP_DIR}/*.sql.gz"):
os.remove(f)
The intent was "Spaces is canonical, wipe local after upload". But main() called cleanup_local() outside the if/else branch:
python
if SPACES_ACCESS_KEY and SPACES_SECRET_KEY:
...upload + cleanup_old_backups...
else:
print("Spaces not configured - backup saved locally only")
cleanup_local() # ← runs in BOTH branches, including local-only
Operator's "local-only choice" (Finding #260b) was therefore impossible: every "successful" run produced a backup, then deleted it. Same outcome when Spaces was configured but upload failed (#251d kept the exit code correct but the cleanup still ran).ok: systemd_last_run:polymarket-backup.service reads Result=success, ExecMainStatus=0. Both were true. The rule had no way to know the file was deleted. (Future improvement: an artifact:polymarket_backup_local rule that checks /tmp/polymarket-backups/*.sql.gz exists with mtime <30h.)cleanup_local(*, keep_recent_days=7, keep_at_least_one=True) with retention semantics. New behaviour:keep_recent_days=0, keep_at_least_one=False → delete every file (use only after a successful Spaces upload — Spaces is canonical).keep_recent_days=N (default 7), keep_at_least_one=True (default) → delete files older than N days but always retain the most recent regardless of age.
Routing in main() now uses three branches:cleanup_local(0, False) (wipe; Spaces canonical).cleanup_local(7, True) + log path (operator needs it for retry).cleanup_local(7, True) + log path (operator's only copy).Result=success, ExecMainStatus=0 ✓/tmp/polymarket-backups/polymarket_db_20260427_173028.sql.gz — 2.3 GB, retained ✓systemd_last_run:polymarket-backup.service integrity rule flipped to ok within 5 min ✓/api/integrity summary: total=34, ok=32, warn=0, fail=0, unknown=2 ✓main() → create_backup() → streaming pg_dump → cleanup_local() decision branches per Spaces config + upload result.deploy/backup_database.py lines 188-237 (rewritten cleanup) + 268-291 (rewired main)./tmp — acceptable on 21 GB free /; operator should add Spaces eventually. (b) clock skew between mtime and datetime.now() → both POSIX/local clock; OSError caught defensively. (c) all files older than 7 days → keep_at_least_one=True preserves the most recent.rg "cleanup|os\.remove|glob\.glob" deploy/ — only backup_database.py has this pattern. No other deploy script wipes its own output.deploy/backup_database.py (cleanup_local + main routing)docs/FINDINGS.md (this entry)polymarket-backup.service OOM-killed on 3 consecutive nights Apr 25-27 (RESOLVED Apr 27 LATE evening)1c11cb3) proven via 2 successive manual triggers; subsequently a related logic flaw caught (#275 — local file wiped); both fixed.polymarket-backup.service:
Apr 25 04:18:13 → 04:25:56 killed signal=KILL (OOM)
Apr 26 04:28:25 → 04:36:07 killed signal=KILL (OOM)
Apr 27 04:00:09 → 04:09:10 killed signal=KILL (OOM)pg_dump | communicate() loaded the entire 32 GB dump into Python bytes before writing. With 7.8 GB system RAM, OOM was guaranteed. Phase 5's streaming fix (1 MB chunks) eliminates the issue.failed after each kill; nothing alerted. Operator only noticed today because the Apr 27 audit explicitly ran systemctl status polymarket-backup./tmp/polymarket-backups/polymarket_db_20260427_173028.sql.gz — 2.3 GB. (BACKUP_DIR is /tmp/polymarket-backups, not /var/backups/polymarket — the original acceptance text was wrong about the path.)systemd_last_run:polymarket-backup.service flipped fail → ok within 5 min (cycle picked up Result=success, ExecMainStatus=0)./api/integrity summary went from fail: 1 → fail: 0. Total 34 rules, 32 ok, 0 warn, 0 fail, 2 unknown (the 2 unknowns are graceful "post-fix sample <2 rows yet" — will auto-resolve).deploy/backup_database.py (streaming fix from 1c11cb3 + retention fix from 44389fc)/api/earnings-calendar returned volume: 1225.63, volume_ok: false for VZ.earnings_dashboard/app.py ~line 1991). The except-fallback (line 2018+) — which is the branch resolved-but-not-archived markets like VZ fall into because Gamma's ?active=true&archived=false excludes them (per Finding #229 mechanics) — kept using cached.get("volume_24h", 0.0) and a hardcoded volume_ok=False. I had simulated the gamma branch (Step 2) but not the except branch.effective_cumulative_volume_usd(db_volume_total, cached_volume_24h) for display and volume_threshold_passes(...) for volume_ok. The dual-key transparency fields (volume_24h, volume_cumulative, volume_db_total, volume_gamma_raw) are also surfaced in this branch so operator can see all four metrics regardless of which path produced the row.db_volume_total (in scope from line 1904) → effective_cumulative_volume_usd returns $3,920.cached_volume_24h=1225.63, db_volume_total=3920.43 → effective_cumulative_volume_usd(3920, 1225)=3920 → volume=3920 and volume_ok=True.earnings_dashboard/app.py lines 2025-2090 (rewritten except block).db_volume_total is None for new/unsynced markets → falls back to cached_volume_24h → consistent with gate. (b) Both are 0 → effective returns 0 → display 0, SKIP, consistent.curl /api/earnings-calendar | grep VZ MUST show volume > 2000 and volume_ok: True. This time I'll actually run it.earnings_dashboard/app.py (except fallback path, ~line 2025-2090)docs/FINDINGS.md (this entry)markets.volume_total = $3,920.43 (cumulative since market creation)markets.volume_24h = $1,238.71 (rolling 24h)volume_threshold_passes(min_vol=2000, db_volume_total, gamma_volume) uses cumulative — effective_cumulative_volume_usd() returns db_volume_total if positive (=$3,920) → passes, allows entry.volume = gamma_volume = m.get("volume") from Polymarket Gamma API, which Gamma returns as 24h rolling (=$1,226) → fails $2k threshold → renders "SKIP".gamma_volume for display because DB cumulative was sometimes stale (15-min sync); the side effect was that gate-vs-display went out of alignment.earnings_dashboard/app.py now uses effective_cumulative_volume_usd(db_volume_total, gamma_volume) for the displayed volume field (the SAME value the gate uses). Operator's "what passes the gate?" view matches reality.volume_24h, volume_cumulative, volume_db_total, volume_gamma_raw so future UI can show all four metrics for full transparency.earnings_dashboard/app.py lines 1925-1935.m.get("volume") callers in dashboard. The cached fallback path (line 2048+) uses cached_volume = cached.get("volume_24h", 0.0) from price_snapshots — also 24h, but this is the OFFLINE path and currently returns the same field name. Acceptable since the primary live path is now consistent.earnings_dashboard/app.py (gamma_volume → effective_cumulative_volume_usd)docs/FINDINGS.md (this entry)volume_24h_at_entry is NULL on VZ trade #120 (and possibly other recent trades). The Jan 29 VZ trade #1 had volume_24h=$1,346 populated. Some regression between Finding #201 (commit 4725cf2) and Finding #222 (commit 364f051) broke entry-side capture. Scoped as a separate investigation since it doesn't affect trader correctness, only audit completeness./var/log/polymarket/earnings_trader.log):
13:16:17 - Polymarket: VZ YES=99.95% → BEAT signal
13:16:17 - ⚡ POLYMARKET SIGNAL: VZ detected as beat (first to detect)
... (BEAT signal repeats every 2-5 sec for ~30s) ...
13:16:55 - earnings-trader.service restarted (systemd)
13:17:04 - NEW trader: Loaded persisted position: VZ LONG @ $46.44
13:17:14 - Yahoo: VZ EPS $1.20 vs $1.21 = miss
13:17:14 - 🚨 VZ: Position is WRONG - LONG but earnings miss
13:17:14 - 🚨 INSTANT EXIT: VZ - earnings_miss
13:17:17 - SELL 8 VZ @ $46.95 — closed for tiny +$4.08 profit (price drift, not signal-correct)earnings_markets.actual_beat=TRUE for VZ).EarningsDetector._confirmed_polymarket: Dict[str, str] is in-memory only. The trader restarted at 13:16:55 (systemd auto-restart, reason unclear). The new process started with empty _confirmed_polymarket. On first VZ check after restart, _check_polymarket_price() saw YES=99.95% but had to start the confirmation timer from zero (Config.POLYMARKET_CONFIRMATION_SEC is ~30s). For ~30s post-restart, Polymarket returned is_released=False (still confirming), so the function fell through to Yahoo.src/earnings/earnings_detector.py — Yahoo contradiction now BLOCKS when pm_confirmed exists (matches PRNewswire/BusinessWire policy)._live_polymarket_extreme_outcome(symbol) queries the latest price_snapshots.yes_price directly. When Yahoo contradicts AND the live price is at extreme (≥99% or ≤1%), block Yahoo regardless of in-memory _confirmed_polymarket. This closes the post-restart vulnerability window.actual_earnings_result and prediction_correct. Backfilled via SQL using earnings_markets.actual_beat. VZ now shows actual_earnings_result='beat' and prediction_correct=TRUE. Exit reason instant_exit:earnings_miss retained for historical forensics — operator can see in trade log that the EXIT was reasoned wrong even though the underlying prediction was right.pm_confirmed AND _live_polymarket_extreme_outcome(symbol). If either says BEAT and Yahoo says MISS (or vice-versa), block._live_polymarket_extreme_outcome('VZ') returns 'beat' (yes_price=0.9995 ≥ 0.99). Yahoo MISS contradicts → BLOCK Yahoo. Position held. Correct.src/earnings/earnings_detector.py lines 260-300 (Yahoo block) + 410-450 (new helper).price_snapshots is stale (collector down), helper returns None and we fall back to old Yahoo behavior. Acceptable.🛡️ CONTRADICTION BLOCKED: Yahoo says ... but live Polymarket price is at extreme appears when triggered. No false instant exits.src/earnings/earnings_detector.py (Yahoo contradiction policy + live-extreme helper)docs/FINDINGS.md (this entry)trade_audit_log (one-shot via psql, not committed):exit_time::timestamptz cast → primary close path failed silently for ALL trades since Phase-2 deploy (RESOLVED)hold_duration_hours, conviction_at_exit, prediction_correct, detection_to_exit_seconds, all financing aggregates)null_rate:trade_audit_log:hold_duration_post_fix showed 100% NULL (1/1) despite Finding #256 supposedly adding the write path. Operator caught this via dashboard.d001fa8) added this clause to the close UPDATE in position_manager.py:569:
sql
hold_duration_hours = ROUND(
EXTRACT(EPOCH FROM (:exit_time::timestamptz - entry_time)) / 3600.0,
2
),
The :: is PostgreSQL's cast operator. But pg8000 (the SQLAlchemy driver in use) parses :NAME as a parameter binding and :: as a continuation of that parameter prefix. It tries to bind :timestamptz as a second parameter, doesn't find it, raises:
pg8000.dbapi.ProgrammingError: {'S':'ERROR','C':'42601',
'M':'syntax error at or near ":"','P':'1665'}
The primary close path then fails for every single close. The fallback close path (line 710, defensive recovery code) takes over and writes status, exit_time, exit_price, exit_reason, realized_pnl, ... BUT does NOT set hold_duration_hours, conviction_at_exit, prediction_correct, detection_to_exit_seconds, margin_interest_*, short_borrow_cost, financing_cost_total, net_pnl_after_financing, notes aggregates.Net effect: the "fix" from Finding #256 silently broke a far wider set of fields, including the very field it was trying to fix. The integrity monitor caught it. Operator caught it via the integrity monitor. Without these signals it would have been undetected for weeks or months.
- Smoking gun in /var/log/polymarket/earnings_trader.log:
13:17:20 - Failed to update position close VZ: pg8000.dbapi.ProgrammingError
{'S':'ERROR','C':'42601','M':'syntax error at or near ":"','P':'1665'}
13:17:20 - Attempting fallback close persistence for VZ after primary failure
13:17:20 - ✅ Fallback close persistence succeeded
The CRITICAL log is technically fired, but it appears as an INFO-level "succeeded" message after a WARNING-level "fallback used". Easy to miss in normal review.
- Fix shipped:
- src/earnings/position_manager.py — replaced :exit_time::timestamptz with CAST(:exit_time AS timestamptz) and refactored to use two separate EXTRACT calls. Equivalent semantics, pg8000-safe.
- SQL backfill: UPDATE trade_audit_log SET hold_duration_hours = ... WHERE status='closed' AND hold_duration_hours IS NULL — backfilled VZ #120 (65.32h) and one historical edge case.
- Same-pattern audit: ran rg ':\w+::' src/ earnings_dashboard/ — only the comment in position_manager.py remains. No other :name:: antipatterns in the codebase.
- Memory §B 5-step verification:
- Step 1 trace: trader.close → persist_position_close_with_exit_details() → primary UPDATE (line 541). Old: SQL syntax error 42601 → except block → fallback UPDATE (line 710) → succeeds with limited fields. New: primary UPDATE succeeds, all fields populated.
- Step 2 simulate: next close (mock or real) executes the primary UPDATE without error. hold_duration_hours, conviction_at_exit, prediction_correct all populate.
- Step 3 lines: src/earnings/position_manager.py lines 564-585 (the rewritten clause).
- Step 4 failure modes: (a) different SQL driver in future — CAST(... AS timestamptz) is portable ANSI SQL, works everywhere. (b) entry_time might be NULL — already handled by EXTRACT returning NULL → ROUND(NULL) → NULL → no row failure. (c) clock skew between Python datetime.now(timezone.utc) and DB entry_time — mathematically possible to get a negative duration; ROUND handles it but it'd be a data quality flag.
- Step 5 acceptance: integrity rule null_rate:trade_audit_log:hold_duration_post_fix transitions from WARNING fail (1/1 NULL) to OK pass. Verified: closed_after_fix=1, with_hold=1 after backfill.
- Memory §E same-pattern check: zero other instances of :name:: in src/ or earnings_dashboard/. Cleared.
- Lessons:
- Driver-specific SQL needs explicit testing. The :name::cast pattern is valid in psycopg2 but breaks in pg8000. Should not have shipped without exercising the close path.
- "Silently using fallback path" is a tier-1 signal. Whenever a fallback fires, it should LOUDLY indicate that the primary failed. The current logger.critical is correct, but the secondary logger.info("Fallback close persistence succeeded") makes the failure look benign in log review. Future improvement: keep fallback CRITICAL-level until 24h of success on primary.
- Integrity rules with a min_rows threshold are working as intended. The hold_duration_post_fix rule fired exactly when expected (1 row arrived after the deploy and was NULL).
- Files:
- src/earnings/position_manager.py (the SQL fix)
- docs/FINDINGS.md (this entry)
d63059f)'ibkr_reality' is undefined errors in v1-v4 were red herrings caused by stale compiled Jinja state across earlier deploy phases. Once the v5 try/except fallback was in place, the actual underlying error surfaced clearly: dashboard.html line 1539 references m.symbol and m.condition_id, but _fetch_db_stats_from_db() was building market_availability dicts with ticker (not symbol) and without condition_id at all. Once the full DB-stats refresh populated those dicts (~3-4 min after each restart), Jinja rendered {{ m.symbol or m.condition_id[:10] }} → m.symbol undefined → falsy → fell through to m.condition_id[:10] → condition_id undefined → UndefinedError.__pycache__, Jinja cache_size config). All those are good fixes that should stay. But none addressed the actual schema drift between Phase 4's redesigned template and the legacy DB-stats query that pre-dates it. The v5 try/except fallback finally exposed the real error message and line number to the journalctl logs.earnings_dashboard/app.py — market_availability dict now provides BOTH legacy keys (ticker, ob_count, ps_reliable) AND template-expected keys (symbol, condition_id, orderbook_count, price_count, mcap_category=None). Dual-keying tolerates either consumer.earnings_dashboard/templates/dashboard.html — Per-Market Data Availability table now uses m.get('field') with fallback chains (m.get('symbol') or m.get('ticker') or (m.get('condition_id') or '')[:10] or '?'). Missing fields render as '—'/'?'/0 instead of raising UndefinedError.Template render of dashboard.html FAILED entries since the 13:52 UTC restart.db_stats. Any field rename/addition needs a producer audit.ibkr_reality in our case) are a SIGNAL that the running process has corrupted compile state, not a description of the actual bug. Address the staleness first (clear caches, restart cleanly), THEN the surfaced error.bff5471)__pycache__ nuke, Jinja2Templates(cache_size=0, auto_reload=True)), the dashboard returned to HTTP 200 — but reverted to HTTP 500 again ~5-6 min after each restart. Probe: [13:22] 200 → [13:23] 200 → [13:24] 500 → [13:25] 500 → [13:26] 500. Same error: 'ibkr_reality' is undefined at dashboard.html line 516.1a2af21074a9621a16232d1255e79902 (correct, 0 references to ibkr_reality, line 516 = <div class="card-header">)..swp/.bak/~ shadow files.[hidden]/... paths loaded; nothing from [hidden]-git/ or other roots.[hidden]/earnings_dashboard/templates/dashboard.html), no other html opens. So Jinja IS reading the correct file with cache_size=0.ibkr_reality into context as a dict, regardless of what state contains. In earnings_dashboard/app.py:dashboard(), after **state and the setdefault fallback, replace any non-dict value with {} and assign explicitly: context["ibkr_reality"] = ibkr_reality_value. The Jinja 'ibkr_reality' is undefined error becomes structurally impossible because the key is unconditionally present.templates.TemplateResponse(...) in try/except. On render failure, log full traceback with context.keys() + type(context.get("ibkr_reality")).__name__ for diagnosis, then serve a 503 fallback HTML page with links to /api/integrity, /api/db-stats, /api/state, /api/health. Operator never sees bare "Internal Server Error" again — they get a usable page with a recovery hint.state["ibkr_reality"] is well-formed.'ibkr_reality' is undefined even if state path corrupts; if the in-memory template ever does diverge from disk, operator sees a fallback page with API links instead of a blank error.templates.TemplateResponse("dashboard.html", ...) call in the codebase; no other template-render hot path. Polymarket-sniper has its own dashboard (out of scope).dashboard() → builds context → ensures context["ibkr_reality"] is a dict → try: TemplateResponse(...) except: log + serve fallback HTML 503.ibkr_reality=None or omits the key, context now has {}. Template's {{ ibkr_reality.X|default(0) }} returns 0. No 500.earnings_dashboard/app.py near the bottom of dashboard() — search for "Finding #262" comment to locate.HTTP 200 and stays 200 across 20 minutes (vs previous regression at ~5 min). If render fails, response is HTTP 503 with usable HTML body, NOT HTTP 500 Internal Server Error.earnings_dashboard/app.py (defensive ibkr_reality block + try/except wrap around TemplateResponse)docs/FINDINGS.md (this entry)cache_size=0, FastAPI/Starlette template-response wrapping around Jinja, late module re-import. To investigate later if symptom recurs through the new fallback path.Internal Server Error for ~3 hours after Phase 4-6 deploy chain)GET / returned HTTP 500 Internal Server Error with body Internal Server Error (21 bytes). /api/integrity and other API endpoints returned HTTP 200 correctly. Dashboard service was active (not crashed). The Jinja traceback in /var/log/polymarket/dashboard.log showed:
jinja2.exceptions.UndefinedError: 'ibkr_reality' is undefined
File ".../templates/dashboard.html", line 516, in top-level template code
<div><strong>DB Open:</strong> {{ ibkr_reality.db_positions_count|default(0) }}</div>dashboard.html lines 1332-2037 around the new 4-pane integrity layout. The redesign correctly removed all ibkr_reality.X template references — the file on disk has 0 occurrences, md5sum confirms identical to the committed version.ibkr_reality.db_positions_count at "line 516" of the OLD pre-Phase-4 template (which DID have an IBKR Reality Overlay section before being moved to System tab in earlier work). Confirmed via strace on the running process: ZERO openat calls to dashboard.html during a request that returned 500 — Jinja was rendering entirely from in-memory bytecode, not re-reading the file from disk.systemctl restart: at 10:26:17 UTC I did an explicit systemctl restart polymarket-dashboard (verified via ActiveEnterTimestamp), the file on disk was correct, yet the post-restart process STILL rendered with ibkr_reality.db_positions_count. Python's bytecode cache was the culprit: 624 __pycache__/ directories under [hidden]/ held .pyc files compiled at 09:11:09 UTC (immediately after Phase 4 deploy). After clearing all __pycache__ directories and restarting, the dashboard renders 200 cleanly. Exact mechanism is opaque (Python's source-mtime check appeared to pass — .pyc was newer than .py — but the running process still loaded a state that rendered the OLD template).deploy/deploy_from_github.sh had a services-list loop that should restart polymarket-dashboard.service on every run. For Phase 5 + Phase 6 the loop silently skipped the restart. Root cause confirmed via bash -x trace: the unit-existence check used systemctl list-unit-files --type=service --no-legend | awk '{print $1}' | grep -qx "$svc". With set -o pipefail, grep -q exits early after first match, sending SIGPIPE (exit 141) to the upstream awk and systemctl list-unit-files. pipefail then reports the pipeline as failing with code 141 even when grep matched correctly (exit 0). The if condition becomes false; the restart is skipped. Non-deterministic — depends on whether grep finds a match before vs. after fully draining the upstream pipe (filesystem read-speed race). Same pipeline appeared in two places in the script: the original services-list loop AND my Finding-#262-v1 explicit-restart-block.GET / was affected./api/integrity and the served HTML on Phase 4 (which DID restart the dashboard via my explicit systemctl restart polymarket-dashboard after the deploy script). For Phase 5 + Phase 6 I only verified systemctl is-active (active = process running) and /api/integrity (HTTP 200), NOT GET / (the Jinja-rendered home page). Memory §H/§I says "verify earnings-trader is active. After deploy, restart polymarket-dashboard if its app.py / dashboard.html changed". Phase 4's dashboard.html change should have triggered the restart in subsequent phases too because Jinja env can hold stale state. Lesson: always smoke-test GET / on the dashboard after any deploy that touches app.py or templates/.systemctl restart polymarket-dashboard.service — restored HTTP 200 briefly. Insufficient.778eb2c): replaced systemctl list-unit-files | awk | grep -qx with systemctl cat $unit >/dev/null 2>&1 in deploy/deploy_from_github.sh. Made the services-restart loop deterministic. Verified via deploy output (Restarted services: earnings-trader.service polymarket-dashboard.service polymarket-collector.service ... — all 7 units now reliably matched, was 0 or 3 before).__pycache__/ directory and .pyc file under [hidden] before the service restarts. After this, the dashboard process always starts fresh from source .py — no stale bytecode can persist across deploys. After clearing the 624 __pycache__/ directories on prod and restarting, dashboard renders 200 cleanly across 5 sequential probes. The specific Python-bytecode-cache mechanism that caused this is not fully understood (cache invalidation should have caught the stale file but didn't), but cache-clearing is a robust workaround.polymarket-dashboard.service and curls GET / → must return HTTP 200 or deploy exits non-zero with a journal dump. Combined with #2, this is now reliable.curl HTTP %{http_code} / → if non-200 → exit 1 with journal context.dashboard.html or app.py template-context dict. Old behaviour: restart sometimes skipped, dashboard could run with stale cache for hours. New behaviour: explicit restart at deploy tail, then smoke-test against the live HTTP endpoint catches Jinja errors immediately, failing the deploy.deploy/deploy_from_github.sh:88-100 (the new explicit-restart + smoke-test block).127.0.0.1:8080 is blocked by firewall, curl returns 000 — still treated as failure.deploy_from_github.sh will print Forced final restart of polymarket-dashboard.service then Dashboard / returned HTTP 200 — deploy verified. If anything regresses, deploy exits 1 with journal lines.earnings_dashboard/app.py:dashboard() is the only template render call — the rest of the dashboard surface area is JSON APIs. Polymarket-sniper has its own dashboard (polymarket_sniper/dashboard/app.py) with separate template — out of scope.deploy/deploy_from_github.sh (final-restart-and-verify block added; restart loop now logs which units were restarted)docs/FINDINGS.md (this entry)bash deploy/deploy_from_github.sh on prod; output ends with Forced final restart of polymarket-dashboard.service and Dashboard / returned HTTP 200 — deploy verified.curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/ → 200.http://[hidden]:8080/ in browser → no "Internal Server Error".earnings_dashboard/, smoke-test GET / (not just /api/health or /api/X) before declaring success. Memory §H/§I now extended in docs/OPERATIONS.md §8.B.docs/DATA_ARCHITECTURE.md v1.5 → v1.6: header refreshed (PostgreSQL 16, ~32 GB, audit context cross-reference); Key Metrics block fully refreshed with current numbers (5,706 markets, 7.79M orderbook snapshots, 10.59M price snapshots, 2.88M trades, 900 earnings markets, 117 trade audit rows, 66,248 conviction history, 47,510 equity bars across 21 symbols, 105 indexes, 5 hypertables, NO retention/compression policies); 8 previously-undocumented tables listed (skipped_signals, collection_jobs, collection_stats, ibkr_executions, margin_ledger, margin_events, exit_execution_analysis, fractional_eligibility_cache, candlesticks); phantom gap_tracking reference removed.docs/DATA_INTEGRITY.md §2 rewritten: replaces "Watchdog Service" content (which described watchdog_v3.py, the never-properly-deployed one) with "Integrity Monitor" architecture: monitor source, systemd unit, state table, snapshot file, alerts category, dashboard surface. New §2.2 lists 6 rule categories (28 rules total). New §2.3 documents severity-to-Telegram policy. New §2.4 documents status transitions and the unknown graceful-fallback semantics.docs/OPERATIONS.md §8.A added: integrity-monitor operator runbook (service status checks, live JSON probes, direct-DB queries, log tail, --once invocation, how to add new rules, "unknown" status semantics, when to expect Telegram alerts, decommissioned watchdogs).docs/OPERATIONS.md §8.B added: service exit-code semantics table (per-script threshold policy from Phase 1 / Finding #251).docs/OPERATIONS.md §8.C added: derived-artifact freshness expectations (ibkr_snapshot.json 10s, strategy_analysis_report.json 14d, finding_a_full.csv manual, integrity_status.json 5min, backups daily).docs/AGENT_HANDOFF.md §11 consolidated: replaces incremental phase-by-phase entries with one comprehensive 6-phase summary (Phases 0-6, Findings #250-#261). Includes production state post-audit (8 services active, 0 watchdogs, integrity_status populated, Telegram alerts wired, backup OOM fixed, logrotate installed) and open known limitations (Timescale Apache edition limits, markets.winning_outcome phantom column, Q2 retention deferral).docs/FINDINGS.md: this entry (Finding #261) closes the audit catalog.docs/DATA_ARCHITECTURE.md (header + Key Metrics + tables-undocumented section refreshed)docs/DATA_INTEGRITY.md (§2 rewritten)docs/OPERATIONS.md (§8.A + §8.B + §8.C added)docs/AGENT_HANDOFF.md §11 (consolidated changelog)docs/FINDINGS.md (this entry)docs/DATA_INTEGRITY.md no longer references watchdog_v3.py as the deployed integrity service.docs/DATA_ARCHITECTURE.md row counts match production within 5% drift tolerance.docs/OPERATIONS.md §8.A runbook commands work as documented (validated against current production).docs/DATABASE_AUDIT_2026_04_27.md §6 + §7) flagged Timescale infrastructure issues + bloat + log growth + Apache-edition limitations. Operator chose Q2=defer for retention/compression policies, narrowing Phase 5 scope. Investigation then surfaced 4 additional production-critical issues outside the original audit:#260a — backup OOM kill (CRITICAL): investigation found that polymarket-backup.service was OOM-killed today at 04:09 UTC. Kernel log: Killed process 4182595 (python) total-vm:9582800kB, anon-rss:6871164kB. Root cause: deploy/backup_database.py:91-104 called process.communicate() which loads the entire pg_dump output into a single Python bytes before writing. With DB now 32 GB raw / ~5 GB gzipped and only 7.8 GB RAM on the prod host, this triggers global OOM every run. Older successful backups (Apr 6 / Apr 9) were 2 GB compressed — within RAM budget. Today's larger DB pushed past the limit.
- Fix: streaming the pg_dump stdout pipe in 1 MB chunks directly into gzip.open(...).write(). Memory footprint now O(chunk_size) ~= 1 MB regardless of DB size. Cleanup on failure (delete partial backup file). Drain stderr only after stdout EOF.
- Per memory §B: traced via OOM kernel log, lines deploy/backup_database.py:91-138. Failure modes: streaming chunk-by-chunk handles arbitrary DB size; partial-file is deleted on exception; pg_dump rc != 0 → return None which Phase 1 #251d's caller already handles (no Spaces upload attempted).
- Acceptance: tomorrow 04:00 UTC backup runs without OOM; integrity_monitor's polymarket-backup.timer rule (which fires WARNING if last trigger > 26 hours) stays green.
#260b — Spaces not configured (operator decision noted): the prod .env lacks SPACES_ACCESS_KEY / SPACES_SECRET_KEY. backup_database.py correctly skips Spaces upload + cleans up local file → no off-site backup happening. Phase 1 #251d only enforces "exit non-zero IF creds set". Per memory §C surgical changes, I'm NOT changing the policy here — operator chose local-only when the env was set up. Documented in this finding for visibility; integrity_monitor's artifact:strategy_analysis_report rule provides Phase 5 coverage proxy.
#260c — watchdog v2 + v3 leftover units: Phase 3's deploy hook tried to rm -f the v2 unit file but the daemon-reload order made it ineffective in the first deploy. v3 had its own unit file (NOT in deploy/systemd/) and was crash-looping with ModuleNotFoundError: No module named 'src' (PYTHONPATH issue) — generating 291 MB of error tracebacks in /var/log/polymarket/watchdog_v3.log since deploy. Fix: extended deploy/deploy_from_github.sh to handle BOTH unit files via a for legacy in polymarket-watchdog.service polymarket-watchdog-v3.service loop. Idempotent.
#260d — log rotation (no config existed): /var/log/polymarket/*.log had grown to:
- collector.log 8.2 GB
- earnings_trader.log 1.7 GB
- earnings_sync.log 293 MB
- watchdog_v3.log 291 MB (just removed via #260c)
- Total /var/log/polymarket/ was over 11 GB.
Fix: new deploy/logrotate/polymarket config: daily rotation, 14 days compressed retention, copytruncate (no SIGHUP needed since services use StandardOutput=append:). Installed via deploy hook. After first nightly run, expected disk reclaim: ~9 GB.
#260e — Timescale job 3 (Apache-edition limitation, ACCEPTED): 166 consecutive failures of policy_job_stat_history_retention job. Investigation: the failure message is functionality not supported under the current "apache" license. Even alter_job(3, scheduled => false) is blocked on Apache edition. Conclusion: this job will keep failing 1×/day on Apache build. The _timescaledb_internal.bgw_job_stat_history table will grow slowly (~1 row/day per job). Documented as known limitation; no fix without upgrading to Timescale community/cloud edition. The integrity_monitor will eventually have a Timescale-job-health rule (deferred to a future phase).
#260f — trade_dedupe 4.1 GB documented as acceptable: investigation showed 2.8M rows × ~1 KB raw_data JSONB + 641 MB indexes + 291k dead tuples (10 % bloat). The dedupe table stores full Polymarket trade payloads for replay analysis. Oldest row Jan 31, newest today — 3 months of dedupe data. Could be pruned to 7-14 days (the dedupe purpose is "did we already insert this trade?" which only matters within a small window) to reclaim ~3.5 GB, BUT that's a behavior change requiring operator decision (would alter analysis-on-historical-data semantics). Documented as acceptable bloat for now; Phase 5 storage rules in integrity_monitor flag it if it crosses 5 GB. Optional VACUUM FULL would reclaim ~410 MB.
deploy/backup_database.py — streaming pg_dump fix (~30 LOC modified).deploy/deploy_from_github.sh — watchdog v2/v3 cleanup loop + logrotate install (~20 LOC modified).deploy/logrotate/polymarket (NEW) — logrotate config.docs/FINDINGS.md (this entry).subprocess.communicate() patterns that could OOM on large output: scripts/auto_login_gateway.py uses subprocess.run(... capture_output=True) for xdotool (small output, irrelevant); scripts/equity_bars_collector.py doesn't capture subprocess output. No other OOM risks.polymarket-backup.service runs without OOM kill; backup file appears in /tmp/polymarket-backups/ ~2-5 GB; integrity_monitor's polymarket-backup.timer stays green.systemctl is-active polymarket-watchdog.service → not-found; same for v3.ls /var/log/polymarket/*.log.gz after first nightly logrotate → compressed daily rotations present.du -sh /var/log/polymarket/ 7 days post-deploy → stabilises around 1-2 GB instead of unbounded growth.docs/DATABASE_AUDIT_2026_04_27.md §2) found the existing Database tab had multiple hardcoded misleading sections (WebSocket: Connected literal, Watchdog auto-restart hardcoded HTML, Market Info: 100% always-green row, Data Reliability Timeline frozen Jan-08 prose, Database Tables size column with count*0.5/N heuristic, Active Earnings Markets using tags instead of canonical earnings_markets). The tab was server-rendered off one get_db_stats() call with no live refresh. Phase 3 (Finding #258) shipped the new integrity_monitor + integrity_status table that this redesign now surfaces./api/integrity endpoint in earnings_dashboard/app.py (~90 LOC). JSON-snapshot-first read from /var/log/polymarket/integrity_status.json (atomic-written by the monitor); falls back to direct integrity_status DB read if the JSON is missing or > 10 minutes old. Returns {summary, checks[], generated_at, source, snapshot_age_seconds}.earnings_dashboard/templates/dashboard.html:1332-1565. 705 lines of legacy SSR widgets replaced with ~230 lines of clean structure:DATABASE_AUDIT_2026_04_27.md, integrity_monitor.py, AlertManager. Live integrity-status summary line (28 rules · X ok · Y warn · Z fail · W unknown) updated every 30 s.integrity_status rows where category='freshness'.category='null_rate'.category='systemd' ∪ category='artifact'.category='storage'.earnings_markets.WebSocket: Connected literal (was always true).Watchdog auto-restart interval/threshold hardcoded HTML.Market Info (basic): 100% always-green row.Data Reliability Timeline static prose with frozen 2026-01-08 17:53 UTC dates.Data Sources & Backfill static marketing block.Historical Backfill Status based on ps aux grep + JSON state files.Database Tables heuristic-size column; the new Pane A + Pane D show real numbers.Active Earnings Markets using 'Earnings' = ANY(tags) (diverged from canonical earnings_markets).setInterval for /api/integrity (only fires when Database tab is active — saves bandwidth on other tabs).s/m/h/d ago).refreshDbIntegrity() → fetch('/api/integrity', cache:'no-store') → backend reads /var/log/polymarket/integrity_status.json (snapshot-first; DB fallback) → JS renders 4 panes by category. Tab-switch to Database triggers immediate refresh; setInterval polls every 30 s while active.integrity_monitor is down (no JSON, no DB rows), the panes show "No X rules." gracefully and the summary line shows "⚠ Failed to load /api/integrity" — visible degradation, no JS crash.app.py:4167-4263 (api_integrity), dashboard.html:1332-1565 (tab body), dashboard.html:3995-4144 (JS render functions)./var/log/polymarket/integrity_status.json becomes stale (>10min) → API falls back to direct DB read → still works (table is the source of truth).{"error": "..."} → JS shows error in summary line; panes stay blank with "Loading…".dbTab.classList.contains('active') check — no wasted requests.curl http://127.0.0.1:8080/api/integrity | jq .summary returns valid JSON with total/ok/warn/fail/unknown counts.http://[hidden]:8080/#database → top banner shows summary; 4 panes populate within 1 s; JS console clean.polymarket-collector for 6 min → Pane A freshness:price_snapshots row goes red; banner summary fail count increments by 1.polymarket-collector → row goes green within 30 s of next monitor cycle (5 min) + 30 s dashboard poll./api/integrity is a new read-only endpoint; no shared trader/dashboard surfaces touched. Dashboard tab is dashboard-only HTML/JS; no impact on trader hot path.refreshUpcomingEarningsTable (Trading), loadDynamicFindings (Findings), refreshStrategyAnalysis (Strategy). New polling pattern matches the existing refreshUpcomingEarningsTable shape (30 s interval, only when tab active).Connected literal in template — only in the now-removed Database tab.earnings_dashboard/app.py (added /api/integrity endpoint; ~90 LOC)earnings_dashboard/templates/dashboard.html (Database tab body redesigned; JS render functions added; net −309 lines from removed legacy widgets, +120 lines of new render code = net −189 lines)docs/FINDINGS.md (this entry)docs/DATABASE_AUDIT_2026_04_27.md §3) found that the deployed watchdog (watchdog.py v2) is what monitors production but docs/DATA_INTEGRITY.md documents watchdog_v3.py. Neither monitors the tables/columns/services with active data-loss risk per Findings #252-#257. Per operator decision Q1, fresh build chosen.src/data_tracker/integrity_monitor.py (~700 LOC) with declarative rule config:price_snapshots, orderbook_snapshots, trades, markets, earnings_markets — each with severity-appropriate thresholds (10 min CRITICAL on continuous-collector tables; 60-90 min WARNING on hourly/daily writers).min_rows floor to avoid alerting on small batches; uses windowed where clauses (evaluated_at > '2026-04-26 17:38:00+00' etc.) to scope to post-fix data.earnings-trader, polymarket-dashboard, polymarket-collector, ibgateway-ibc.service (correct unit name — v3 watchdog incorrectly used ibgateway).LastTriggerUSec slack window.polymarket-integrity-monitor.service is active (catches monitor itself crashed).logs/ibkr_snapshot.json (5min CRITICAL), strategy_analysis_report.json (14d WARNING).trades (warn at 15GB / fail at 22.5GB), orderbook_snapshots (warn at 20GB / fail at 30GB), price_snapshots (warn at 10GB / fail at 15GB), equity_bars_1m (warn at 5GB). Operator chose Q2 = "defer retention" so these size rules ARE the safety net until Phase 5 (deferred).src/data_tracker/migrations/027_integrity_status.sql: integrity_status with (check_name PK, category, severity, status, message, detail JSONB, evaluated_at, last_status_change_at, consecutive_failures). Indexes on (category, status), (evaluated_at DESC), (status, severity).AlertCategory.DATA_INTEGRITY in src/earnings/alerts.py. Plugged into existing AlertManager (rate-limit + dedup + circuit breakers + retry-with-backoff inherited for free).deploy/systemd/polymarket-integrity-monitor.service (Type=simple, Restart=always, RestartSec=30, StartLimitBurst=5/5min). Internal 5-minute loop.deploy/deploy_from_github.sh: installs the new unit, enables + starts it; idempotently decommissions the v2 watchdog (systemctl stop + disable + rm /etc/systemd/system/polymarket-watchdog.service). v2 watchdog source files (src/data_tracker/watchdog.py, watchdog_v3.py) left in tree for reference; per memory §C "surgical changes" don't delete pre-existing dead code unless explicitly asked.polymarket-trades-backfill.service so Phase 1's Type=oneshot fix gets installed via deploy_from_github.sh instead of needing a manual one-shot.integrity_monitor.py --once invoked via PYTHONPATH-prepend on prod, reading the actual production DB:/tmp/integrity_status.json (160 KB-ish, well under any size concern).IntegrityMonitor.__init__ registers SIGTERM/SIGINT handlers → run_loop enters 5-min cycle. Each cycle: fetch prev_statuses → for each rule: rule.evaluate(engine) → _upsert_rule_status (UPSERT into integrity_status with last-changed tracking) → _write_status_json (atomic move) → for transitions (OK→fail/warn or fail/warn→OK): _send_integrity_alert (Telegram via AlertManager.send).polymarket-collector.service becomes inactive between cycles, status changes from ok → fail, transition detected, send_alert(level=CRITICAL, category=data_integrity, ...) fires. AlertManager's 30-s same-category rate limit prevents repeat-spam if multiple checks fail simultaneously.src/data_tracker/integrity_monitor.py:579-666 (run_once + run_loop), :519-561 (_upsert_rule_status), :563-602 (_write_status_json), :606-655 (_send_integrity_alert with severity gate at line 622), :677-693 (main + DATABASE_URL handling).Rule.evaluate wrapper → status='unknown', message contains exception type → no alert (we don't alert on unknown — could be evaluator bug, not real integrity issue).run_once try; logged + sleep + retry next cycle. After ~5 cycles of failure systemd will restart per StartLimitBurst=5/5min.AlertManager has its own circuit breaker; will degrade silently after repeated failures (logged + manager.send returns False; doesn't crash the monitor).systemd:polymarket-integrity-monitor.service) will report unknown for ~30 s during restart but won't fire a Telegram (the monitor needs to be up to fire alerts in the first place). Heartbeat-style detection via the dashboard's separate /api/integrity freshness check (Phase 4).systemctl is-active polymarket-integrity-monitor.service → activepsql ... SELECT count(*) FROM integrity_status → 28psql ... SELECT status, COUNT(*) FROM integrity_status GROUP BY status shows mostly ok after first cyclecat /var/log/polymarket/integrity_status.json returns valid JSON with summary + checkssystemctl stop polymarket-collector → wait 6 min → Telegram alert arrives [Data Integrity TRIGGERED] Rule: systemd:polymarket-collector.service ...systemctl start polymarket-collector → wait 6 min → Telegram alert arrives [Data Integrity RESOLVED] ...systemctl is-active polymarket-watchdog.service → not-found (decommissioned)polymarket_sniper/watchdog.py) — separate scope, not touched.data_gaps reads/writes: confirmed v2 watchdog is the only writer; one read in earnings_dashboard/app.py (the Database tab). Phase 4 will retire that read.src/data_tracker/integrity_monitor.py (NEW — ~700 LOC)src/data_tracker/migrations/027_integrity_status.sql (NEW)src/earnings/alerts.py (added AlertCategory.DATA_INTEGRITY)deploy/systemd/polymarket-integrity-monitor.service (NEW)deploy/deploy_from_github.sh (added install hook + v2 watchdog decommission)docs/FINDINGS.md (this entry)docs/DATABASE_AUDIT_2026_04_27.md §5.6 found markets.winning_outcome is 100% NULL on all 4,016 closed markets. Trace:src/data_tracker/migrations/001_initial_schema.sql:36 and src/data_tracker/models/database.py:46 as VARCHAR(10) — yes, no, null.grep -r "winning_outcome" src/ shows it's read but never set: signal_generator.py:547 and database.py:46, 322.signal_generator.py:547 read selects it inside a query that filters WHERE m.status = 'open' (line 557) — open markets shouldn't have a winning_outcome anyway, so the SELECT effectively returns NULL by design.earnings_markets.actual_beat (boolean), populated by outcome_resolver from EPS sources — different concept (true/false/null vs yes/no/null) but covers the same need.markets.winning_outcome is a phantom column from the original Gamma schema that no live code path depends on. Two options:continuous_collector.sync_markets_from_gamma (small fix; Gamma's API may or may not return this — needs verification).earnings_markets.actual_beat for resolution data". No active fix shipped. Integrity-monitor will NOT include this column in NULL-rate rules per Phase 3.docs/DATABASE_AUDIT_2026_04_27.md §5.5 — trade_audit_log.hold_duration_hours is NULL on 116/117 closed trades. Only one row populated (GPRO from Mar 5, 2026). Schema column exists but no code writes it.grep -r "hold_duration_hours" src/ returned only:src/data_tracker/models/trade_history.py:131 — column definitionsrc/data_tracker/migrations/... — schema definitionsbackfill_prediction_correct.py script (per docs/STRATEGY_ANALYSIS_ARCHITECTURE.md §0.1.4 deprecated path).hold_duration_hours = ROUND(EXTRACT(EPOCH FROM (:exit_time::timestamptz - entry_time)) / 3600.0, 2) to the trade_audit_log close-path UPDATE in src/earnings/position_manager.py (~line 562 of the UPDATE block, between conviction_at_exit and margin_interest_days).entry_time and exit_time are populated:
sql
UPDATE trade_audit_log
SET hold_duration_hours = ROUND(
EXTRACT(EPOCH FROM (exit_time - entry_time)) / 3600.0, 2)
WHERE status = 'closed'
AND exit_time IS NOT NULL
AND entry_time IS NOT NULL
AND hold_duration_hours IS NULL;position_manager.close_position() → UPDATE trade_audit_log (now includes hold_duration_hours) → row reflects (exit_time - entry_time) hours.(T1 - T0) / 3600 seconds-to-hours. ROUND to 2dp matches the NUMERIC(8,2) column type.position_manager.py:562-571 (the new SET clause).entry_time is NULL (row created via reconciliation only), EXTRACT returns NULL — column stays NULL, no SQL error. If exit_time and entry_time happen in the same microsecond, value rounds to 0.00 (correct). If entry_time > exit_time (impossible in normal flow but conceivable in reconciliation edge cases), value goes negative — that's still informative, not a regression.src/earnings/position_manager.py (close-path UPDATE — 1 SQL clause added)docs/FINDINGS.md (this entry)entry_time > 2026-04-26 17:38:00 UTC (the deploy timestamp of Finding #234).docs/DATABASE_AUDIT_2026_04_27.md §5.4 found sp500_change_at_entry is 100% NULL on all 117 trade_audit_log rows. Finding #234 (Apr 26, 2026) claimed the orchestrator INSERT path now mirrors PositionManager.persist_position_open and writes the SP500 change. Reality check:docs/STRATEGY_ANALYSIS_ARCHITECTURE.md §0.4 Bug #4 already noted this is forward-only: "FORMALIZED (forward-only post-Bug-#2 fix; historical formally NULL)". Audit was searching for evidence of the fix working — but no closes have happened since the fix.sp500_change_at_entry IS NULL AND entry_time > '2026-04-26 17:38:00+00' AND row count of such NULLs > 5 (allows transient yfinance failures while still catching regression).conviction_at_exit 117/117 NULL. Finding #237 (Apr 26) claimed forward-only writer-fix in position_manager.py:413-456. Reality:position_manager.py:563 (in the close-path UPDATE) DOES include conviction_at_exit = :conviction_at_exit and the binding at line 634 reads to_decimal(conviction_at_exit) if conviction_at_exit is not None else None. The lookup logic at lines ~458-472 reads live PM conviction at close.conviction_at_exit IS NULL AND exit_time > '2026-04-26 17:38:00+00' AND status='closed' AND such-NULL count > 2.docs/DATABASE_AUDIT_2026_04_27.md §5.2 found 0 new rows since 2026-04-24 (3 days idle) while prices/trades stream to today. Probe:MAIN LOOP STALL DETECTED: no progress for 124s, stage=write_ibkr_snapshot/run_scheduled_cycles). Both followed an IBKR connectivity blip (Error 1100 → 1102 reconnect) plus PRNewswire 403 / BusinessWire 403 timeouts during VZ earnings monitoring. The stall protection is working as designed (better to crash and restart than silently hang); not a new bug. PRNewswire/BusinessWire 403s may need investigation as a separate finding if pattern continues.docs/DATABASE_AUDIT_2026_04_27.md §5.1 claimed snapshot is 88% empty and "no schema_version field anywhere despite Finding #243 claiming pm_v2". Reality:snapshot.snapshot_schema = "pm_v2" (per signal_generator.py:1019), NOT schema_version. There is also a row-level column log_schema_version smallint DEFAULT 1 (different concept).snapshot ? 'snapshot_schema'): 0/1064 rows. Still no pm_v2.snapshot_schema is added inside _emit_pm_opportunity_log at line 1019 ONLY when conviction_result is not None. Code grep shows 11 of 14 call sites pass conviction_result=conviction_result. Three early-stage skip emissions (line 1442, 1462, 1484 — timing_channel_mismatch, low_volume, etc. before conviction is computed) don't pass it — those will always have snapshot={}. That's design.578dee5, deployed ~17:38 UTC). The audit captured 0 post-deploy rows because no evaluation cycles have run since deploy (per Finding #253 — weekend + pre-window).evaluated_at > '2026-04-26 17:38:00+00' AND snapshot IS NOT NULL AND NOT (snapshot ? 'snapshot_schema') AND skip_stage NOT IN ('timing', 'no_conviction_yet') AND such-row count > 5. (Excludes the early-stage skips that legitimately don't have ConvictionResult.)systemctl is-active/exit-code)docs/DATABASE_AUDIT_2026_04_27.md §4) identified six services that exit 0 despite per-row failures, hiding issues from systemd. Per operator Q3 decision, threshold-based policy chosen (only exit non-zero when failure ratio exceeds a per-script threshold, not on every transient flake).#251a — scripts/equity_bars_collector.py (line 380-403)
- Threshold: exit non-zero when total_failed / attempted > 0.10 where attempted = len(jobs) - total_skipped (cache-hits don't count as attempts).
- Below threshold: transient Polygon flake on a few illiquid symbols, expected.
- Above threshold: likely Polygon outage or DB connectivity issue. Exit 2.
- Matches the original docstring claim of "exit 2 for unrecoverable Polygon errors" (which the implementation never honoured).
#251b — src/data_tracker/collectors/trade_reconciler.py (line 1156-1186)
- Refactored _persist_job_result to return its computed status ("ok" or "error").
- main() now captures job_status and exits non-zero whenever status='error' was persisted (i.e. any trade/position/stop issue detected during the run).
- if __name__ == "__main__": sys.exit(main() or 0) so systemd sees the exit code.
#251c — src/data_tracker/collectors/outcome_resolver.py (line 1037-1080)
- Distinguishes per-row miss (current fail-soft, exit 0) from infrastructure failure.
- Threshold: exit 2 when ANY tier (earnings / opportunity / skipped) has failed / attempted > 50 % AND attempted ≥10 (so a tiny batch of bad rows doesn't trip it).
- Hits when all 4 EPS sources unreachable on most rows, or DB connectivity is intermittent.
#251d — deploy/backup_database.py (line 186-204)
- When SPACES_ACCESS_KEY + SPACES_SECRET_KEY are both set, Spaces upload failure now exits 2.
- When Spaces creds are absent, exit stays 0 (operator chose local-only).
- Off-site backup is the entire purpose of the service; "backup file created locally but never reached Spaces" is no longer a silent success.
#251e — deploy/systemd/polymarket-trades-backfill.service (line 6-22)
- Type=simple → Type=oneshot + RemainAfterExit=no.
- The script iterates all markets and EXITS when finished — it is a batch job, not a daemon. Type=oneshot is the correct semantic.
- Restart=on-failure retained so a crash mid-backfill triggers a restart, but successful completion no longer leaves the unit in a confusing "inactive (dead)" state forever.
#251f — scripts/auto_login_gateway.py broken import (src/earnings/alerts.py line 587-606)
- auto_login_gateway.py:158, 376 imports send_telegram_message from src.earnings.alerts — that function did not exist (only send_alert and a local send_telegram inside AlertManager.send).
- Any IBKR login-lockout warning or 2FA-required notification would ImportError at line 158/376 before reaching Telegram.
- Fix: added a real async def send_telegram_message(message, level=WARNING, category="system_notification") to alerts.py as a thin wrapper around send_alert(...). Preserves the legacy call sites without further script refactoring.
POLYGON_KEY=invalid for #251a; pulling network for the upload step in #251d) → script exits non-zero → systemd unit shows failed not success.len(jobs) == total_skipped (everything cache-hit), attempted = 0 → no exit-code change (correct — nothing was attempted, nothing can fail)._persist_job_result itself raises (e.g. DB connection lost), the exception propagates and main() exits non-zero via uncaught exception (correct behaviour — that IS an error).attempted < 10) cannot trip the 50 % threshold (avoids false alarm on quiet days).systemctl start polymarket-trades-backfill invocations behave the same; the visible state is just clearer.AlertManager retry/circuit-breaker logic (no regression).Step 5 acceptance: per-fix acceptance tests below.
Memory §E same-pattern check:
scripts/leverage_live_validation_autorun.py already does the right thing (exit 2 on lock-file collision). scripts/sync_earnings_markets.py has retry-with-backoff but no failure-ratio threshold; given it's invoked manually (not in deploy/systemd/), out of scope for this phase.Other broken imports searched: grep -r "from src.earnings.alerts import" shows 31 call sites; only auto_login_gateway.py referenced the non-existent send_telegram_message. All other imports resolve to real symbols.
Memory §M cross-interface:
alerts.py wrapper is additive (new function); existing send_alert callers unaffected.
Files changed:
scripts/equity_bars_collector.py — exit-code threshold (~25 LOC added).src/data_tracker/collectors/trade_reconciler.py — _persist_job_result returns status; main returns int; entrypoint uses sys.exit(main() or 0) (~15 LOC modified).src/data_tracker/collectors/outcome_resolver.py — three-tier threshold check (~40 LOC added).deploy/backup_database.py — capture upload result and exit 2 on failure (~10 LOC modified).deploy/systemd/polymarket-trades-backfill.service — Type oneshot + comment (~5 LOC modified).src/earnings/alerts.py — send_telegram_message wrapper (~20 LOC added).docs/FINDINGS.md — this entry.
Acceptance tests (post-deploy):
equity_bars_collector --polygon-sleep 0.05 with all-bad symbols (force POLYGON_KEY=invalid in env override) → exit code 2. Normal run with healthy POLYGON_KEY → exit 0. systemctl status polymarket-equity-bars shows correct state.journalctl -u polymarket-trade-reconcile --since now shows correct state.outcome_resolver --skip-polygon --earnings-limit 50 with a small batch where >50 % rows fail (e.g. all-NULL condition_id) → exit 2. Normal run → exit 0.backup_database.py with SPACES_ACCESS_KEY=invalid → upload fails, exit 2. Run with valid creds → exit 0.systemctl restart polymarket-trades-backfill → unit transitions through activating → active → deactivating → inactive (dead) correctly. No "Restart=on-failure" loop.python -c "import asyncio; from src.earnings.alerts import send_telegram_message; asyncio.run(send_telegram_message('test #251f'))" from [hidden] → no ImportError; Telegram message arrives in chat (subject to min_level=WARNING config).dashboard.html:1332-2037 with backend tracing.deploy/systemd/ (20 units), the script it runs, the table(s) it writes, error path, exit-code semantics, retry logic.src/earnings/alerts.py infrastructure + dashboard cache key inventory.docs/DATABASE_AUDIT_2026_04_27.md):polymarket_opportunity_log and skipped_signals haven't received new rows since Apr 24, 2026 (3 days idle) while prices/trades stream to today. Signal logging is silently broken. → Finding #253.polymarket_opportunity_log.snapshot is 88 % empty {} with NO schema_version field anywhere despite Finding #243 claiming a pm_v2 schema with 12 fields. The pm_v2 writer is broken or never deployed. → Finding #252.trade_audit_log.conviction_at_exit is 100 % NULL (117/117) despite Finding #237 claiming a forward-only fix. → Finding #254.trade_audit_log.sp500_change_at_entry is 100 % NULL (117/117) despite Finding #234 capture fix. → Finding #255.trade_audit_log.hold_duration_hours is 99 % NULL (116/117). → Finding #256.markets.winning_outcome is 100 % NULL on all 4,016 closed markets — never populated despite the schema column existing. → Finding #257.equity_bars_collector, outcome_resolver, trade_reconciler, backup_database, polymarket-trades-backfill.service Type misconfig, auto_login_gateway.py broken send_telegram_message import. → Finding #251.watchdog.py (v2) but docs/DATA_INTEGRITY.md documents watchdog_v3.py (which exists in deploy/polymarket-watchdog-v3.service but isn't in deploy/systemd/ and would false-alarm on production unit names like ibgateway vs ibgateway-ibc). The data_gaps table is the real gap store but all 804 rows are tombstoned (status=deleted / gap_type=incorrect_source). v2 watchdog pgreps backfill_trades (a batch job that legitimately exits) every cycle — false "Stopped" noise. → Finding #258 will replace.trades 9.8 GB, orderbook_snapshots 12 GB, price_snapshots 5.4 GB). DB has grown from doc's 9.3 GB to 32 GB. Timescale's own policy_job_stat_history_retention has failed 166 consecutive times. → Finding #260.WebSocket: Connected is a literal, watchdog interval/threshold are HTML literals, Market Info (basic): 100 % is hardcoded green, Data Reliability Timeline is frozen Jan-08 prose, Database Tables size column is count * 0.5 / N heuristic, Active Earnings Markets uses tags instead of canonical earnings_markets. The tab is server-rendered off one get_db_stats() call; no live refresh. → Finding #259.gap_tracking referenced in docs but doesn't exist; DATA_ARCHITECTURE.md row counts are 6+ weeks stale; eight real tables completely undocumented (skipped_signals, collection_jobs, ibkr_executions, margin_ledger, margin_events, exit_execution_analysis, fractional_eligibility_cache, candlesticks); DATA_INTEGRITY.md describes the wrong watchdog. → Finding #261.src/earnings/alerts.py AlertManager is solid: 5 severity levels, per-category 30 s rate limit, CRITICAL dedup, circuit breakers, retry with backoff, Telegram + optional webhook. Phase 3's data-integrity alerts plug straight into it via one new AlertCategory.DATA_INTEGRITY.continuous_collector is healthy — price_snapshots, orderbook_snapshots, trades are fresh to within ~3 minutes of NOW().src/data_tracker/integrity_monitor.py (replaces watchdog v2 + v3); new integrity_status table; new polymarket-integrity-monitor.service; AlertCategory.DATA_INTEGRITY Telegram alerts./api/integrity endpoint + Database tab redesign around 4 panes (Freshness / Coverage / Services & Pipelines / Storage & Retention).trade_dedupe bloat; fix Timescale job 3; backup hygiene; log rotation.DATA_ARCHITECTURE.md, DATA_INTEGRITY.md, OPERATIONS.md, AGENT_HANDOFF.md §11 to match reality post-remediation.trades / orderbook_snapshots / price_snapshots?docs/DATABASE_AUDIT_2026_04_27.md (NEW — canonical baseline; 9 sections, all SQL evidence)docs/FINDINGS.md (this entry)docs/AGENT_HANDOFF.md §11 — refresheddocs/STRATEGY_ANALYSIS_ARCHITECTURE.md §0.12 — strategy-impact cross-reference (the polymarket_opportunity_log.snapshot 88 % empty issue contradicts the Strategy-Architecture doc's claim of pm_v2)docs/DATABASE_AUDIT_2026_04_27.md renders./api/architecture-findings picks up Finding #250 in the dynamic findings container on the dashboard's Findings tab.git -C [hidden] log -1 --oneline matches new commit on prod.active (this is doc-only ship — should not affect any service).📅 Upcoming Earnings with Polymarket Data widget showing Live 0 rows · DB 148 rows · Next 2026-04-27 00:17:32 — the live API call returned an empty list while the DB had 148 upcoming earnings. RCA traced this to a long-standing slow-cold-start problem made visible by today's restart cascade (deploy_from_github.sh + manual restart for dashboard.html refresh = 2 cold-starts in 35 min)._dashboard_cache.get('earnings_calendar') was None — the cache key had never been written since process start._refresh_cache() is the writer, runs in a daemon thread on FastAPI startup (app.py:3811-3817)._refresh_cache() calls get_earnings_with_polymarket(days=14) — which (a) pulls Yahoo's earnings calendar (~540 names over 14 days), (b) calls populate_mcap_cache(symbols_to_cache) which serially hits yfinance_limiter.get_market_cap for every symbol..set('earnings_calendar', ...) call landed, the API returned upcoming: [] and the widget showed "Live 0 rows".src/utils/caching.py (yfinance → Polygon → Finnhub) — slotted BEFORE Finnhub because Polygon (a) returns mcap + name + exchange + shares in one response, (b) had a verified 98 % hit rate in a live pre-deploy probe of the actual cold-symbol set including the worst-case 12-retry tail (TRP $64.6B, ING $80.3B, NVT $23B all resolved in ~150 ms each; only WSO.B missed and falls through to Finnhub). Memory §M cross-interface compliance: single shared module yfinance_limiter is the only mcap entry point — populate_mcap_cache, _fetch_single_mcap, discover_unknown_mcaps, and signal_generator.get_market_cap_for_symbol all benefit automatically._bootstrap_cache_from_db() runs BEFORE the slow _refresh_cache()) — populates earnings_calendar, db_stats, trade_history, high_conviction, recent_trades, dependencies from DB only (no yfinance, no Yahoo earnings calendar, no Gamma batch). Took the architecture doc §0.11 Phase-4-bootstrap idea and made it concrete via a new db_only=True parameter on get_earnings_with_polymarket() and get_polymarket_earnings_from_db(). mcap_tier read DB-first per memory §L (Finding #219). Steady-state _refresh_cache() 30 s loop unchanged — overwrites with fully-enriched values when ready.@app.on_event("startup") (app.py:3803) → init_cache daemon thread → start_background_cache() → _bootstrap_cache_from_db() (NEW) → 6 fast DB-only cache sets → _refresh_cache() (existing slow path, now Polygon-accelerated) → _dashboard_cache.start_background_refresh(_refresh_cache). yfinance → Polygon → Finnhub chain in src/utils/caching.py:get_market_cap at the four fallback decision points (lines ~520, 533, 555, 567). All other limiter callers benefit transitively.src/utils/caching.py:361-393 — Polygon init/check/throttle/mark-called.src/utils/caching.py:836-873 — _polygon_get_market_cap method.src/utils/caching.py:511-534 — _polygon_mcap() + _finnhub_mcap() + _fallback_mcap() helpers in get_market_cap.src/utils/caching.py:539, 552, 575, 587 — _fallback_mcap() replaces previous _finnhub_mcap() at the four call sites.earnings_dashboard/app.py:1662-1679 — db_only=False parameter on get_polymarket_earnings_from_db.earnings_dashboard/app.py:1797-1804 — gate Gamma batch behind if db_only: bypass.earnings_dashboard/app.py:1981-2007 — db_only=False parameter on get_earnings_with_polymarket + skip Yahoo earnings calendar.earnings_dashboard/app.py:2027-2032 — gate populate_mcap_cache behind if not db_only:.earnings_dashboard/app.py:2107-2110 — gate get_cached_mcap per-row fallback.earnings_dashboard/app.py:2133-2151 — gate yfinance_limiter.get_ticker_info per-row company name fallback.earnings_dashboard/app.py:2160-2178 — gate yfinance_limiter.get_exchange per-row.earnings_dashboard/app.py:1672-1731 — new _bootstrap_cache_from_db() function.earnings_dashboard/app.py:1733-1755 — start_background_cache() two-phase startup._check_polygon_key()=False → returns None → falls through to Finnhub (current behavior; no regression).market_cap: null (foreign / delisted ticker like WSO.B) → returns None → falls through to Finnhub._bootstrap_cache_from_db raises → wrapped per-key try/except, logs WARNING, leaves that key unset → _refresh_cache populates it later (no regression).market_cap_category → cold-bootstrap leaves mcap_tier empty → widget shows "Unknown" → enriched on next full refresh.(None, None) → row renders with "Unknown" tier (today's behavior; no regression).db_only defaults to False, all existing callers unchanged.curl /api/earnings-calendar?days=14 returns len(upcoming) >= 100 (was 0 for ~40 min); journal logs 📊 DB-bootstrap: earnings_calendar = 148 rows (no yfinance, no Gamma) BEFORE any "yfinance rate limited" line; _dashboard_cache.get_age('earnings_calendar') is non-null within 5 s; within ~5 min Cache background refresh started (interval: 30s) fires; some symbols log 📊 Polygon fallback: X market cap = $Y.YB; service stays active.yfinance_limiter.get_market_cap callers (populate_mcap_cache, _fetch_single_mcap, discover_unknown_mcaps, signal_generator.get_market_cap_for_symbol) inherit the Polygon tier via the central limiter — single shared module per §M ✓_finnhub_get_exchange exists as a sibling — _polygon_get_exchange deferred (out of scope; user asked for the chain on market_cap, surgical per memory §C; can add later if exchange lookup becomes a bottleneck).get_cached_market_health() (VIX/SPX yfinance) at _refresh_cache line 1568 is the only other startup-blocking external call — has its own cache + always-fallback semantics; not blocking the same way; out of scope.yfinance_limiter is the single shared module across dashboard + signal_generator + earnings_sync. No parallel logic introduced. Polygon has identical signature (returns billions) — downstream consumers see no behavior change. DB-first cold path is dashboard-only (touches _dashboard_cache only, not trader). ✓/v3/reference/tickers/{X} call against a 50-symbol sample including the actual 12-retry tail and major caps. Hits: AAPL, MSFT, TRP, TPG, NWG, NVT, MGA, LYB, ING, IMO, IESC, FRT, DINO, D, CHD, BEP, AON, BRK.A, BRK.B, CBNA, CZWI, OMC, VLTO, CNC, INCY, ZBH, WSO, FFIV, ESS, WPC, PNR, TRU, BEN, OHI, RNR, AVY, ARCC, UNM, RBLX, IRM, HSY, RMD, MLM, DTE, JPM, BAC, GS, C, MS = 49/50. Miss: WSO.B (no_mcap; canonical WSO succeeds). Avg latency ~150 ms.src/utils/caching.py — Polygon init/check/throttle/mark-called + _polygon_get_market_cap + chain rewiring in get_market_cap (~80 LOC added)earnings_dashboard/app.py — db_only parameter on get_earnings_with_polymarket + get_polymarket_earnings_from_db + new _bootstrap_cache_from_db() + two-phase start_background_cache() (~75 LOC added)docs/FINDINGS.md — this entrydocs/STRATEGY_ANALYSIS_ARCHITECTURE.md — §0.11.5 Phase 4 follow-ondocs/AGENT_HANDOFF.md §11 — current state refresheddocs/OPERATIONS.md — Polygon-fallback note + new "cold-start expectations" subsectioncurl http://[hidden]:8080/api/earnings-calendar?days=14 | jq '.upcoming | length' returns ≥100 within 5 s of systemctl restart polymarket-dashboardjournalctl -u polymarket-dashboard --since "1m ago" | grep "DB-bootstrap" returns 1+ linesjournalctl -u polymarket-dashboard --since "5m ago" | grep "Polygon fallback" returns multiple lines (varies with how many symbols fell to that tier)journalctl -u polymarket-dashboard --since "5m ago" | grep "Cache background refresh started" returns 1 line within 5 min of restartcurl http://[hidden]:8080/api/earnings-calendar?days=14 | jq '.cache_age_seconds' is non-null within 5 s of restartsystemctl is-active earnings-trader polymarket-dashboard polymarket-collector polymarket-resolver.timer polymarket-data-backfill.timer polymarket-equity-bars.timer ibgateway-ibc returns 7× activedocs/STRATEGY_ANALYSIS_ARCHITECTURE.md §0.7 Phase 4 split into two halves: (a) widget consolidation that depends on the Phase 3 Unified Simulator deliverable (replaces Universe View + Finding-A + Backtest Scenarios), and (b) Phase-3-independent dead-UI cleanup + label disambiguation. While the Phase 3 calendar gate runs (Apr 26 → ~May 26), operator commissioned the (b) half so the tab is cleaner and reviewers know exactly where in the multi-phase rollout we sit.findings-legacy-cards block + dead Stop Reversal wider-stops populator + dual-panel label fixes are all small, low-risk, isolated edits that do not touch the analysis script's JSON contract.#phase-status-banner): explicit reference to docs/STRATEGY_ANALYSIS_ARCHITECTURE.md (§0.1 / §0.7 / §0.10), STRATEGY_PLAYBOOK.md §1+§2, and .cursor/memory.md §J/§K/§M/§N. Phase progress chips show 0/1/2/2.1/2.2/2.x done; Phase 3 ⏳ gated by 30-day clock; Phase 4 🛠 in progress; Phase 5 ○ post-Phase-3. Two-column "what's done / what's left" grid with the live Apr-26 evening numbers (98.1% actual_beat, 90.9% actual_earnings_time, 21 symbols / 47.5k bars Day 0). 5-outlier curse + 4-check gauntlet + true-simulator rule reminder pinned at the bottom.findings-legacy-cards hidden block (dashboard.html lines 2071-7843). The block was display: none and predates findings-dynamic-container + /api/architecture-findings; per memory §J the canonical narrative lives only in docs/FINDINGS.md, surfaced via the dynamic container. Replaced with a 6-line provenance comment.<table id="stop-reversal-scenarios-DELETED"> was already display: none (per Apr 25 cleanup), but the JS at renderStopReversalAnalysis still computed and wrote the inner tbody on every load. Both the hidden DOM stub and the populator removed. The stop-rev-savings card populator was also dead (no consumer in HTML) — removed.strategy_analysis_report.json (weekly_performance, hold_duration, cumulative_pnl_clean/all, red_flags, trade_details, missed_signals_analysis) — partially intentional (some are stdout-only via report["red_flags"] print path); cleaning them touches the JSON contract for /api/strategy-analysis. Cheap to keep; remove during post-Phase-3 widget consolidation.earnings_dashboard/templates/dashboard.html — banner added; legacy cards block + dead populator removed; 6 label tweaks; net −5,723 lines (12,285 → 6,562)docs/STRATEGY_ANALYSIS_ARCHITECTURE.md — §0.11 Phase 4 changelog addeddocs/FINDINGS.md — this entrydocs/AGENT_HANDOFF.md §11 — current state refreshedhttp://[hidden]:8080/#strategy. Banner is the very first thing visible, with all phase chips correctly colored (6× green, 1× amber Phase 3, 1× indigo Phase 4 in-progress, 1× neutral Phase 5).ReferenceError, no missing-element warnings from the removed populator).wc -l earnings_dashboard/templates/dashboard.html returns ≤6,600 (was 12,285 in HEAD before this change).release_dt() helper) keys ±90-min stop-loss heuristics off actual_earnings_time. For traded markets the earnings_detector populates this field in trading_positions; for non-traded markets the simulator falls back to the BMO/AMC schedule defaults (07:30 ET / 16:15 ET) which can be off by ±60 minutes.src/data_tracker/migrations/026_actual_earnings_time_on_earnings_markets.sql): adds actual_earnings_time column to earnings_markets (was only on trading_positions). Idempotent + indexed.scripts/backfill_press_release_times_from_sec.py): fetches SEC's company_tickers.json (one-time cached), maps ticker → CIK, queries data.sec.gov/submissions/CIK{cik}.json for each market, finds the 8-K filing whose acceptedDate is closest to (and within ±3 days of) earnings_date, updates earnings_markets.actual_earnings_time. Rate-limit-aware (0.15s sleep = ~6 req/s; SEC's hard cap is 10/s). Class-share variant retry for tickers like BRK / MOG / BF.polymarket-data-backfill.service as a 5th pass with --age-days 90. New markets get SEC timestamps every Sunday.Markets processed: 752
Updated with SEC time: 684
No CIK in SEC map: 5 (non-US filers / recent IPOs)
No 8-K filings found: 32 (companies that don't file routine 8-Ks for earnings)
No 8-K within ±3 days: 31 (timing mismatch on holiday-adjacent earnings)
Errors: 0
Coverage: 0% → 90.9% (667 / 734) past-settled markets with precise actual_earnings_time from SEC EDGAR.src/data_tracker/migrations/026_actual_earnings_time_on_earnings_markets.sql (new)scripts/backfill_press_release_times_from_sec.py (new)scripts/_sec_ticker_to_cik.json (cached SEC ticker map, ~10MB)deploy/systemd/polymarket-data-backfill.service (5th ExecStart pass added)SELECT count(*) FILTER (WHERE actual_earnings_time IS NOT NULL) FROM earnings_markets WHERE earnings_date < CURRENT_DATE - INTERVAL '3 days' returns ≥667 (90.9%).earnings_markets.actual_earnings_time for the ±90-min RuleB window; should observe more precise window placement on the 90.9% covered markets.yes_price >12h after earnings_date to read). Originally framed as "needs orderbook collector enhancement" (a forward-looking fix). Operator pushback: do this historically via the same data-api /trades mechanism that closed the pre-event gap (Finding #230).continuous_collector hot path to keep sampling resolved markets has its own risk surface (memory load, rate limits) that's avoidable via the historical fix + weekly self-heal pattern.scripts/backfill_post_event_snapshots_from_trades.py): pulls all trades for each unresolved market via data-api /trades, filters to trades after earnings_date::timestamp + 12h (matches resolver's PM-tier cutoff exactly), inserts one price_snapshots row per post-event trade. Idempotent via PRIMARY KEY (time, condition_id) + ON CONFLICT DO NOTHING.polymarket-data-backfill.service as a 4th pass with --age-days 60. New markets get post-event snapshots every Sunday.Markets processed: 109 (the unresolved set)
Markets with new rows: 103
Total snapshot rows: 5697
No trades from data-api: 0
No post-event trades: 6 (genuinely no PM activity post-event)
Errors: 0--force): processed 119 rows; coverage went 84.1% → 98.1% (617 → 720). +103 markets newly resolved — a near-1:1 conversion from the 103 markets that got post-event snapshots.POST_EVENT_HOURS = 12 cutoff was using earnings_dt + 12h (next-day 00:00 UTC for BMO) instead of the resolver's earnings_date::timestamp + 12h (same-day 12:00 UTC = 08:00 ET). This excluded all valid same-day post-announcement trading. Fixed to match resolver SQL exactly (line ~451 of outcome_resolver.py). Lesson: when writing scripts that mirror an existing SQL constraint, copy the SQL fragment verbatim rather than reconstructing in Python.scripts/backfill_post_event_snapshots_from_trades.py (new)deploy/systemd/polymarket-data-backfill.service (4th ExecStart pass added)SELECT count(*) FROM price_snapshots ps JOIN earnings_markets em USING (condition_id) WHERE ps.time > em.earnings_date::timestamp + INTERVAL '12 hours' AND em.earnings_date < CURRENT_DATE - INTERVAL '3 days' returns ≥5697.SELECT count(*) FILTER (WHERE actual_beat IS NOT NULL) FROM earnings_markets WHERE earnings_date < CURRENT_DATE - INTERVAL '3 days' returns ≥720 (98.1%).actual_beat coverage ≥97% on the rolling 60-day window._ticker_variants(symbol) in outcome_resolver.py returns variants for known multi-class names (BRK, BF, MOG, RDS, FOX, GOOG, GOOGL, DISCA, DISCK, VIA, VIAC, LBRD, LBRDA, LEN, LGF, NWS, NWSA, STZ, WSO, HEI, HUBB). Tries B-shares before A-shares (B is typically more liquid for retail)._yahoo_earnings_actual, _polygon_earnings_actual, _finnhub_earnings_actual) now wrap a _*_single helper and retry across variants when the canonical ticker fails.actual_beat rows resolved on the existing unresolved set (BRK / MOG / etc. variants all 404'd from Yahoo too — these specific Berkshire / Moog filings just aren't in Yahoo's earnings dataset regardless of ticker format). However, the helper is now in place for future API-coverage shifts and does not slow normal-ticker queries (it only retries when the canonical lookup returns None).src/data_tracker/collectors/outcome_resolver.py — _ticker_variants + 3× _*_single wrapperspython3 -c "from src.data_tracker.collectors.outcome_resolver import _ticker_variants; print(_ticker_variants('BRK'))" returns ['BRK', 'BRK-B', 'BRK.B', 'BRK-A', 'BRK.A'].yes_price directly for the 208 unresolved markets revealed something unexpected: nearly all recent ones (BA, IBM, TSLA, AVGO, COF, IBKR, MMM, MCO, EFX, MSCI, UNH, etc. on Apr 21-22) had clear post-event yes_price at 0.9995 / 0.0005 — the resolver's PM-tier (Pass 1 final fallback) should have picked these up trivially.outcome_resolver.py had a 6-hour updated_at guard intended to prevent same-day re-spinning on legitimately-unresolvable rows:
sql
AND (em.updated_at IS NULL OR em.updated_at < NOW() - INTERVAL '6 hours')
But polymarket-earnings-sync.timer runs hourly and updates updated_at on every sync. The 6-hour guard was effectively NEVER releasing rows once the sync touched them, because the sync re-fired before the lockout drifted past 6 hours. SQL probe confirmed: 208 / 208 unresolved past-settled rows had updated_at within the last 6 hours.--force flag to outcome_resolver.py that bypasses the 6-hour guard on operator-driven runs. The default cadence (daily timer) still uses the guard for automatic runs (correct behavior — the timer fires once per 24h, no risk of spinning). The --force flag is for one-shot backfills like this Phase 2.2 pass.
python
guard_clause = "" if cfg.force_recent else (
"AND (em.updated_at IS NULL OR em.updated_at < NOW() - INTERVAL '6 hours')"
)python3 src/data_tracker/collectors/outcome_resolver.py --force \
--earnings-limit 300 --lookback-days 365 --polygon-sleep 0.05
→ Pass 1 (earnings_markets): 228 rows → 226 with pricing
Coverage went from 526 / 734 (71.6%) → 617 / 734 (84.1%) in one resolver pass. +91 rows resolved, all of which had been silently locked out for days/weeks.src/data_tracker/collectors/outcome_resolver.py — added force_recent to ResolverConfig; conditional guard_clause in Pass-1 SELECT; --force CLI argSELECT count(*) FILTER (WHERE actual_beat IS NOT NULL) AS resolved, count(*) AS total FROM earnings_markets WHERE earnings_date < CURRENT_DATE - INTERVAL '3 days' returns ≥0.84 ratio.--force after a few days should pick up newly-resolvable markets without timing out on the lock.updated_at write was an unmodelled cross-effect on the resolver's guard. Future resolver-style guards should prefer a dedicated *_attempted_at column or a *_resolution_status enum so unrelated writes don't create silent lockouts. Same pattern lesson as Audit_v3 §14.7 (canonical-field-validity rule from Finding #233): single-purpose fields are more reliable than overloaded ones. Tracked as a Phase 2.x improvement.ConvictionResult dataclass already exposes best_bid, best_ask, spread, bid_depth, ask_depth, snapshots_checked, time_delta_seconds, reliability_reason, conviction_window_min/max. The signal generator computes a ConvictionResult for every Polymarket evaluation, then logs to polymarket_opportunity_log — but the snapshot JSONB was not capturing these fields. Trivial enhancement.src/earnings/signal_generator.py:_emit_pm_opportunity_log accepts a new conviction_result: Optional[ConvictionResult] kwarg.conviction_result is provided, the helper merges 12 PM-book + conviction-window fields into the JSONB snapshot under prefixed keys: pm_best_bid, pm_best_ask, pm_spread, pm_bid_depth, pm_ask_depth, pm_snapshots_checked, pm_time_delta_seconds, pm_reliability_reason, pm_conviction_window_min, pm_conviction_window_max, pm_source_time. Plus a snapshot_schema: pm_v2 marker so the Phase 3 simulator can detect schema version.signal_generator.py updated to pass conviction_result=conviction_result (BMO depth-skip, BMO reject, BMO success, BMO success-but-invalid; AMC depth-skip, AMC reject, AMC success, AMC success-but-invalid). Call sites that don't have a ConvictionResult in scope (e.g. exception handlers) gracefully degrade — they get the same snapshot they already had.pm_v1 (no snapshot_schema key); new rows from the next signal cycle onwards will have pm_v2. The Phase 3 simulator detects the schema version and degrades gracefully on older rows.src/earnings/signal_generator.py:_emit_pm_opportunity_log (+ 8 call sites)snapshot ? 'snapshot_schema'. Verification SQL:
sql
SELECT count(*) FILTER (WHERE snapshot ? 'snapshot_schema') AS pm_v2_count
FROM polymarket_opportunity_log
WHERE evaluated_at > '2026-04-26 18:00:00 UTC';
Expected: ≥1 within 24 hours of the next active earnings-day cycle.actual_beat coverage rose 71.6% → 84.1% (+91 rows) in a single operator-driven backfill once the 6-hour guard bug (Finding #244) was lifted. The remaining 117 unresolved markets break down as:/vX/reference/financials query — Polygon ingests SEC XBRL filings as its data source. For the 109 where Polygon failed, SEC EDGAR is unlikely to add ≥10 more rows. Effort:value ratio is poor.actual_beat gate at ≥84.1% (well above the revised ≥70% threshold; close to the original ≥95% target if the 8 PM-ambiguous markets count as "indeterminate" rather than "unresolved"). Phase 3 gate condition #3 fully met.actual_beat coverage.Apr 26 evening update: this finding's "PM book + IBKR L1 deferred" position was superseded by Finding #243 (PM-book enrichment shipped same day). IBKR L1 specifically remains deferred to Phase 3 due to signal-hot-path risk (see #243). Current schema is
pm_v2going forward.
polymarket_opportunity_log.snapshot JSONB: 1064 / 1064 rows populated (100%).stop_loss_pct, symbol, market_cap, market_cap_value, entry_datetime, earnings_datetime, exit_datetime, condition_id, conviction, confidence, is_valid, earnings_timing, signal_generated_at, direction. (Older rows have a different shape but ALL have a snapshot.)regime_at_entry, vix_at_entry, sp500_change_at_entry) — sufficient for Phase 3 simulator joins.signal_generator.log_polymarket_opportunity that would require modifying the live signal path. Decision: defer to Phase 3 as a "nice to have"; current snapshot + dedicated columns are adequate for the unified simulator's basic needs.STRATEGY_ANALYSIS_ARCHITECTURE.md §0.10 Phase 2 changelog.docs/STRATEGY_ANALYSIS_ARCHITECTURE.md §0.7) — without persistent equity 1m bar storage, every simulator run requires ~600 fresh Polygon fetches, and Phase 3 (unified simulator on the 488 in-band markets the bot didn't trade) becomes prohibitive on Polygon rate limits. Cached bars also let analyze_finding_a_full.py re-runs complete in seconds instead of minutes.src/data_tracker/migrations/025_equity_bars_1m.sql): new TimescaleDB hypertable equity_bars_1m with PRIMARY KEY (symbol, ts), 7-day chunks, indexed for "give me bars for symbol X between A and B" queries. Apache-edition-safe (compression + retention policies wrapped in DO blocks since prod runs Apache, not Community — same constraint as price_snapshots).scripts/equity_bars_collector.py): full arg surface (--upcoming-days, --lookback-days, --lookforward-days, --backfill-past, --symbol, --limit, --prune-older-than, --polygon-sleep, --dry-run). Picks symbols with in-band Polymarket markets in the rolling window; checks existing coverage before fetching (cache-hit threshold = 80% of expected bars per day); idempotent UPSERT via ON CONFLICT (symbol, ts) DO NOTHING.src/earnings/equity_bar_cache.py): single shared fetch_minute_bars_cached() per memory §M cross-interface rule. DB-first read; falls back to Polygon for cache misses; writes back to cache automatically. Backwards-compatible with database_url=None (delegates to fetch_polygon_minute_bars).scripts/analyze_finding_a_full.py:fetch_bars): when DATABASE_URL is set, uses fetch_minute_bars_cached; falls back to Polygon-only if DB unavailable. Existing callers (compute_counterfactual_pnl.py, analyze_stop_counterfactual.py, analyze_atr_prescreen.py) untouched in this finding — they can opt in later via the same helper.deploy/systemd/polymarket-equity-bars.{service,timer}): daily at 10:00 UTC (~05:00-06:00 ET, before market open). Single ExecStart pass with --upcoming-days 7 --lookback-days 3 --lookforward-days 1 --limit 100 --prune-older-than 730. Wired into deploy/deploy_from_github.sh so the timer auto-installs on every deploy.--prune-older-than 730): substitute for the Community-edition add_retention_policy that's not available on prod's Apache TimescaleDB. Keeps storage at <100MB/year steady-state.src/data_tracker/migrations/025_equity_bars_1m.sql (new)scripts/equity_bars_collector.py (new)src/earnings/equity_bar_cache.py (new)scripts/analyze_finding_a_full.py:fetch_bars (cache-first wiring)deploy/systemd/polymarket-equity-bars.service (new)deploy/systemd/polymarket-equity-bars.timer (new)deploy/deploy_from_github.sh (extended to install + enable new timer)compute_counterfactual_pnl.py calls run_counterfactual_for_row which uses fetch_polygon_minute_bars — could opt in to caching but currently passes through. Will migrate in Phase 2.x as workload demands.analyze_stop_counterfactual.py, analyze_atr_prescreen.py — legacy scripts, no migration needed unless re-run actively.fetch_minute_bars_cached helper — no parallel cache implementations.systemctl is-active polymarket-equity-bars.timer → activesystemctl list-timers polymarket-equity-bars.timer shows next-fire time = upcoming 10:00 UTC ± 10 min jitter.SELECT count(DISTINCT symbol), count(*), pg_size_pretty(pg_total_relation_size('equity_bars_1m')) FROM equity_bars_1m returns growing counts week over week.scripts/analyze_finding_a_full.py (when DATABASE_URL is set) completes faster on second run — Polygon fetches replaced by cache reads.docs/STRATEGY_ANALYSIS_ARCHITECTURE.md §0.2 rows #15 + #16) flagged two doc inconsistencies that could mislead a future agent: long-weekend sizing was documented as 50% in STRATEGY.md §5.2.1 but 75% in EDGE_CASES.md Category 2; trade-count Phase 1→2 transition was documented as 100 trades in one bullet of STRATEGY.md §4 and 150 trades in another. Operator instruction was to check the live bot for the canonical value and reconcile both docs.src/earnings/signal_generator.py:1168 reads long_weekend_multiplier = 0.5 if is_long_weekend else 1.0; risk_manager applies it at line 938-943 with log [LONG_WEEKEND @50%]. Canonical value is 50%. STRATEGY.md is correct; EDGE_CASES.md was wrong and has been reconciled to 50% with an explanatory note tracing back to the canonical code line.docs/STRATEGY.md — §4.0 transition process + §8.2 capital figure refresh + §8.3 leverage calloutdocs/EDGE_CASES.md — Category 2 sizing 75% → 50%docs/HOLISTIC_REVIEW_PROMPT.md — refreshed stale parametersdocs/STRATEGY_ANALYSIS_ARCHITECTURE.md (Phase 0 reference)0.5 in signal_generator.py for long_weekend_multiplier (no 0.75); EDGE_CASES.md Category 2.1 reads "50%" with a note pointing to the canonical code line; STRATEGY.md §4.0 Transition Process bullet 1 reads "150 round-trip trades" with the 200-trade fallback decision tree explicit.polymarket_opportunity_log.hypothetical_pnl (DONE)docs/STRATEGY_ANALYSIS_ARCHITECTURE.md §0.4 Bug #6) flagged that 416 of 1064 (39%) opportunity_log rows had hypothetical_pnl populated despite all 1064 having counterfactual_computed_at set. Initial assumption was a backfill gap.counterfactual_version showed:polygon_minute_v1: 214 rows (successful Polygon 1m simulation)price_data_unavailable_v1: 202 rows (Polygon returned no data — delisted, OTC, etc.)no_price_simulation_v1: 648 rows (correctly classified as non-simulatable: 422 timing_channel_mismatch, 165 conviction_deadband, 50 timing_unknown, 6 conviction_unavailable, 5 unreliable_orderbook). For these rows the bot never determined a direction so a counterfactual is meaningless by design.compute_counterfactual_pnl.py --limit 500 --sleep-seconds 0.05 as a fourth pass in the existing polymarket-data-backfill.timer (Sun 04:00 ET). New opportunity_log rows accumulate weekly; the timer keeps the counterfactual-completion ratio steady on simulatable rows.deploy/systemd/polymarket-data-backfill.service — added 4th ExecStart pass + timer description updatedsystemctl is-active polymarket-data-backfill.timer → activecounterfactual_computed_at IS NULL row count on polymarket_opportunity_log ≤ ~10 (any new signals from the past week will have been processed).journalctl -u polymarket-data-backfill.service shows the 4th pass executing without errors.trade_audit_log.conviction_at_exit writer added (DONE)docs/STRATEGY_ANALYSIS_ARCHITECTURE.md §0.4 Bug #3) found that trade_audit_log.conviction_at_exit column existed (since the Finding #227 schema work) but no close path was writing it. 116/116 closed trades had NULL despite 75/116 trading_positions having final_conviction populated by the earnings-detection path.PositionManager.persist_position_close_with_exit_details (src/earnings/position_manager.py:398) — the canonical close path — did not include conviction_at_exit in its UPDATE statement. The earnings-detection path (persist_earnings_detection line 919) DID read live conviction via _utils.get_current_conviction_and_volume() but wrote it only to trading_positions.final_conviction, not to trade_audit_log._utils.get_current_conviction_and_volume(condition_id) source. The condition_id is fetched from trading_positions (the same SELECT that already pulls entry_price / shares). Added conviction_at_exit = :conviction_at_exit to the audit_log UPDATE SET clause.src/earnings/position_manager.py:398-440 + :585-635 — read condition_id, call _utils.get_current_conviction_and_volume, add conviction_at_exit to UPDATE_utils.get_current_conviction_and_volume reads LIVE Polymarket book; we cannot reconstruct what conviction was at the past exit time. Historical rows remain NULL by design (forward-only after Apr 26, 2026).conviction_at_exit populated (NOT NULL) when condition_id is set and _utils.get_current_conviction_and_volume returns a value.SELECT count(*) FILTER (WHERE conviction_at_exit IS NOT NULL) FROM trade_audit_log WHERE status = 'closed' AND exit_time > '2026-04-26' should be ≥ 1 within 24 hours of the next earnings event.trade_audit_log.stop_triggered writer fix + 55-row backfill (DONE)trade_audit_log.stop_triggered was set by only 1 of the multiple stop-exit code paths — 2 of 57 historical stops had the flag set, while 57/57 had exit_reason ILIKE '%stop%'. Analysis-side workaround (use exit_reason) shipped Apr 25; the writer-side fix was deferred to Phase 1.src/earnings/position_manager.py close path, derive the boolean from the textual exit_reason at write time:
python
derived_stop_triggered = bool(safe_exit_reason and 'stop' in safe_exit_reason.lower())
Added to BOTH the primary close UPDATE (line 504+) and the fallback recovery UPDATE (line 670+). This guarantees the flag stays in sync with the textual reason regardless of which exit code path runs.sql
UPDATE trade_audit_log SET stop_triggered = TRUE
WHERE status = 'closed' AND realized_pnl IS NOT NULL
AND exit_reason ILIKE '%stop%'
AND coalesce(stop_triggered, FALSE) = FALSE;
-- 55 rows updated (was 2, now 57; matches exit_reason canonical count)SELECT count(*) FILTER (WHERE stop_triggered) AS flag, count(*) FILTER (WHERE exit_reason ILIKE '%stop%') AS canonical, count(*) FILTER (WHERE stop_triggered AND exit_reason ILIKE '%stop%') AS both, count(*) FILTER (WHERE stop_triggered AND NOT exit_reason ILIKE '%stop%') AS flag_only returns 57 / 57 / 57 / 0. Perfect agreement.src/earnings/position_manager.py — primary + fallback close paths derive stop_triggeredprediction_correct, is_external_trade, used_market_fallback) already derive from a unified write-path; spot-check on prod showed no analogous drift. Tracked in Architecture doc §0.4 as "no further regressions found".renderRecommendations ReferenceError on Strategy tab load (DONE)docs/STRATEGY_ANALYSIS_ARCHITECTURE.md §0.4 Bug #1) found that dashboard.html:11254 invoked renderRecommendations(analysis, backtest, cohortTiming) but the function was deleted Apr 25, 2026 (comment at line 11482 confirmed removal). The function had been replaced by the new combined renderActionQueue (single SoT for queue rendering) but the legacy call site was not removed.ReferenceError: renderRecommendations is not defined. Because renderStrategyAnalysis invoked it before renderConfidence, the Statistical Confidence section may not have rendered reliably. Earlier sections rendered fine because they ran before the error.js
// Recommendations: removed Apr 25, 2026. Single SoT is the
// 🎯 Action Queue (rendered above by renderActionQueue). The old
// renderRecommendations() function was deleted but the call here
// remained — caused a runtime ReferenceError that aborted the
// remainder of renderStrategyAnalysis on every Strategy tab load
// (Finding #235, Apr 26, 2026).earnings_dashboard/templates/dashboard.html:11251-11260http://[hidden]:8080/#strategy, open browser dev console, no ReferenceError: renderRecommendations is not defined should appear. All Strategy-tab sections render cleanly to completion (including renderConfidence).TradingOrchestrator._persist_position_open was missing regime/VIX/SP500 columns (DONE)regime_at_entry pre-fix, all from post-Apr-21 entries)docs/STRATEGY_ANALYSIS_ARCHITECTURE.md §0.4 Bug #2) discovered that TradingOrchestrator._persist_position_open (src/earnings/trading_orchestrator.py:2389-2447) and PositionManager.persist_position_open (src/earnings/position_manager.py:324-349) had divergent INSERT statements for trade_audit_log: PositionManager included regime_at_entry, regime_details, vix_at_entry, sp500_change_at_entry (per Finding #227) but the orchestrator path did NOT. Trades that flowed through the orchestrator silently lost regime / VIX / SP500 capture.AAL, LMT, BX, TSLA, TXN), consistent with the orchestrator path being the dominant entry path in recent weeks. The bug was a regression that would have continued to drop regime/VIX coverage on every new trade until fixed.PositionManager.persist_position_open:307-322 into TradingOrchestrator._persist_position_open immediately before the trade_audit_log INSERT. The orchestrator already had access to self.regime_provider (set at constructor line 132 from MarketRegimeProvider) — the field was just being silently ignored. Added the four columns + four placeholders + four parameter dict entries.scripts/backfill_regime_for_audit_log.py (yfinance historical SPY MA20/50/200 + VIX) on the 7 affected rows (5 from Apr 22 + 2 older with NULL VIX). Post-backfill coverage:KeyError('chart') on that specific date — recoverable when yfinance returns)INSERT INTO trade_audit_log sites:src/earnings/reconciliation_manager.py:774 — auto-recovery insert when audit_log missing a row that exists in trading_positions. Correctly does NOT capture regime/VIX because we don't know what the regime was at the historical entry time, and writing NOW's regime would be wrong. Acceptable design.src/earnings/trading_orchestrator.py:2387-2487 — regime/VIX/SP500 capture block + 4 new INSERT columnsregime_provider.get_snapshot() returns a non-NULL snapshot.SELECT count(*) FILTER (WHERE regime_at_entry IS NULL) FROM trade_audit_log WHERE entry_time > '2026-04-26' should remain 0.trade_audit_log.stop_triggered field undercounts stops by 28× (DONE — analysis-side fix Apr 25; writer-side fix shipped Apr 26 as #236)stop_triggered was wrong by orders of magnitude — including v2 #6 "no historical evidence", v3 D / v3 E ON-HOLD reasoning, and the rank of RuleB)exit_reason ILIKE '%stop%' on 57/57 closed stop-exits.trade_audit_log.stop_triggered is set by the bot's exit-handling path, but only one of the multiple stop-exit code paths actually flips it to true. Most stop exits set exit_reason = 'stop_loss:hardware' (or one of 7 other stop-related values) and leave stop_triggered = false. The flag and the textual reason fell out of sync silently, probably during one of the early position-manager refactors. No alarm fires when the two disagree because nothing in the runtime path cross-checks them.stop_triggered = true: 2 trades (1.7 %)exit_reason ILIKE '%stop%': 57 trades (49.1 %)| stop_triggered | exit_reason | n |
|---|---|---|
| true | stop_loss:stp_lmt_realtime_fill |
1 |
| true | stop_loss:hardware_reconciled |
1 |
| false | stop_loss:hardware |
40 |
| false | stop_loss:hardware_reconciled |
7 |
| false | instant_exit:stop_loss_backup |
5 |
| false | stop_loss_software_backup |
1 |
| false | hardware_stop |
1 |
| false | stop_loss_triggered |
1 |
So 55 of 57 stop-exits (96 %) have the flag wrong.
- Strategy impact (analyses that were wrong):
- v2 #6 (3-stop session breaker) was claimed in earlier docs to have ZERO historical sessions with ≥3 stops → actually 8 sessions (Feb 19 had 5 stops). The rule has real defensive value, not theoretical.
- v3 D ON-HOLD reasoning ("stops eat winners on Mid-cap, RuleB will fix this") was tentative in earlier docs because stop rates appeared low → actually strongly vindicated: Mid 80-90 % has 51.6 % stop rate, Mid 90-95 % has 86.7 % stop rate.
- v3 E ON-HOLD reasoning rested on claimed sideways-SHORT 67 % stop rate → actually 83.3 % (corrected).
- RuleB urgency was understated → corrected stop bleed is −$676 across 57 stops (avg −$11.86/trade), erasing ~80 % of the +$843 contribution from non-stops. Promoted rank 4 → 2 in queue.
- Mar 9 #202 wider stops impact was misjudged: pre-Mar-9 stop rate 50 %, post-Mar-9 45.5 % (frequency dropped 10 % only). But avg stop loss rose from −$9.66 → −$22.23 (each stop costs more because stop distance is wider). RuleB targeting the ±90-min window is a better fix than further widening.
- Fix (analysis-side, immediate):
- Updated scripts/strategy_performance_analysis.py:build_action_queue() to compute live evidence using exit_reason ILIKE '%stop%'.
- Updated docs/STRATEGY_PLAYBOOK.md §1 (verdict) + §3 (per-rule evidence tables) + §6 (#5 implementation notes — explicit warning to use exit_reason).
- Updated docs/AGENT_HANDOFF.md §11 verification block with stop-rate sanity-check query.
- Updated docs/STRATEGY_AUDIT_v3.md §14 (re-validation pass) + §14.7 (lesson #7 — canonical-field-validity rule).
- Future agents: any WHERE stop_triggered = true query is suspect — replace with exit_reason ILIKE '%stop%'.
- Deferred (writer-side, longer fix):
- Patch every stop-exit code path in src/earnings/order_executor.py + src/earnings/position_manager.py + the IBKR fill-event handler to also set stop_triggered = true. Then backfill the 55 historical rows where exit_reason ILIKE '%stop%' AND stop_triggered = false.
- Ideal forward-fix: trigger the boolean from the textual reason in a DB-level CHECK or via a unified _persist_exit() helper that derives the boolean from the reason. Removes the drift surface entirely.
- Tracked in docs/STRATEGY_AUDIT_v3.md §15 next-pass to-do.
- Same-pattern check (memory §E): Are there other audit-log boolean flags that may have drifted from their canonical textual counterparts? Audit candidates: prediction_correct vs actual_earnings_result, is_external_trade vs entry_reason, used_market_fallback vs entry_order_type. Spot-check deferred to next pass.
- Process learning (added to STRATEGY_AUDIT_v3.md §14.7 as lesson #7): Canonical-field-validity rule. Never trust convenience flags like stop_triggered without cross-checking against the source-of-truth field like exit_reason. Audit-log boolean fields can fall out of sync silently when writer code paths drift; the textual exit_reason is the canonical record. When in doubt: query both, compare counts, investigate the gap.
- Acceptance test:
- SELECT count(*) FILTER (WHERE stop_triggered) AS broken, count(*) FILTER (WHERE exit_reason ILIKE '%stop%') AS canonical FROM trade_audit_log WHERE status = 'closed' → should show broken ~2 / canonical ~57 (will rise as more trades close).
- scripts/strategy_performance_analysis.py re-run produces an action_queue with v2 #6 in IN-QUEUE and live_evidence strings showing real stop rates (not 0-3 % phantoms).
- Files:
- scripts/strategy_performance_analysis.py (live-evidence + bias_check fields rewritten to use exit_reason)
- docs/STRATEGY_PLAYBOOK.md (§1 verdict + §3 evidence tables + §6 implementation note)
- docs/STRATEGY_AUDIT_v3.md (§14 + §15)
- docs/AGENT_HANDOFF.md (§11)
- docs/FINDINGS.md (this entry)
- .cursor/memory.md (no change needed — §F project-specific checks already mention reconciliation and audit-log integrity)
scripts/_revalidate_queue_evidence.sql, _revalidate_f_and_ruleb.sql, _revalidate_corrected.sql). Re-ran the 4-check bias gauntlet on every IN-QUEUE + ON-HOLD + key RETRACTED item. Discovered Finding #233 mid-investigation (broken stop_triggered field) and re-pulled all stop-related queries with exit_reason ILIKE '%stop%' instead.| Metric | Handoff (Apr 25 morning) | Apr 25 LATE evening | Δ |
|---|---|---|---|
| Closed trades | 109 | 116 | +7 |
| Net P&L | +$61 | +$167 | +$106 |
| Win rate | 33 % | 33.6 % | ≈ same |
| Universe in-band | 568 | 604 | +36 (organic + backfill timer fire) |
| Volume coverage on in-band | 91 % positive | 90.4 % at $2K gate | comparable |
- Per-rule re-validation results (full table in docs/STRATEGY_AUDIT_v3.md §14.3): |
| # | Rule | Old rank | New rank | Change |
|---|---|---|---|---|
| 1 | v3 B — Skip downtrend LONGs | 1 | 1 | KEEP — bias check STRONGER (ex-top-2 = -$175, more negative because SWBI was masking; 22/22 distinct tickers) |
| 2 | RuleB — ±90 min stop window | 4 | 2 | PROMOTED — corrected stop classification (Finding #233) revealed live stop bleed is the strategy's biggest leak (-$676/57 stops) |
| 3 | v2 #6 — 3-stop session breaker | 2 | 3 | DEMOTE rank but UPGRADE evidence: 8 historical ≥3-stop sessions confirmed (was: claimed zero) |
| 4 | Audit re-validation @ #150+ | 5 | 4 | DEMOTE — F drop clears a slot |
| — | v3 F — BMO + VIX sizing | 3 | 🛑 RETRACTED | DROPPED — see retraction below |
| ⏸️ | v3 D — Mid-cap 80%+ LONG skip | ⏸️ | ⏸️ | KEEP ON-HOLD; reasoning REINFORCED (Mid 90-95% has 87% stop rate) |
| ⏸️ | v3 E — Skip non-downtrend SHORTs | ⏸️ | ⏸️ | KEEP ON-HOLD; reasoning REINFORCED (sideways SHORT 83% stop rate) |
| - v3 F (BMO + VIX sizing) — RETRACTION DETAILS: | ||||
| Original claim was +$60/100 with both ends robust per audit's ex-top-3 check. Apr 25 LATE deep gauntlet split verdict: |
| Arm | Bucket | n | Headline | Ex-top-2 | Ex-top-3 | Verdict |
|---|---|---|---|---|---|---|
| 0.6× scale-DOWN | BMO+VIX 18-22 | 30 | −$54 | −$135 (more negative) | n/a | ✅ PASS — robust |
| 1.2× scale-UP | BMO+VIX <18 | 20 | +$100 | n/a | −$1.58 | ❌ FAIL bias #2 |
The +$100 winner bucket is concentrated in STT +$37 / VZ +$33 / ALLY +$32 = +$102 — drop those 3 outliers and 17 trades sum to −$1.58 (essentially flat). Direct multiplier-on-realized-pnl modeling gives total per-100 lift of +$36 (NOT +$60), and that's optimistic. Operator dropped both arms — scale-DOWN-only ~$20/100 not worth implementation + variance + capital-constraint risk at current ~$2.4k capital.
- 5-outlier curse rename (was 3-outlier): TXN (+$92, Apr 22 — LONG large NULL-regime, NEW big winner) and STUB (+$58, Mar 4 — SHORT mid downtrend, NEW) join GPRO + DELL + SWBI as dominant outliers carrying ~$430 of $167 net P&L on 116 trades. Ex-top-5 the strategy is −$263 across 111 trades. Memory §K + STRATEGY_AUDIT_v3 §14.4 + STRATEGY_PLAYBOOK §1 + §5 updated.
- Combined realistic lift, revised: +$190 to +$310 / 100 trades (slightly UP from prior +$160-$280 because B's bias check came out stronger and v2 #6 is no longer purely defensive; offset by v3 F retraction). Flips the strategy from current +$1.44/trade to +$3.30 to +$4.50/trade average.
- Acceptance test:
- Run scripts/strategy_performance_analysis.py on prod; new action_queue should have 4 IN-QUEUE (B, RuleB, 3-stop breaker, audit-reval), 2 ON-HOLD (D, E), 8 RETRACTED (now includes v3 F).
- Dashboard 🎯 Action Queue tab refreshed — RuleB shows at rank 2, v3 F at the bottom under RETRACTED with detailed bias-gauntlet narrative.
- Bias gauntlet section shows "5-outlier rule" not "3-outlier".
- Files:
- scripts/strategy_performance_analysis.py — build_action_queue() rewritten + four_check_gauntlet_doc() 5-outlier rename
- earnings_dashboard/templates/dashboard.html — header text "5-outlier reminder" + JS label "5-outlier rule"
- docs/STRATEGY_PLAYBOOK.md — §1 verdict + §2 queue table + §3 per-rule evidence + §4 v3 F retraction + §5 5-outlier rule + §6 implementation notes
- docs/STRATEGY_AUDIT_v3.md — §14 (re-validation pass) + §15 (deferred to-do)
- docs/AGENT_HANDOFF.md — §11 current state + Action Queue table + verification block + closing footer
- docs/FINDINGS.md — this entry + Finding #233
- .cursor/memory.md §K — 5-outlier rename
- Re-validation evidence (kept in repo for audit trail): scripts/_revalidate_queue_evidence.sql, _revalidate_f_and_ruleb.sql, _revalidate_corrected.sql
?active=true Gamma filter and price_snapshots start-too-late behavior continue to silently exclude data.continuous_collector.sync_markets_from_gamma queried Gamma with ?active=true&archived=false, silently excluding settled-but-not-archived markets (#229; fixed in collector code).continuous_collector orderbook sampler starts sampling many markets only a few hours before earnings, leaving the 24h/12h/6h-before snapshot windows outside the sampled timespan (#230; not fully fixable without orderbook collector rework — handled here via fallback instead of source fix).polymarket-data-backfill.timer runs weekly (Sun 04:00 ET, well before any market wake-up) and chains three passes in sequence:scripts/backfill_markets_volume.py --apply --age-days 60 — re-queries Gamma with closed=true for any in-band market with volume_total = 0 whose earnings_date >= today − 60 days. Mops up newly-resolved markets within ~7 days of resolution.src/data_tracker/collectors/conviction_tracker.py --backfill — runs the existing tracker's price_snapshots-based backfill. Cheap (no external API) but only useful when the orderbook collector has data in the target window.scripts/backfill_conviction_from_trades.py --apply --age-days 60 — falls back to Polymarket data-api /trades for any past market still missing prob_*_before after pass 2. Recovered 98 % of historical gap on the one-shot run.deploy/systemd/polymarket-data-backfill.service (new)deploy/systemd/polymarket-data-backfill.timer (new — Sun 04:00 ET, persistent, ±10 min jitter)/var/log/polymarket/data_backfill.logdeploy/deploy_from_github.sh extended to install + enable both unit files via the same pattern as polymarket-resolver.timer (Finding #227). Auto-installed on every deploy.systemctl is-active polymarket-data-backfill.timer → activesystemctl list-timers polymarket-data-backfill.timer shows next-fire = upcoming Sunday 04:00 ET ± 10 minjournalctl -u polymarket-data-backfill.service after first run shows three sequential backfill passesSELECT count(*) FROM earnings_markets WHERE prob_24h_before IS NULL AND earnings_date < CURRENT_DATE should remain ≤ ~10 (the one-shot baseline left 54; new resolutions add ~10/week, all should be backfilled).journalctl -u polymarket-data-backfill.service -n 200 for the most recent run.systemctl status polymarket-data-backfill.timer.python3 scripts/backfill_conviction_from_trades.py --age-days 365 (dry-run) to inspect; many "still NULL" markets genuinely have no Polymarket trade activity and are unrecoverable.deploy/systemd/polymarket-data-backfill.service (new)deploy/systemd/polymarket-data-backfill.timer (new)deploy/deploy_from_github.sh (install + enable block)docs/FINDINGS.md (this entry)docs/OPERATIONS.md (cross-reference in service-management section)earnings_markets.prob_*_before snapshots missing for 401 of 900 markets (orderbook collector started too late) (DONE)earnings_markets rows we only had 279 (31 %) with conviction snapshots AND resolved prices. The biggest "missing" bucket was 549 rows with NULL prob_24h_before — 401 past earnings + 148 future. The conviction_tracker had been running for months but couldn't fill these from price_snapshots.continuous_collector (orderbook sampler) only starts sampling a market when its condition_id enters the active markets list — for many earnings markets that's only a few hours before the earnings event, leaving the 24h / 12h / 6h-before windows outside the sampled timespan. (2) conviction_tracker --backfill looks for snapshots within ±30 min of the target time — a perfectly valid match window, but useless when the collector has no rows from that timeframe at all. Example: MRNA had 96 snapshots, all between Feb 13 06:50 UTC (= 01:50 ET earnings day) and Feb 13 15:09 UTC; the prob_24h_before target (Feb 12 08:00 ET) is 1,070 min outside the sampled window./trades. Every single one returned 37–656 trades over the full pre-event window — the data exists on Polymarket, we just hadn't ingested it. Examples: PANW 656 trades, MRNA/TRIP 251 trades each, GRAB 267 trades. All have $1.5K–$33K Gamma volume.python3 src/data_tracker/collectors/conviction_tracker.py --backfill (no code change). Re-ran the backfill from price_snapshots; recovered 8 markets (most fills were no-op refreshes — the rest had snapshots outside the ±30 min window).scripts/backfill_conviction_from_trades.py (read-only by default, --apply to write). For each NULL-prob past market: pulls all trades from data-api /trades, normalizes each trade's price to YES-token probability (yes_prob = price if trade on YES, 1 − price if on NO), and finds the trade closest to each target timestamp within ±60 min (wider than the snapshot tracker's ±30 min because trades are sparser than orderbook samples). Updates earnings_markets.prob_*_before + matching prob_*_timestamp columns.| Metric | Pre-backfill | Post-backfill | Δ |
|---|---|---|---|
| In-band Universe LONG (>60%, resolved) | 253 | 529 | +276 (+109 %) |
| In-band Universe SHORT (<30%, resolved) | 26 | 39 | +13 |
| Total operational universe | 279 | 568 | +289 (+104 %) |
| Coverage of all 900 markets | 31 % | 63 % | +32 pp |
| NULL prob_24h_before — past earnings | 401 | 54 | −347 |
| NULL prob_24h_before — future earnings | 148 | 148 | (will fill as earnings approach) |
| LONG ALL >60% sum P&L at $10K notional | +$17,831 | +$26,506 | +$8,675 |
| LONG 80-90% bucket size | 95 | 223 | +128 (+135 %) |
| LONG 95%+ bucket size | 13 | 27 | +14 (+108 %) — emerges as strong (+$192/trade) |
| SHORT 20-30% verdict | "negative -$985 / 12 markets" | +$2,870 / 25 markets | sign FLIPPED (the original was a small-sample artifact) |
| PM prediction accuracy LONG >60% | 75 % (n=249) | 71 % (n=412) | tempered with more data |
| PM prediction accuracy SHORT <30% | 88 % (n=26) | 81 % (n=27) | unchanged |
_pre_signal_quote() → no live trades affected. Only the historical earnings_markets.prob_*_before analytics columns were incomplete./trades fallback into conviction_tracker.py:backfill_historical_convictions() so it auto-fallbacks when price_snapshots is sparse. Deferred — the one-off backfill brought us to 91 % operational coverage (568 of 622 resolvable past markets), which is sufficient for current analyses.SELECT count(*) FROM earnings_markets WHERE prob_24h_before IS NULL AND earnings_date < CURRENT_DATE → was 401, now 54.scripts/backfill_conviction_from_trades.py (new — data-api /trades fallback)docs/FINDINGS.md (this entry)closed=true) (DONE)markets.volume_total. Initial hypothesis was "old condition_ids — Polymarket archived them." Probing Gamma directly proved the opposite: the data is there, our collector just doesn't ask for it.continuous_collector.sync_markets_from_gamma queried Gamma with ?active=true&archived=false. Polymarket's market state machine has FOUR flags (active, closed, archived, accepting_orders), and a recently-resolved earnings market sits in active=true closed=true archived=false for days/weeks before being archived. The active=true filter does NOT include those markets — they only appear when you explicitly query with closed=true. As a result, every earnings market silently lost its final volume_total update on the day it resolved, leaving markets.volume_total at whatever was last captured days earlier (often $0 for new markets).earnings_date in the last 60 days that had volume_total = 0 in our DB. Re-querying Gamma with closed=true recovered volume on 25/25 (100%) — total $221,606 across the sample. Examples: TSLA $59,683, HON $17,729, INTC $14,118, PG $12,175, BX $11,863, BA $15,706. All in the a=True c=True ar=False state.sync_markets_from_gamma now runs TWO passes per cycle: pass 1 (active=true&archived=false) + pass 2 (closed=true&archived=false), de-duped by condition_id with the closed-pass winning (latest settlement volume). Also fixed the status field to read from Polymarket's closed flag rather than active (was incorrectly marking settled markets as 'open').scripts/backfill_markets_volume.py (read-only by default, --apply to write). Iterates every market in earnings_markets ⨝ markets with volume_total = 0 AND earnings_date >= today−N days (default 365), tries Gamma with multiple flag combos, updates markets.volume_total + volume_24h + last_synced_at if a positive volume is found.scripts/strategy_performance_analysis.py and check Universe View with_positive_volume chip — should rise from 111 to ~280+ (most in-band markets have real volume on Polymarket).v3 D ON-HOLD verdict should refresh — the 80-90% mcap split at $2K gate now has a much larger sample (was n=43 across 4 buckets, should grow to ~100+).polymarket_opportunity_log and skipped_signals use the bot's runtime effective_cumulative_volume_usd (positive markets.volume_total else Gamma fallback per Finding #224) — those tables are unaffected because the runtime path always queries Gamma at signal time. Only the historical markets.volume_total analytics column was wrong.scripts/backfill_markets_volume.py (new, read-only safe; --apply for writes)src/data_tracker/collectors/continuous_collector.py (sync_markets_from_gamma two-pass + correct status mapping)docs/FINDINGS.md (this entry)These findings were originally documented as hardcoded dashboard cards before the FINDINGS.md consolidation (Mar 7, 2026). All are resolved/done.
Finding #1: IBKR Session Persistence - Weekly 2FA Achieved ✅ (Updated Jan 12, 2026) - Severity: Medium - Status: done - Summary: Original Issue: Current Configuration (Option A: IBC-Managed): ⚠️ CRITICAL: Session Invalidation Rules: 📊 Broker Comparison for Our Strategy: *Note: IBKR now configured for weekly 2FA only (Sundays at 11 AM Abu Dhabi). Session token preserved Mon-Sat. Recommendation: Continue with IBKR for testin...
Finding #2: Polymarket Conviction Changes Before Earnings Release (Jan 8, 2026) - Severity: Medium - Status: done - Summary: IMPORTANT OPEN Potential Solutions: Decision Needed: What should the system do if Polymarket conviction drops significantly after entry but before earnings? Current behavior: Hold until earnings release, then instant exit if wrong
Finding #3: JEF Trade Timeline Analysis (Jan 8, 2026) - Severity: Medium - Status: done - Summary: OBSERVATION MONITORING JEF Trade Facts (First Live Test):
Finding #4: Fractional Shares - IMPLEMENTED (Resolved Jan 11, 2026) - Severity: Medium - Status: done - Summary: Original Issue: ✅ Resolution (Jan 11, 2026): See Finding #28 for full implementation details.
Finding #5: Polymarket Volume Quality - $2,000 Minimum Threshold (Updated Jan 9, 2026) - Severity: Medium - Status: done - Summary: Original Issue: Threshold Configuration:
Finding #6: Earnings Release Time Uncertainty (Jan 8, 2026) - Severity: Medium - Status: done - Summary: OBSERVATION MONITORING Current Behavior: Future Enhancement: Consider adding more detection sources: SEC 8-K filings, Twitter/X, company IR pages
Finding #7: Extended Hours Liquidity & Slippage (Jan 8, 2026) - Severity: Medium - Status: done - Summary: OBSERVATION MONITORING Observation: Monitoring:
Finding #8: Historical Exchange Asymmetry (Superseded by unified stop/exit logic) (Jan 8, 2026) - Severity: Medium - Status: done - Summary: HISTORICAL CONTEXT SUPERSEDED Initial observation (JEF trade, Jan): 🎯 Strategy Impact Analysis: Unified Order Handling (UPDATED Feb 15, 2026): Key: NYSE and NASDAQ follow the same stop/instant-exit policy. Difference is now session state (open vs fully closed), not exchange. 📊 SIGNAL PRIORITY UPD...
Finding #9: Small Cap 90% Conviction Threshold Enforced (Jan 8, 2026) - Severity: Medium - Status: done - Summary: Backtest Data: Review After 100 Trades: Query skipped_signals to analyze actual outcomes of skipped small cap signals. May adjust threshold if data supports.
Finding #10: Data Integrity: price_snapshots Bug Fixed (Jan 8, 2026) - Severity: Medium - Status: done - Summary: FIXED CRITICAL Root Cause: ✅ FIX APPLIED: Data Reliability Timeline: Lesson Learned: Always go to ROOT CAUSE. Check all dependencies. Never assume data is garbage without investigation.
Finding #11: IBKR Gateway Weekly 2FA - Configuration Details ✅ (Updated Jan 12, 2026) - Severity: Medium - Status: done - Summary: VERIFIED ACTIVE Architecture (Option A: IBC-Managed): ✅ FINAL CONFIGURATION (Jan 12, 2026): Service Configuration Checklist:
Finding #12: Watchdog v3.0 - Robust Service Monitoring (Jan 9, 2026) - Severity: Medium - Status: done - Summary: DEPLOYED ACTIVE Background: ✅ WATCHDOG v3.0 FEATURES: Monitored Services: Service: polymarket-watchdog-v3.service • Interval: Every 5 minutes
Finding #13: Market Discovery - Complete Earnings Coverage (Jan 9, 2026) - Severity: Medium - Status: done - Summary: FIXED FUTURE-PROOF ✅ SOLUTION: Verification - Previously Missing Tickers Now Found: 💡 KEY INSIGHT: API-based discovery is more reliable than web scraping. The tag_id=1013 parameter was discovered by intercepting network requests from Polymarket's frontend using Playwright. See Finding #17 for the...
Finding #14: Priority System Redesign (0-6 ROI-First Scale) (Jan 9, 2026) - Severity: Medium - Status: done - Summary: Previous System: legacy scoring over-weighted very high confidence and was too coarse-grained for ROI optimization. ✅ NEW SYSTEM: 0-6 ROI-First Priority Scale Key Factors (in order): 📦 DATABASE TRACKING: Module: src/earnings/priority.py • Docs: STRATEGY.md §3.1
Finding #15: APT Daily Timer Conflict - CRITICAL ROOT CAUSE - Severity: Medium - Status: done - Summary: Finding #15: 🔴 APT Daily Timer Conflict (ROOT CAUSE) CRITICAL/FIXED 🔍 ISSUE: IBKR Gateway unexpectedly disconnected at 10:50 UTC (2:50 PM Abu Dhabi) despite being configured for auto-restart at 06:00 UTC. ROOT CAUSE IDENTIFIED: 📁 Configuration: Impact: Prevents unexpected Gateway disconnects due ...
Finding #16: IBKR Gateway Final Configuration - WEEKLY 2FA CONFIRMED - Severity: Medium - Status: done - Summary: Finding #16: ✅ IBKR Gateway Final Configuration (WEEKLY 2FA) CONFIRMED/IMPLEMENTED 🔍 ROOT CAUSE OF REPEATED 2FA: Systemd's Restart=always in ibgateway.service was interfering with Gateway's internal auto-restart, invalidating session tokens. KEY DISCOVERY (from IBKR community): ✅ FINAL CONFIGURAT...
Finding #17: Earnings Detection - Single Source of Truth - Severity: Medium - Status: done - Summary: Finding #17: ✅ Earnings Detection - Single Source of Truth CRITICAL/FIXED 🔍 PROBLEM: Dashboard and Signal Generator were using unreliable methods to detect earnings markets, potentially missing trades. ❌ OLD (UNRELIABLE) Methods: Real tag distribution: Hide From New (132), Finance (127), Economy ...
Finding #18: Earnings Timing Source Accuracy Analysis - Severity: Medium - Status: done - Summary: Finding #18: ⏰ Earnings Timing Source - HYBRID Approach (97%+ Accuracy) CRITICAL/IMPLEMENTED 🔍 PROBLEM: Yahoo Finance and Polymarket timing only agreed 79.5% of the time. Deep analysis needed to determine which source is correct. 📊 DEEP DIVE ANALYSIS: ❌ ROOT CAUSE OF 10.8% ERRORS: ✅ SOLUTION: HYB...
Finding #19: Trading Fees Recording - Severity: Medium - Status: done - Summary: Finding #19: 💰 Trading Fees Recording & Display IMPLEMENTED 🔍 REQUIREMENT: Record trading fees directly from IBKR with order types and display in dashboard trade log. ✅ IMPLEMENTATION: 📊 DATABASE SCHEMA: Note: Historical trades show "--" for fees as commission data was not captured before impleme...
Finding #20: IBKR Overnight Trading Deep Dive - Severity: Medium - Status: done - Summary: Finding #20: 🌙 IBKR Overnight Trading & Gap Risk Analysis CRITICAL/AWARENESS 🔍 QUESTION: Is overnight trading (8 PM - 4 AM) possible? How does it affect gap risk and ROI? 📊 IBKR TRADING SESSIONS (Eastern Time): ⚠️ GAP RISK ANALYSIS: ✅ CURRENT IMPLEMENTATION STATUS: 💰 ROI IMPACT ANALYSIS: Conclusi...
Finding #21: Shorts Implementation Deep Dive - Severity: Medium - Status: done - Summary: Finding #21: 📉 SHORT Selling Implementation CORE COMPLETE ✓ 🔍 QUESTION: Do we have full SHORT selling implementation? Is prioritization for shorts at the same level as longs? ✅ WHAT'S WORKING: ⚠️ REMAINING GAPS (Nice-to-Have): 📋 STRATEGY RULES FOR SHORTS (from STRATEGY.md): 🔧 IMPLEMENTATION STATU...
Finding #22: 2x Leverage Implementation - Severity: Medium - Status: done - Summary: Finding #22: 💰 2x Leverage Implementation (Target: 50 Trades) CRITICAL/PENDING 🎯 GOAL: Enable 2x Reg T margin leverage at 50/100 trades in TESTING_PROD phase. 📊 IBKR LEVERAGE OPTIONS: ⚠️ CURRENT STATE - NO LEVERAGE: 🔧 REQUIRED IMPLEMENTATION: 💵 MARGIN COST ANALYSIS (2x Leverage): Conclusion: Marg...
Finding #23: Concurrent Trade Execution - Severity: Medium - Status: done - Summary: Finding #23: ⚡ Concurrent Trade Execution (Multiple Trades Same Time) IMPLEMENTED ✓ 🎯 PROBLEM SOLVED: The system now handles unlimited concurrent trades at the same entry time with fair capital allocation and rapid execution. ✅ IMPLEMENTATION COMPLETE: 📊 PERFORMANCE COMPARISON: 🔄 EXECUTION FLOW: ...
Finding #24: System Health Check Script - Severity: Medium - Status: done - Summary: Finding #24: 🔍 Comprehensive System Health Check Script IMPLEMENTED ✓ 🎯 PURPOSE: A comprehensive testing script that verifies all system components are working correctly. Run before trading sessions to ensure system health. 📋 TESTS INCLUDED: # Quick check (no IBKR): python tests/system_health_che...
Finding #25: Sunday 2FA Fix - Severity: Medium - Status: done - Summary: Finding #25: 🔐 Sunday Weekly 2FA - Root Cause & Fix FIXED ✓ 🎯 ISSUE: Sunday Jan 11, 2026 - User received reminder notifications but 2FA request never came through. ❌ ROOT CAUSE: 📋 NEW BEHAVIOR: 2FA Frequency: Only on Sundays (weekly security token expiry). Mon-Sat restarts are automatic. Files Ch...
Finding #26: State Machine Documentation - Severity: Medium - Status: done - Summary: Finding #26: 📊 Comprehensive State Machine Documentation DOCUMENTED ✓ 🎯 PURPOSE: Complete system state documentation for all components, including states, transitions, and critical paths. 📑 COMPONENTS DOCUMENTED (11 State Machines): ✅ KEY DIAGRAMS: 📋 CRITICAL PATHS DOCUMENTED: File: docs/STATE_MA...
Finding #27: Position Sizing & Batch Order Analysis - Severity: Medium - Status: done - Summary: Finding #27: 💰 Position Sizing & Batch Order Analysis VERIFIED ✓ 🎯 PURPOSE: Verify position sizing flexibility, batch ordering, and capital reservation for concurrent positions. ✅ TEST CASE: 4 Concurrent + 2 Reserved Positions Reserved for BMO: $21,500 (GS + BK) | Total Deviation: -0.4% ✓ 🔍 KEY V...
Finding #28: Fractional Shares Implementation - Severity: Medium - Status: done - Summary: Finding #28: ✨ Fractional Shares Implementation IMPLEMENTED ✓ 🎯 PURPOSE: Eliminate capital waste from integer share rounding by implementing IBKR fractional shares support. ✅ IMPLEMENTATION COMPLETE 📊 CAPITAL EFFICIENCY IMPROVEMENT: ℹ️ HOW IT WORKS: Files: src/ibkr/client.py , src/earnings/batch_...
Finding #29: Critical Logger Bug - Missed Trades - Severity: Medium - Status: done - Summary: Finding #29: 🚨 Critical Logger Bug - Missed Trades FIXED ✓ ❌ IMPACT: 3 BMO trades missed (JPM, BK, DAL) for Jan 13 earnings 🔍 ROOT CAUSE: ✅ FIX APPLIED (Jan 12, 2026 21:07 UTC): # Added to conviction_calculator.py: import logging logger = logging.getLogger(name) ℹ️ POST-FIX VERIFICATION: Less...
Finding #30: Volume Threshold Bug - Wrong Volume Field Used - Severity: Medium - Status: done - Summary: Finding #30: 🔴 Volume Threshold Bug - Wrong Field Used FIXED ✓ ❌ IMPACT: Signal generator was using wrong volume field for threshold check Root Cause: ✅ FIX APPLIED: Lesson: Volume thresholds should use volume_total (cumulative) not volume_24h (24hr rolling). Files Fixed: src/earnings/signal_gene...
Finding #31: SHORT Signals Missing from Timeline - Severity: Medium - Status: done - Summary: Finding #31: ⚠️ SHORT Signals Missing from Timeline FIXED ✓ ⚠️ IMPACT: WFC SHORT signal (30% conviction) not showing in Active Positions Timeline The "Pending Opportunities" section only showed LONG signals (≥70% conviction), ignoring valid SHORT signals (≤30% conviction). Root Cause: ✅ FIX APPLI...
Finding #32: Three Critical Pre-Trade Bugs Fixed - Severity: Medium - Status: done - Summary: Finding #32: 🔴 Three Critical Pre-Trade Bugs - ALL FIXED FIXED ✓ 🔴 IMPACT: Signal generation returned 0 signals for tomorrow's BMO trades Pre-trade readiness check discovered 3 critical bugs that would have prevented all trades from executing. Bug #1: Conviction Calculator Entry Time Bug Bug #2: ...
Finding #33: IBKR Market Data Subscription Missing - RESOLVED - Severity: Medium - Status: done - Summary: Finding #33: ✅ IBKR Market Data Subscription - RESOLVED FIXED ✓ Added IBKR market data subscriptions. Tested SPY, BAC, BLK, GS - all returning valid prices. Original Issue: Resolution (Option A applied): Verified: Jan 15, 2026 at 10:50 AM UTC Test Results: SPY=$692.81, BAC=$52.70, BLK=$1,081.50, ...
Finding #34: TSM Timing Mismatch - RESOLVED - Severity: Medium - Status: done - Summary: Finding #34: ✅ TSM Timing Mismatch - RESOLVED FIXED ✓ TSM and other international ADRs now correctly categorized as BMO. Root Cause: Fixes Applied: Timing Priority Flow (all paths lead to BMO/AMC/INTRADAY/None): Verified: Jan 15, 2026 Test Result: TSM now correctly resolves to BMO via COMPANY_TIM...
Finding #35: CRITICAL - Duplicate Trade Entry on Service Restart - Severity: Medium - Status: done - Summary: Finding #35: 🚨 CRITICAL - Duplicate Trade Entry on Service Restart CRITICAL - TODO 🚨 Service restart during BMO window caused DUPLICATE ENTRIES MTB, STT, PNC were entered 3 times, resulting in 3x intended position size. Manual intervention required to fix. What Happened: Root Cause: Required Fix:...
Finding #36: EarningsDetector.register_position Missing - Severity: Medium - Status: done - Summary: Finding #36: 🔴 EarningsDetector.register_position Missing Method CRITICAL - TODO Fatal error: 'EarningsDetector' object has no attribute 'register_position' This crash caused the service to restart, which triggered Finding #35. Error Context: Required Fix: Priority: CRITICAL - Causes service cras...
Finding #37: Stop-Loss Orders Not Placed Due to PDT - Severity: Medium - Status: done - Summary: Finding #37: ⚠️ Stop-Loss Orders Blocked by PDT Rule MEDIUM - KNOWN LIMITATION PDT rule prevents stop-loss orders when account < $25K and day trade limit reached What Happened: Mitigation: Account Requirement: $25,000 to avoid PDT restrictions Current Balance: ~$2,700
Finding #38: Fractional Shares Check Bug - FIXED - Severity: Medium - Status: done - Summary: Finding #38: ✅ Fractional Shares Check Bug - FIXED FIXED Bug: Wrong variable used in fractional eligibility check causing TypeError Root Cause: # BEFORE (broken): primary_exchange = contract[0].primaryExchange if qualified else None # Error: 'Stock' object is not subscriptable # AFTER (fixed): pr...
Finding #39: Batch Position Sizing Oversized - FIXED - Severity: Medium - Status: done - Summary: Finding #39: ✅ Batch Position Sizing Oversized - FIXED FIXED Bug: Batch sizing distributed entire budget instead of using strategy target sizes What Was Happening: Fixed Logic: # Now correctly uses calculate_position_size() for each signal # Only scales down if total target > available budget MTB...
Finding #40: Dashboard Data Display & IBKR P&L - FIXED - Severity: Medium - Status: done - Summary: Finding #40: ✅ Dashboard Data Display & IBKR P&L - FIXED FIXED Issues: Wrong positions displayed, missing P&L, stale data Problems Found: Fixes Applied: File: earnings_dashboard/app.py - get_active_positions_from_db() Result: Dashboard now shows real-time IBKR P&L: $-9.04
Finding #41: IBKR Position Reconciliation Auto-Fix - ENHANCEMENT - Severity: Medium - Status: done - Summary: Finding #41: ✅ IBKR Reconciliation Auto-Fix - ENHANCEMENT ENHANCED Old behavior: Detect issues and alert. New: AUTO-FIX discrepancies! What It Now Does: Also Added: Files: src/earnings/trading_orchestrator.py Methods: _reconcile_positions_with_ibkr(), _persist_position_open()
Finding #42: Long Weekend / Holiday Trade Handling - ENHANCEMENT - Severity: Medium - Status: done - Summary: Finding #42: ✅ Long Weekend / Holiday Trade Handling - ENHANCEMENT ENHANCED Edge Case: Tuesday BMO after Monday holiday (MLK Day, Presidents Day, etc.) The Problem: Solution Implemented: 2026 Affected Holidays: Files: market_calendar.py, signal_generator.py, risk_manager.py, dashboard.html Docume...
Finding #43: IBKR Gateway Auto-Login on Session Timeout - ENHANCEMENT - Severity: Medium - Status: done - Summary: Finding #43: ✅ IBKR Gateway Auto-Login - ENHANCEMENT ENHANCED Root Cause: IBKR enforces ~24-hour session timeout for Gateway API connections The Problem (Last 3 Days): Solution Implemented: 2FA Still Required: Files: scripts/auto_login_gateway.py, src/ibkr/gateway_monitor.py Service: ibkr-monitor...
Finding #44: Polymarket Price-Based Earnings Detection - ENHANCEMENT - Severity: Medium - Status: done - Summary: Finding #44: ✅ Polymarket Price-Based Earnings Detection - ENHANCEMENT ENHANCED Discovery: Polymarket markets resolve 10-30 minutes FASTER than Yahoo Finance updates The Problem: Solution - First-to-Detect-Wins: Test Results (Jan 16 BMO): Files: src/earnings/earnings_detector.py, src/earnings/tra...
Finding #45: BMO Exit Timing Research - ROI vs Capital Efficiency - RESEARCH - Severity: Medium - Status: done - Summary: Finding #45: 🔍 BMO Exit Timing Research - ROI vs Capital Efficiency RESEARCH FUTURE ENHANCEMENT Original Research Data (EARNINGS_TRADING_STRATEGY.md §6.1-6.7): Key Research Findings: Current Implementation (Exit at 10:32 AM - Feb 2026): Implementation Status: Status: Research complete • Hybrid im...
Finding #46: Scheduled Exit Time Bug on Service Restart - FIXED - Severity: Medium - Status: done - Summary: Finding #46: ✅ Scheduled Exit Time Bug on Service Restart - FIXED CRITICAL BUG FIXED Root Cause: Service restart set scheduled_exit_time = now + 1 day instead of calculating from earnings timing Impact (Jan 16): Fix Implemented: Files: trading_orchestrator.py, trading_positions table Method: _cal...
Finding #47: Trade Data Integrity - Tables Desynchronized - CRITICAL FIX - Severity: Medium - Status: done - Summary: Finding #47: ✅ Trade Data Integrity - Table Desynchronization - FIXED CRITICAL BUG FIXED COMPREHENSIVE AUDIT: trading_positions and trade_audit_log were completely desynchronized Bugs Discovered: Root Cause: Fixes Applied: Files: trading_orchestrator.py, STRATEGY.md Section 7.5-7.6 Methods: _reco...
Finding #48: IBKR API Fractional Shares - cashQty Required - FIXED - Severity: Medium - Status: done - Summary: Finding #48: ✅ IBKR API Fractional Shares - cashQty Required - FIXED CRITICAL BUG FIXED Root Cause: IBKR API does NOT support fractional totalQuantity - must use cashQty (dollar amount) Why Desktop Works but API Doesn't: Fix Implemented: // WRONG (Error 10243) order = LimitOrder('BUY', Decimal('0...
Finding #49: Database Backup & Retention Policy - IMPLEMENTED - Severity: Medium - Status: done - Summary: Finding #49: ✅ Database Backup & Retention Policy - IMPLEMENTED OPERATIONS IMPLEMENTED Issue: trades table grew to 14GB causing "out of shared memory" errors on managed PostgreSQL The Problem: Solution Implemented: Backup Location: [hidden]/backups/ ├── trades_archive_20260118_165503.c...
Finding #50: Database Connection Pooling - IMPLEMENTED - Severity: Medium - Status: done - Summary: Finding #50: ✅ Database Connection Pooling - IMPLEMENTED PERFORMANCE IMPLEMENTED Problem: 36+ idle database connections consuming ~360MB memory Root Cause: Files: src/db/connection.py (new), 13 collector/trader files updated Docs: docs/STRATEGY.md (Section 7.8), docs/OPERATIONS.md (Connection Poo...
Finding #51: Whole Shares Only & Scheduled Exit Fix - FIXED - Severity: Medium - Status: done - Summary: Finding #51: ✅ Whole Shares Only & Scheduled Exit Fix - FIXED CRITICAL BUG FIXED Two Critical Bugs: cashQty unreliable + check_scheduled_exits never called Bug #1: cashQty Unreliable Bug #2: Scheduled Exits Not Triggering Files: src/ibkr/client.py, src/earnings/trading_orchestrator.py, src/earnin...
Finding #52: Smart Restart & 10 AM Abu Dhabi Schedule - IMPLEMENTED - Severity: Medium - Status: done - Summary: Finding #52: ✅ Smart Restart & 10 AM Abu Dhabi Schedule - IMPLEMENTED RELIABILITY IMPLEMENTED Root Cause: IBKR Gateway shuts down at 23:45 UTC daily (IBKR internal behavior) Fixes Implemented: New Schedule (Abu Dhabi Time): Files: /opt/ibc/config.ini, /etc/crontab, scripts/ibc_smart_restart.sh (N...
Finding #53: Position Size Flex (+57%) - IMPLEMENTED - Severity: Medium - Status: done - Summary: Finding #53: ✅ Position Size Flex (+57%) - IMPLEMENTED ENHANCEMENT IMPLEMENTED Enable trading expensive stocks by flexing position size up to +57% when needed Solution: Position Size Flex Impact (with $2,694 capital): Result: 15 of 17 stocks tradeable (88%, up from 65%) Files: src/ibkr/client.py ...
Finding #54: IBC AutoRestartTime Format Fix - ROOT CAUSE FOUND - Severity: Medium - Status: done - Summary: Finding #54: ✅ IBC AutoRestartTime Format - ROOT CAUSE FOUND ROOT CAUSE FIXED Root Cause: AutoRestartTime=06:00 (24-hour format) was being IGNORED by IBC - format must be 06:00 AM (12-hour with AM/PM) Expected Behavior After Fix: Files: /opt/ibc/config.ini (on server) Docs: docs/IBKR_CONTINUITY.m...
Finding #55: Smart Web Fallback for Earnings Timing - IMPLEMENTED - Severity: Medium - Status: done - Summary: Finding #55: ✅ Smart Web Fallback for Earnings Timing - IMPLEMENTED ENHANCEMENT IMPLEMENTED Never miss a trade due to unknown earnings timing - 5-tier lookup with web fallback Solution: 5-Tier Priority System with Web Fallback Files: src/earnings/earnings_timing_fallback.py (NEW), src/earnings/si...
Finding #56: AMC Batch Sizing Capital Reservation Bug - FIXED - Severity: Medium - Status: done - Summary: Finding #56: ✅ AMC Batch Sizing Over-Reservation - FIXED BUG FIXED Valid AMC trades were skipped because batch sizing used full 5-day BMO reservation Symptom: TXN trade at 10:34 AM skipped with "No budget available for batch sizing" Root Cause: Files: trading_orchestrator.py (run_amc_cycle) Docs:...
Finding #57: Stop-Loss Order ID Bug Fix - FIXED - Severity: Medium - Status: done - Summary: Finding #57: ✅ Stop-Loss Order ID Bug - FIXED BUG FIXED Stop-loss orders were not being placed due to attribute access error Error: 'Trade' object has no attribute 'order_id' Root Cause: Files: order_executor.py (line 616), batch_order_manager.py (line 740)
Finding #58: Duplicate Order Prevention (Pre-Order Check) - HAD REGRESSION - Severity: Medium - Status: done - Summary: Finding #58: ⚠️ Duplicate Order Prevention - HAD REGRESSION CRITICAL BUG REGRESSED → See #83 ⚠️ This fix was broken from the start - see Finding #83 for the real fix The code used .get() on PositionInfo (a dataclass, not dict) causing silent exception. Original Symptom: TXN position was 4 shares ...
Finding #59: NYSE Always STOP-LIMIT - FIXED - Severity: Medium - Status: done - Summary: Finding #59: ✅ NYSE Always STOP-LIMIT - FIXED LOGIC GAP FIXED NYSE stop-loss could fail if triggered during extended hours Fix: NYSE Always Uses STOP-LIMIT Files: order_executor.py (_place_stop_order) Docs: STRATEGY.md Section 7.1
Finding #60: Timezone-Naive Datetime Crash - FIXED - Severity: Medium - Status: done - Summary: Finding #60: ✅ Timezone Crash Fix - CRITICAL ROOT CAUSE FIXED Service crashed 4x in 5 minutes during trading - caused 4x TXN position Crash Error: Root Cause: Comprehensive Fix: Files: trading_orchestrator.py, signal_generator.py Docs: STRATEGY.md Section 7.9
Finding #61: Conviction Data Collection Gap - FIXED - Severity: Medium - Status: done - Summary: Finding #61: ✅ Conviction Data Collection - IMPLEMENTED DATA GAP FIXED Conviction data not being captured for strategy refinement Comprehensive Implementation: Backfill Results: Files: conviction_tracker.py, earnings-conviction-tracker.service Tables: earnings_markets (prob_* columns), conviction...
Finding #62: is_connected() Bug - CRITICAL FIX - Severity: Medium - Status: done - Summary: Finding #62: 🚨 is_connected() Bug - ROOT CAUSE of SBUX Stop Loss Failure CRITICAL BUG FIXED Root Cause: Code called is_connected() METHOD but IBKRClient only has connected PROPERTY The Bug (Active since Jan 16, 2026!): Impact - SBUX Trade (Jan 28): Fix Applied: Files: src/earnings/trading_orchest...
Finding #63: Startup Resilience - Retry Loop - Severity: Medium - Status: done - Summary: Finding #63: ✅ Startup Resilience - IBKR Retry Loop ENHANCEMENT IMPLEMENTED When IBKR Gateway restarts, earnings-trader now waits up to 15 minutes instead of failing immediately Solution - Exponential Backoff: Files: run_earnings_trader.py (connect_ibkr method)
Finding #64: VIX/SPY from IBKR - Severity: Medium - Status: done - Summary: Finding #64: ✅ VIX/SPY Data from IBKR (yfinance fallback) ENHANCEMENT IMPLEMENTED Market data for circuit breakers now uses IBKR first (no rate limits), yfinance as fallback Data Source Priority: Files: src/earnings/trading_orchestrator.py (_update_market_data_for_breakers, _get_ibkr_index_quote)
Finding #65: yfinance Rate Limit Tracking - Severity: Medium - Status: done - Summary: Finding #65: ✅ yfinance Rate Limit Tracking & 5min Backoff ENHANCEMENT IMPLEMENTED yfinance usage is now tracked and rate limits trigger automatic 5-minute backoff Monitoring: Files: src/earnings/trading_orchestrator.py (_log_yfinance_usage, _is_yfinance_rate_limited, _set_yfinance_rate_limited)
Finding #66: Hardware Stop Orders - Severity: Medium - Status: done - Summary: Finding #66: ✅ Hardware Stop Orders (STP LMT, outsideRth, GTC) - Updated #179 CRITICAL FIX IMPLEMENTED Stop orders now use STP LMT (STOP-LIMIT) for ALL exchanges with outsideRth=True and GTC (Finding #179 - Feb 10, 2026) Stop Order Configuration: Key Benefit: Files: src/earnings/order_executor.py...
Finding #67: Software Backup Stop Loss - Severity: Medium - Status: done - Summary: Finding #67: ✅ Software Backup Stop Loss (Two-Layer Protection) CRITICAL FIX IMPLEMENTED Two-layer stop loss: Hardware (IBKR) + Software backup (Python) for maximum safety Layer 1: Hardware Stop (IBKR) Layer 2: Software Backup (Python) Verified Working: Files: src/earnings/trading_orchestrator.py...
Finding #68: Centralized Earnings Timing - Severity: Medium - Status: done - Summary: Finding #68: ✅ Centralized Earnings Timing System ENHANCEMENT IMPLEMENTED SBUX showed AMC instead of BMO - earnings timing was hardcoded in 4+ files Solution - Single Source of Truth: Files: src/earnings/earnings_timing.py, timing_data/overrides.json, scripts/check_earnings_timing.py Docs: STRATE...
Finding #69: Database Commission Type Fix - Severity: Medium - Status: done - Summary: Finding #69: ✅ Database Commission Type Fix (float→int) BUG FIX FIXED Files: src/earnings/trading_orchestrator.py (lines 1363, 1464)
Finding #70: Reconciliation P&L Calculation - Severity: Medium - Status: done - Summary: Finding #70: 🚨 Reconciliation Was Recording $0 P&L - FIXED CRITICAL BUG FIXED Reconciliation closed orphaned positions with $0 P&L and generic notes - actual losses were hidden! Problem (SBUX Example): Fix - Now Calculates Real P&L: SBUX Record Corrected: Files: src/earnings/trading_orchestrator....
Finding #71: Dashboard Statistics Fix - Severity: Medium - Status: done - Summary: Finding #71: 🚨 Dashboard Stats Wrong (ROI negative with positive P&L) - FIXED CRITICAL BUG FIXED Dashboard showed 11 trades, -1.93% ROI with +$55.34 P&L, and inconsistent counts across tables Problems Found: Fixes Applied: Corrected Stats: Files: earnings_dashboard/app.py (3 queries updated + sum...
Finding #72: Capital Timing Bug - Severity: Medium - Status: done - Summary: Finding #72: ⏱️ Capital Timing for Position Sizing - FIXED LOGIC BUG FIXED Position sizes calculated before exits don't account for capital freed from imminent exits Example (T position today): Files: src/earnings/risk_manager.py, src/earnings/trading_orchestrator.py
Finding #73: Orphaned Stop Orders - Severity: Medium - Status: done - Summary: Finding #73: 🚨 Orphaned Stop Orders Trigger Unintended Trades - FIXED CRITICAL BUG FIXED When positions closed, stop orders were NOT cancelled → orphaned stops triggered unintended trades SBUX Incident: Files: src/earnings/trading_orchestrator.py
Finding #74: Position Size Flex Not Applied - Severity: Medium - Status: done - Summary: Finding #74: 🚨 Position Size Flex Not Applied - FIXED CRITICAL BUG FIXED The Position Size Flex feature was documented but NEVER actually applied! Root Cause: Verified (Jan 28, 2026): Files: src/ibkr/client.py (POSITION_SIZE_FLEX_MAX = 0.57)
Finding #75: Centralized yfinance Rate Limiter - Severity: Medium - Status: done - Summary: Finding #75: 📊 Centralized yfinance Rate Limiter - FIXED HIGH PRIORITY FIXED 815+ rate limit errors per day from scattered yfinance calls Fix - Centralized YFinanceRateLimiter: Files: src/utils/caching.py (YFinanceRateLimiter), signal_generator.py, trading_orchestrator.py, earnings_detector.py, e...
Finding #76: Dashboard Timing Priority Bug - Severity: Medium - Status: done - Summary: Finding #76: 🚨 Dashboard Timing Priority Wrong (MA Shows AMC Instead of BMO) - FIXED CRITICAL FIXED Dashboard showed MA as AMC, but Yahoo Finance shows BMO - timing priorities were backwards Root Cause (Agent Error): Fix (app.py lines 1605-1629): Files: earnings_dashboard/app.py (lines 1605-1629)
Finding #77: Active Positions Not Showing in Timeline - Severity: Medium - Status: done - Summary: Finding #77: 🚨 Active Positions Not Showing in Timeline - FIXED CRITICAL FIXED IBM and MA positions were not visible in the Active Positions Timeline after earnings release Files: earnings_dashboard/app.py (get_active_positions_from_db), dashboard.html (timeline_positions section)
Finding #78: Yahoo Finance Rate Limit Bypass - Severity: Medium - Status: done - Summary: Finding #78: ⚡ Yahoo Finance Rate Limit Bypass - IMPLEMENTED PERFORMANCE FIXED Dashboard slow (~2 min load) due to Yahoo Finance rate limiting New Stats Tracked: Files: src/utils/caching.py (YFinanceRateLimiter), .env.example (FINNHUB_API_KEY)
Finding #79: P&L Audit and Database Integrity Fix - Severity: Medium - Status: done - Summary: Finding #79: 🔍 P&L Audit & Database Integrity Fix - FIXED CRITICAL FIXED Dashboard P&L numbers inconsistent: History=$60.59, Header=$87.33, Unrealized=$28.74 Root Cause (IBKR as Source of Truth): Fixes Applied: After Fix: Total Realized P&L = $29.29 | Discrepancy = $0.00 Action Required: ...
Finding #80: Earnings Detection Persistence Fix - Severity: Medium - Status: done - Summary: Finding #80: 🎯 Earnings Detection Persistence Fix - FIXED CRITICAL FIXED MA/IBM earnings status not showing on dashboard despite logs showing "BEAT signal" Root Cause: Fixes Applied (trading_orchestrator.py): After Fix: MA & IBM show earnings_outcome='beat', actual_beat=True ✅ Principle: Serv...
Finding #81: NYSE Stop-Loss Order Type Fix - Severity: Medium - Status: done - Summary: Finding #81/#179: 🛡️ ALL Exchanges Stop-Loss Order Type Fix - FIXED CRITICAL FIXED Pure STOP orders are unreliable in extended hours (NYSE MARKET rejection + NASDAQ Warning 2109 outsideRth ignored) Background: Root Cause: Fix Applied: NYSE: STOP-LIMIT | NASDAQ: STOP-LIMIT ✅ Trade-off: STOP-LIMIT ...
Finding #82: Table Synchronization Share Count Fix - Severity: Medium - Status: done - Summary: Finding #82: 🔄 Table Sync Share Count Fix - FIXED DATA INTEGRITY FIXED Dashboard showed V=1 share while IBKR had 2 shares Root Cause: Fix Applied: V now shows 2 shares in both tables ✅ Principle: Both tables must always be synchronized. When fixing one, fix both.
Finding #83: Duplicate Order Prevention Fix (PositionInfo Attribute Access) - Severity: Medium - Status: done - Summary: Finding #83: 🚨 Duplicate Order Prevention Fix (Finding #58 Regression) CRITICAL BUG FIXED 🚨 Finding #58 "fix" was NEVER working - AAPL/V got 2x position size! Service restart at 9:38 AM triggered duplicate orders. Finding #58 check failed silently due to code bug. What Happened: Root Cause - Find...
Finding #84: Earnings Timing Data Source Priority Fix - Severity: Medium - Status: done - Summary: Finding #84: 📅 Earnings Timing Data Source Priority Fix DATA INTEGRITY FIXED DIS showed as BMO when it's actually AMC! External source (EarningsCalendar) returned wrong timing, overriding correct database value. Root Cause: Fix Applied: Principle: "Once verified in database, don't override from e...
Finding #85: Market Health Indicators Fix (VIX/SPX) - Severity: Medium - Status: done - Summary: Finding #85: 📊 Market Health Indicators Fix (VIX/SPX) ENHANCEMENT FIXED VIX showed "--" and SPX showed "0" most of the time yfinance API is rate-limited and unreliable, causing empty responses. Root Cause: Fix Applied: Market health indicators now always show valid data ✅ Files: app.py (get_cache...
Finding #86: Exposure Limit Not Enforced - Severity: Medium - Status: done - Summary: Finding #86: 📈 Exposure Limit Not Enforced CRITICAL FIXED Exposure reached 78.1% when max should be 70% Logs showed "Current exposure: $0" when actual positions were worth $1,170. Root Cause: Fix Applied (4 locations): Exposure now correctly calculated from actual positions ✅ Principle: When the ...
Finding #87: Earnings Detection Not Persisting (ib_insync Thread Safety) - Severity: Medium - Status: done - Summary: Finding #87: 🧵 Earnings Detection Not Persisting CRITICAL FIXED V and AAPL earnings detected but never persisted to database Polymarket showed 99.95% beat signal, callback fired, but dashboard showed no outcome. V detected 20+ times without removing from monitoring. Root Cause: Fix Applied: Earni...
Finding #88: Stop-Loss Tracking and Slippage Analysis - Severity: Medium - Status: done - Summary: Finding #88: 📊 Stop-Loss Tracking & Slippage ENHANCEMENT IMPLEMENTED XOM stop-loss exit: -2.46% actual vs -2.00% designed Needed to track slippage ($0.65 / 0.46%) and understand stop order execution. XOM Analysis: What Happened: Tracking Implemented: Market Session Values: Future trades track sto...
Finding #89: Exit Verification Ignored - Severity: Medium - Status: done - Summary: Finding #89: 🚨 Exit Verification Ignored CRITICAL BUG FIXED XOM position closed in DB but still existed in IBKR! Exit verification detected failure but code ignored it and closed position anyway. What Happened: Root Cause: Code checked if result.success (order submitted) but not result.exit_verif...
Finding #90: Root Cause Analysis - Severity: Medium - Status: done - Summary: Finding #90: 🔍 Stop Order & Market Hours Root Cause ROOT CAUSE FIXED Three root causes discovered in IBKR/DB audit: 90A: Orphaned Stops (IBM) Cancellation used openTrades() which missed PreSubmitted orders. Now uses tracked order ID with 3 retries. 90B: Duplicate Stops (V, AAPL) No duplicate chec...
Finding #91: Stop Orders Dictionary Not Populated - Severity: Medium - Status: done - Summary: Finding #91: 🔄 Stop Orders Not Repopulated on Restart ROOT CAUSE FIXED XOM LIMIT orders kept appearing on service restart stop_orders dictionary was empty after restart → software backup thought no hardware stop existed. Two Gaps Found: Fixes Applied: All positions now have stop_order_id populate...
Finding #92: Dashboard Reality Gap - IBKR Overlay Added (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: ✅ FIX APPLIED: Result: Dashboard now shows IBKR reality alongside DB state.
Finding #93: Findings Log Completeness & Dashboard Doc Sync (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: ✅ FIX APPLIED: Result: Findings are searchable, complete, and consistent with live trading logic.
Finding #94: XOM Reconciliation Drift - History vs IBKR Reality (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: ✅ FIX APPLIED: Result: History reflects only real trades; DB positions converge to IBKR reality automatically.
Finding #95: Trade Log Accuracy - Slippage, Fees, Order Types, Times (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: ✅ FIX APPLIED: Result: Trade log reflects actual execution details and consistent order types.
Finding #96: Historical Trade Backfill - IBKR Executions (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: Older trades were missing commissions, order types, and slippage because those fields were never persisted at execution time. ✅ FIX APPLIED: Result: Historical trade log reflects IBKR execution reality.
Finding #97: Flex Query Backfill - Full Historical Coverage (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: Live IBKR API only returns same-day executions, so older trades could not be fully backfilled. ✅ FIX APPLIED: Result: Historical trade log can be fully aligned to IBKR execution history.
Finding #98: Upcoming Earnings Table Regression - Gamma Batch Fetch (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: MCap blank, priority SKIP for most entries, conviction unreliable (0%/100%). ✅ FIX APPLIED: Result: Conviction, priority, and MCap tiers display correctly with reliable data.
Finding #99: Live Data Freshness & IBKR Position Truth (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: Dashboard treated DB state as live truth and silently used stale snapshot data. ✅ FIX APPLIED: Result: Live state reflects broker truth with explicit freshness controls.
Finding #100: AXP Earnings Timing Correction (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: AXP timing showed AMC in dashboard while NASDAQ listed BMO. ✅ FIX APPLIED: Result: Dashboard timing aligns with NASDAQ for AXP.
Finding #101: IBKR Execution Source‑of‑Truth Pipeline (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: Trade log drifted from IBKR reality (missing commissions, unclear order types, stop-loss slippage not visible). ✅ FIX APPLIED: Result: Trade log aligns to broker‑truth with explicit slippage handling.
Finding #102: Daily Trade Reconciliation Job (Updated Jan 31, 2026)
- Severity: Medium
- Status: done
- Summary: Original Issue: No daily check to confirm trade_audit_log stays aligned with the IBKR execution ledger. ✅ FIX APPLIED: Result: Daily alerts surface any drift between the trade log and broker‑truth.
Finding #103: IBKR Reconciliation Auto-Fix (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: Reconciliation surfaced mismatches but didn’t auto-fix or report combined trade + position status. ✅ FIX APPLIED: Result: Continuous broker‑truth reconciliation with visible auto‑fix status.
Finding #104: Entry + Stop Atomicity (IBKR Parent/Child) (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: Entry orders could be acknowledged even if the attached stop order failed, leaving positions unprotected. ✅ FIX APPLIED: Result: Entries are only confirmed when a valid protective stop is in place.
Finding #105: Batch Stop Placement Safety (Updated Jan 30, 2026) - Severity: Medium - Status: done - Summary: Original Issue: Batch entries could fill without a confirmed stop‑loss if stop placement failed, leaving positions unprotected. ✅ FIX APPLIED: Result: Batch entries are never left unprotected after fills.
/api/state includes leverage + financing blocks. /api/trade-history includes financing-aware fields.open_positions=0, ledger_rows_today=0, leverage_cols_populated=0.TESTING_PROD_SIZE_MULTIPLIER=1.0, MAX_DAILY_EXPOSURE=1.4./api/state reports max_exposure_pct=140.0.leverage_used=0.0888, margin_used=217.42.