signal_log: outcomes for every setup, event dedup, interned news; fix over-scan, dead outlook, panel OPPOSE bias

- seed an advisory outcome on every setup row; same-setup re-logs within 60 min
  are DUPLICATE rows (advisory_event_id); resolver rebuilds bar streams across gaps
- headlines/events interned into news_snapshots (DB 667 MB -> 262 MB); readers
  use signals_with_news / read_news and mode=ro (immutable=1 missed WAL rows)
- post-gate truth columns: gate_reason, actionable, cleaned_warnings, scan_kind,
  config_fingerprint; SELL-hour gates fail closed
- telegram: breach rescan reuses this bar's report; grade dropped from alert
  fingerprint (upgrade-only re-alert)
- market_outlook: hour-bucket dedup (old rule skipped alternate hours), skip heartbeats
- agent_panel: macro-anchored OPPOSE guard (opposed trades were +30R over 3 weeks)
- analyze_signal_log --stored-candles/--parity; backfill + news migration scripts
- tests: Module 25 (analyze_signal_log) + 27 (observability); 429 total

Claude-Session: https://claude.ai/code/session_01FC4s6VCURFte4dub4xKxYL
This commit is contained in:
Harwinder Sekhon
2026-09-03 20:47:33 -04:00
parent 83ffd290ca
commit b1e880be4f
13 changed files with 3120 additions and 121 deletions
+8 -7
View File
@@ -11,7 +11,7 @@ Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtest
## Commands ## Commands
```bash ```bash
python test_all.py # QA suite: 406 tests, 24 modules, no network (mocked) python test_all.py # QA suite: 430 tests, 27 modules, no network (mocked)
python main.py --pair XAUUSD --timeframe H1 --detail # CLI signal report (--json for full dump, python main.py --pair XAUUSD --timeframe H1 --detail # CLI signal report (--json for full dump,
# --panel to run the advisory LLM agent panel on-demand) # --panel to run the advisory LLM agent panel on-demand)
python telegram_bot.py # the bot (refuses to boot without credentials) python telegram_bot.py # the bot (refuses to boot without credentials)
@@ -29,7 +29,7 @@ python backtest/gpu_backtest.py --pair XAUUSD --start 2025-01-01 --end 2025-06-0
# 2026-08-05: NOT shipped — backtest/research/fixedtp_2026-08-05.md) # 2026-08-05: NOT shipped — backtest/research/fixedtp_2026-08-05.md)
``` ```
**`test_all.py` is NOT pytest** — it uses a custom `@test("name")` decorator that runs each test immediately at import; don't grep for `def test_` or try `pytest test_all.py`. Run the whole file; it prints per-module results and exits non-zero on failure. There is no way to run a single test short of commenting modules out. Tests must stay green; every new gate/knob needs tests. When patching weekend logic in tests, patch `providers.base._is_forex_weekend` — but know there are THREE independent signal-path weekend implementations (`providers/base.py`, `signal_aggregator.py`, and an inline copy in `backtest/gpu_backtest.py:_is_xau_blocked_by_calendar`) plus the advisory-only continuity copy in `advisory_outcome.py`; patching one does not reach the others. **`test_all.py` is NOT pytest** — it uses a custom `@test("name")` decorator that runs each test immediately at import; don't grep for `def test_` or try `pytest test_all.py`. Run the whole file; it prints per-module results and exits non-zero on failure. There is no way to run a single test short of commenting modules out. Tests must stay green; every new gate/knob needs tests. Signal-log fixtures: use a WEEKDAY signal bar when a test exercises candle continuity — the outcome model treats weekend and 21:00 UTC gaps as scheduled, so Saturday-dated bars never produce a gap. `_candles_json` stores `df.tail(14)`, so a test snapshot longer than 14 bars silently drops its head. When patching weekend logic in tests, patch `providers.base._is_forex_weekend` — but know there are THREE independent signal-path weekend implementations (`providers/base.py`, `signal_aggregator.py`, and an inline copy in `backtest/gpu_backtest.py:_is_xau_blocked_by_calendar`) plus the advisory-only continuity copy in `advisory_outcome.py`; patching one does not reach the others.
Python 3.12; a `venv/` exists in-repo. Dependencies in `requirements.txt` (numpy, pandas, requests, twelvedata, python-telegram-bot ≥20, websockets). Python 3.12; a `venv/` exists in-repo. Dependencies in `requirements.txt` (numpy, pandas, requests, twelvedata, python-telegram-bot ≥20, websockets).
@@ -58,12 +58,12 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
- **`providers/`** — one class per provider subclassing `BaseDataProvider` (`providers/base.py`), which owns everything the pipeline depends on: TTL+boundary cache, forming-candle drop, freshness check, validate/clean, paginated `fetch_range` (feeds the backtester from any provider), optional tick streaming. Subclasses implement only `_fetch_raw` + symbol/interval maps. Registry + `make_data_provider(config, name=None)` in `providers/__init__.py`; config.json `data_providers.active` selects the live candle feed and the optional `data_providers.streaming` scalar selects a different provider for the tick stream. A test enforces that subclasses don't override shared base methods (`supports_streaming`/`iter_ticks` are the sanctioned streaming override points). Tiingo streams mid-price ticks (`wss://api.tiingo.com/fx`) — do not re-add spread display from its free FX feed (two-tier indicative quotes, removed 2026-07-23); the no-spread rule extends to the WS stream. - **`providers/`** — one class per provider subclassing `BaseDataProvider` (`providers/base.py`), which owns everything the pipeline depends on: TTL+boundary cache, forming-candle drop, freshness check, validate/clean, paginated `fetch_range` (feeds the backtester from any provider), optional tick streaming. Subclasses implement only `_fetch_raw` + symbol/interval maps. Registry + `make_data_provider(config, name=None)` in `providers/__init__.py`; config.json `data_providers.active` selects the live candle feed and the optional `data_providers.streaming` scalar selects a different provider for the tick stream. A test enforces that subclasses don't override shared base methods (`supports_streaming`/`iter_ticks` are the sanctioned streaming override points). Tiingo streams mid-price ticks (`wss://api.tiingo.com/fx`) — do not re-add spread display from its free FX feed (two-tier indicative quotes, removed 2026-07-23); the no-spread rule extends to the WS stream.
- **`engine.py`** — `TechnicalEngine`: 9 scored indicators (ema_trend, rsi, macd, bollinger, adx, roc, keltner, divergence, candlestick) + ATR as context. Regime classification (trending_up/down, `trending` = mixed/direction-UNKNOWN, transitioning, ranging) drives per-indicator weight multipliers and regime-aware RSI reinterpretation. Compute order matters (ATR first; MACD always computed even when not a pillar — divergence needs the histogram). Private series ride on the result as `_underscore` attrs; scorers/formatters skip anything `_`-prefixed or weight-0. - **`engine.py`** — `TechnicalEngine`: 9 scored indicators (ema_trend, rsi, macd, bollinger, adx, roc, keltner, divergence, candlestick) + ATR as context. Regime classification (trending_up/down, `trending` = mixed/direction-UNKNOWN, transitioning, ranging) drives per-indicator weight multipliers and regime-aware RSI reinterpretation. Compute order matters (ATR first; MACD always computed even when not a pillar — divergence needs the histogram). Private series ride on the result as `_underscore` attrs; scorers/formatters skip anything `_`-prefixed or weight-0.
- **`signal_aggregator.py`** — MTF agreement, diminishing-decay penalty system, exhaustion hard block, quality grading, then an ordered chain of post-grade gates (session, XAU SELL quality/vol/hour, regime blocks, M5 noise gates), then three setup generators: XAU ATR (A+/A), generic swing-based, plus the M5 pullback (`XAU_PB`) and chart-pattern (`XAU_CP`, `chart_patterns.py`) entry paths. The PB/CP generators run AFTER the gate chain and force quality to "B"; the CP path deliberately accepts grade-F composites and re-checks only session/day/big-candle (via warning prefixes) plus its own SELL/vol/RSI gates — it does NOT re-check exhaustion or the M5 min-score/MTF gates. **This is validated behavior, not a bug** (2026-08-04 audit: 226/586 trades in the shipped M5 12mo book are XAU_CP, +38.9R, 104 of them exhaustion-flagged and net-positive) — changing it is a rule-1 revalidation event. `TradeSetup.strategy` selects trailing params; trail params are stamped on the setup from the *producing* config so per-TF overrides reach formatters. - **`signal_aggregator.py`** — MTF agreement, diminishing-decay penalty system, exhaustion hard block, quality grading, then an ordered chain of post-grade gates (session, XAU SELL quality/vol/hour, regime blocks, M5 noise gates), then three setup generators: XAU ATR (A+/A), generic swing-based, plus the M5 pullback (`XAU_PB`) and chart-pattern (`XAU_CP`, `chart_patterns.py`) entry paths. The PB/CP generators run AFTER the gate chain and force quality to "B"; the CP path deliberately accepts grade-F composites and re-checks only session/day/big-candle (via warning prefixes) plus its own SELL/vol/RSI gates — it does NOT re-check exhaustion or the M5 min-score/MTF gates. **This is validated behavior, not a bug** (2026-08-04 audit: 226/586 trades in the shipped M5 12mo book are XAU_CP, +38.9R, 104 of them exhaustion-flagged and net-positive) — changing it is a rule-1 revalidation event. `TradeSetup.strategy` selects trailing params; trail params are stamped on the setup from the *producing* config so per-TF overrides reach formatters.
- **`pair_signals/`** — per-pair config factories. **Always use the factory (`produce_signal` / `make_config`), never bare `PlatformConfig()`** — the factory applies the config.json overlay first, then the validated per-TF deltas (M5 hour-9 block, M15 trail arm 0.70, M15 SELL-hour block, etc.; evidence in the docstring). A test enforces factory ≡ inline-config equivalence. Confirmation map: M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1]. - **`pair_signals/`** — per-pair config factories. **Always use the factory (`produce_signal` / `make_config`), never bare `PlatformConfig()`** — the factory applies the config.json overlay first, then the validated per-TF deltas (M5 hour-9 block, M15 trail arm 0.70, M15 SELL-hour block, M5 SELL-in-transitioning block, H1 all-shorts block (2026-09-03), etc.; evidence in the docstring). A test enforces factory ≡ inline-config equivalence. Confirmation map: M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1].
- **`signal_log.py` / `advisory_outcome.py`** — SQLite signal-history logger (stdlib only). Hooked into `SignalAggregator.analyze_pair` only (the live entry point — the backtester never writes here, preserving parity), so every live scan from every surface (Telegram `telegram`, CLI `cli`, Oanda daemon `oanda_daemon`, dashboard `dashboard`) lands one row in `logs/signals.db`: signal/grade/price, MTF + trade-setup + reasons JSON, full per-TF indicator snapshots, the last 14 closed OHLCV candles per fetched TF, and the news at scan time (event risk + calendar events + scored headlines JSON, re-fetched via the NewsEngine class-level caches). Config.json `signal_log` block (`enabled`, `path`; env `SIGNAL_LOG_ENABLED`/`SIGNAL_LOG_PATH`). **Fail-safe by contract** — a logging failure can never break a scan. Panel fields arrive post-hoc: `log_signal` stashes the rowid and `agent_panel.attach_panel` writes verdict + creation/cache provenance + payload hash onto the same row. A versioned advisory-only outcome then advances from later stored closed-candle snapshots using next-open reanchoring, close-only trailing, continuity checks that allow only scheduled weekend/maintenance closures, a 300-bar timeout, and both $0.30/$0.65 cost views; it is explicitly a cleaned-mid evidence model, not account execution or raw-path backtest P&L. No rowid means silent no-op. - **`signal_log.py` / `advisory_outcome.py`** — SQLite signal-history logger (stdlib only). Hooked into `SignalAggregator.analyze_pair` only (the live entry point — the backtester never writes here, preserving parity), so every live scan from every surface (Telegram `telegram`, CLI `cli`, Oanda daemon `oanda_daemon`, dashboard `dashboard`) lands one row in `logs/signals.db`: signal/grade/price, MTF + trade-setup + reasons JSON, full per-TF indicator snapshots, the last 14 closed OHLCV candles per fetched TF, and the news at scan time (event risk + calendar events + scored headlines, re-fetched via the NewsEngine class-level caches). Config.json `signal_log` block (`enabled`, `path`, `log_blocked_scans`; env `SIGNAL_LOG_ENABLED`/`SIGNAL_LOG_PATH`/`SIGNAL_LOG_BLOCKED_SCANS`). **Fail-safe by contract** — a logging failure can never break a scan. Panel fields arrive post-hoc: `log_signal` stashes the rowid and `agent_panel.attach_panel` writes verdict + creation/cache provenance + payload hash onto the same row. **Advisory outcomes (2026-09):** EVERY row with a trade setup is seeded at log time (not only panel rows); same-setup re-logs within 60 min from any surface (`setup_key` = tf|direction|entry|stop|strategy) are `DUPLICATE` rows pointing at the canonical row via `advisory_event_id`, so sum `advisory_r_multiple` over `RESOLVED` rows only. The versioned model advances from later stored closed-candle snapshots (next-open reanchoring, intrabar peak with close-only ratchet, SL-first, scheduled weekend/21:00-UTC closures allowed, 300-bar timeout, $0.30/$0.65 views); when a snapshot no longer overlaps the outcome's last bar it rebuilds the stream from every stored snapshot of that TF before giving up. It is a cleaned-mid evidence model, not execution P&L. **Post-gate truth columns:** `gate_reason` (the warning that removed the setup), `actionable` (grade ≥ C AND setup — the `action` text is written BEFORE the gate chain and can say "STRONG SELL — High-quality setup" on a D row), `cleaned_warnings_json` (what the XAU_CP acceptance stripped), `bar_range_median` (= the misnamed `spread` column: median High-Low, NOT a broker spread), `scan_kind` (`blocked` weekend/day early returns carry no news/candles), `config_fingerprint` (gate config hash — surfaces config drift between processes). **News is content-addressed:** `headlines_json`/`events_json` are interned into `news_snapshots` (they were 73% of a 667 MB DB); read through the `signals_with_news` view or `signal_log.read_news`. Readers must open with `mode=ro`, never `immutable=1` (blind to WAL rows). Tools: `analyze_signal_log.py` (`--stored-candles` DB-only replay with event/opportunity/one-position books + persona scorecard, `--parity` telegram-vs-dashboard divergence report), `scripts/backfill_signal_outcomes.py` (seed/dedup/advance legacy rows), `scripts/migrate_signal_log_news.py` (intern legacy blobs; `--vacuum` with both writers stopped).
- **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). Optional DeepSeek enrichment via `llm_client.py` (requests-only, no SDK): batched headline sentiment overriding keyword scores, gold-direction on release surprises, and a market-summary blurb (`SignalReport.news_summary`) — **display/warning-only, keyword fallback on any failure, inert without a key**; prompts are static module constants (DeepSeek prefix-cache pricing) and requests send `thinking: disabled` (V4-Flash reasoning otherwise blows the timeout). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`) — the LLM does not change that. - **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). Optional DeepSeek enrichment via `llm_client.py` (requests-only, no SDK): batched headline sentiment overriding keyword scores, gold-direction on release surprises, and a market-summary blurb (`SignalReport.news_summary`) — **display/warning-only, keyword fallback on any failure, inert without a key**; prompts are static module constants (DeepSeek prefix-cache pricing) and requests send `thinking: disabled` (V4-Flash reasoning otherwise blows the timeout). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`) — the LLM does not change that.
- **`agent_panel.py`** — ai-hedge-fund-style advisory panel: 4 DeepSeek persona calls (macro/technical/risk/contrarian, concurrent on a panel-private pool) + 1 synthesizer producing an **ADVISORY-ONLY** consensus (`AGREE/NEUTRAL/OPPOSE` + confidence + rationale) written onto `SignalReport.panel_*` fields. Attached **post-hoc** by consumers (`telegram_bot` alert path, `main --panel`, `dashboard_publisher`) — never runs inside `analyze_from_dataframes` (backtest parity, test-locked) and no gate reads its fields. Persona payloads are role-filtered: macro gets high-impact events plus only code-scored directional news; technical gets strategy/pattern, primary indicators, MTF, levels, and ATR-normalized candles; risk gets setup strategy, strategy cost assumption, trailing semantics, volatility/events/warnings (never the median candle range as spread); contrarian gets directional news, setup, timestamped latest-closed price, indicators, reasons, and warnings. Spend is bounded by exact cache, full-evidence-identity TTL reuse, and `max_panels_per_day`; changed evidence never inherits a direction-only cached verdict. Verdict provenance logs creation time, `MISS`/`EXACT_HIT`/`TTL_REUSE`, and a SHA-256 payload hash. Prompts forbid outside knowledge and outputs remain short. - **`agent_panel.py`** — ai-hedge-fund-style advisory panel: 4 DeepSeek persona calls (macro/technical/risk/contrarian, concurrent on a panel-private pool) + 1 synthesizer producing an **ADVISORY-ONLY** consensus (`AGREE/NEUTRAL/OPPOSE` + confidence + rationale) written onto `SignalReport.panel_*` fields. Attached **post-hoc** by consumers (`telegram_bot` alert path, `main --panel`, `dashboard_publisher`) — never runs inside `analyze_from_dataframes` (backtest parity, test-locked) and no gate reads its fields. Persona payloads are role-filtered: macro gets high-impact events plus only code-scored directional news; technical gets strategy/pattern, primary indicators, MTF, levels, and ATR-normalized candles; risk gets setup strategy, strategy cost assumption, trailing semantics, volatility/events/warnings (never the median candle range as spread); contrarian gets directional news, setup, timestamped latest-closed price, indicators, reasons, and warnings. Spend is bounded by exact cache, full-evidence-identity TTL reuse, and `max_panels_per_day`; changed evidence never inherits a direction-only cached verdict (test-locked — which also means TTL reuse effectively never fires live because price/candles change every bar; the daily cap is the real spend bound). Verdict provenance logs creation time, `MISS`/`EXACT_HIT`/`TTL_REUSE`, and a SHA-256 payload hash. Prompts forbid outside knowledge and outputs remain short. **OPPOSE guard (2026-09):** the 3-week signal-log scorecard showed the contrarian/risk/technical personas oppose almost every trend entry (chair said OPPOSE 73% of the time; opposed trades made +30R, PF 2.6) while the macro persona was the only informative voice (macro=support → 88% win). `_apply_oppose_guard` therefore downgrades a chair OPPOSE to NEUTRAL unless macro opposes, or technical opposes while macro is not supportive; the synthesizer prompt states the same rule. Re-score with `analyze_signal_log.py --stored-candles` (persona scorecard) before changing it again.
- **`market_outlook.py`** — hourly DeepSeek market brief for XAU/USD, using ATR-normalized M15/H1/H4 candles, visible indicator states, levels, news, and immutable recent signal history. It returns a written **ADVISORY-ONLY** bias/setup-forming assessment and persists it independently in `outlooks` within `logs/signals.db` together with the news, indicator/regime/level, and candle evidence sent to the model. It may be sent post-scan by Telegram or requested with `main.py --outlook`; it never imports or runs inside the signal path. A lock enforces hourly dedup plus its own UTC-day cap (default 12), and provider/news failures leave a safe partial brief while LLM/database failures degrade to no outlook. - **`market_outlook.py`** — hourly DeepSeek market brief for XAU/USD, using ATR-normalized M15/H1/H4 candles, visible indicator states, levels, news, and recent signal history (read `mode=ro`). It returns a written **ADVISORY-ONLY** bias/setup-forming assessment and persists it independently in `outlooks` within `logs/signals.db` together with the news, indicator/regime/level, and candle evidence sent to the model. It may be sent post-scan by Telegram or requested with `main.py --outlook`; it never imports or runs inside the signal path. Hourly dedup is by UTC hour BUCKET (the old `now − last < 3600` rule dropped every other hour because the H1 loop fires at boundary+10 s+scan time) plus its own UTC-day cap (default 12); every silent early return logs `market outlook: skipped (...)` and an empty LLM reply logs a warning — a dark feature must be visible in `logs/telegram.log`. Provider/news failures leave a safe partial brief while LLM/database failures degrade to no outlook.
- **`telegram_bot.py`** — owner-locked bot + subscriber channel; candle-boundary auto-scan loops (M5/M15/H1/H4, boundary+10 s), alert quality threshold + fingerprint dedup (committed only after a successful send), 📰 LLM market-context line on alerts when available, WebSocket level-breach monitor fed by the streaming provider (`data_providers.streaming`, falling back to the active provider; auto-disabled when the resolved provider can't stream or has no API key — Oanda does not stream, Tiingo does). Rotating log: `logs/telegram.log`, 3-h rotation × 240 backups. - **`telegram_bot.py`** — owner-locked bot + subscriber channel; candle-boundary auto-scan loops (M5/M15/H1/H4, boundary+10 s), alert quality threshold + fingerprint dedup (committed only after a successful send; the fingerprint is signal+direction — grade is NOT part of it, a grade UPGRADE re-alerts, an A↔B flicker does not), 📰 LLM market-context line on alerts when available, WebSocket level-breach monitor fed by the streaming provider (`data_providers.streaming`, falling back to the active provider; auto-disabled when the resolved provider can't stream or has no API key — Oanda does not stream, Tiingo does). The breach handler reuses this bar's M15 report (`_cached_or_scan`, keyed by closed-bar epoch) instead of rescanning — it used to log 3.5 M15 rows per candle. Rotating log: `logs/telegram.log`, 3-h rotation × 240 backups.
- **`oanda_daemon.py` / `oanda_executor.py`** — practice-account auto-execution. Book-parity rules (see daemon docstring): no broker-side TP (trailing model — TP1/TP2 advisory), 5-bar per-TF cooldown (wall-clock approximation, so it expires over weekends where the backtester would still count bars), trail ratchets at bar close once per *observed* bar (a missed/failed cycle skips those bars entirely — the intra-gap high/low never enters the peak). Accepted divergence: one position total; orders send `positionFill=OPEN_ONLY` so a state/broker divergence cancels the order instead of silently netting. State/broker re-sync runs at boot and after any suspicious fill (`_reconcile_positions`). - **`oanda_daemon.py` / `oanda_executor.py`** — practice-account auto-execution. Book-parity rules (see daemon docstring): no broker-side TP (trailing model — TP1/TP2 advisory), 5-bar per-TF cooldown (wall-clock approximation, so it expires over weekends where the backtester would still count bars), trail ratchets at bar close once per *observed* bar (a missed/failed cycle skips those bars entirely — the intra-gap high/low never enters the peak). Accepted divergence: one position total; orders send `positionFill=OPEN_ONLY` so a state/broker divergence cancels the order instead of silently netting. State/broker re-sync runs at boot and after any suspicious fill (`_reconcile_positions`).
- **`dashboard_publisher.py` / `static/dashboard.html`** — web dashboard as a **static publisher** (live LAMP server): standalone daemon scans at candle closes via `produce_signal` and atomically writes `snapshot.json`/`news.json`/`dashboard.html` to `$DASHBOARD_OUT_DIR` (Apache serves them; page is public by owner decision). **Hard independence rule: never imports, and is never imported by, `telegram_bot`/`oanda_daemon`** — confirmation map and boundary math are deliberate local copies, so a web-side failure can only mean a stale page. Due-TF classification uses the RAW wake time (flooring wake−buffer skips every H1/H4 scan — 2026-08-02 bug). News panel reads `NewsEngine.get_fundamental_context()` plus the additive `get_week_events()` accessor (display-only; no signal path calls it); as a separate process it has its own news caches and DeepSeek daily cap. News/calendar rows are clickable (detail dialog); headlines carry `id` = sha1(headline) in news.json, and exactly one on-demand endpoint exists: `static/news_summary.php` (installed by `install_static`), which serves a DeepSeek gold-impact summary per headline — whitelist-gated against news.json ids, cache-forever in `ai/`, own fail-closed daily cap (150/day, file-based, separate from the publisher's), key at `/etc/gold-dashboard/deepseek.key`, never reads repo config. Deployment checklist: `deploy/README-dashboard.md`. - **`dashboard_publisher.py` / `static/dashboard.html`** — web dashboard as a **static publisher** (live LAMP server): standalone daemon scans at candle closes via `produce_signal` and atomically writes `snapshot.json`/`news.json`/`dashboard.html` to `$DASHBOARD_OUT_DIR` (Apache serves them; page is public by owner decision). **Hard independence rule: never imports, and is never imported by, `telegram_bot`/`oanda_daemon`** — confirmation map and boundary math are deliberate local copies, so a web-side failure can only mean a stale page. Due-TF classification uses the RAW wake time (flooring wake−buffer skips every H1/H4 scan — 2026-08-02 bug). News panel reads `NewsEngine.get_fundamental_context()` plus the additive `get_week_events()` accessor (display-only; no signal path calls it); as a separate process it has its own news caches and DeepSeek daily cap. News/calendar rows are clickable (detail dialog); headlines carry `id` = sha1(headline) in news.json, and exactly one on-demand endpoint exists: `static/news_summary.php` (installed by `install_static`), which serves a DeepSeek gold-impact summary per headline — whitelist-gated against news.json ids, cache-forever in `ai/`, own fail-closed daily cap (150/day, file-based, separate from the publisher's), key at `/etc/gold-dashboard/deepseek.key`, never reads repo config. Deployment checklist: `deploy/README-dashboard.md`.
- **`backtest/gpu_backtest.py`** — bar-by-bar replay through the real `analyze_from_dataframes` (news off). Realism model (do not regress): next-bar-open entries re-anchored to the raw price path, round-trip `--spread` charged per trade, trade resolution on RAW bars (cleaning applies only to signal-side data), same-bar SL+TP tie → SL first, gap fills at bar open, trail ratchet at bar close only. Indicators are precomputed once per TF (CuPy GPU or NumPy fallback — check the `Backend:` log line); replay fans out over worker processes and the 5-bar cooldown is applied in an ordered pass afterwards, so parallel and sequential books are identical. Parquet cache in `backtest/cache/` keyed by provider/symbol/TF/window. `backtest/*.csv` are gitignored pinned trade books from past campaigns — used for manual before/after comparison. `backtest/instructions_for_backtest.txt` lists what every backtest report must record. - **`backtest/gpu_backtest.py`** — bar-by-bar replay through the real `analyze_from_dataframes` (news off). Realism model (do not regress): next-bar-open entries re-anchored to the raw price path, round-trip `--spread` charged per trade, trade resolution on RAW bars (cleaning applies only to signal-side data), same-bar SL+TP tie → SL first, gap fills at bar open, trail ratchet at bar close only. Indicators are precomputed once per TF (CuPy GPU or NumPy fallback — check the `Backend:` log line); replay fans out over worker processes and the 5-bar cooldown is applied in an ordered pass afterwards, so parallel and sequential books are identical. Parquet cache in `backtest/cache/` keyed by provider/symbol/TF/window. `backtest/*.csv` are gitignored pinned trade books from past campaigns — used for manual before/after comparison. `backtest/instructions_for_backtest.txt` lists what every backtest report must record.
@@ -94,3 +94,4 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
- Twelve Data is signal-incompatible with Oanda (Phase 1: about 19% M5 bar-direction disagreement and a systematic ~$1 high bias; see `backtest/research/feed_divergence_2026-08-14.md`). On an Oanda candle outage, halt auto-execution rather than switching to TD candles. - Twelve Data is signal-incompatible with Oanda (Phase 1: about 19% M5 bar-direction disagreement and a systematic ~$1 high bias; see `backtest/research/feed_divergence_2026-08-14.md`). On an Oanda candle outage, halt auto-execution rather than switching to TD candles.
- Tiingo is streaming-only (`data_providers.streaming: "tiingo"`), not a designated candle fallback. Oanda-Tiingo signal parity is documented in `backtest/research/signal_parity_2026-08-14.md`; Phase 4's two-week live shadow scan remains a manual operational step. - Tiingo is streaming-only (`data_providers.streaming: "tiingo"`), not a designated candle fallback. Oanda-Tiingo signal parity is documented in `backtest/research/signal_parity_2026-08-14.md`; Phase 4's two-week live shadow scan remains a manual operational step.
- Human-facing docs in `docs/` (`user_guide.html`, `beginner_guide.html` + PDF builds) must be kept in sync when signal behavior changes. - Human-facing docs in `docs/` (`user_guide.html`, `beginner_guide.html` + PDF builds) must be kept in sync when signal behavior changes.
- 2026-09-03 signal-log campaign shipped two new per-TF gates (`backtest/research/signal_log_campaign_2026-09.md`): M5 `sell_blocked_regimes=["transitioning"]` and H1 `sell_min_quality=[]` (all H1 shorts blocked). Current-HEAD M5 canonical book is now `backtest/oanda_m5_12mo_065_nopb_20260903.csv` (557 / +112.21R / PF 1.56, halves +55.9/+56.4); H1 reference `backtest/oanda_h1_12mo_065_20260903.csv` (91 / +22.2R / PF 1.79; 5yr +49.1R PF 1.46; OOS +16.7R PF 1.74).
+53 -3
View File
@@ -253,9 +253,13 @@ Weigh the takes into ONE consensus on whether to act on the signal:
"AGREE" (the panel backs the trade), "OPPOSE" (the panel is against it), "AGREE" (the panel backs the trade), "OPPOSE" (the panel is against it),
or "NEUTRAL" (genuinely split or low conviction). The risk manager and or "NEUTRAL" (genuinely split or low conviction). The risk manager and
contrarian have intentionally defensive roles; their objections are not contrarian have intentionally defensive roles; their objections are not
automatic vetoes. Do not count repeated versions of the same warning as automatic vetoes: "OPPOSE" requires that the macro analyst opposes, or
independent evidence. Weigh role relevance, evidence quality, and that the technical analyst opposes while the macro analyst is not
confidence rather than averaging blindly. supportive. When only the defensive roles object, or the macro analyst
supports the trade, the most the panel can say is "NEUTRAL". Do not
count repeated versions
of the same warning as independent evidence. Weigh role relevance,
evidence quality, and confidence rather than averaging blindly.
Base the verdict and rationale ONLY on the takes given — do not add Base the verdict and rationale ONLY on the takes given — do not add
outside analysis. outside analysis.
Confidence scale (0-100): 80-100 the panel clearly leans one way; 50-79 Confidence scale (0-100): 80-100 the panel clearly leans one way; 50-79
@@ -674,6 +678,15 @@ def _panel_jobs(
) )
# NOTE (2026-09 audit): the TTL reuse key differs from the exact key
# only by the contrarian's bar timestamp, and every other block (price,
# ATR-normalized candles, indicator readings, drifting entry/stop) moves
# each closed bar — so TTL_REUSE never fired live (30/30 attaches MISS).
# That is the documented, test-locked decision ("changed evidence never
# inherits a direction-only cached verdict"); spend is bounded by
# max_panels_per_day instead. Relaxing the identity is an owner call.
def _panel_payload_hash( def _panel_payload_hash(
jobs: Tuple[Tuple[str, str, Dict[str, Any]], ...], jobs: Tuple[Tuple[str, str, Dict[str, Any]], ...],
ignore_bar_timestamp: bool = False, ignore_bar_timestamp: bool = False,
@@ -747,11 +760,48 @@ def run_panel(report: "SignalReport",
if verdict not in _VALID_VERDICTS or confidence is None: if verdict not in _VALID_VERDICTS or confidence is None:
return None return None
rationale = str(reply.get("rationale", "")).strip()[:240] rationale = str(reply.get("rationale", "")).strip()[:240]
verdict, confidence, rationale = _apply_oppose_guard(
verdict, confidence, rationale, takes)
return PanelVerdict(verdict=verdict, confidence=confidence, return PanelVerdict(verdict=verdict, confidence=confidence,
rationale=rationale, takes=takes, rationale=rationale, takes=takes,
created_ts=time.time(), payload_hash=payload_hash) created_ts=time.time(), payload_hash=payload_hash)
# Deterministic backstop for the chair (ADVISORY-ONLY, no gate reads it).
#
# Evidence (2026-09 signal-log audit, 133 live verdicts, 107 with a
# replayed outcome): the contrarian and risk personas opposed 117/133
# alerts each and the technical persona 88/133 — they object to the
# trend extension the engine already discounted — so the chair said
# OPPOSE 73% of the time while the OPPOSED trades made +30.1R (PF 2.6).
# The macro persona was the only informative voice: macro=support
# trades won 88% (+0.56R mean, n=69); macro=oppose won 25%; macro=neutral
# 38%. With this rule the OPPOSE bucket becomes the weakest one
# (n=29, +0.08R mean, 41% win) instead of the best. Small sample, one
# bullish episode — re-score with analyze_signal_log --stored-candles
# after more history before tightening further.
#
# Rule: OPPOSE stands only if the macro analyst opposes, or the technical
# analyst opposes while the macro analyst is not supportive. Otherwise
# the verdict is NEUTRAL (confidence capped below 50).
def _apply_oppose_guard(verdict: str, confidence: float, rationale: str,
takes: List[PersonaTake]):
if verdict != "OPPOSE":
return verdict, confidence, rationale
stance = {t.persona: t.stance for t in takes}
macro = stance.get("macro")
technical = stance.get("technical")
if macro == "oppose":
return verdict, confidence, rationale
if technical == "oppose" and macro != "support":
return verdict, confidence, rationale
note = ("The big-picture view does not object, so the panel stays "
"neutral rather than against. ")
return "NEUTRAL", min(float(confidence), 49.0), (note + rationale)[:240]
# ── Cache, budget, attach ──────────────────────────────────────────── # ── Cache, budget, attach ────────────────────────────────────────────
_state_lock = threading.Lock() _state_lock = threading.Lock()
+951
View File
@@ -0,0 +1,951 @@
#!/usr/bin/env python3
"""
analyze_signal_log.py — signal_log performance evaluator
=========================================================
READ-ONLY analysis of logs/signals.db: how are the logged live signals
and the advisory LLM panel actually performing?
Three lenses:
1. TRADE REPLAY — every logged row carrying a trade_setup is deduped
(telegram + dashboard log the same scan; a persisting signal re-fires
every bar) into unique signal EVENTS, then replayed against the RAW
Oanda price path with the backtester's own resolution code
(backtest.gpu_backtest.simulate_trades): entry at the next bar open
after the scan boundary, SL/TP distances preserved, SL-first ties,
gap fills at bar open, trail ratchet at bar close using the trail
params stamped on the setup. Dual cost reporting per repo convention:
$0.65 round trip headline, $0.30 derived analytically.
2. PANEL SCORECARD — for every row with a panel verdict, the
counterfactual: what did/would the underlying trade do? OPPOSE on a
loser = R saved; OPPOSE on a winner = R cost.
3. DIRECTIONAL HIT-RATE — non-setup BUY/SELL rows scored by forward
close-to-close return at +1h/+4h/+24h vs signal direction.
4. SURFACE PARITY (--parity) — telegram vs dashboard rows on the same
(timeframe, signal bar): signal / grade / setup / action / gate config
fingerprint must agree; a divergence means the two processes ran
different configs (2026-08-19: the dashboard applied a different
session filter for a day). Also reports rows-per-bar per source (the
over-scanning symptom).
5. STORED-CANDLE REPLAY (--stored-candles) — the same event replay, but
resolved on the closed-candle streams stored in the DB itself (no
network, no parquet): identical model to advisory_outcome.py, plus
the "opportunity" (first event per tf+direction cluster) and
one-position-per-timeframe views.
The DB is opened read-only (mode=ro — WAL-visible; the older immutable=1
URI silently missed the newest rows). Bar data for the default replay
comes from the active provider via the backtester's fetch_history
(parquet-cached). Sample-size caveat: the log started 2026-08-13, so
results are directional, not statistically significant.
Usage:
python analyze_signal_log.py # full report
python analyze_signal_log.py --csv out.csv # + per-event CSV
python analyze_signal_log.py --no-fetch # cache only
python analyze_signal_log.py --parity # surface parity only
python analyze_signal_log.py --stored-candles # DB-only replay
"""
import argparse
import json
import math
import os
import sqlite3
import sys
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Sequence, Tuple
import pandas as pd
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config import Timeframe # noqa: E402
# Dual-cost convention (CLAUDE.md): $0.65 measured-Oanda headline,
# $0.30 retained for cross-book comparability.
COST_HEADLINE = 0.65
COST_COMPARE = 0.30
DEFAULT_DB = os.path.join("logs", "signals.db")
MAX_BARS_DEFAULT = 300 # matches the backtester's trailing default
CLUSTER_GAP_DEFAULT_MIN = 60 # same setup re-logged within this = one event
# ── Data model ────────────────────────────────────────────────────────
@dataclass
class LogRow:
"""One signals.db row, decoded."""
id: int
logged_at: datetime
source: str
timeframe: str
signal: str
score: Optional[float]
quality: str
price: Optional[float]
market_regime: str
setup: Optional[dict]
panel_verdict: str
panel_confidence: Optional[float]
action: str = ""
bar_ts: Optional[str] = None # primary-TF signal bar label
gate_reason: str = ""
actionable: Optional[int] = None
config_fingerprint: str = ""
advisory_event_id: Optional[int] = None
advisory_status: str = ""
advisory_r: Optional[float] = None
scan_kind: str = ""
@dataclass
class SignalEvent:
"""A deduped actionable signal (cluster of identical re-logs)."""
rows: List[LogRow]
representative: LogRow # FIRST occurrence — when a trader acts
panel_verdict: str = ""
panel_confidence: Optional[float] = None
# Filled by replay:
exit_reason: str = ""
exit_time: Optional[pd.Timestamp] = None
bars_held: int = 0
r_headline: Optional[float] = None # R at $0.65 round trip
r_compare: Optional[float] = None # R at $0.30 round trip
mfe_r: Optional[float] = None
mae_r: Optional[float] = None
entry_fill: Optional[float] = None # actual next-open fill
# ── Loading (immutable — never writes to the DB) ─────────────────────
_COLS = ("id, logged_at, source, timeframe, signal, score, quality,"
" price, market_regime, setup_json, panel_verdict,"
" panel_confidence, action")
# Columns added by the 2026-09 observability work; absent on older DBs.
_OPTIONAL_COLS = ("gate_reason", "actionable", "config_fingerprint",
"advisory_event_id", "advisory_outcome_status",
"advisory_r_multiple", "scan_kind")
def open_readonly(db_path: str) -> sqlite3.Connection:
"""Read-only connection that still sees WAL-resident rows.
``immutable=1`` (the original choice) ignores the -wal file, so the
rows the live loggers wrote since the last checkpoint were invisible.
``mode=ro`` needs the -shm/-wal files to be readable, which they are
while a writer exists; fall back to immutable only if that fails."""
path = os.path.abspath(db_path)
try:
con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
con.execute("SELECT 1 FROM sqlite_master LIMIT 1").fetchall()
return con
except sqlite3.OperationalError:
print("WARNING: mode=ro open failed — falling back to immutable=1 "
"(newest WAL rows may be missing)")
return sqlite3.connect(f"file:{path}?immutable=1", uri=True)
def load_rows(db_path: str) -> List[LogRow]:
"""Read every signals row (read-only, WAL-visible)."""
con = open_readonly(db_path)
try:
have = {r[1] for r in con.execute("PRAGMA table_info(signals)")}
opt = [c for c in _OPTIONAL_COLS if c in have]
cols = _COLS + ("".join(", " + c for c in opt))
bar_expr = (", json_extract(indicators_json, '$.\"' || timeframe"
" || '\".timestamp')" if "indicators_json" in have
else ", NULL")
out: List[LogRow] = []
for r in con.execute(f"SELECT {cols}{bar_expr} FROM signals"
" ORDER BY id"):
setup = json.loads(r[9]) if r[9] else None
extra = dict(zip(opt, r[13:13 + len(opt)]))
out.append(LogRow(
id=r[0],
logged_at=datetime.fromisoformat(r[1]),
source=r[2], timeframe=r[3], signal=r[4], score=r[5],
quality=r[6] or "", price=r[7], market_regime=r[8] or "",
setup=setup,
panel_verdict=r[10] or "", panel_confidence=r[11],
action=r[12] or "",
bar_ts=r[-1],
gate_reason=extra.get("gate_reason") or "",
actionable=extra.get("actionable"),
config_fingerprint=extra.get("config_fingerprint") or "",
advisory_event_id=extra.get("advisory_event_id"),
advisory_status=extra.get("advisory_outcome_status") or "",
advisory_r=extra.get("advisory_r_multiple"),
scan_kind=extra.get("scan_kind") or "",
))
return out
finally:
con.close()
# ── Dedup / clustering ────────────────────────────────────────────────
def _setup_key(row: LogRow) -> Tuple:
"""Delegates to signal_log.setup_key — the SAME identity the logger
uses for advisory-event dedup, so both tools cluster identically."""
from signal_log import setup_key
return (row.timeframe, setup_key(row.setup, row.timeframe))
def cluster_setup_rows(
rows: Sequence[LogRow],
gap: timedelta = timedelta(minutes=CLUSTER_GAP_DEFAULT_MIN),
) -> List[SignalEvent]:
"""Collapse re-logs of the same setup into one SignalEvent.
Two layers of duplication exist in the log:
- telegram AND dashboard both log the same scan (seconds apart);
- a persisting signal re-fires every bar with an identical setup
(live, the one-position rule means it is taken at most once).
Rows with an identical (tf, direction, entry, SL, strategy) key and
consecutive-log gaps <= `gap` are ONE event; the representative is
the FIRST occurrence (when a trader/daemon would have acted).
"""
events: List[SignalEvent] = []
last_by_key: Dict[Tuple, SignalEvent] = {}
for row in sorted((r for r in rows if r.setup), key=lambda r: r.id):
key = _setup_key(row)
ev = last_by_key.get(key)
if ev is not None and row.logged_at - ev.rows[-1].logged_at <= gap:
ev.rows.append(row)
else:
ev = SignalEvent(rows=[row], representative=row)
events.append(ev)
last_by_key[key] = ev
# Any panel verdict on any member row attaches to the event.
if row.panel_verdict and not ev.panel_verdict:
ev.panel_verdict = row.panel_verdict
ev.panel_confidence = row.panel_confidence
return events
# ── Trade construction + replay (backtester realism, reused code) ────
def entry_bar_open(ts: datetime, tf: Timeframe) -> pd.Timestamp:
"""The bar opening at the scan boundary just before/at `ts`.
Live scans run at boundary+buffer, so flooring the log time to the
TF boundary gives the entry bar — identical to the backtester's
"first raw bar strictly after the signal bar".
"""
secs = {Timeframe.M5: 300, Timeframe.M15: 900, Timeframe.M30: 1800,
Timeframe.H1: 3600, Timeframe.H4: 14400,
Timeframe.D1: 86400}[tf]
epoch = int(ts.timestamp())
return pd.Timestamp(epoch - epoch % secs, unit="s", tz="UTC")
def build_trade(spec: LogRow, raw_df: pd.DataFrame):
"""LogRow with setup -> gpu_backtest.Trade at the next-open fill.
SL/TP distances from the logged setup are preserved relative to the
actual fill, exactly like the backtester's replay loop. Returns
(trade, risk_distance) or (None, 0) when no bar exists at/after the
entry boundary (signal too recent).
"""
from backtest.gpu_backtest import Trade
s = spec.setup
tf = Timeframe(spec.timeframe)
want = entry_bar_open(spec.logged_at, tf)
pos = int(raw_df.index.searchsorted(want))
if pos >= len(raw_df):
return None, 0.0
entry_time = raw_df.index[pos]
entry_price = float(raw_df["Open"].iloc[pos])
direction = s["direction"]
log_entry = float(s["entry"])
sl_dist = abs(log_entry - float(s["stop_loss"]))
tp1_dist = abs(float(s["take_profit_1"]) - log_entry)
tp2_dist = abs(float(s["take_profit_2"]) - log_entry)
if sl_dist <= 0:
return None, 0.0
sign = 1.0 if direction == "BUY" else -1.0
risk_pips = float(s.get("risk_pips") or 0)
pip_value = sl_dist / risk_pips if risk_pips > 0 else 0.1
trade = Trade(
pair="XAU/USD",
entry_time=entry_time,
entry_price=entry_price,
sl=entry_price - sign * sl_dist,
tp1=entry_price + sign * tp1_dist,
tp2=entry_price + sign * tp2_dist,
direction=direction,
pip_value=pip_value,
risk_pips=risk_pips,
reward_pips=float(s.get("reward_pips") or 0),
rr_planned=float(s.get("risk_reward") or 0),
quality=spec.quality,
final_score=float(spec.score or 0),
final_confidence=0.0,
market_regime=spec.market_regime,
strategy_tag=s.get("strategy") or "GENERIC",
trail_arm_r=float(s.get("trail_arm_r") or 0),
trail_dist_r=float(s.get("trail_dist_r") or 0),
)
return trade, sl_dist
def replay_events(
events: List[SignalEvent],
raw_by_tf: Dict[Timeframe, pd.DataFrame],
max_bars: int = MAX_BARS_DEFAULT,
spread: float = COST_HEADLINE,
) -> None:
"""Resolve every event on the raw price path and stamp R multiples.
Reuses backtest.gpu_backtest.simulate_trades verbatim: raw-path
resolution, SL-first ties, gap fills at open, close-only trail
ratchet with the per-setup trail params as per-trade overrides.
The $0.30 book is derived analytically (R is linear in cost).
"""
from backtest.gpu_backtest import simulate_trades
trades, owners, risks = [], [], []
for ev in events:
tf = Timeframe(ev.representative.timeframe)
raw = raw_by_tf.get(tf)
if raw is None or raw.empty:
ev.exit_reason = "NO_DATA"
continue
trade, risk = build_trade(ev.representative, raw)
if trade is None:
ev.exit_reason = "PENDING" # no bar at/after the boundary
continue
trades.append(trade)
owners.append(ev)
risks.append(risk)
# simulate per-TF so each trade's window indexes the right frame.
# Run-level trail params are placeholders: every trailing trade
# carries its own stamped overrides, which win inside
# _simulate_trailing. Static trades (arm=0) resolve SL/TP1.
# Group by (TF, exit mode): a static trade inside a trailing call
# would wrongly inherit the run-level arm.
groups: Dict[Tuple[Timeframe, bool], list] = {}
for t, ev in zip(trades, owners):
key = (Timeframe(ev.representative.timeframe), t.trail_arm_r > 0)
groups.setdefault(key, []).append(t)
for (gtf, is_trailing), gtrades in groups.items():
simulate_trades(gtrades, raw_by_tf[gtf],
max_bars=max_bars, spread=spread,
trail_arm_r=0.6 if is_trailing else 0.0,
trail_dist_r=0.75)
for trade, ev, risk in zip(trades, owners, risks):
ev.entry_fill = trade.entry_price
ev.exit_time = trade.exit_time
ev.bars_held = trade.bars_held
ev.mfe_r = trade.mfe_r
ev.mae_r = trade.mae_r
if trade.exit_reason in ("NO_DATA", ""):
ev.exit_reason = "NO_DATA"
continue
# Data ran out before max_bars → position still open, marked to
# market; distinct from a true 300-bar TIMEOUT.
if trade.exit_reason == "TIMEOUT" and trade.bars_held < max_bars:
ev.exit_reason = "OPEN"
else:
ev.exit_reason = trade.exit_reason
ev.r_headline = trade.r_multiple
ev.r_compare = trade.r_multiple + (spread - COST_COMPARE) / risk
# ── Panel scorecard ───────────────────────────────────────────────────
def panel_scorecard(
rows: Sequence[LogRow],
events: Sequence[SignalEvent],
m5: Optional[pd.DataFrame],
) -> List[dict]:
"""Counterfactual per panel verdict: did it save or cost R?
Verdicted rows with a setup join their SignalEvent outcome; rows
without a setup are scored on the +4h forward return instead.
"""
by_row: Dict[int, SignalEvent] = {}
for ev in events:
for r in ev.rows:
by_row[r.id] = ev
out = []
for row in rows:
if not row.panel_verdict:
continue
entry = {"id": row.id, "logged_at": row.logged_at,
"timeframe": row.timeframe, "signal": row.signal,
"quality": row.quality, "verdict": row.panel_verdict,
"confidence": row.panel_confidence,
"basis": "", "r_headline": None, "note": ""}
ev = by_row.get(row.id)
if ev is not None and ev.r_headline is not None:
entry["basis"] = "trade_replay"
entry["r_headline"] = ev.r_headline
entry["note"] = f"exit={ev.exit_reason}"
elif m5 is not None and row.price:
fwd = _forward_return(m5, row.logged_at, row.price, hours=4)
if fwd is not None:
sign = 1 if row.signal in ("BUY", "STRONG_BUY") else -1
entry["basis"] = "fwd_4h_return"
entry["r_headline"] = None
entry["note"] = f"4h move {sign * fwd:+.2f}$ in dir"
out.append(entry)
return out
# ── Directional hit-rate (non-setup signals) ──────────────────────────
def _forward_return(
df: pd.DataFrame, ts: datetime, price: float, hours: float
) -> Optional[float]:
"""Close-to-close move from `price` to the last close at ts+hours."""
target = pd.Timestamp(ts) + pd.Timedelta(hours=hours)
closes = df["Close"]
pos = int(df.index.searchsorted(target, side="right")) - 1
if pos < 0:
return None
return float(closes.iloc[pos]) - price
def directional_hit_rate(
rows: Sequence[LogRow],
m5: pd.DataFrame,
horizons_h: Sequence[float] = (1, 4, 24),
) -> List[dict]:
"""Forward-return direction check for non-setup BUY/SELL rows.
Cross-source dupes collapse on (tf, signal, 5-minute bucket).
Returns per-horizon aggregates plus the per-row detail under 'rows'.
"""
seen = set()
detail = []
for row in sorted(rows, key=lambda r: r.id):
if row.setup or row.signal not in (
"BUY", "STRONG_BUY", "SELL", "STRONG_SELL"):
continue
if row.price is None:
continue
bucket = int(row.logged_at.timestamp()) // 300
key = (row.timeframe, row.signal, bucket)
if key in seen:
continue
seen.add(key)
sign = 1 if row.signal in ("BUY", "STRONG_BUY") else -1
rec = {"id": row.id, "logged_at": row.logged_at,
"timeframe": row.timeframe, "signal": row.signal,
"quality": row.quality, "regime": row.market_regime}
for h in horizons_h:
fwd = _forward_return(m5, row.logged_at, row.price, h)
rec[f"fwd_{h}h"] = None if fwd is None else sign * fwd
detail.append(rec)
agg = []
for h in horizons_h:
k = f"fwd_{h}h"
vals = [d[k] for d in detail if d[k] is not None]
wins = [v for v in vals if v > 0]
agg.append({"horizon_h": h, "n": len(vals),
"hit_rate": (len(wins) / len(vals)) if vals else None,
"mean_move": (sum(vals) / len(vals)) if vals else None})
return {"aggregate": agg, "rows": detail}
# ── Data fetching (backtester fetch_history: provider + parquet) ─────
def fetch_raw_frames(
tfs: Sequence[Timeframe],
start: datetime,
end: datetime,
no_fetch: bool = False,
) -> Dict[Timeframe, pd.DataFrame]:
"""RAW (uncleaned) bars per TF — trade resolution must see spikes."""
from backtest.gpu_backtest import _cache_path, fetch_history
from pair_signals.xauusd import make_config
from providers import make_data_provider
# Factory config (never bare PlatformConfig) — fetch_range and
# validate_and_clean read lookback/refresh knobs off it.
provider = make_data_provider(make_config(Timeframe.M15))
out = {}
for tf in tfs:
if no_fetch and not _cache_path(
"XAU/USD", tf, start, end, provider.name).exists():
raise SystemExit(
f"--no-fetch: no cached {tf.value} bars for "
f"{start.date()}→{end.date()}; run without --no-fetch once")
df = fetch_history(provider, "XAU/USD", tf, start, end,
use_cache=True, clean=False)
out[tf] = df
return out
# ── Surface parity ────────────────────────────────────────────────────
def parity_report(rows: Sequence[LogRow]) -> dict:
"""Compare every surface's row on the same (timeframe, signal bar).
Groups rows by (tf, bar label). For groups with 2+ sources, the first
row per source is compared on signal / quality / setup presence /
action / config fingerprint. Returns coverage, rows-per-bar per
source (over-scanning symptom), and the divergence list."""
groups: Dict[Tuple[str, str], List[LogRow]] = {}
for r in rows:
if not r.bar_ts or r.scan_kind == "blocked":
continue
groups.setdefault((r.timeframe, str(r.bar_ts)), []).append(r)
per_bar: Dict[Tuple[str, str], List[int]] = {}
divergences = []
both = 0
for (tf, bar), members in groups.items():
by_src: Dict[str, LogRow] = {}
counts: Dict[str, int] = {}
for m in sorted(members, key=lambda x: x.id):
counts[m.source] = counts.get(m.source, 0) + 1
by_src.setdefault(m.source, m)
for src, n in counts.items():
per_bar.setdefault((src, tf), []).append(n)
if len(by_src) < 2:
continue
both += 1
srcs = sorted(by_src)
a = by_src[srcs[0]]
for src in srcs[1:]:
b = by_src[src]
diffs = []
if a.signal != b.signal:
diffs.append(f"signal {a.signal}/{b.signal}")
if a.quality != b.quality:
diffs.append(f"quality {a.quality}/{b.quality}")
if (a.setup is None) != (b.setup is None):
diffs.append("setup presence")
if a.action != b.action:
diffs.append("action")
if (a.config_fingerprint and b.config_fingerprint
and a.config_fingerprint != b.config_fingerprint):
diffs.append("config fingerprint")
if diffs:
divergences.append({
"timeframe": tf, "bar": bar, "sources": (srcs[0], src),
"ids": (a.id, b.id), "diffs": diffs,
"a_action": a.action, "b_action": b.action,
})
rows_per_bar = {
f"{src}/{tf}": sum(v) / len(v)
for (src, tf), v in per_bar.items() if v
}
return {"bars_with_2_sources": both, "n_groups": len(groups),
"rows_per_bar": rows_per_bar, "divergences": divergences}
def print_parity(rep: dict) -> None:
print("\n── Surface parity (same tf + signal bar) " + "─" * 30)
print(f" bars seen by 2+ sources: {rep['bars_with_2_sources']} "
f"of {rep['n_groups']}")
print(" rows per bar (>1.05 = the same bar was scanned more than once):")
for k, v in sorted(rep["rows_per_bar"].items()):
flag = " <-- over-scanning" if v > 1.05 else ""
print(f" {k:20} {v:.2f}{flag}")
divs = rep["divergences"]
print(f" divergences: {len(divs)}")
by_day: Dict[str, int] = {}
for d in divs:
by_day[d["bar"][:10]] = by_day.get(d["bar"][:10], 0) + 1
for day, n in sorted(by_day.items()):
print(f" {day}: {n}")
for d in divs[:25]:
print(f" {d['timeframe']:5} {d['bar']} ids {d['ids']} "
f"{'; '.join(d['diffs'])} | {d['a_action'][:32]!r} vs "
f"{d['b_action'][:32]!r}")
# ── Stored-candle replay (no network) ────────────────────────────────
_TF_SECS = {"1min": 60, "5min": 300, "15min": 900, "30min": 1800,
"1h": 3600, "4h": 14400, "1d": 86400, "1day": 86400}
def stored_streams(db_path: str) -> Dict[str, List[dict]]:
"""Union of every closed bar in candles_json per timeframe."""
con = open_readonly(db_path)
bars: Dict[str, Dict[str, dict]] = {}
try:
for (blob,) in con.execute(
"SELECT candles_json FROM signals WHERE candles_json"
" IS NOT NULL ORDER BY id"):
try:
snap = json.loads(blob)
except (TypeError, ValueError):
continue
for tf, arr in (snap or {}).items():
store = bars.setdefault(tf, {})
for b in arr or []:
ts = b.get("timestamp")
if ts:
store.setdefault(str(ts), b)
finally:
con.close()
return {tf: [v[k] for k in sorted(v)] for tf, v in bars.items()}
def replay_stored(events: List[SignalEvent],
streams: Dict[str, List[dict]],
max_bars: int = MAX_BARS_DEFAULT) -> None:
"""Resolve events with advisory_outcome's model on the stored bars.
Same next-open reanchoring, intrabar peak, close-only ratchet, SL-
first, 300-bar timeout and dual cost as the DB's own outcome column
— extended to EVERY event, not only panel rows."""
from advisory_outcome import advance_outcome, initial_outcome
for ev in events:
rep_row = ev.representative
bars = streams.get(rep_row.timeframe) or []
if not bars or not rep_row.bar_ts:
ev.exit_reason = "NO_DATA"
continue
indicators = {rep_row.timeframe: {"timestamp": rep_row.bar_ts}}
status, state = initial_outcome(rep_row.setup, indicators,
rep_row.timeframe)
if status != "PENDING":
ev.exit_reason = "NO_DATA"
continue
state["policy"]["max_hold_bars"] = max_bars
state = advance_outcome(state, bars)
prog = state.get("progress") or {}
ev.entry_fill = prog.get("entry_price")
ev.bars_held = int(prog.get("bars_held") or 0)
ev.mfe_r = prog.get("mfe_r")
ev.mae_r = prog.get("mae_r")
res = state.get("result")
if state.get("status") == "RESOLVED" and res:
ev.exit_reason = res["exit_reason"]
ev.exit_time = pd.Timestamp(res["exit_at"])
ev.r_headline = res["net_r_065"]
ev.r_compare = res["net_r_030"]
elif state.get("status") == "PENDING":
ev.exit_reason = "OPEN" if ev.entry_fill else "PENDING"
else:
ev.exit_reason = "UNRESOLVABLE"
def opportunity_view(events: Sequence[SignalEvent],
gap_min: float = 60) -> List[SignalEvent]:
"""First event per (tf, direction) cluster — consecutive same-direction
events on a timeframe within ``gap_min`` are one opportunity."""
out: List[SignalEvent] = []
last: Dict[Tuple[str, str], datetime] = {}
for ev in sorted(events, key=lambda e: e.representative.logged_at):
r = ev.representative
key = (r.timeframe, r.setup["direction"])
prev = last.get(key)
if prev is None or r.logged_at - prev > timedelta(minutes=gap_min):
out.append(ev)
last[key] = r.logged_at
return out
def one_position_view(events: Sequence[SignalEvent],
cooldown_bars: int = 5) -> List[SignalEvent]:
"""Daemon-style book: one open position per timeframe, next signal
only after exit + cooldown (wall-clock bars)."""
out: List[SignalEvent] = []
busy: Dict[str, datetime] = {}
for ev in sorted(events, key=lambda e: e.representative.logged_at):
if ev.r_headline is None or ev.exit_time is None:
continue
r = ev.representative
until = busy.get(r.timeframe)
if until is not None and r.logged_at <= until:
continue
out.append(ev)
secs = _TF_SECS.get(r.timeframe, 900) * cooldown_bars
busy[r.timeframe] = (ev.exit_time.to_pydatetime()
+ timedelta(seconds=secs))
return out
def _book_line(label: str, evs: Sequence[SignalEvent]) -> str:
closed = [e for e in evs if e.r_headline is not None]
if not closed:
return f" {label:28} n=0"
tot = sum(e.r_headline for e in closed)
wins = [e for e in closed if e.r_headline > 0]
gp = sum(e.r_headline for e in wins)
gl = -sum(e.r_headline for e in closed if e.r_headline < 0)
pf = gp / gl if gl > 0 else math.inf
return (f" {label:28} n={len(closed):3d} wins {len(wins):3d} "
f"({100 * len(wins) / len(closed):3.0f}%) "
f"total {tot:+7.2f}R @0.65 PF {pf:.2f}")
def print_stored_books(events: Sequence[SignalEvent]) -> None:
print("\n── Stored-candle replay books ($0.65 rt) " + "─" * 30)
print(_book_line("all unique events", events))
print(_book_line("opportunities (60-min)", opportunity_view(events)))
print(_book_line("one-position-per-TF", one_position_view(events)))
by_dir: Dict[str, List[SignalEvent]] = {}
for e in events:
by_dir.setdefault(e.representative.setup["direction"], []).append(e)
for d, evs in sorted(by_dir.items()):
print(_book_line(f" direction {d}", evs))
by_tf: Dict[str, List[SignalEvent]] = {}
for e in events:
by_tf.setdefault(e.representative.timeframe, []).append(e)
for tf, evs in sorted(by_tf.items()):
print(_book_line(f" timeframe {tf}", evs))
# ── Reporting ─────────────────────────────────────────────────────────
def _fmt_r(r: Optional[float]) -> str:
return " n/a " if r is None else f"{r:+.2f}R"
def persona_scorecard(rows: Sequence[LogRow], events: Sequence[SignalEvent],
db_path: str) -> List[dict]:
"""Per persona × stance: n, mean R and win rate of the underlying
event (needs panel_takes_json — read straight from the DB)."""
by_row: Dict[int, SignalEvent] = {}
for ev in events:
for r in ev.rows:
by_row[r.id] = ev
con = open_readonly(db_path)
agg: Dict[Tuple[str, str], List[float]] = {}
try:
for rid, takes in con.execute(
"SELECT id, panel_takes_json FROM signals"
" WHERE panel_takes_json IS NOT NULL"):
ev = by_row.get(rid)
if ev is None or ev.r_headline is None:
continue
try:
for t in json.loads(takes):
agg.setdefault((t.get("persona", "?"),
t.get("stance", "?")), []).append(
ev.r_headline)
except (TypeError, ValueError):
continue
finally:
con.close()
return [{"persona": p, "stance": s, "n": len(v),
"mean_r": sum(v) / len(v),
"win_rate": sum(1 for x in v if x > 0) / len(v)}
for (p, s), v in sorted(agg.items())]
def print_report(
rows: List[LogRow],
events: List[SignalEvent],
panel: List[dict],
hit: dict,
) -> None:
print("=" * 72)
print("SIGNAL LOG PERFORMANCE REPORT")
print("=" * 72)
if rows:
print(f"Window: {rows[0].logged_at:%Y-%m-%d %H:%M} → "
f"{rows[-1].logged_at:%Y-%m-%d %H:%M} UTC "
f"({(rows[-1].logged_at - rows[0].logged_at).days + 1} days)")
by_src: Dict[str, int] = {}
by_tf: Dict[str, int] = {}
for r in rows:
by_src[r.source] = by_src.get(r.source, 0) + 1
by_tf[r.timeframe] = by_tf.get(r.timeframe, 0) + 1
print(f"Rows: {len(rows)} by source {by_src} by tf {by_tf}")
n_setup = sum(1 for r in rows if r.setup)
print(f"Rows with trade setup: {n_setup} → "
f"{len(events)} unique signal events after dedup")
print("\n── Trade replay (next-open fill, raw path, trail per setup, "
f"cost ${COST_HEADLINE:.2f} rt) " + "─" * 8)
hdr = (f"{'first logged':16} {'tf':5} {'dir':11} {'q':2} {'strategy':8}"
f" {'fill':>9} {'exit':7} {'bars':>4} {'R@0.65':>8} {'R@0.30':>8}"
f" {'MFE':>6} {'MAE':>6} panel")
print(hdr)
print("-" * len(hdr))
for ev in events:
rep = ev.representative
print(f"{rep.logged_at:%m-%d %H:%M} "
f"{rep.timeframe:5} {rep.signal:11} {rep.quality:2} "
f"{(rep.setup.get('strategy') or ''):8} "
f"{ev.entry_fill if ev.entry_fill else float('nan'):9.2f} "
f"{ev.exit_reason:7} {ev.bars_held:4} "
f"{_fmt_r(ev.r_headline):>8} {_fmt_r(ev.r_compare):>8} "
f"{(f'{ev.mfe_r:.2f}' if ev.mfe_r is not None else 'n/a'):>6} "
f"{(f'{ev.mae_r:.2f}' if ev.mae_r is not None else 'n/a'):>6} "
f"{ev.panel_verdict or ''}")
closed = [e for e in events if e.r_headline is not None]
if closed:
tot = sum(e.r_headline for e in closed)
tot30 = sum(e.r_compare for e in closed)
wins = [e for e in closed if e.r_headline > 0]
gp = sum(e.r_headline for e in wins)
gl = -sum(e.r_headline for e in closed if e.r_headline < 0)
pf = gp / gl if gl > 0 else math.inf
print(f"\nClosed: {len(closed)} wins: {len(wins)} "
f"({100 * len(wins) / len(closed):.0f}%) "
f"total {tot:+.2f}R @0.65 / {tot30:+.2f}R @0.30 PF {pf:.2f}")
by_q: Dict[str, List[SignalEvent]] = {}
for e in closed:
by_q.setdefault(e.representative.quality, []).append(e)
for q, grp in sorted(by_q.items()):
print(f" grade {q}: n={len(grp)} "
f"total {sum(e.r_headline for e in grp):+.2f}R")
n_open = sum(1 for e in events if e.exit_reason == "OPEN")
n_pend = sum(1 for e in events if e.exit_reason in ("PENDING", "NO_DATA"))
if n_open or n_pend:
print(f" ({n_open} still open / mark-to-market, "
f"{n_pend} pending — insufficient bars yet)")
print("\n── Advisory panel scorecard " + "─" * 42)
if not panel:
print(" no panel verdicts logged yet")
for p in panel:
r = p["r_headline"]
if r is None:
impact = p["note"] or "no outcome yet"
elif p["verdict"] == "OPPOSE":
impact = f"saved {-r:+.2f}R" if r < 0 else f"cost {r:+.2f}R"
elif p["verdict"] == "AGREE":
impact = f"confirmed {r:+.2f}R"
else:
impact = f"passed, trade {r:+.2f}R"
print(f" #{p['id']:<5} {p['logged_at']:%m-%d %H:%M} "
f"{p['timeframe']:5} {p['signal']:11} {p['quality']:2} "
f"{p['verdict']:7} @{p['confidence'] or 0:3.0f}% "
f"[{p['basis'] or 'pending'}] {impact}")
print("\n── Directional hit-rate (non-setup signals, M5 closes) " + "─" * 12)
for a in hit["aggregate"]:
if a["n"] == 0:
print(f" +{a['horizon_h']}h: no data")
continue
print(f" +{a['horizon_h']}h: n={a['n']} "
f"hit-rate {100 * a['hit_rate']:.0f}% "
f"mean move in dir {a['mean_move']:+.2f}$")
print("\nCaveat: log started 2026-08-13 — small sample, directional "
"read only.")
def export_csv(events: Sequence[SignalEvent], path: str) -> None:
recs = []
for ev in events:
rep = ev.representative
s = rep.setup
recs.append({
"row_ids": ",".join(str(r.id) for r in ev.rows),
"first_logged": rep.logged_at.isoformat(),
"timeframe": rep.timeframe, "signal": rep.signal,
"quality": rep.quality, "score": rep.score,
"strategy": s.get("strategy"),
"logged_entry": s.get("entry"), "fill": ev.entry_fill,
"sl": s.get("stop_loss"), "tp1": s.get("take_profit_1"),
"trail_arm_r": s.get("trail_arm_r"),
"trail_dist_r": s.get("trail_dist_r"),
"exit_reason": ev.exit_reason,
"exit_time": ev.exit_time.isoformat() if ev.exit_time is not None else "",
"bars_held": ev.bars_held,
"r_at_0.65": ev.r_headline, "r_at_0.30": ev.r_compare,
"mfe_r": ev.mfe_r, "mae_r": ev.mae_r,
"panel_verdict": ev.panel_verdict,
"panel_confidence": ev.panel_confidence,
})
pd.DataFrame(recs).to_csv(path, index=False)
print(f"\nWrote {len(recs)} events to {path}")
# ── main ──────────────────────────────────────────────────────────────
def main(argv: Optional[Sequence[str]] = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--db", default=DEFAULT_DB)
ap.add_argument("--csv", default=None, help="per-event CSV export")
ap.add_argument("--max-bars", type=int, default=MAX_BARS_DEFAULT)
ap.add_argument("--cluster-gap-min", type=float,
default=CLUSTER_GAP_DEFAULT_MIN)
ap.add_argument("--no-fetch", action="store_true",
help="parquet cache only, never hit the network")
ap.add_argument("--parity", action="store_true",
help="surface parity report only (no bar data needed)")
ap.add_argument("--stored-candles", action="store_true",
help="replay on the candle streams stored in the DB "
"(no network) instead of provider history")
args = ap.parse_args(argv)
rows = load_rows(args.db)
if not rows:
print(f"No rows in {args.db}")
return 0
if args.parity:
print_parity(parity_report(rows))
return 0
events = cluster_setup_rows(
rows, gap=timedelta(minutes=args.cluster_gap_min))
if args.stored_candles:
streams = stored_streams(args.db)
replay_stored(events, streams, max_bars=args.max_bars)
panel = panel_scorecard(rows, events, None)
m5 = streams.get(Timeframe.M5.value) or []
m5_df = pd.DataFrame(m5)
if not m5_df.empty:
m5_df.index = pd.to_datetime(m5_df["timestamp"], utc=True)
m5_df = m5_df.rename(columns={"close": "Close"})
hit = directional_hit_rate(rows, m5_df) if not m5_df.empty else \
{"aggregate": [], "rows": []}
print_report(rows, events, panel, hit)
print_stored_books(events)
print("\n── Persona scorecard (stance × event outcome) " + "─" * 22)
for p in persona_scorecard(rows, events, args.db):
print(f" {p['persona']:11} {p['stance']:8} n={p['n']:3d} "
f"mean {p['mean_r']:+.3f}R win {100 * p['win_rate']:.0f}%")
print_parity(parity_report(rows))
if args.csv:
export_csv(events, args.csv)
return 0
tfs = {Timeframe(r.timeframe) for r in rows if r.setup}
tfs.add(Timeframe.M5) # hit-rate + panel fallback frame
start = (rows[0].logged_at - timedelta(days=1)).replace(
hour=0, minute=0, second=0, microsecond=0)
end = datetime.now(timezone.utc)
raw = fetch_raw_frames(sorted(tfs, key=lambda t: t.value),
start, end, no_fetch=args.no_fetch)
replay_events(events, raw, max_bars=args.max_bars)
panel = panel_scorecard(rows, events, raw.get(Timeframe.M5))
hit = directional_hit_rate(rows, raw[Timeframe.M5])
print_report(rows, events, panel, hit)
print_parity(parity_report(rows))
if args.csv:
export_csv(events, args.csv)
return 0
if __name__ == "__main__":
sys.exit(main())
+16 -1
View File
@@ -101,7 +101,7 @@ _KNOWN_AGENT_PANEL = {
"candles_enabled", "candle_bars_primary", "candle_bars_confirm", "candles_enabled", "candle_bars_primary", "candle_bars_confirm",
} }
_KNOWN_MARKET_OUTLOOK = {"enabled", "telegram", "max_per_day", "candles_bars"} _KNOWN_MARKET_OUTLOOK = {"enabled", "telegram", "max_per_day", "candles_bars"}
_KNOWN_SIGNAL_LOG = {"enabled", "path"} _KNOWN_SIGNAL_LOG = {"enabled", "path", "log_blocked_scans"}
_KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"} _KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"}
_KNOWN_SCANNING = {"pairs", "timeframes"} _KNOWN_SCANNING = {"pairs", "timeframes"}
_KNOWN_PAIR_KEYS = {"blocked_hours_utc"} _KNOWN_PAIR_KEYS = {"blocked_hours_utc"}
@@ -355,6 +355,10 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
or not slog["path"]): or not slog["path"]):
raise AppConfigError( raise AppConfigError(
"config.json: signal_log.path must be a non-empty string") "config.json: signal_log.path must be a non-empty string")
if ("log_blocked_scans" in slog
and not isinstance(slog["log_blocked_scans"], bool)):
raise AppConfigError(
"config.json: signal_log.log_blocked_scans must be true or false")
app.signal_log_settings = dict(slog) app.signal_log_settings = dict(slog)
level = raw.get("log_level", app.log_level) level = raw.get("log_level", app.log_level)
@@ -478,6 +482,17 @@ def signal_log_enabled(app: Optional[AppConfig] = None) -> bool:
return bool(app.signal_log_settings.get("enabled", True)) return bool(app.signal_log_settings.get("enabled", True))
def signal_log_blocked_scans(app: Optional[AppConfig] = None) -> bool:
"""Whether weekend/day-filter early returns are logged (as minimal
rows without news/candles). Default ON — they are liveness evidence;
turn off to keep the history table to real scans only."""
env = os.environ.get("SIGNAL_LOG_BLOCKED_SCANS")
if env:
return env.strip().lower() in ("1", "true", "yes", "on")
app = app or get_app_config()
return bool(app.signal_log_settings.get("log_blocked_scans", True))
def signal_log_path(app: Optional[AppConfig] = None) -> str: def signal_log_path(app: Optional[AppConfig] = None) -> str:
"""Absolute path to the SQLite signal-history DB. A relative config """Absolute path to the SQLite signal-history DB. A relative config
value resolves against the repo root so CWD never matters.""" value resolves against the repo root so CWD never matters."""
@@ -0,0 +1,113 @@
# Signal-log live performance evaluation — 2026-08-18
**Tool:** `analyze_signal_log.py` (new, repo root). Read-only on
`logs/signals.db` (sqlite `immutable=1`); replays every logged trade
setup through the backtester's own `simulate_trades` on the RAW Oanda
price path (next-open fill, SL/TP distances preserved, SL-first ties,
gap-at-open fills, close-only trail ratchet with the per-setup stamped
trail params). Reproduce:
```bash
python analyze_signal_log.py --csv backtest/signal_log_eval_20260818.csv
```
## Window & sample
- Log window: **2026-08-13 14:31 → 2026-08-18 14:13 UTC (5 days)** —
the entire signal_log history to date.
- 2,654 rows (telegram 2,180 / dashboard 474); M5 1,308, M15 1,189,
H1 119, H4 38.
- 39 rows carry a `trade_setup` → **17 unique signal events** after
dedup (cross-source double-logs + persisting-signal re-fires collapse
on (tf, direction, entry, SL, strategy) with a 60-min gap).
- **No real executions exist** (`oanda_state.json` empty, no
`oanda_daemon` rows) — every outcome below is a hypothetical replayed
fill, not a broker fill.
- Cost: dual convention — **$0.65 rt headline**, $0.30 comparative
(derived analytically, R is linear in cost).
## Trade replay results
| First logged | TF | Signal | Grade | Strategy | Fill | Exit | Bars | R@0.65 |
|---|---|---|---|---|---|---|---|---|
| 08-13 15:30 | 5min | SELL | B | GENERIC | 4365.39 | TRAIL | 19 | −0.20 |
| 08-13 18:00 | 15min | SELL | B | GENERIC | 4356.05 | TRAIL | 44 | +0.91 |
| 08-14 07:45 | 5min | BUY | B | XAU_CP | 4339.72 | TRAIL | 60 | +2.56 |
| 08-14 12:15 | 15min | BUY | B | GENERIC | 4371.91 | TRAIL | 27 | +0.06 |
| 08-14 12:25 | 5min | STRONG_BUY | A | GENERIC | 4379.12 | TRAIL | 277 | +0.20 |
| 08-14 12:30 | 5min | STRONG_BUY | A | GENERIC | 4379.87 | TRAIL | 276 | +0.16 |
| 08-14 12:30 | 15min | BUY | B | GENERIC | 4379.87 | TRAIL | 63 | +0.18 |
| 08-17 08:00 | 1h | BUY | C | GENERIC | 4405.19 | TRAIL | 18 | −0.13 |
| 08-17 14:15 | 5min | STRONG_BUY | A | GENERIC | 4412.35 | TRAIL | 46 | −0.10 |
| 08-17 14:20 | 5min | STRONG_BUY | A | GENERIC | 4418.55 | TRAIL | 125 | −0.23 |
| 08-17 14:25 | 5min | STRONG_BUY | A | GENERIC | 4424.10 | SL | 151 | −1.02 |
| 08-17 14:30 | 15min | BUY | A | GENERIC | 4418.23 | SL | 17 | −1.03 |
| 08-17 14:45 | 15min | STRONG_BUY | A | GENERIC | 4417.49 | SL | 16 | −1.03 |
| 08-17 15:00 | 15min | STRONG_BUY | A | GENERIC | 4417.48 | SL | 15 | −1.03 |
| 08-17 15:00 | 5min | NEUTRAL | C | GENERIC | 4417.48 | TRAIL | 117 | −0.16 |
| 08-17 15:15 | 15min | STRONG_BUY | A | GENERIC | 4421.30 | SL | 14 | −1.04 |
| 08-17 15:30 | 15min | BUY | B | GENERIC | 4425.70 | SL | 85 | −1.02 |
**Aggregate: 17 closed, 6 wins (35%), total −2.91R @0.65 / −2.67R
@0.30, PF 0.58.** By grade: A n=9 → −5.12R; B n=6 → +2.50R; C n=2 →
−0.30R.
Reading: the book is two regimes. Everything through the 08-14 rally
made money (+4.7R across 8 events, incl. the XAU_CP +2.56R); the 08-17
afternoon BUY/STRONG_BUY cluster chased the spike to ~4425 and got
stopped as price faded — 9 events, −7.6R combined. One bad afternoon
dominates the 5-day sample. Notable: a NEUTRAL-graded row carried a
setup (post-gate CP/PB-style path) and lost small.
## Advisory panel scorecard
Only **7 verdicts** logged so far (2 NEUTRAL, 5 OPPOSE, 0 AGREE) — the
panel's min-quality gate + daily budget keep it dark on most scans.
| Row | When | Signal | Verdict | Outcome basis | Impact |
|---|---|---|---|---|---|
| #1358 | 08-17 07:40 M5 | BUY B | NEUTRAL @55% | +4h move | −$8.19 in dir (pass was right) |
| #1361 | 08-17 07:45 M5 | BUY A | NEUTRAL @55% | +4h move | −$12.43 in dir (pass was right) |
| #1550 | 08-17 14:15 M5 | STRONG_BUY A | OPPOSE @80% | replay | saved 0.10R |
| #1553 | 08-17 14:25 M5 | STRONG_BUY A | OPPOSE @80% | replay | saved 1.02R |
| #1555 | 08-17 14:30 M15 | BUY A | OPPOSE @75% | replay | saved 1.03R |
| #1561 | 08-17 14:45 M15 | STRONG_BUY A | OPPOSE @75% | replay | saved 1.03R |
| #1588 | 08-17 15:30 M15 | BUY B | OPPOSE @75% | replay | saved 1.02R |
**Every OPPOSE was correct: ≈ +4.2R would have been lost taking those
five trades.** The panel's rationale ("price climbed too fast, profit
too small") correctly identified the 08-17 exhaustion spike that the
composite scored A. Both NEUTRAL passes also preceded adverse moves.
Caveat: n=7, one market episode, zero AGREE verdicts — the panel has
only proven itself as a veto so far, never as a confirmation.
## Directional hit-rate (non-setup signals)
27 deduped non-setup BUY/SELL rows, forward close-to-close on M5:
| Horizon | Hit rate | Mean move in direction |
|---|---|---|
| +1h | 48% | −$0.72 |
| +4h | 44% | −$2.30 |
| +24h | 7% | −$27.45 |
The 24h figure is dominated by the 08-17 buy-cluster reversal; not
independently meaningful at n=27.
## Method notes / caveats
- **Small sample**: 5 days, 17 events, one trending episode + one
reversal. Directional read only; do not tune anything on this.
- Entry model = next bar open after the scan boundary (live scans run
at boundary+10s), SL/TP distances preserved from the logged setup —
identical to the backtester replay loop.
- Cross-source dedup verified: telegram+dashboard double-logs and
5-min re-fires of a persisting signal collapse to one event each.
- The stray repo-root `signals.db` (828 rows, Aug 13–14, pre-panel
schema) is an early duplicate subset of `logs/signals.db` — excluded.
- Tests: Module 25 in `test_all.py` was only added on 2026-09-03 (the
2026-08-18 claim of 7 tests was never true at HEAD). It now covers
boundary flooring, clustering (shared `signal_log.setup_key`), trade
building, the stored-candle replay books, surface parity, WAL-visible
loading (`mode=ro`; `immutable=1` missed the newest rows) and hit-rate
signing.
+2 -1
View File
@@ -59,7 +59,8 @@
}, },
"signal_log": { "signal_log": {
"enabled": true, "enabled": true,
"path": "logs/signals.db" "path": "logs/signals.db",
"log_blocked_scans": true
}, },
"log_level": "INFO" "log_level": "INFO"
} }
+39 -7
View File
@@ -253,7 +253,9 @@ def _recent_signals() -> List[Dict[str, Any]]:
"""Read the latest signal history through SQLite's immutable URI.""" """Read the latest signal history through SQLite's immutable URI."""
try: try:
path = os.path.abspath(signal_log_path()) path = os.path.abspath(signal_log_path())
uri = f"file:{path}?immutable=1" # mode=ro (not immutable=1): immutable ignores the WAL, so the
# newest rows written by the live loggers were invisible here.
uri = f"file:{path}?mode=ro"
con = sqlite3.connect(uri, uri=True) con = sqlite3.connect(uri, uri=True)
try: try:
rows = con.execute( rows = con.execute(
@@ -328,28 +330,44 @@ def _collect_payload(settings: Dict[str, Any]) -> Dict[str, Any]:
_state_lock = threading.Lock() _state_lock = threading.Lock()
_last_run_ts: Optional[float] = None _last_run_ts: Optional[float] = None
_last_run_hour: Optional[int] = None # UTC hour bucket of the last attempt
_outlook_day: Optional[_dt.date] = None _outlook_day: Optional[_dt.date] = None
_outlook_count = 0 _outlook_count = 0
_warned_budget = False _warned_budget = False
def _skip(reason: str) -> None:
"""Heartbeat for every silent early return — a dark outlook feature
(zero rows for 15 days, 2026-08/09) must be visible in the log."""
logger.info("market outlook: skipped (%s)", reason)
def reset_outlook_state() -> None: def reset_outlook_state() -> None:
"""Drop hourly and daily state (tests / deployment config reloads).""" """Drop hourly and daily state (tests / deployment config reloads)."""
global _last_run_ts, _outlook_day, _outlook_count, _warned_budget global _last_run_ts, _last_run_hour, _outlook_day, _outlook_count
global _warned_budget
with _state_lock: with _state_lock:
_last_run_ts = None _last_run_ts = None
_last_run_hour = None
_outlook_day = None _outlook_day = None
_outlook_count = 0 _outlook_count = 0
_warned_budget = False _warned_budget = False
def _begin_attempt(cap: int, force: bool) -> bool: def _begin_attempt(cap: int, force: bool) -> bool:
"""Atomically apply hourly dedup, then reserve a daily attempt.""" """Atomically apply hourly dedup, then reserve a daily attempt.
global _last_run_ts, _outlook_day, _outlook_count, _warned_budget
Hourly dedup is by UTC hour BUCKET, not ``now - last < 3600``: the
H1 loop calls this at boundary+10 s+scan time, so consecutive hours
were routinely 3,590 s apart and every other hour was dropped."""
global _last_run_ts, _last_run_hour, _outlook_day, _outlook_count
global _warned_budget
now = time.time() now = time.time()
hour_bucket = int(now // 3600)
today = _dt.datetime.now(_dt.timezone.utc).date() today = _dt.datetime.now(_dt.timezone.utc).date()
with _state_lock: with _state_lock:
if not force and _last_run_ts is not None and now - _last_run_ts < 3600: if not force and _last_run_hour == hour_bucket:
_skip(f"hour bucket {hour_bucket} already attempted")
return False return False
if _outlook_day != today: if _outlook_day != today:
_outlook_day = today _outlook_day = today
@@ -361,9 +379,12 @@ def _begin_attempt(cap: int, force: bool) -> bool:
logger.warning( logger.warning(
"market outlook: daily budget (%d) exhausted — no more " "market outlook: daily budget (%d) exhausted — no more "
"outlooks until UTC midnight", cap) "outlooks until UTC midnight", cap)
else:
_skip("daily budget exhausted")
return False return False
_outlook_count += 1 _outlook_count += 1
_last_run_ts = now _last_run_ts = now
_last_run_hour = hour_bucket
return True return True
@@ -372,8 +393,13 @@ def run_outlook(settings: Dict[str, Any],
"""Build, ask, validate, and persist one advisory outlook.""" """Build, ask, validate, and persist one advisory outlook."""
try: try:
payload = _collect_payload(settings) payload = _collect_payload(settings)
result = _validate_outlook(get_llm_client().chat_json(SYSTEM_PROMPT, payload)) reply = get_llm_client().chat_json(SYSTEM_PROMPT, payload)
result = _validate_outlook(reply)
if result is None: if result is None:
logger.warning(
"market outlook: no outlook produced (%s)",
"LLM returned nothing — cap/timeout/disabled" if reply is None
else "reply failed validation")
return None return None
try: try:
from signal_log import log_outlook from signal_log import log_outlook
@@ -396,13 +422,19 @@ def maybe_run_outlook(surface: str, force: bool = False) -> Optional[OutlookResu
settings = get_app_config().market_outlook_settings settings = get_app_config().market_outlook_settings
if not force: if not force:
if not market_outlook_enabled(): if not market_outlook_enabled():
_skip("disabled")
return None return None
if surface == "telegram" and not settings.get("telegram", False): if surface == "telegram" and not settings.get("telegram", False):
_skip("telegram surface off")
return None return None
if _provider_base._is_forex_weekend(): if _provider_base._is_forex_weekend():
_skip("weekend")
return None return None
cap = int(settings.get("max_per_day", DEFAULT_MAX_PER_DAY)) cap = int(settings.get("max_per_day", DEFAULT_MAX_PER_DAY))
if cap < 1 or not _begin_attempt(cap, force): if cap < 1:
_skip("max_per_day < 1")
return None
if not _begin_attempt(cap, force):
return None return None
return run_outlook(settings, surface=surface) return run_outlook(settings, surface=surface)
except Exception: except Exception:
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
backfill_signal_outcomes.py — seed and advance advisory outcomes for every
logged trade setup (not only panel rows), one per advisory event.
Pass A (offline, no network): assign setup_key / advisory_event_id to all
legacy setup rows, mark same-setup re-logs within 60 min as DUPLICATE, and
seed a PENDING outcome on each canonical row that has none. Existing
(panel-seeded) outcomes are never overwritten.
Pass B: advance PENDING rows from the closed-candle streams already stored
in the DB (union across every snapshot of the timeframe — no provider).
Pass C (--reseed-unresolvable): bounded re-seed of continuity-broken rows,
then Pass B again.
Safe beside the live writers: short per-row transactions with a busy
timeout. Read-only preview with --dry-run.
python scripts/backfill_signal_outcomes.py --dry-run
python scripts/backfill_signal_outcomes.py
python scripts/backfill_signal_outcomes.py --reseed-unresolvable
"""
import argparse
import os
import sqlite3
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import app_config # noqa: E402
from signal_log import SignalLogger # noqa: E402
def _status_counts(con: sqlite3.Connection) -> dict:
return dict(con.execute(
"SELECT COALESCE(advisory_outcome_status, 'NULL'), COUNT(*)"
" FROM signals WHERE setup_json IS NOT NULL AND setup_json != 'null'"
" GROUP BY 1").fetchall())
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
ap.add_argument("--db", default=None, help="defaults to signal_log.path")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--reseed-unresolvable", action="store_true")
ap.add_argument("--symbol", default="XAU/USD")
args = ap.parse_args(argv)
path = args.db or app_config.signal_log_path()
if not os.path.exists(path):
print(f"no database at {path}")
return 1
if args.dry_run:
con = sqlite3.connect(f"file:{os.path.abspath(path)}?mode=ro", uri=True)
print("setup rows by outcome status:", _status_counts(con))
have = {r[1] for r in con.execute("PRAGMA table_info(signals)")}
if "setup_key" in have:
n_keyless = con.execute(
"SELECT COUNT(*) FROM signals WHERE setup_json IS NOT NULL"
" AND setup_json != 'null' AND setup_key IS NULL").fetchone()[0]
print(f"setup rows without setup_key: {n_keyless}")
else:
print("setup_key column not present yet (schema migrates on"
" the first non-dry run)")
con.close()
return 0
lg = SignalLogger(path)
try:
print("before:", _status_counts(lg._conn))
print("pass A (seed/dedup):", lg.backfill_seeds())
total = 0
for _ in range(50):
n = lg.resolve_from_store(args.symbol)
total += n
if n == 0:
break
print(f"pass B (advance from stored bars): {total} rows changed")
if args.reseed_unresolvable:
reseeded = lg.reseed_unresolvable()
print(f"pass C (re-seed): {len(reseeded)} rows re-seeded")
for _ in range(50):
if lg.resolve_from_store(args.symbol) == 0:
break
print("after:", _status_counts(lg._conn))
finally:
lg.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
migrate_signal_log_news.py — move inline news blobs into news_snapshots.
Legacy rows store the full RSS headline list (~33 KB) and calendar events
(~1.2 KB) inline on EVERY scan row; the same blob repeats ~5× because the
news caches outlive a candle. This script interns each distinct blob once
(content hash), stamps `headlines_hash` / `events_hash` on the row and
NULLs the inline columns. Readers use the `signals_with_news` view or
`signal_log.read_news`.
Idempotent and resumable (batched transactions). Run --vacuum with BOTH
live writers (telegram bot, dashboard publisher) stopped — VACUUM needs
exclusive access and ~1× the final size of free disk.
python scripts/migrate_signal_log_news.py --dry-run
python scripts/migrate_signal_log_news.py
python scripts/migrate_signal_log_news.py --vacuum # bots stopped
"""
import argparse
import hashlib
import os
import sqlite3
import sys
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import app_config # noqa: E402
from signal_log import SignalLogger # noqa: E402
def _hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def migrate(con: sqlite3.Connection, batch: int = 500) -> dict:
"""Intern legacy inline blobs; returns counters. Caller owns the
connection (schema must already exist — open a SignalLogger once)."""
stats = {"rows": 0, "headlines_interned": 0, "events_interned": 0}
now = datetime.now(timezone.utc).isoformat()
while True:
rows = con.execute(
"SELECT id, headlines_json, events_json FROM signals"
" WHERE (headlines_json IS NOT NULL AND headlines_hash IS NULL)"
" OR (events_json IS NOT NULL AND events_hash IS NULL)"
" ORDER BY id LIMIT ?", (batch,)).fetchall()
if not rows:
break
con.execute("BEGIN IMMEDIATE")
try:
for rowid, headlines, events in rows:
sets, params = [], []
if headlines is not None:
h = _hash(headlines)
cur = con.execute(
"INSERT OR IGNORE INTO news_snapshots"
" (hash, kind, body, first_seen, last_seen)"
" VALUES (?, 'headlines', ?, ?, ?)",
(h, headlines, now, now))
stats["headlines_interned"] += cur.rowcount
sets += ["headlines_hash=?", "headlines_json=NULL"]
params.append(h)
if events is not None:
h = _hash(events)
cur = con.execute(
"INSERT OR IGNORE INTO news_snapshots"
" (hash, kind, body, first_seen, last_seen)"
" VALUES (?, 'events', ?, ?, ?)",
(h, events, now, now))
stats["events_interned"] += cur.rowcount
sets += ["events_hash=?", "events_json=NULL"]
params.append(h)
params.append(rowid)
con.execute(
f"UPDATE signals SET {', '.join(sets)} WHERE id=?",
params)
stats["rows"] += 1
con.commit()
except Exception:
con.rollback()
raise
return stats
def prune_orphans(con: sqlite3.Connection) -> int:
cur = con.execute(
"DELETE FROM news_snapshots WHERE hash NOT IN"
" (SELECT headlines_hash FROM signals WHERE headlines_hash IS NOT NULL"
" UNION SELECT events_hash FROM signals WHERE events_hash IS NOT NULL)")
con.commit()
return cur.rowcount
def prune_news_older_than(con: sqlite3.Connection, days: int) -> int:
"""Drop the news reference (not the row) from rows older than N days,
then delete snapshots nothing references any more."""
cur = con.execute(
"UPDATE signals SET headlines_hash=NULL, events_hash=NULL"
" WHERE logged_at < datetime('now', ?)", (f"-{int(days)} days",))
con.commit()
prune_orphans(con)
return cur.rowcount
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
ap.add_argument("--db", default=None)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--batch", type=int, default=500)
ap.add_argument("--prune-news-older-than", type=int, default=None,
metavar="DAYS")
ap.add_argument("--prune-orphans", action="store_true")
ap.add_argument("--vacuum", action="store_true",
help="checkpoint + VACUUM (all writers must be stopped)")
ap.add_argument("--no-backup", action="store_true")
args = ap.parse_args(argv)
path = args.db or app_config.signal_log_path()
if not os.path.exists(path):
print(f"no database at {path}")
return 1
size0 = os.path.getsize(path)
if args.dry_run:
con = sqlite3.connect(f"file:{os.path.abspath(path)}?mode=ro", uri=True)
n = con.execute(
"SELECT COUNT(*) FROM signals WHERE headlines_json IS NOT NULL"
" OR events_json IS NOT NULL").fetchone()[0]
d = con.execute(
"SELECT COUNT(DISTINCT headlines_json) FROM signals"
" WHERE headlines_json IS NOT NULL").fetchone()[0]
print(f"{n} rows carry inline news blobs ({d} distinct headline "
f"blobs); file size {size0 / 1e6:.1f} MB")
con.close()
return 0
if not args.no_backup:
backup = f"{path}.pre-news-{datetime.now(timezone.utc):%Y%m%d}"
if not os.path.exists(backup):
src = sqlite3.connect(path)
dst = sqlite3.connect(backup)
src.backup(dst)
dst.close()
src.close()
print(f"backup written: {backup}")
lg = SignalLogger(path) # applies the additive schema
lg.close()
con = sqlite3.connect(path)
con.execute("PRAGMA busy_timeout=10000")
try:
print("migrate:", migrate(con, batch=args.batch))
if args.prune_news_older_than is not None:
print("rows de-referenced:",
prune_news_older_than(con, args.prune_news_older_than))
if args.prune_orphans:
print("orphan snapshots removed:", prune_orphans(con))
if args.vacuum:
try:
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
con.execute("VACUUM")
except sqlite3.OperationalError as exc:
print(f"VACUUM failed ({exc}) — stop the bot and dashboard "
"publisher first, then re-run with --vacuum")
return 2
finally:
con.close()
print(f"size {size0 / 1e6:.1f} MB -> {os.path.getsize(path) / 1e6:.1f} MB"
+ ("" if args.vacuum else " (run --vacuum to reclaim space)"))
return 0
if __name__ == "__main__":
sys.exit(main())
+68 -10
View File
@@ -5,6 +5,8 @@ Combines multi-timeframe technical analysis with optional news context
into a graded actionable signal. into a graded actionable signal.
""" """
import hashlib
import json
import logging import logging
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -116,6 +118,9 @@ class SignalReport:
bullish_reasons: List[str] = field(default_factory=list) bullish_reasons: List[str] = field(default_factory=list)
bearish_reasons: List[str] = field(default_factory=list) bearish_reasons: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list) warnings: List[str] = field(default_factory=list)
# Warnings the XAU_CP acceptance removed from `warnings` (metadata
# only — logged so the gates a CP trade bypassed stay auditable).
cleaned_warnings: List[str] = field(default_factory=list)
current_price: float = 0.0 current_price: float = 0.0
spread_estimate: float = 0.0 spread_estimate: float = 0.0
@@ -178,7 +183,7 @@ class SignalAggregator:
report.trade_setup = None report.trade_setup = None
report.warnings.append("WEEKEND FILTER: XAU/USD market closed — no liquidity") report.warnings.append("WEEKEND FILTER: XAU/USD market closed — no liquidity")
report.action_recommendation = "NO TRADE — Weekend (market closed)" report.action_recommendation = "NO TRADE — Weekend (market closed)"
self._log_signal(report, log_source) self._log_signal(report, log_source, blocked=True)
return report return report
if xau_cfg.allowed_weekdays and now_utc.weekday() not in xau_cfg.allowed_weekdays: if xau_cfg.allowed_weekdays and now_utc.weekday() not in xau_cfg.allowed_weekdays:
@@ -196,7 +201,7 @@ class SignalAggregator:
f"DAY FILTER: {day_names.get(now_utc.weekday(), '?')} not in allowed days ({allowed})" f"DAY FILTER: {day_names.get(now_utc.weekday(), '?')} not in allowed days ({allowed})"
) )
report.action_recommendation = f"NO TRADE — Only trading {allowed}" report.action_recommendation = f"NO TRADE — Only trading {allowed}"
self._log_signal(report, log_source) self._log_signal(report, log_source, blocked=True)
return report return report
timeframes = [self.config.primary_timeframe] + list( timeframes = [self.config.primary_timeframe] + list(
@@ -280,6 +285,7 @@ class SignalAggregator:
def _log_signal( def _log_signal(
self, report: SignalReport, source: Optional[str], self, report: SignalReport, source: Optional[str],
dataframes: Optional[Dict[Timeframe, pd.DataFrame]] = None, dataframes: Optional[Dict[Timeframe, pd.DataFrame]] = None,
blocked: bool = False,
) -> None: ) -> None:
"""Persist the scan to the signal-history DB (signal_log.py). """Persist the scan to the signal-history DB (signal_log.py).
@@ -287,12 +293,21 @@ class SignalAggregator:
live scan. The news context re-fetch rides the NewsEngine's live scan. The news context re-fetch rides the NewsEngine's
class-level caches, so it costs nothing within a scan and class-level caches, so it costs nothing within a scan and
matches the context the signal just used; if it fails, the row matches the context the signal just used; if it fails, the row
is still written, just without the news JSON blobs.""" is still written, just without the news JSON blobs. ``blocked``
marks weekend/day early returns: no news fetch, no snapshots —
a ~1 KB liveness row (or nothing, per signal_log.log_blocked_scans)."""
context = None context = None
if not blocked:
try:
context = self._get_news_engine().get_fundamental_context()
except Exception:
logger.debug("signal log: news context unavailable",
exc_info=True)
extra = {"scan_kind": "blocked" if blocked else "scan"}
try: try:
context = self._get_news_engine().get_fundamental_context() extra["config_fingerprint"] = self.gate_config_fingerprint()
except Exception: except Exception:
logger.debug("signal log: news context unavailable", logger.debug("signal log: config fingerprint unavailable",
exc_info=True) exc_info=True)
signal_log.log_signal( signal_log.log_signal(
report, report,
@@ -300,8 +315,38 @@ class SignalAggregator:
source=source or "live", source=source or "live",
timeframe=self.config.primary_timeframe.value, timeframe=self.config.primary_timeframe.value,
dataframes=dataframes, dataframes=dataframes,
extra=extra,
) )
def gate_config_fingerprint(self) -> str:
"""Short hash of the gate configuration this process runs with.
Logged on every row so two surfaces disagreeing on the same bar
(2026-08-19: the dashboard process applied a different blocked-
hours list than the bot for a day) is diagnosable from the DB."""
xs = self.config.xau_strategy
bt = self.config.backtest
payload = {
"primary": self.config.primary_timeframe.value,
"confirm": [tf.value for tf in self.config.confirmation_timeframes],
"blocked_hours": list(xs.blocked_hours_utc),
"sell_blocked_hours": list(xs.sell_blocked_hours_utc),
"allowed_weekdays": list(xs.allowed_weekdays),
"blocked_regimes": list(xs.blocked_regimes),
"sell_blocked_regimes": list(xs.sell_blocked_regimes),
"sell_min_quality": list(xs.sell_min_quality),
"sell_vol_regimes": list(xs.sell_vol_regimes),
"big_candle": xs.big_candle_atr_multiple,
"cp": bool(xs.cp_enabled),
"pb": bool(xs.m5_pullback_enabled),
"session": [bt.session_filter, bt.session_start_hour,
bt.session_end_hour],
"regime_filter": bool(bt.regime_filter),
}
return hashlib.sha1(
json.dumps(payload, sort_keys=True, default=str).encode("utf-8")
).hexdigest()[:12]
def _apply_regime_filter_live( def _apply_regime_filter_live(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame] self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> None: ) -> None:
@@ -744,9 +789,9 @@ class SignalAggregator:
close_h = None close_h = None
logger.warning( logger.warning(
f"{report.symbol}: bar-close hour underivable — " f"{report.symbol}: bar-close hour underivable — "
f"SELL hour gate skipped" f"CP SELL hour gate FAILS CLOSED (short rejected)"
) )
if close_h in cfg.sell_blocked_hours_utc: if close_h is None or close_h in cfg.sell_blocked_hours_utc:
_reject("sell_hour") _reject("sell_hour")
return return
if self.config.primary_timeframe == Timeframe.M5: if self.config.primary_timeframe == Timeframe.M5:
@@ -851,14 +896,18 @@ class SignalAggregator:
"Weak pullback", "Weak pullback",
) )
filtered: List[str] = [] filtered: List[str] = []
dropped: List[str] = []
for warning in report.warnings: for warning in report.warnings:
if warning.startswith(stale_prefixes): if warning.startswith(stale_prefixes):
dropped.append(warning)
continue continue
if (warning.startswith("REGIME FILTER: '") if (warning.startswith("REGIME FILTER: '")
and warning.endswith("blocked for XAU")): and warning.endswith("blocked for XAU")):
dropped.append(warning)
continue continue
if (warning.startswith("M5 FILTER: '") if (warning.startswith("M5 FILTER: '")
and "volatility" in warning): and "volatility" in warning):
dropped.append(warning)
continue continue
filtered.append(warning) filtered.append(warning)
@@ -869,6 +918,7 @@ class SignalAggregator:
] ]
if not extension_items: if not extension_items:
report.warnings[:] = filtered report.warnings[:] = filtered
report.cleaned_warnings.extend(dropped)
return return
first_index = extension_items[0][0] first_index = extension_items[0][0]
priority = {prefix: rank for rank, prefix in enumerate(extension_prefixes)} priority = {prefix: rank for rank, prefix in enumerate(extension_prefixes)}
@@ -883,7 +933,10 @@ class SignalAggregator:
if not warning.startswith(extension_prefixes) if not warning.startswith(extension_prefixes)
] ]
kept.insert(first_index, strongest) kept.insert(first_index, strongest)
dropped.extend(
warning for _, warning in extension_items if warning != strongest)
report.warnings[:] = kept report.warnings[:] = kept
report.cleaned_warnings.extend(dropped)
# ── Public: bar-replayable entry point ───────────────────────── # ── Public: bar-replayable entry point ─────────────────────────
@@ -1116,11 +1169,16 @@ class SignalAggregator:
close_h = None close_h = None
logger.warning( logger.warning(
f"{report.symbol}: bar-close hour underivable — " f"{report.symbol}: bar-close hour underivable — "
f"SELL hour gate skipped" f"SELL hour gate FAILS CLOSED (short blocked)"
) )
if close_h in self.config.xau_strategy.sell_blocked_hours_utc: # Fail CLOSED: an underivable close hour blocks the short
# (`None in [15, 17]` used to pass silently). Never fires
# on parseable timestamps, so pinned books are unchanged.
if (close_h is None
or close_h in self.config.xau_strategy.sell_blocked_hours_utc):
hour_txt = "??" if close_h is None else f"{close_h:02}"
report.warnings.append( report.warnings.append(
f"SELL FILTER: {close_h:02}:00 UTC entry blocked for shorts" f"SELL FILTER: {hour_txt}:00 UTC entry blocked for shorts"
) )
report.signal_quality = "D" report.signal_quality = "D"
report.trade_setup = None report.trade_setup = None
+632 -85
View File
@@ -27,16 +27,47 @@ Fail-safe contract: logging must NEVER break a scan. Every public entry
point catches all exceptions and downgrades them to a warning log line. point catches all exceptions and downgrades them to a warning log line.
Report/context objects are duck-typed (no imports from signal_aggregator Report/context objects are duck-typed (no imports from signal_aggregator
or news_engine) so this module stays at the bottom of the import graph. or news_engine) so this module stays at the bottom of the import graph.
2026-09 observability additions (signal-log audit):
* Every row with a trade setup is an advisory-outcome candidate, not only
panel rows. Re-logs of the same setup (same ``setup_key`` — tf,
direction, entry, stop, strategy — within OUTCOME_DEDUP_GAP_SECONDS,
from ANY surface) are marked ``DUPLICATE`` and point at the canonical
row through ``advisory_event_id``; only the canonical row is seeded and
advanced, so summing ``advisory_r_multiple`` over
``advisory_outcome_status='RESOLVED'`` counts each trade once.
* Pending outcomes advance from the inserting scan's own snapshot first;
when that snapshot no longer overlaps the outcome's last processed bar
(downtime, weekend straddle) the resolver rebuilds the bar stream from
every stored snapshot of that timeframe, so a >14-bar gap in one
process's scans is not a terminal ``UNRESOLVABLE``.
* News blobs are content-addressed: ``headlines_json``/``events_json`` are
interned once into ``news_snapshots`` and rows carry ``headlines_hash``
/ ``events_hash`` (the legacy columns stay NULL on new rows; the
``signals_with_news`` view and ``read_news`` resolve both eras).
* Post-gate truth columns: ``gate_reason`` (the warning that suppressed
the setup), ``actionable`` (grade >= C AND setup present — what an
alert surface would act on; the ``action`` text is written BEFORE the
gate chain and can disagree), ``cleaned_warnings_json`` (warnings the
XAU_CP acceptance removed from ``warnings_json``), ``bar_range_median``
(the value historically stored as ``spread`` — it is the median
High-Low of the primary frame, NOT a broker spread), ``scan_kind``
(``scan`` / ``blocked`` for weekend/day early returns, which carry no
news or candles) and ``config_fingerprint`` (hash of the gate config the
producing process ran with — surfaces config drift between processes).
""" """
import hashlib
import json import json
import logging import logging
import math import math
import os import os
import sqlite3 import sqlite3
import threading import threading
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, Iterable, List, Optional, Tuple
import app_config import app_config
from advisory_outcome import ( from advisory_outcome import (
@@ -90,6 +121,13 @@ CREATE TABLE IF NOT EXISTS signals (
); );
CREATE INDEX IF NOT EXISTS idx_signals_sym_tf_ts CREATE INDEX IF NOT EXISTS idx_signals_sym_tf_ts
ON signals (symbol, timeframe, logged_at); ON signals (symbol, timeframe, logged_at);
CREATE TABLE IF NOT EXISTS news_snapshots (
hash TEXT PRIMARY KEY,
kind TEXT NOT NULL,
body TEXT NOT NULL,
first_seen TEXT NOT NULL,
last_seen TEXT
);
CREATE TABLE IF NOT EXISTS outlooks ( CREATE TABLE IF NOT EXISTS outlooks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
logged_at TEXT NOT NULL, logged_at TEXT NOT NULL,
@@ -120,25 +158,72 @@ _ADDITIVE_SIGNAL_COLS = (
("advisory_outcome_status", "TEXT"), ("advisory_outcome_status", "TEXT"),
("advisory_r_multiple", "REAL"), ("advisory_r_multiple", "REAL"),
("advisory_outcome_json", "TEXT"), ("advisory_outcome_json", "TEXT"),
# 2026-09 observability columns (see module docstring).
("setup_key", "TEXT"),
("advisory_event_id", "INTEGER"),
("headlines_hash", "TEXT"),
("events_hash", "TEXT"),
("gate_reason", "TEXT"),
("actionable", "INTEGER"),
("cleaned_warnings_json", "TEXT"),
("bar_range_median", "REAL"),
("scan_kind", "TEXT"),
("config_fingerprint", "TEXT"),
) )
# Read-side compatibility view `signals_with_news`: legacy rows keep
# their inline news blobs, new rows resolve them through news_snapshots.
# Recreated on every logger init (SignalLogger._ensure_news_view) so it
# always exposes the current column set.
# Same-setup re-logs (cross-surface double logs, persisting-signal
# re-fires) within this gap are ONE advisory event. Mirrors
# analyze_signal_log's 60-minute clustering.
OUTCOME_DEDUP_GAP_SECONDS = 3600
# Warning prefixes that mean "a gate removed the setup", checked in gate
# order (live wrapper first, then the post-grade chain, then the setup
# generators). Kept here — signal_log must not import signal_aggregator.
_GATE_PREFIXES = (
"WEEKEND FILTER",
"DAY FILTER",
"SESSION FILTER",
"Primary timeframe stale",
"Primary timeframe data unavailable",
"REGIME FILTER",
"EXHAUSTION BLOCK",
"EXHAUSTED TREND FILTER",
"SELL FILTER",
"MTF OPPOSITION",
"BIG CANDLE FILTER",
"M5 FILTER",
"LOW ATR FILTER",
"SPREAD GATE",
)
_ACTIONABLE_GRADES = ("A+", "A", "B", "C")
_ADDITIVE_OUTLOOK_COLS = ( _ADDITIVE_OUTLOOK_COLS = (
("news_json", "TEXT"), ("news_json", "TEXT"),
("indicators_json", "TEXT"), ("indicators_json", "TEXT"),
("candles_json", "TEXT"), ("candles_json", "TEXT"),
) )
_INSERT = """ _SIGNAL_INSERT_COLS = (
INSERT INTO signals ( "logged_at", "source", "symbol", "timeframe", "signal", "score",
logged_at, source, symbol, timeframe, signal, score, confidence, "confidence", "quality", "action", "price", "spread", "market_regime",
quality, action, price, spread, market_regime, volatility_regime, "volatility_regime", "mtf_json", "setup_json", "reasons_json",
mtf_json, setup_json, reasons_json, warnings_json, "warnings_json", "event_risk", "event_description", "news_sentiment",
event_risk, event_description, news_sentiment, news_description, "news_description", "news_summary", "events_json", "headlines_json",
news_summary, events_json, headlines_json, "indicators_json", "candles_json", "panel_verdict", "panel_confidence",
indicators_json, candles_json, "panel_rationale", "panel_takes_json",
panel_verdict, panel_confidence, panel_rationale, panel_takes_json "setup_key", "headlines_hash", "events_hash", "gate_reason",
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "actionable", "cleaned_warnings_json", "bar_range_median", "scan_kind",
""" "config_fingerprint",
)
_INSERT = (
"INSERT INTO signals (" + ", ".join(_SIGNAL_INSERT_COLS) + ") VALUES ("
+ ", ".join("?" for _ in _SIGNAL_INSERT_COLS) + ")"
)
_OUTLOOK_INSERT = """ _OUTLOOK_INSERT = """
INSERT INTO outlooks ( INSERT INTO outlooks (
@@ -173,6 +258,57 @@ def _dumps(obj: Any) -> str:
return json.dumps(obj, default=str, allow_nan=False) return json.dumps(obj, default=str, allow_nan=False)
def _blob_hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def setup_key(setup: Optional[Dict[str, Any]], timeframe: Any) -> Optional[str]:
"""Identity of a logged trade setup for event dedup.
``"<tf>|<direction>|<entry>|<stop>|<strategy>"`` — the same tuple
analyze_signal_log clusters on (tf, direction, round(entry, 2),
round(SL, 2), strategy). None when the setup lacks a usable price."""
if not isinstance(setup, dict):
return None
entry = _finite(setup.get("entry"))
stop = _finite(setup.get("stop_loss"))
if entry is None or stop is None:
return None
return "|".join((
str(getattr(timeframe, "value", timeframe) or ""),
str(setup.get("direction") or ""),
f"{entry:.2f}",
f"{stop:.2f}",
str(setup.get("strategy") or ""),
))
def gate_reason(
warnings: Iterable[str],
cleaned_warnings: Iterable[str],
quality: str,
has_setup: bool,
) -> Optional[str]:
"""The warning that explains why a row carries no setup, or None.
Rows WITH a setup have no gate reason. Rows without one report the
first gate-prefixed warning (in gate order); an actionable grade with
no setup and no gate warning is the setup generator returning None
(RR floor / geometry), which the aggregator does not warn about."""
if has_setup:
return None
pool = list(warnings or []) + list(cleaned_warnings or [])
for prefix in _GATE_PREFIXES:
for warning in pool:
if str(warning).startswith(prefix):
return str(warning)
if quality in _ACTIONABLE_GRADES:
return "SETUP GENERATOR: no setup (RR floor / geometry)"
if quality:
return f"GRADE {quality}: below setup threshold"
return None
# ── Serializers (duck-typed; deliberately NOT imported from ── # ── Serializers (duck-typed; deliberately NOT imported from ──
# ── dashboard_publisher — the dashboard's hard independence rule) ── # ── dashboard_publisher — the dashboard's hard independence rule) ──
@@ -322,7 +458,12 @@ def _report_row(
source: str, source: str,
timeframe: str, timeframe: str,
dataframes: Any = None, dataframes: Any = None,
) -> tuple: extra: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Column-name → value mapping for one scan row (see
_SIGNAL_INSERT_COLS). ``extra`` carries producer-side metadata
(``scan_kind``, ``config_fingerprint``)."""
extra = extra or {}
mtf_signals = getattr(report, "mtf_signals", None) or {} mtf_signals = getattr(report, "mtf_signals", None) or {}
mtf_json = _dumps({ mtf_json = _dumps({
"agreement": bool(getattr(report, "mtf_agreement", False)), "agreement": bool(getattr(report, "mtf_agreement", False)),
@@ -363,38 +504,56 @@ def _report_row(
signal = getattr(report, "final_signal", None) signal = getattr(report, "final_signal", None)
panel_takes = getattr(report, "panel_takes", None) or [] panel_takes = getattr(report, "panel_takes", None) or []
panel_takes_json = _dumps(panel_takes) if panel_takes else None panel_takes_json = _dumps(panel_takes) if panel_takes else None
return ( quality = getattr(report, "signal_quality", "") or ""
datetime.now(timezone.utc).isoformat(), warnings = list(getattr(report, "warnings", None) or [])
source, cleaned = list(getattr(report, "cleaned_warnings", None) or [])
str(getattr(report, "symbol", "")), setup_dict = json.loads(setup_json) if setup_json else None
timeframe, bar_range = _finite(getattr(report, "spread_estimate", None))
getattr(signal, "name", str(signal)), return {
_finite(getattr(report, "final_score", None)), "logged_at": datetime.now(timezone.utc).isoformat(),
_finite(getattr(report, "final_confidence", None)), "source": source,
getattr(report, "signal_quality", "") or "", "symbol": str(getattr(report, "symbol", "")),
getattr(report, "action_recommendation", "") or "", "timeframe": timeframe,
_finite(getattr(report, "current_price", None)), "signal": getattr(signal, "name", str(signal)),
_finite(getattr(report, "spread_estimate", None)), "score": _finite(getattr(report, "final_score", None)),
getattr(report, "market_regime", "") or "", "confidence": _finite(getattr(report, "final_confidence", None)),
getattr(report, "volatility_regime", "") or "", "quality": quality,
mtf_json, "action": getattr(report, "action_recommendation", "") or "",
setup_json, "price": _finite(getattr(report, "current_price", None)),
reasons_json, "spread": bar_range,
warnings_json, "market_regime": getattr(report, "market_regime", "") or "",
getattr(report, "event_risk", "") or "", "volatility_regime": getattr(report, "volatility_regime", "") or "",
getattr(report, "event_description", "") or "", "mtf_json": mtf_json,
_finite(getattr(report, "news_sentiment", None)), "setup_json": setup_json,
getattr(report, "news_description", "") or "", "reasons_json": reasons_json,
getattr(report, "news_summary", "") or "", "warnings_json": warnings_json,
events_json, "event_risk": getattr(report, "event_risk", "") or "",
headlines_json, "event_description": getattr(report, "event_description", "") or "",
_report_indicators_json(report), "news_sentiment": _finite(getattr(report, "news_sentiment", None)),
_candles_json(dataframes), "news_description": getattr(report, "news_description", "") or "",
getattr(report, "panel_verdict", None), "news_summary": getattr(report, "news_summary", "") or "",
_finite(getattr(report, "panel_confidence", None)), # Interned by SignalLogger.log into news_snapshots; the inline
getattr(report, "panel_rationale", None), # columns stay NULL on new rows (legacy rows keep theirs).
panel_takes_json, "events_json": events_json,
) "headlines_json": headlines_json,
"indicators_json": _report_indicators_json(report),
"candles_json": _candles_json(dataframes),
"panel_verdict": getattr(report, "panel_verdict", None),
"panel_confidence": _finite(getattr(report, "panel_confidence", None)),
"panel_rationale": getattr(report, "panel_rationale", None),
"panel_takes_json": panel_takes_json,
"setup_key": setup_key(setup_dict, timeframe),
"headlines_hash": None,
"events_hash": None,
"gate_reason": gate_reason(warnings, cleaned, quality,
setup_dict is not None),
"actionable": int(setup_dict is not None
and quality in _ACTIONABLE_GRADES),
"cleaned_warnings_json": _dumps(cleaned) if cleaned else None,
"bar_range_median": bar_range,
"scan_kind": str(extra.get("scan_kind") or "scan"),
"config_fingerprint": extra.get("config_fingerprint"),
}
class SignalLogger: class SignalLogger:
@@ -408,13 +567,53 @@ class SignalLogger:
if parent: if parent:
os.makedirs(parent, exist_ok=True) os.makedirs(parent, exist_ok=True)
self._conn = sqlite3.connect(path, check_same_thread=False) self._conn = sqlite3.connect(path, check_same_thread=False)
self._conn.execute("PRAGMA journal_mode=WAL") # busy_timeout BEFORE the journal-mode switch: several processes
# (bot, dashboard, tests) open the same file at once and the WAL
# switch plus the additive migration need brief exclusive locks.
self._conn.execute("PRAGMA busy_timeout=5000") self._conn.execute("PRAGMA busy_timeout=5000")
self._lock = threading.Lock() self._lock = threading.Lock()
with self._lock: with self._lock:
self._conn.executescript(_SCHEMA) self._with_lock_retry(
self._migrate_signal_columns() lambda: self._conn.execute("PRAGMA journal_mode=WAL"))
self._migrate_outlook_columns() self._with_lock_retry(
lambda: self._conn.executescript(_SCHEMA))
self._with_lock_retry(self._migrate_signal_columns)
self._with_lock_retry(self._migrate_outlook_columns)
self._with_lock_retry(self._ensure_news_view)
def _ensure_news_view(self) -> None:
"""(Re)create the read-side view atomically. Several processes
open the DB at once; DROP+CREATE inside one IMMEDIATE transaction
serialises them, and a view another opener created in between is
not an error."""
try:
self._conn.execute("BEGIN IMMEDIATE")
self._conn.execute("DROP VIEW IF EXISTS signals_with_news")
self._conn.execute(
"CREATE VIEW signals_with_news AS"
" SELECT s.*,"
" COALESCE(s.headlines_json, nh.body) AS headlines_resolved,"
" COALESCE(s.events_json, ne.body) AS events_resolved"
" FROM signals s"
" LEFT JOIN news_snapshots nh ON nh.hash = s.headlines_hash"
" LEFT JOIN news_snapshots ne ON ne.hash = s.events_hash")
self._conn.commit()
except sqlite3.OperationalError as exc:
self._conn.rollback()
if "already exists" not in str(exc).lower():
raise
@staticmethod
def _with_lock_retry(fn, attempts: int = 8, delay: float = 0.05):
"""Run ``fn`` again on 'database is locked' (concurrent openers
racing the same schema step); other errors propagate at once."""
for attempt in range(attempts):
try:
return fn()
except sqlite3.OperationalError as exc:
if "locked" not in str(exc).lower() or attempt == attempts - 1:
raise
time.sleep(delay * (attempt + 1))
@property @property
def path(self) -> str: def path(self) -> str:
@@ -436,6 +635,12 @@ class SignalLogger:
self._conn.execute( self._conn.execute(
"CREATE INDEX IF NOT EXISTS idx_signals_advisory_pending" "CREATE INDEX IF NOT EXISTS idx_signals_advisory_pending"
" ON signals (symbol, advisory_outcome_status, id)") " ON signals (symbol, advisory_outcome_status, id)")
self._conn.execute(
"CREATE INDEX IF NOT EXISTS idx_signals_setup_key"
" ON signals (symbol, setup_key, id)")
self._conn.execute(
"CREATE INDEX IF NOT EXISTS idx_signals_event"
" ON signals (advisory_event_id)")
self._conn.commit() self._conn.commit()
def _migrate_outlook_columns(self) -> None: def _migrate_outlook_columns(self) -> None:
@@ -460,20 +665,109 @@ class SignalLogger:
source: str = "live", source: str = "live",
timeframe: str = "", timeframe: str = "",
dataframes: Any = None, dataframes: Any = None,
extra: Optional[Dict[str, Any]] = None,
) -> int: ) -> int:
"""Insert one scan row; returns the rowid (panel-update key).""" """Insert one scan row; returns the rowid (panel-update key).
row = _report_row(report, context, source, timeframe, dataframes)
One transaction interns the news blobs, inserts the row and seeds
(or dedups) its advisory outcome; a seeding failure is logged and
the row still commits. Pending outcomes are then advanced from
this scan's candle snapshot."""
row = _report_row(report, context, source, timeframe, dataframes,
extra=extra)
now_iso = row["logged_at"]
with self._lock: with self._lock:
cur = self._conn.execute(_INSERT, row) self._conn.execute("BEGIN IMMEDIATE")
self._conn.commit() try:
rowid = int(cur.lastrowid) row["headlines_hash"] = self._intern_news(
"headlines", row.pop("headlines_json"), now_iso)
row["events_hash"] = self._intern_news(
"events", row.pop("events_json"), now_iso)
row["headlines_json"] = None
row["events_json"] = None
cur = self._conn.execute(
_INSERT, tuple(row[c] for c in _SIGNAL_INSERT_COLS))
rowid = int(cur.lastrowid)
try:
self._seed_outcome(rowid, row)
except Exception:
logger.warning("signal_log: outcome seeding failed",
exc_info=True)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
try: try:
self.resolve_pending(str(row[2]), row[25]) self.resolve_pending(str(row["symbol"]), row["candles_json"])
except Exception: except Exception:
logger.warning("signal_log: advisory outcome resolver failed", logger.warning("signal_log: advisory outcome resolver failed",
exc_info=True) exc_info=True)
return rowid return rowid
# ── news interning ────────────────────────────────────────────
def _intern_news(self, kind: str, body: Optional[str],
now_iso: str) -> Optional[str]:
"""Store one news blob once; returns its content hash."""
if not body:
return None
digest = _blob_hash(body)
self._conn.execute(
"INSERT OR IGNORE INTO news_snapshots"
" (hash, kind, body, first_seen, last_seen)"
" VALUES (?, ?, ?, ?, ?)",
(digest, kind, body, now_iso, now_iso))
self._conn.execute(
"UPDATE news_snapshots SET last_seen=? WHERE hash=?",
(now_iso, digest))
return digest
def news_for(self, rowid: int) -> Tuple[Optional[str], Optional[str]]:
"""(events_json, headlines_json) for a row, legacy or interned."""
with self._lock:
return read_news(self._conn, rowid)
# ── advisory-event seeding / dedup ────────────────────────────
def _seed_outcome(self, rowid: int, row: Dict[str, Any]) -> None:
"""Seed a PENDING outcome for a new setup row, or mark it a
DUPLICATE of the most recent same-setup row within the gap.
Runs inside log()'s transaction."""
key = row.get("setup_key")
if not key or not row.get("setup_json"):
return
prior = self._conn.execute(
"SELECT id, advisory_event_id, logged_at FROM signals"
" WHERE symbol=? AND setup_key=? AND id<?"
" ORDER BY id DESC LIMIT 1",
(row["symbol"], key, rowid)).fetchone()
if prior is not None:
try:
gap = (datetime.fromisoformat(row["logged_at"])
- datetime.fromisoformat(prior[2])).total_seconds()
except (TypeError, ValueError):
gap = None
if gap is not None and 0 <= gap <= OUTCOME_DEDUP_GAP_SECONDS:
event_id = int(prior[1] or prior[0])
self._conn.execute(
"UPDATE signals SET advisory_event_id=?,"
" advisory_outcome_status='DUPLICATE',"
" advisory_outcome_json=? WHERE id=?",
(event_id,
_dumps({"model": "advisory-event-dedup-v1",
"status": "DUPLICATE", "event_id": event_id}),
rowid))
return
setup = json.loads(row["setup_json"])
indicators = (json.loads(row["indicators_json"])
if row.get("indicators_json") else None)
status, state = initial_outcome(setup, indicators, row["timeframe"])
self._conn.execute(
"UPDATE signals SET advisory_event_id=?,"
" advisory_outcome_status=?, advisory_outcome_json=?"
" WHERE id=?",
(rowid, status, _dumps(state), rowid))
def update_panel( def update_panel(
self, self,
rowid: int, rowid: int,
@@ -486,7 +780,8 @@ class SignalLogger:
cache_status: Optional[str] = None, cache_status: Optional[str] = None,
payload_hash: Optional[str] = None, payload_hash: Optional[str] = None,
) -> None: ) -> None:
"""Fill panel provenance and initialize its advisory outcome.""" """Fill panel provenance; seeds an outcome only for rows that
log() did not already seed (rows written by an older process)."""
with self._lock: with self._lock:
row = self._conn.execute( row = self._conn.execute(
"SELECT setup_json, indicators_json, timeframe," "SELECT setup_json, indicators_json, timeframe,"
@@ -520,7 +815,15 @@ class SignalLogger:
def resolve_pending( def resolve_pending(
self, symbol: str, candles_json: Optional[str], limit: int = 100 self, symbol: str, candles_json: Optional[str], limit: int = 100
) -> int: ) -> int:
"""Advance pending advisory outcomes from a new candle snapshot.""" """Advance pending advisory outcomes from a new candle snapshot.
The inserting scan's own snapshot is tried first (cheap, covers the
steady state). When it does not overlap an outcome's last processed
bar — that process missed >14 bars of this timeframe, a weekend
straddle, a restart — the resolver rebuilds the bar stream for the
timeframe from every stored snapshot after that bar (any surface)
and retries, so the outcome is not marked UNRESOLVABLE for a gap
the database can fill. Each row commits on its own."""
if not candles_json: if not candles_json:
return 0 return 0
snapshots = json.loads(candles_json) snapshots = json.loads(candles_json)
@@ -528,42 +831,248 @@ class SignalLogger:
return 0 return 0
changed = 0 changed = 0
with self._lock: with self._lock:
self._conn.execute("BEGIN IMMEDIATE") rows = self._conn.execute(
try: "SELECT id, timeframe FROM signals"
rows = self._conn.execute( " WHERE symbol=? AND advisory_outcome_status='PENDING'"
"SELECT id, timeframe, advisory_outcome_json FROM signals" " ORDER BY id",
" WHERE symbol=? AND advisory_outcome_status='PENDING'" (symbol,),
" ORDER BY id", ).fetchall()
(symbol,), eligible = 0
).fetchall() for rowid, timeframe in rows:
eligible = 0 bars = timeframe_value(snapshots, timeframe)
for rowid, timeframe, state_json in rows: if not bars:
bars = timeframe_value(snapshots, timeframe) continue
if not bars or not state_json: if eligible >= max(1, int(limit)):
break
eligible += 1
# One IMMEDIATE transaction per row: the state is re-read
# under the write lock, so the bot and the dashboard (each
# advancing from its own snapshot) serialise instead of
# overwriting each other's progress.
self._conn.execute("BEGIN IMMEDIATE")
try:
hit = self._conn.execute(
"SELECT advisory_outcome_json FROM signals"
" WHERE id=? AND advisory_outcome_status='PENDING'",
(rowid,)).fetchone()
if hit is None or not hit[0]:
self._conn.rollback()
continue continue
if eligible >= max(1, int(limit)): before = _dumps(json.loads(hit[0]))
break advanced = advance_outcome(json.loads(before), bars)
eligible += 1 if (advanced.get("status") == "UNRESOLVABLE"
state = json.loads(state_json) and "continuity" in str(advanced.get("reason", ""))):
before = _dumps(state) union = self._stream_since(
state = advance_outcome(state, bars) symbol, timeframe,
after = _dumps(state) json.loads(before)["progress"]["last_processed_bar"])
if union:
advanced = advance_outcome(json.loads(before), union)
after = _dumps(advanced)
if after == before: if after == before:
self._conn.rollback()
continue continue
status = state.get("status", "UNRESOLVABLE") status = advanced.get("status", "UNRESOLVABLE")
self._conn.execute( self._conn.execute(
"UPDATE signals SET advisory_outcome_status=?," "UPDATE signals SET advisory_outcome_status=?,"
" advisory_r_multiple=?, advisory_outcome_json=?" " advisory_r_multiple=?, advisory_outcome_json=?"
" WHERE id=? AND advisory_outcome_status='PENDING'", " WHERE id=?",
(status, canonical_r(state), after, rowid), (status, canonical_r(advanced), after, rowid),
) )
self._conn.commit()
changed += 1 changed += 1
self._conn.commit() except Exception:
except Exception: self._conn.rollback()
self._conn.rollback() raise
raise
return changed return changed
def _stream_since(self, symbol: str, timeframe: str,
last_bar: str, max_rows: int = 4000) -> List[Dict[str, Any]]:
"""Union of every stored closed bar of ``timeframe`` from
``last_bar`` onward, across all rows/surfaces (oldest first).
Only the requested timeframe's array is pulled out of each
candles_json (json_extract), so the cost is bounded by the number
of rows logged since that bar, not by blob size."""
tf = str(getattr(timeframe, "value", timeframe))
try:
since = datetime.fromisoformat(
str(last_bar).replace(" ", "T")).isoformat()
except (TypeError, ValueError):
return []
bars: Dict[str, Dict[str, Any]] = {}
for key in _tf_storage_keys(tf):
rows = self._conn.execute(
"SELECT json_extract(candles_json, ?) FROM signals"
" WHERE symbol=? AND candles_json IS NOT NULL"
" AND logged_at >= ? ORDER BY id LIMIT ?",
(f'$."{key}"', symbol, since, int(max_rows))).fetchall()
for (blob,) in rows:
if not blob:
continue
try:
for bar in json.loads(blob):
stamp = bar.get("timestamp")
if stamp and stamp >= since:
bars.setdefault(str(stamp), bar)
except (TypeError, ValueError, AttributeError):
continue
return [bars[k] for k in sorted(bars)]
def advance_with_bars(self, rowid: int, bars: Iterable[Dict[str, Any]],
evidence: str = "external_bars") -> str:
"""Advance ONE pending outcome from caller-supplied bars (backfill
from provider history). Returns the resulting status."""
with self._lock:
row = self._conn.execute(
"SELECT advisory_outcome_status, advisory_outcome_json"
" FROM signals WHERE id=?", (rowid,)).fetchone()
if row is None or row[0] != "PENDING" or not row[1]:
return str(row[0]) if row else "MISSING"
state = advance_outcome(json.loads(row[1]), list(bars))
state["evidence"] = evidence
status = state.get("status", "UNRESOLVABLE")
self._conn.execute(
"UPDATE signals SET advisory_outcome_status=?,"
" advisory_r_multiple=?, advisory_outcome_json=?"
" WHERE id=? AND advisory_outcome_status='PENDING'",
(status, canonical_r(state), _dumps(state), rowid))
self._conn.commit()
return status
def backfill_seeds(self, limit: Optional[int] = None) -> Dict[str, int]:
"""Assign setup_key / advisory_event_id to legacy setup rows and
seed the canonical ones that carry no outcome yet. Existing
outcomes (panel-seeded rows) are never overwritten. Safe to run
beside live writers (short per-row transactions)."""
stats = {"keyed": 0, "duplicates": 0, "seeded": 0}
with self._lock:
rows = self._conn.execute(
"SELECT id, symbol, timeframe, logged_at, setup_json,"
" indicators_json, advisory_outcome_status, advisory_event_id"
" FROM signals WHERE setup_json IS NOT NULL"
" AND setup_json != 'null' ORDER BY id"
+ (f" LIMIT {int(limit)}" if limit else "")).fetchall()
last_by_key: Dict[Tuple[str, str], Tuple[int, int, datetime]] = {}
for (rowid, symbol, tf, logged_at, setup_json, ind_json,
status, event_id) in rows:
setup = json.loads(setup_json)
key = setup_key(setup, tf)
if not key:
continue
try:
ts = datetime.fromisoformat(logged_at)
except (TypeError, ValueError):
continue
prior = last_by_key.get((symbol, key))
if (prior is not None
and 0 <= (ts - prior[2]).total_seconds()
<= OUTCOME_DEDUP_GAP_SECONDS):
# Same setup re-logged inside the gap: a duplicate of
# the canonical event regardless of its legacy status
# (a panel-seeded RESOLVED re-log measured the very
# same trade — keeping it would double count R).
canonical = prior[1]
if status != "DUPLICATE" or event_id != canonical:
self._conn.execute(
"UPDATE signals SET setup_key=?,"
" advisory_event_id=?,"
" advisory_outcome_status='DUPLICATE',"
" advisory_r_multiple=NULL,"
" advisory_outcome_json=? WHERE id=?",
(key, canonical,
_dumps({"model": "advisory-event-dedup-v1",
"status": "DUPLICATE",
"event_id": canonical,
"superseded_status": status}), rowid))
stats["duplicates"] += 1
last_by_key[(symbol, key)] = (rowid, canonical, ts)
continue
canonical = int(event_id or rowid)
if status is None:
indicators = json.loads(ind_json) if ind_json else None
new_status, state = initial_outcome(setup, indicators, tf)
self._conn.execute(
"UPDATE signals SET setup_key=?, advisory_event_id=?,"
" advisory_outcome_status=?, advisory_outcome_json=?"
" WHERE id=?",
(key, canonical, new_status, _dumps(state), rowid))
stats["seeded"] += 1
else:
self._conn.execute(
"UPDATE signals SET setup_key=?, advisory_event_id=?"
" WHERE id=?", (key, canonical, rowid))
stats["keyed"] += 1
last_by_key[(symbol, key)] = (rowid, canonical, ts)
self._conn.commit()
return stats
def resolve_from_store(self, symbol: str, limit: int = 500) -> int:
"""Advance every PENDING outcome from the stored bar streams alone
(no provider). Used by the backfill script after seeding."""
changed = 0
with self._lock:
rows = self._conn.execute(
"SELECT id, timeframe, advisory_outcome_json FROM signals"
" WHERE symbol=? AND advisory_outcome_status='PENDING'"
" ORDER BY id LIMIT ?", (symbol, int(limit))).fetchall()
for rowid, timeframe, state_json in rows:
if not state_json:
continue
state = json.loads(state_json)
union = self._stream_since(
symbol, timeframe, state["progress"]["last_processed_bar"])
if not union:
continue
before = _dumps(state)
advanced = advance_outcome(json.loads(before), union)
after = _dumps(advanced)
if after == before:
continue
self._conn.execute(
"UPDATE signals SET advisory_outcome_status=?,"
" advisory_r_multiple=?, advisory_outcome_json=?"
" WHERE id=? AND advisory_outcome_status='PENDING'",
(advanced.get("status", "UNRESOLVABLE"),
canonical_r(advanced), after, rowid))
self._conn.commit()
changed += 1
return changed
def reseed_unresolvable(self, max_reseeds: int = 1) -> List[int]:
"""Bounded retry for continuity-broken outcomes: re-freeze the
seed from the row's own setup/indicators (at most ``max_reseeds``
times per row) so the next resolver pass can walk the now-filled
stream. Invalid-seed rows stay terminal."""
reseeded: List[int] = []
with self._lock:
rows = self._conn.execute(
"SELECT id, timeframe, setup_json, indicators_json,"
" advisory_outcome_json FROM signals"
" WHERE advisory_outcome_status='UNRESOLVABLE'"
" AND advisory_event_id = id").fetchall()
for rowid, tf, setup_json, ind_json, state_json in rows:
old = json.loads(state_json) if state_json else {}
if "continuity" not in str(old.get("reason", "")) and \
"gap" not in str(old.get("reason", "")):
continue
count = int((old.get("reseed") or {}).get("count", 0))
if count >= max_reseeds or not setup_json:
continue
indicators = json.loads(ind_json) if ind_json else None
status, state = initial_outcome(
json.loads(setup_json), indicators, tf)
if status != "PENDING":
continue
state["reseed"] = {
"count": count + 1,
"at": datetime.now(timezone.utc).isoformat()}
self._conn.execute(
"UPDATE signals SET advisory_outcome_status=?,"
" advisory_r_multiple=NULL, advisory_outcome_json=?"
" WHERE id=?", (status, _dumps(state), rowid))
reseeded.append(int(rowid))
self._conn.commit()
return reseeded
def log_outlook(self, outlook: Any, surface: str, def log_outlook(self, outlook: Any, surface: str,
payload: Any = None) -> int: payload: Any = None) -> int:
"""Insert one advisory market outlook; returns its rowid.""" """Insert one advisory market outlook; returns its rowid."""
@@ -596,6 +1105,37 @@ class SignalLogger:
self._conn.close() self._conn.close()
def _tf_storage_keys(tf: str) -> List[str]:
"""Keys under which a timeframe's bars may be stored in candles_json
(enum value plus the D1 alias used by older rows)."""
keys = [tf]
if tf in ("1d", "1day"):
keys = ["1d", "1day"]
return keys
def read_news(con: sqlite3.Connection, rowid: int
) -> Tuple[Optional[str], Optional[str]]:
"""(events_json, headlines_json) for ``rowid`` on ANY connection —
resolves interned snapshots and falls back to the legacy inline
columns. Read-only."""
row = con.execute(
"SELECT events_json, headlines_json, events_hash, headlines_hash"
" FROM signals WHERE id=?", (rowid,)).fetchone()
if row is None:
return None, None
events, headlines, ev_hash, hl_hash = row
if events is None and ev_hash:
hit = con.execute("SELECT body FROM news_snapshots WHERE hash=?",
(ev_hash,)).fetchone()
events = hit[0] if hit else None
if headlines is None and hl_hash:
hit = con.execute("SELECT body FROM news_snapshots WHERE hash=?",
(hl_hash,)).fetchone()
headlines = hit[0] if hit else None
return events, headlines
# ── Module-level entry point (config-driven, fail-safe) ─────────────── # ── Module-level entry point (config-driven, fail-safe) ───────────────
_loggers: Dict[str, SignalLogger] = {} _loggers: Dict[str, SignalLogger] = {}
@@ -608,14 +1148,20 @@ def log_signal(
source: str = "live", source: str = "live",
timeframe: str = "", timeframe: str = "",
dataframes: Any = None, dataframes: Any = None,
extra: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
"""Persist one scan if the signal_log config block enables it. """Persist one scan if the signal_log config block enables it.
Stashes the rowid on the report (``_signal_log_rowid``) so a post-hoc Stashes the rowid on the report (``_signal_log_rowid``) so a post-hoc
panel attach can update this same row. NEVER raises — a logging panel attach can update this same row. NEVER raises — a logging
failure must not break a live scan.""" failure must not break a live scan. ``extra`` = producer metadata
(``scan_kind``, ``config_fingerprint``); blocked scans are skipped
when ``signal_log.log_blocked_scans`` is false."""
try: try:
if not app_config.signal_log_enabled(): if not app_config.signal_log_enabled():
return return
if ((extra or {}).get("scan_kind") == "blocked"
and not app_config.signal_log_blocked_scans()):
return
path = app_config.signal_log_path() path = app_config.signal_log_path()
with _loggers_lock: with _loggers_lock:
lg = _loggers.get(path) lg = _loggers.get(path)
@@ -623,7 +1169,8 @@ def log_signal(
lg = SignalLogger(path) lg = SignalLogger(path)
_loggers[path] = lg _loggers[path] = lg
rowid = lg.log(report, context=context, source=source, rowid = lg.log(report, context=context, source=source,
timeframe=timeframe, dataframes=dataframes) timeframe=timeframe, dataframes=dataframes,
extra=extra)
try: try:
report._signal_log_rowid = rowid report._signal_log_rowid = rowid
except Exception: except Exception:
+58 -5
View File
@@ -549,9 +549,50 @@ async def run_signal(
confirmation_tfs: Optional[List[Timeframe]] = None, confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport: ) -> SignalReport:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
return await loop.run_in_executor( report = await loop.run_in_executor(
_executor, _run_signal_sync, symbol, timeframe, confirmation_tfs _executor, _run_signal_sync, symbol, timeframe, confirmation_tfs
) )
_remember_scan(symbol, timeframe, report)
return report
# Most recent report per (symbol, timeframe) keyed by the closed-bar epoch
# it was computed for. Closed-bar inputs are identical within a bar, so a
# rescan inside the same bar (the WS level-breach handler fired up to
# ~5 min apart all day, logging 3.5 M15 rows per candle — 2026-09 audit)
# can reuse it instead of re-fetching, re-scoring and re-logging.
_last_scan: Dict[str, tuple] = {}
_TF_SECONDS_CACHE = {
Timeframe.M1: 60, Timeframe.M5: 300, Timeframe.M15: 900,
Timeframe.M30: 1800, Timeframe.H1: 3600, Timeframe.H4: 14400,
Timeframe.D1: 86400,
}
def _bar_epoch(tf: Timeframe, now: Optional[float] = None) -> int:
"""Epoch of the most recent candle boundary for ``tf`` (UTC-anchored,
the same grid _seconds_until_boundary sleeps to)."""
secs = _TF_SECONDS_CACHE.get(tf, 900)
t = int(time.time() if now is None else now)
return t - t % secs
def _remember_scan(symbol: str, tf: Timeframe, report: SignalReport) -> None:
_last_scan[f"{symbol}_{tf.value}"] = (_bar_epoch(tf), report)
async def _cached_or_scan(
symbol: str,
timeframe: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
"""run_signal, unless this bar already has a report for the key."""
cached = _last_scan.get(f"{symbol}_{timeframe.value}")
if cached is not None and cached[0] == _bar_epoch(timeframe):
logger.info(
f"{symbol} {timeframe.value}: reusing this bar's scan — no rescan")
return cached[1]
return await run_signal(symbol, timeframe, confirmation_tfs)
# ── Alert gating (quality threshold + dedup) ───────────────────────── # ── Alert gating (quality threshold + dedup) ─────────────────────────
@@ -572,17 +613,22 @@ def _passes_quality(quality: str) -> bool:
return _QUALITY_RANK.get(quality, 0) >= _QUALITY_RANK.get(_alert_min_quality, 1) return _QUALITY_RANK.get(quality, 0) >= _QUALITY_RANK.get(_alert_min_quality, 1)
_last_alert_quality: Dict[str, str] = {} # key → grade of last ALERTED signal
def _alert_fingerprint(report: SignalReport) -> tuple: def _alert_fingerprint(report: SignalReport) -> tuple:
"""Alert identity for dedup: signal, quality, setup direction. """Alert identity for dedup: signal and setup direction.
Entry movement is checked separately in _should_alert against the Entry movement is checked separately in _should_alert against the
last ALERTED entry/risk (a stable reference). Deriving a price bucket last ALERTED entry/risk (a stable reference). Deriving a price bucket
from the CURRENT SL distance here made the fingerprint churn on from the CURRENT SL distance here made the fingerprint churn on
normal ATR/anchor drift — near-duplicate alerts every candle.""" normal ATR/anchor drift — near-duplicate alerts every candle. The
grade is deliberately NOT part of the identity (2026-09 audit: an
A↔B flicker on an unchanged idea re-sent the full alert ~4× in 45
min); a grade UPGRADE re-alerts via _should_alert instead."""
setup = report.trade_setup setup = report.trade_setup
return ( return (
report.final_signal.name, report.final_signal.name,
report.signal_quality,
setup.direction if setup else None, setup.direction if setup else None,
) )
@@ -602,6 +648,12 @@ def _should_alert(key: str, report: SignalReport) -> bool:
return True return True
if time.time() - _last_alert_ts.get(key, 0) >= _ALERT_REARM_SECONDS: if time.time() - _last_alert_ts.get(key, 0) >= _ALERT_REARM_SECONDS:
return True return True
# Same idea at a HIGHER grade than last alerted → worth re-sending;
# a downgrade or flicker back is not.
last_q = _last_alert_quality.get(key)
if last_q is not None and (_QUALITY_RANK.get(report.signal_quality, 0)
> _QUALITY_RANK.get(last_q, 0)):
return True
setup = report.trade_setup setup = report.trade_setup
last = _last_alert_setup.get(key) last = _last_alert_setup.get(key)
if setup is not None and last is not None: if setup is not None and last is not None:
@@ -614,6 +666,7 @@ def _should_alert(key: str, report: SignalReport) -> bool:
def _commit_alert(key: str, report: SignalReport) -> None: def _commit_alert(key: str, report: SignalReport) -> None:
_last_alert_fp[key] = _alert_fingerprint(report) _last_alert_fp[key] = _alert_fingerprint(report)
_last_alert_ts[key] = time.time() _last_alert_ts[key] = time.time()
_last_alert_quality[key] = report.signal_quality
setup = report.trade_setup setup = report.trade_setup
if setup is not None: if setup is not None:
_last_alert_setup[key] = ( _last_alert_setup[key] = (
@@ -871,7 +924,7 @@ async def _handle_breach(bot, pair_symbol: str, price: float, level_name: str) -
logger.info(f"Level breach: {display} crossed {label} at {price} — rescanning M15") logger.info(f"Level breach: {display} crossed {label} at {price} — rescanning M15")
try: try:
report = await run_signal(pair_symbol, Timeframe.M15) report = await _cached_or_scan(pair_symbol, Timeframe.M15)
_update_level_cache([report]) _update_level_cache([report])
except Exception: except Exception:
logger.exception(f"Breach scan failed for {display}") logger.exception(f"Breach scan failed for {display}")
+918 -2
View File
File diff suppressed because it is too large Load Diff