Compare commits
136 Commits
31002cb0a6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 250078c987 | |||
| d1d002a4f7 | |||
| edefd90aaa | |||
| b21c75a1f8 | |||
| a2f951f5c9 | |||
| 858c12280a | |||
| 525a1cd1c1 | |||
| b693f757f8 | |||
| 7dbcbc6c82 | |||
| 5b3f47f3e1 | |||
| 99eb268cf3 | |||
| 18e4fd0403 | |||
| ebb83749ef | |||
| 8acc1e18f8 | |||
| 28a1c375bd | |||
| 775330ff16 | |||
| 1f54452007 | |||
| be5dd4a25b | |||
| 0445a20a76 | |||
| 4e654b5edb | |||
| 9e1c321f05 | |||
| 6fff06ff90 | |||
| f7d8f1113c | |||
| bcda6e0ebd | |||
| 042b079861 | |||
| dc7beb2877 | |||
| 637c39e201 | |||
| 083a4e8d63 | |||
| f760e9070a | |||
| 9a384935bf | |||
| d669227ddc | |||
| c3329d6cd5 | |||
| 7a810c4a4c | |||
| 3a95a37a0c | |||
| f814485615 | |||
| 65a1debc4e | |||
| 1499bb7cf2 | |||
| 902122dbfe | |||
| f0f9dcb169 | |||
| c8a2c83820 | |||
| 2d3e524f1c | |||
| f017f5aef5 | |||
| a56a622846 | |||
| b9633389fa | |||
| 6e2fdbd684 | |||
| 80a2b89769 | |||
| 1610507d57 | |||
| 1dab14d298 | |||
| cbc01565a2 | |||
| 53b65e1bbb | |||
| 1a141abc7c | |||
| 096b69686d | |||
| c21c102536 | |||
| c87bb14830 | |||
| a3279e54fa | |||
| 1bce289132 | |||
| f3d3e56aa8 | |||
| 14448c10e8 | |||
| 665ffdda48 | |||
| fe7d4c5d4a | |||
| 4d54fddf80 | |||
| aaf1d37b14 | |||
| 18b5ef27d2 | |||
| 26f0681ad1 | |||
| dde41fbeb8 | |||
| 6f69b9dd90 | |||
| a2bf80054c | |||
| 60fb526fab | |||
| 18abff85a3 | |||
| 183f148bb4 | |||
| 528a308d36 | |||
| 9c72e90ce2 | |||
| 8b086f4f49 | |||
| f02f4b4d37 | |||
| 74829541d6 | |||
| 6546b89d6b | |||
| 9ed07d4b65 | |||
| 0da5ed03af | |||
| db50bf02d7 | |||
| 1bc48915bd | |||
| 9d3729ea73 | |||
| f3b90d3862 | |||
| ea306df484 | |||
| 1038a53bac | |||
| 305f1d1857 | |||
| 0393087168 | |||
| 8687f98b39 | |||
| 2291fc3936 | |||
| 3a85c6e23b | |||
| 09ee5208d4 | |||
| 1f076c0b54 | |||
| baeee4997a | |||
| bb40bceb4c | |||
| c46bb85012 | |||
| cc53448c34 | |||
| 15a7074574 | |||
| 35e9b3750d | |||
| 68cc5b28a5 | |||
| 5c0adfab27 | |||
| b7bfd9c830 | |||
| 04328eaeab | |||
| bb3c475f22 | |||
| 31ecdf4292 | |||
| 39c91c1aa4 | |||
| cd69980f1f | |||
| 67a9169eb3 | |||
| f99fcc60b9 | |||
| 4d3f05e2ba | |||
| 2deea1ed7f | |||
| 89030c96b6 | |||
| 59a18b8bc6 | |||
| 49260abaa2 | |||
| 4ce09c2f79 | |||
| 02eedcc875 | |||
| 8ead05432e | |||
| 90b7e0c54e | |||
| 058ef57d8f | |||
| cc66e65f2d | |||
| 873cf5487e | |||
| a708609f8b | |||
| 7ccd8c4b04 | |||
| 2e09237671 | |||
| a12084537d | |||
| a55aca21b1 | |||
| e57f4a1ecd | |||
| 17cb63e86e | |||
| 5a392a578d | |||
| be42736223 | |||
| 2e5d66db1b | |||
| fb1eb5b73f | |||
| 28a00b7da0 | |||
| 3e078cfe7d | |||
| ce76684a62 | |||
| 045f4248f3 | |||
| c04f451c0c | |||
| 0a374cfea7 |
@@ -11,9 +11,8 @@ Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtest
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
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,
|
||||
# --panel to run the advisory LLM agent panel on-demand)
|
||||
python test_all.py # QA suite: 359 tests, 22 modules, no network (mocked)
|
||||
python main.py --pair XAUUSD --timeframe H1 --detail # CLI signal report (--json for full dump)
|
||||
python telegram_bot.py # the bot (refuses to boot without credentials)
|
||||
python oanda_daemon.py # auto-execution daemon on Oanda practice account
|
||||
python dashboard_publisher.py --once # web dashboard: one scan+publish cycle to $DASHBOARD_OUT_DIR
|
||||
@@ -29,13 +28,13 @@ 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)
|
||||
```
|
||||
|
||||
**`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.
|
||||
**`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 weekend implementations (`providers/base.py`, `signal_aggregator.py`, and an inline copy in `backtest/gpu_backtest.py:_is_xau_blocked_by_calendar`); patching the provider one does not reach the other two.
|
||||
|
||||
Python 3.12; a `venv/` exists in-repo. Dependencies in `requirements.txt` (numpy, pandas, requests, twelvedata, python-telegram-bot ≥20, websockets).
|
||||
|
||||
## Configuration & credentials
|
||||
|
||||
Precedence for every credential/deployment setting: **env var → config.json → refuse to run** (no hardcoded fallbacks). A gitignored repo-root `.env` is loaded at `app_config` import via `os.environ.setdefault`. `config.json` (gitignored; schema in `config.json.example`) holds secrets plus deployment settings: `data_providers.active` (the live-feed switch), `data_providers.streaming` (optional tick-stream provider for the level-breach monitor), `scanning.pairs`/`timeframes`, per-pair `blocked_hours_utc`, `log_level`. Key env vars: `TELEGRAM_TOKEN`, `TELEGRAM_CHAT_ID`, `TELEGRAM_CHANNEL_ID`, `TWELVE_DATA_API_KEY`, `TIINGO_API_KEY`, `OANDA_ACCOUNT_ID`/`OANDA_API_TOKEN`/`OANDA_BASE_URL`, `DEEPSEEK_API_KEY` (news LLM; the `deepseek` config.json block holds model/enabled/timeout/daily cap). The `agent_panel` config.json block (all-defaults = feature dark) controls the advisory LLM agent panel: `enabled`, per-surface `telegram`/`dashboard` toggles, `min_quality`, `max_panels_per_day`, `reuse_ttl_seconds`, plus the technical-payload enrichment knobs `candles_enabled`/`candle_bars_primary`/`candle_bars_confirm`. The dark-by-default `market_outlook` block controls the hourly advisory market brief: `enabled`, `telegram`, `max_per_day`, `candles_bars`; `MARKET_OUTLOOK_ENABLED` overrides its enabled setting.
|
||||
Precedence for every credential/deployment setting: **env var → config.json → refuse to run** (no hardcoded fallbacks). A gitignored repo-root `.env` is loaded at `app_config` import via `os.environ.setdefault`. `config.json` (gitignored; schema in `config.json.example`) holds secrets plus deployment settings: `data_providers.active` (the live-feed switch), `data_providers.streaming` (optional tick-stream provider for the level-breach monitor), `scanning.pairs`/`timeframes`, per-pair `blocked_hours_utc`, `log_level`. Key env vars: `TELEGRAM_TOKEN`, `TELEGRAM_CHAT_ID`, `TELEGRAM_CHANNEL_ID`, `TWELVE_DATA_API_KEY`, `TIINGO_API_KEY`, `OANDA_ACCOUNT_ID`/`OANDA_API_TOKEN`/`OANDA_BASE_URL`, `DEEPSEEK_API_KEY` (news LLM; the `deepseek` config.json block holds model/enabled/timeout/daily cap).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -58,19 +57,16 @@ 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.
|
||||
- **`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.
|
||||
- **`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, 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).
|
||||
- **`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].
|
||||
- **`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 (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 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; 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.
|
||||
- **`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.
|
||||
- **`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`.
|
||||
- **`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.
|
||||
|
||||
## Rules for making changes (read before editing signal logic)
|
||||
|
||||
1. **Never change a tuned value without re-validation.** Acceptance rule: positive total R in BOTH train and validation halves of a ≥12-month window, PF ≥ 1.3, at realistic cost (XAU $0.65 round trip headline; retain $0.30 for TD-era cross-book comparability), trailing exits, next-open fills. `round_trip_cost` stays $0.30 because it also drives the live TP spread gate; changing it is a separate rule-1 event. Marginal edges must additionally pass the 2024-06→2025-03 out-of-sample window — within-year splits alone have approved configs that later failed OOS.
|
||||
1. **Never change a tuned value without re-validation.** Acceptance rule: positive total R in BOTH train and validation halves of a ≥12-month window, PF ≥ 1.3, at realistic cost (XAU $0.30 round trip), trailing exits, next-open fills. Marginal edges must additionally pass the 2024-06→2025-03 out-of-sample window — within-year splits alone have approved configs that later failed OOS.
|
||||
2. **Per-timeframe overrides live in the pair factory**, never in global defaults — the same knob has opposite effects on different TFs (proven repeatedly: `tp_floor_rr`, hour-9 block, trail params). Document validation evidence in the factory docstring.
|
||||
3. **Pair isolation is a hard invariant.** XAU gates in the aggregator are display-name-gated so a future pair port cannot inherit gold-calibrated logic by accident.
|
||||
4. **Live == backtest parity.** If you add a live gate, add it to the replay path in the same commit; all time filters evaluate bar-close time in both paths.
|
||||
@@ -82,16 +78,12 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
|
||||
|
||||
## 2026-08-04 QA audit — owner decisions ledger
|
||||
|
||||
- **Weekend calendar: FIXED (owner-approved 2026-08-04).** All three signal-path `_is_forex_weekend` copies are NY-anchored (Fri 17:00 → Sun 18:00 ET; during US DST that is Fri 21:00 UTC — measured on 5yr of Oanda bars). Kills the weekly false-"STALE DATA/feed lagging" hours. Book impact at the audit HEAD: M15 byte-identical; M5 12mo dropped exactly ONE trade — a phantom whose signal fired on the final summer-Friday bar (close 21:00 UTC) and "filled" at Sunday's reopen 49 h later (−1.08R, live-impossible), leaving 555 trades / +98.64R / PF≈1.50 @ $0.65 (was 556/+97.56/1.48). The current-HEAD canonical reference is recorded below. A Module 21 test sweeps those copies plus the advisory outcome's continuity copy hourly across both DST transitions.
|
||||
- **Weekend calendar: FIXED (owner-approved 2026-08-04).** All three `_is_forex_weekend` copies are NY-anchored (Fri 17:00 → Sun 18:00 ET; during US DST that is Fri 21:00 UTC — measured on 5yr of Oanda bars). Kills the weekly false-"STALE DATA/feed lagging" hours. Book impact, fully accounted: M15 byte-identical; M5 12mo dropped exactly ONE trade — a phantom whose signal fired on the final summer-Friday bar (close 21:00 UTC) and "filled" at Sunday's reopen 49 h later (−1.08R, live-impossible). M5 12mo reference at HEAD: 555 trades, +98.64R PF≈1.50 @ $0.65 (was 556/+97.56/1.48). A Module 21 test sweeps all three copies hourly across both DST transitions.
|
||||
- **Spread in exit-trigger detection (rule 6): owner DECLINED 2026-08-04.** Backtest stop triggers stay on raw mid High/Low; the measured optimism (~4–6% of total R: −3.55R @ $0.30, −4.24R @ $0.65 on the M15 12mo book) is accepted and books stay comparable. Re-open only as a conscious re-pin campaign.
|
||||
- **`FB2_EMA_LO`: HARDENED 2026-08-04 (delegated).** Research override for the EMA200-proximity band, default = tuned 0.5; an unparseable value now degrades to the default with a warning instead of crashing every scan. Documented here — it is a knob that exists.
|
||||
- **H4 seeding divergence: parked.** Live H4 fetches only 380 bars vs the backtest's unbounded history — ~2% of sampled H4 bars flip an indicator signal (composite Δ up to 12.3). M5/M15/H1 clean at their real fetch depths. Fold into any future H4 campaign; H4 remains "unproven for auto-execution".
|
||||
|
||||
## Current state notes (2026-08-14)
|
||||
## Current state notes (2026-07-30)
|
||||
|
||||
- Oanda v20 is the live feed (`data_providers.active: "oanda"`). The 2026-08-04 re-pin made the 27 `backtest/oanda_*.csv` books the baseline; all TD-era books are superseded. Current-HEAD M5 canonical book: `backtest/oanda_m5_12mo_065_nopb_20260814.csv`, +106.82R PF 1.51 at $0.65 (both halves positive; PF 1.46 / 1.55). M15 reference: +73.93R PF 1.53. See `backtest/research/repin_verification_2026-08-14.md`.
|
||||
- Cost reporting is dual: $0.65 is the measured-Oanda realistic headline ($0.65-$0.72 round trip), while $0.30 remains for cross-book comparability and as the live TP spread-gate input. Raising `round_trip_cost` from $0.30 would move the TP floor from about $9 to $19.50 and requires full rule-1 revalidation.
|
||||
- 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.
|
||||
- Oanda v20 is the live feed (`data_providers.active: "oanda"`), but the pinned backtest books are still Twelve-Data-pinned and the backtest cost assumption is $0.30 while measured real spreads are ~$0.65–0.72 — re-pin books on Oanda bars before treating feed-switch comparisons as valid (see Oanda/TD divergence study in memory).
|
||||
- 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).
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
"""Deterministic advisory trade outcomes from stored closed candles.
|
||||
|
||||
This module measures the hypothetical setup reviewed by the LLM panel. It
|
||||
never changes a signal, panel verdict, order, or daemon state. The model uses
|
||||
the same next-open reanchoring and close-only trailing rules as the backtester,
|
||||
but its evidence is the cleaned mid-candle snapshots stored in signals.db.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
MODEL = "stored-closed-mid-next-open-trail-v1"
|
||||
MAX_HOLD_BARS = 300
|
||||
REFERENCE_COST = 0.30
|
||||
REALISTIC_COST = 0.65
|
||||
|
||||
_TF_ALIASES = {
|
||||
"M1": "1min", "1MIN": "1min", "1min": "1min",
|
||||
"M5": "5min", "5MIN": "5min", "5min": "5min",
|
||||
"M15": "15min", "15MIN": "15min", "15min": "15min",
|
||||
"M30": "30min", "30MIN": "30min", "30min": "30min",
|
||||
"H1": "1h", "1H": "1h", "1h": "1h",
|
||||
"H4": "4h", "4H": "4h", "4h": "4h",
|
||||
"D1": "1day", "1D": "1day", "1d": "1day",
|
||||
"1DAY": "1day", "1day": "1day",
|
||||
}
|
||||
|
||||
_TF_SECONDS = {
|
||||
"1min": 60,
|
||||
"5min": 5 * 60,
|
||||
"15min": 15 * 60,
|
||||
"30min": 30 * 60,
|
||||
"1h": 60 * 60,
|
||||
"4h": 4 * 60 * 60,
|
||||
"1day": 24 * 60 * 60,
|
||||
}
|
||||
|
||||
_NY_TZ = ZoneInfo("America/New_York")
|
||||
|
||||
|
||||
def _is_forex_weekend(now: datetime) -> bool:
|
||||
"""NY-anchored XAU weekend closure; test-locked to the signal copies."""
|
||||
ny = now.astimezone(_NY_TZ)
|
||||
weekday = ny.weekday()
|
||||
if weekday == 4:
|
||||
return ny.hour >= 17
|
||||
if weekday == 5:
|
||||
return True
|
||||
if weekday == 6:
|
||||
return ny.hour < 18
|
||||
return False
|
||||
|
||||
|
||||
def normalize_timeframe(value: Any) -> str:
|
||||
text = str(getattr(value, "value", value) or "")
|
||||
return _TF_ALIASES.get(text, _TF_ALIASES.get(text.upper(), text))
|
||||
|
||||
|
||||
def timeframe_value(mapping: Any, timeframe: Any) -> Any:
|
||||
"""Read a timeframe-keyed snapshot regardless of enum/storage alias."""
|
||||
if not isinstance(mapping, dict):
|
||||
return None
|
||||
wanted = normalize_timeframe(timeframe)
|
||||
for key, value in mapping.items():
|
||||
if normalize_timeframe(key) == wanted:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _finite(value: Any) -> Optional[float]:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if math.isfinite(number) else None
|
||||
|
||||
|
||||
def _timestamp(value: Any) -> Optional[datetime]:
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace(" ", "T"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def initial_outcome(
|
||||
setup: Optional[Dict[str, Any]],
|
||||
indicators: Optional[Dict[str, Any]],
|
||||
timeframe: Any,
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""Freeze the setup and deterministic evaluation policy."""
|
||||
if not setup:
|
||||
return "NOT_APPLICABLE", {
|
||||
"model": MODEL,
|
||||
"status": "NOT_APPLICABLE",
|
||||
"reason": "panel row has no trade setup",
|
||||
}
|
||||
|
||||
tf = normalize_timeframe(timeframe)
|
||||
analysis = timeframe_value(indicators, tf)
|
||||
signal_bar = analysis.get("timestamp") if isinstance(analysis, dict) else None
|
||||
direction = str(setup.get("direction", ""))
|
||||
entry = _finite(setup.get("entry"))
|
||||
stop = _finite(setup.get("stop_loss"))
|
||||
tp1 = _finite(setup.get("take_profit_1"))
|
||||
arm = _finite(setup.get("trail_arm_r"))
|
||||
distance = _finite(setup.get("trail_dist_r"))
|
||||
risk = abs(entry - stop) if entry is not None and stop is not None else 0.0
|
||||
if (direction not in ("BUY", "SELL") or not signal_bar
|
||||
or _timestamp(signal_bar) is None or risk <= 0 or tp1 is None
|
||||
or arm is None or distance is None or arm < 0 or distance < 0):
|
||||
return "UNRESOLVABLE", {
|
||||
"model": MODEL,
|
||||
"status": "UNRESOLVABLE",
|
||||
"reason": "invalid or incomplete setup/outcome seed",
|
||||
}
|
||||
|
||||
state = {
|
||||
"model": MODEL,
|
||||
"status": "PENDING",
|
||||
"timeframe": tf,
|
||||
"signal_bar_at": str(signal_bar),
|
||||
"setup": {
|
||||
"direction": direction,
|
||||
"reported_entry": entry,
|
||||
"stop_distance": risk,
|
||||
"tp1_distance": abs(tp1 - entry),
|
||||
"strategy": setup.get("strategy"),
|
||||
"setup_pattern": setup.get("setup_pattern"),
|
||||
},
|
||||
"policy": {
|
||||
"entry": "next_primary_bar_open_reanchored",
|
||||
"max_hold_bars": MAX_HOLD_BARS,
|
||||
"trail_arm_r": arm,
|
||||
"trail_dist_r": distance,
|
||||
"tp1_is_advisory": arm > 0,
|
||||
"reference_cost": REFERENCE_COST,
|
||||
"realistic_cost": REALISTIC_COST,
|
||||
},
|
||||
"progress": {
|
||||
"last_processed_bar": str(signal_bar),
|
||||
"entry_at": None,
|
||||
"entry_price": None,
|
||||
"initial_stop": None,
|
||||
"current_stop": None,
|
||||
"peak": None,
|
||||
"bars_held": 0,
|
||||
"mfe_r": 0.0,
|
||||
"mae_r": 0.0,
|
||||
},
|
||||
"result": None,
|
||||
}
|
||||
return "PENDING", state
|
||||
|
||||
|
||||
def _valid_bars(bars: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
valid = []
|
||||
for bar in bars:
|
||||
stamp = _timestamp(bar.get("timestamp")) if isinstance(bar, dict) else None
|
||||
values = [_finite(bar.get(key)) for key in ("open", "high", "low", "close")]
|
||||
if stamp is None or any(value is None for value in values):
|
||||
continue
|
||||
valid.append({
|
||||
"timestamp": str(bar["timestamp"]),
|
||||
"_timestamp": stamp,
|
||||
"open": values[0], "high": values[1],
|
||||
"low": values[2], "close": values[3],
|
||||
})
|
||||
valid.sort(key=lambda item: item["_timestamp"])
|
||||
return valid
|
||||
|
||||
|
||||
def _has_continuity(
|
||||
bars: List[Dict[str, Any]], last: datetime, timeframe: str
|
||||
) -> bool:
|
||||
"""Reject missing tradable bars while allowing scheduled XAU closures."""
|
||||
seconds = _TF_SECONDS.get(normalize_timeframe(timeframe))
|
||||
if seconds is None:
|
||||
return False
|
||||
step = timedelta(seconds=seconds)
|
||||
previous = last
|
||||
seen = {last}
|
||||
for bar in bars:
|
||||
stamp = bar["_timestamp"]
|
||||
if stamp <= previous or stamp in seen:
|
||||
continue
|
||||
expected = previous + step
|
||||
while expected < stamp:
|
||||
ny = expected.astimezone(_NY_TZ)
|
||||
daily_maintenance = ny.weekday() < 4 and ny.hour == 17
|
||||
if not (_is_forex_weekend(expected) or daily_maintenance):
|
||||
return False
|
||||
expected += step
|
||||
if expected != stamp:
|
||||
return False
|
||||
previous = stamp
|
||||
seen.add(stamp)
|
||||
return True
|
||||
|
||||
|
||||
def _finish(
|
||||
state: Dict[str, Any], reason: str, exit_at: str, exit_price: float
|
||||
) -> None:
|
||||
setup = state["setup"]
|
||||
progress = state["progress"]
|
||||
entry = float(progress["entry_price"])
|
||||
risk = float(setup["stop_distance"])
|
||||
pnl = ((exit_price - entry) if setup["direction"] == "BUY"
|
||||
else (entry - exit_price))
|
||||
gross_r = pnl / risk
|
||||
state["status"] = "RESOLVED"
|
||||
state["result"] = {
|
||||
"exit_at": exit_at,
|
||||
"exit_price": exit_price,
|
||||
"exit_reason": reason,
|
||||
"bars_held": progress["bars_held"],
|
||||
"gross_r": gross_r,
|
||||
"net_r_030": (pnl - REFERENCE_COST) / risk,
|
||||
"net_r_065": (pnl - REALISTIC_COST) / risk,
|
||||
"mfe_r": progress["mfe_r"],
|
||||
"mae_r": progress["mae_r"],
|
||||
}
|
||||
|
||||
|
||||
def advance_outcome(
|
||||
state: Dict[str, Any], bars: Iterable[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Advance one pending outcome with overlapping closed-bar snapshots."""
|
||||
if state.get("status") != "PENDING":
|
||||
return state
|
||||
try:
|
||||
progress = state["progress"]
|
||||
setup = state["setup"]
|
||||
policy = state["policy"]
|
||||
last = _timestamp(progress["last_processed_bar"])
|
||||
signal_bar = _timestamp(state["signal_bar_at"])
|
||||
if last is None or signal_bar is None:
|
||||
raise ValueError("invalid progress timestamp")
|
||||
|
||||
buy = setup["direction"] == "BUY"
|
||||
risk = float(setup["stop_distance"])
|
||||
trail_arm = float(policy["trail_arm_r"])
|
||||
trail_distance = float(policy["trail_dist_r"])
|
||||
|
||||
valid_bars = _valid_bars(bars)
|
||||
has_future = any(bar["_timestamp"] > last for bar in valid_bars)
|
||||
has_overlap = any(bar["_timestamp"] == last for bar in valid_bars)
|
||||
if has_future and not has_overlap:
|
||||
state["status"] = "UNRESOLVABLE"
|
||||
state["result"] = None
|
||||
state["reason"] = "stored candle snapshots lost continuity"
|
||||
return state
|
||||
if has_future and not _has_continuity(valid_bars, last,
|
||||
state["timeframe"]):
|
||||
state["status"] = "UNRESOLVABLE"
|
||||
state["result"] = None
|
||||
state["reason"] = "stored candle snapshots contain an internal gap"
|
||||
return state
|
||||
|
||||
for bar in valid_bars:
|
||||
stamp = bar["_timestamp"]
|
||||
if stamp <= signal_bar or stamp <= last:
|
||||
continue
|
||||
|
||||
if progress["entry_price"] is None:
|
||||
entry = bar["open"]
|
||||
initial_stop = entry - risk if buy else entry + risk
|
||||
progress.update({
|
||||
"entry_at": bar["timestamp"],
|
||||
"entry_price": entry,
|
||||
"initial_stop": initial_stop,
|
||||
"current_stop": initial_stop,
|
||||
"peak": entry,
|
||||
})
|
||||
|
||||
entry = float(progress["entry_price"])
|
||||
current_stop = float(progress["current_stop"])
|
||||
initial_stop = float(progress["initial_stop"])
|
||||
progress["bars_held"] += 1
|
||||
progress["last_processed_bar"] = bar["timestamp"]
|
||||
last = stamp
|
||||
|
||||
if buy:
|
||||
mfe = (bar["high"] - entry) / risk
|
||||
mae = (entry - bar["low"]) / risk
|
||||
stop_hit = bar["low"] <= current_stop
|
||||
else:
|
||||
mfe = (entry - bar["low"]) / risk
|
||||
mae = (bar["high"] - entry) / risk
|
||||
stop_hit = bar["high"] >= current_stop
|
||||
progress["mfe_r"] = max(float(progress["mfe_r"]), max(0.0, mfe))
|
||||
progress["mae_r"] = max(float(progress["mae_r"]), max(0.0, mae))
|
||||
|
||||
if stop_hit:
|
||||
exit_price = current_stop
|
||||
if (buy and bar["open"] < current_stop) or \
|
||||
(not buy and bar["open"] > current_stop):
|
||||
exit_price = bar["open"]
|
||||
reason = "SL" if current_stop == initial_stop else "TRAIL"
|
||||
_finish(state, reason, bar["timestamp"], exit_price)
|
||||
return state
|
||||
|
||||
if trail_arm <= 0:
|
||||
target = entry + setup["tp1_distance"] if buy \
|
||||
else entry - setup["tp1_distance"]
|
||||
target_hit = bar["high"] >= target if buy else bar["low"] <= target
|
||||
if target_hit:
|
||||
exit_price = target
|
||||
if (buy and bar["open"] > target) or \
|
||||
(not buy and bar["open"] < target):
|
||||
exit_price = bar["open"]
|
||||
_finish(state, "TP1", bar["timestamp"], exit_price)
|
||||
return state
|
||||
else:
|
||||
favorable = bar["high"] if buy else bar["low"]
|
||||
peak = max(float(progress["peak"]), favorable) if buy \
|
||||
else min(float(progress["peak"]), favorable)
|
||||
progress["peak"] = peak
|
||||
excursion = ((peak - entry) if buy else (entry - peak)) / risk
|
||||
if excursion >= trail_arm:
|
||||
level = peak - trail_distance * risk if buy \
|
||||
else peak + trail_distance * risk
|
||||
progress["current_stop"] = max(current_stop, level) if buy \
|
||||
else min(current_stop, level)
|
||||
|
||||
if progress["bars_held"] >= int(policy["max_hold_bars"]):
|
||||
_finish(state, "TIMEOUT", bar["timestamp"], bar["close"])
|
||||
return state
|
||||
return state
|
||||
except Exception as exc:
|
||||
state["status"] = "UNRESOLVABLE"
|
||||
state["result"] = None
|
||||
state["reason"] = f"outcome evaluator error: {type(exc).__name__}"
|
||||
return state
|
||||
|
||||
|
||||
def canonical_r(state: Dict[str, Any]) -> Optional[float]:
|
||||
result = state.get("result")
|
||||
value = result.get("net_r_065") if isinstance(result, dict) else None
|
||||
return _finite(value)
|
||||
-1019
File diff suppressed because it is too large
Load Diff
@@ -1,951 +0,0 @@
|
||||
#!/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())
|
||||
+1
-133
@@ -86,8 +86,7 @@ _VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
||||
_SCANNABLE_TFS = ("M5", "M15", "H1", "H4")
|
||||
|
||||
_KNOWN_TOP = {"telegram", "data_providers", "scanning", "pairs", "log_level",
|
||||
"oanda", "deepseek", "agent_panel", "market_outlook",
|
||||
"signal_log"}
|
||||
"oanda", "deepseek"}
|
||||
_KNOWN_OANDA = {
|
||||
"account_id", "api_token", "base_url", "lot_size",
|
||||
"quality_gate", "max_hold_bars",
|
||||
@@ -95,24 +94,10 @@ _KNOWN_OANDA = {
|
||||
_KNOWN_DEEPSEEK = {
|
||||
"api_key", "model", "enabled", "timeout_seconds", "daily_request_cap",
|
||||
}
|
||||
_KNOWN_AGENT_PANEL = {
|
||||
"enabled", "telegram", "dashboard", "min_quality",
|
||||
"max_panels_per_day", "reuse_ttl_seconds",
|
||||
"candles_enabled", "candle_bars_primary", "candle_bars_confirm",
|
||||
}
|
||||
_KNOWN_MARKET_OUTLOOK = {"enabled", "telegram", "max_per_day", "candles_bars"}
|
||||
_KNOWN_SIGNAL_LOG = {"enabled", "path", "log_blocked_scans"}
|
||||
_KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"}
|
||||
_KNOWN_SCANNING = {"pairs", "timeframes"}
|
||||
_KNOWN_PAIR_KEYS = {"blocked_hours_utc"}
|
||||
|
||||
_MARKET_OUTLOOK_DEFAULTS = {
|
||||
"enabled": False,
|
||||
"telegram": False,
|
||||
"max_per_day": 12,
|
||||
"candles_bars": 20,
|
||||
}
|
||||
|
||||
|
||||
class AppConfigError(Exception):
|
||||
"""config.json is present but invalid — fail fast at boot."""
|
||||
@@ -133,10 +118,6 @@ class AppConfig:
|
||||
pair_overrides: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
oanda_settings: Dict[str, Any] = field(default_factory=dict)
|
||||
deepseek_settings: Dict[str, Any] = field(default_factory=dict)
|
||||
agent_panel_settings: Dict[str, Any] = field(default_factory=dict)
|
||||
market_outlook_settings: Dict[str, Any] = field(
|
||||
default_factory=lambda: dict(_MARKET_OUTLOOK_DEFAULTS))
|
||||
signal_log_settings: Dict[str, Any] = field(default_factory=dict)
|
||||
log_level: str = "INFO"
|
||||
|
||||
|
||||
@@ -295,72 +276,6 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
|
||||
"config.json: deepseek.model must be a non-empty string")
|
||||
app.deepseek_settings = dict(deepseek)
|
||||
|
||||
panel = raw.get("agent_panel", {})
|
||||
_check_keys(panel, _KNOWN_AGENT_PANEL, '"agent_panel"')
|
||||
for flag in ("enabled", "telegram", "dashboard"):
|
||||
if flag in panel and not isinstance(panel[flag], bool):
|
||||
raise AppConfigError(
|
||||
f"config.json: agent_panel.{flag} must be true or false")
|
||||
if "min_quality" in panel and panel["min_quality"] not in _VALID_QUALITIES:
|
||||
raise AppConfigError(
|
||||
f'config.json: agent_panel.min_quality "{panel["min_quality"]}" '
|
||||
f"not in {_VALID_QUALITIES}")
|
||||
if "max_panels_per_day" in panel:
|
||||
mpd = panel["max_panels_per_day"]
|
||||
if isinstance(mpd, bool) or not isinstance(mpd, int) or mpd < 1:
|
||||
raise AppConfigError(
|
||||
"config.json: agent_panel.max_panels_per_day must be a "
|
||||
"positive integer")
|
||||
if "reuse_ttl_seconds" in panel:
|
||||
ttl = panel["reuse_ttl_seconds"]
|
||||
if isinstance(ttl, bool) or not isinstance(ttl, (int, float)) \
|
||||
or ttl < 0:
|
||||
raise AppConfigError(
|
||||
"config.json: agent_panel.reuse_ttl_seconds must be a "
|
||||
"non-negative number")
|
||||
if "candles_enabled" in panel \
|
||||
and not isinstance(panel["candles_enabled"], bool):
|
||||
raise AppConfigError(
|
||||
"config.json: agent_panel.candles_enabled must be true or false")
|
||||
for key in ("candle_bars_primary", "candle_bars_confirm"):
|
||||
if key in panel:
|
||||
n = panel[key]
|
||||
if isinstance(n, bool) or not isinstance(n, int) \
|
||||
or not 1 <= n <= 100:
|
||||
raise AppConfigError(
|
||||
f"config.json: agent_panel.{key} must be an integer "
|
||||
"between 1 and 100")
|
||||
app.agent_panel_settings = dict(panel)
|
||||
|
||||
outlook = raw.get("market_outlook", {})
|
||||
_check_keys(outlook, _KNOWN_MARKET_OUTLOOK, '"market_outlook"')
|
||||
for flag in ("enabled", "telegram"):
|
||||
if flag in outlook and not isinstance(outlook[flag], bool):
|
||||
raise AppConfigError(
|
||||
f"config.json: market_outlook.{flag} must be true or false")
|
||||
for key in ("max_per_day", "candles_bars"):
|
||||
if key in outlook:
|
||||
value = outlook[key]
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise AppConfigError(
|
||||
f"config.json: market_outlook.{key} must be a positive integer")
|
||||
app.market_outlook_settings.update(outlook)
|
||||
|
||||
slog = raw.get("signal_log", {})
|
||||
_check_keys(slog, _KNOWN_SIGNAL_LOG, '"signal_log"')
|
||||
if "enabled" in slog and not isinstance(slog["enabled"], bool):
|
||||
raise AppConfigError(
|
||||
"config.json: signal_log.enabled must be true or false")
|
||||
if "path" in slog and (not isinstance(slog["path"], str)
|
||||
or not slog["path"]):
|
||||
raise AppConfigError(
|
||||
"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)
|
||||
|
||||
level = raw.get("log_level", app.log_level)
|
||||
if level not in _VALID_LOG_LEVELS:
|
||||
raise AppConfigError(
|
||||
@@ -460,53 +375,6 @@ def deepseek_api_key(app: Optional[AppConfig] = None) -> str:
|
||||
app.deepseek_settings.get("api_key", ""))
|
||||
|
||||
|
||||
# ── AI market-outlook resolution helper ──────────────────────────────
|
||||
|
||||
def market_outlook_enabled(app: Optional[AppConfig] = None) -> bool:
|
||||
"""Whether the advisory outlook feature is enabled (env wins)."""
|
||||
env = os.environ.get("MARKET_OUTLOOK_ENABLED")
|
||||
if env:
|
||||
return env.strip().lower() in ("1", "true", "yes", "on")
|
||||
app = app or get_app_config()
|
||||
return bool(app.market_outlook_settings.get("enabled", False))
|
||||
|
||||
|
||||
# ── Signal-log resolution helpers (env → config.json → default) ──────
|
||||
|
||||
def signal_log_enabled(app: Optional[AppConfig] = None) -> bool:
|
||||
"""Default ON — signal history is the platform's audit trail."""
|
||||
env = os.environ.get("SIGNAL_LOG_ENABLED")
|
||||
if env:
|
||||
return env.strip().lower() in ("1", "true", "yes", "on")
|
||||
app = app or get_app_config()
|
||||
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:
|
||||
"""Absolute path to the SQLite signal-history DB. A relative config
|
||||
value resolves against the repo root so CWD never matters."""
|
||||
env = os.environ.get("SIGNAL_LOG_PATH")
|
||||
if env:
|
||||
path = env
|
||||
else:
|
||||
app = app or get_app_config()
|
||||
path = str(app.signal_log_settings.get("path", "logs/signals.db"))
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), path)
|
||||
return path
|
||||
|
||||
|
||||
# ── PlatformConfig overlay ────────────────────────────────────────────
|
||||
|
||||
def apply_pair_overrides(
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
# AI signal enrichment — analysis + build record (2026-08-15)
|
||||
|
||||
Owner questions:
|
||||
1. If the AI panel also received indicator values and (limited) candle data, would the system be more accurate?
|
||||
2. If AI reviewed every candle alongside the local score system, would prediction accuracy justify the token burn — or is there a middle ground?
|
||||
|
||||
## What the panel already received (pre-change baseline)
|
||||
|
||||
The four personas (`agent_panel.py`) already got a *distilled* picture:
|
||||
per-indicator signal+confidence, MTF signals/scores, key levels,
|
||||
exhaustion count, full trade-setup math, warnings, headlines/events/
|
||||
sentiment. Missing: raw indicator **values** and any **candle** data.
|
||||
|
||||
Cost guardrails already in place: 5 requests/panel, `max_panels_per_day`
|
||||
20 (≤100 requests/day), 3600 s reuse-TTL, per-bar exact cache, shared
|
||||
500 req/day DeepSeek cap, static prompts on the ~50× prefix cache.
|
||||
|
||||
## Evidence used
|
||||
|
||||
- **LLM is strong at text, weak at raw numerics** (measured in-repo,
|
||||
`llm_news_eval_2026-08-06.md`): headline sentiment 100% (78/78) vs
|
||||
keyword scorer 31%; but ~11% of event-direction calls misread close
|
||||
numeric comparisons until code pre-computed the comparison
|
||||
(`vs_forecast`) and the model only mapped it to doctrine.
|
||||
- **The book any filter would act on** (M5 canonical,
|
||||
`oanda_m5_12mo_065_nopb_20260814.csv`): 577 trades, +106.82R,
|
||||
PF 1.51, win rate 52.9%; A-grade 59.5%, B-grade 51.4% (+67.8R of the
|
||||
total), C-grade 43.8% (−0.23R, 16 trades). Average edge ≈ 0.185R/trade.
|
||||
|
||||
## Answer 1 — enrich the existing panel? YES (cheap, safe)
|
||||
|
||||
- A compact candle is ~15–25 tokens; 20 primary-TF + 10 confirm-TF bars
|
||||
≈ 500–1,500 tokens per persona call → cents/day at the 20-panel cap.
|
||||
**Tokens are not the constraint; the request cap is.**
|
||||
- Measured after the build (dry-test wire log): enriched technical
|
||||
payload with 20 candles ≈ **~380 tokens**; replies ≈ 25–35 tokens
|
||||
under the new 15-word note cap.
|
||||
- Caveats: (a) the panel is advisory-only — richer payloads improve the
|
||||
human's context, not the book's P&L; (b) raw prices are the model's
|
||||
weakest input, so candles are sent **ATR-normalized** (code does the
|
||||
arithmetic, LLM reads the shape) — the same pattern that fixed the
|
||||
event-direction misreads.
|
||||
|
||||
## Answer 2 — AI on every candle? NO
|
||||
|
||||
- **Feasibility**: M5+M15+H1+H4 ≈ 414 bars/day → one call/bar nearly
|
||||
exhausts the shared 500/day cap (news needs it too); a panel per bar
|
||||
≈ 2,070/day = 4× over. Most of that spend reviews HOLD bars.
|
||||
- **Parity (rule 4)**: an AI gate cannot be replayed by the backtester
|
||||
(news off; no recorded verdicts) — every pinned book becomes
|
||||
non-comparable, and model-version drift silently mutates the strategy.
|
||||
- **Edge**: the engine already compresses 380 bars into 9 tuned
|
||||
indicators + regime + MTF validated over 5 years; an LLM's generic
|
||||
prior on raw candles won't beat fitted thresholds, and the margin it
|
||||
would filter is ~0.185R/trade. B-grade holds +67.8R at 51.4% win —
|
||||
a blunt veto is net-negative.
|
||||
|
||||
## The middle ground (adopted)
|
||||
|
||||
1. **Enrich payloads** (built today, see below) — near-zero cost, no
|
||||
rule-1 exposure.
|
||||
2. **Log panel verdicts to signals.db** (built today) — creates the
|
||||
"does OPPOSE predict losers?" dataset with zero behavior change.
|
||||
3. **After 4–8 weeks**: join logged verdicts to outcomes; simulate an
|
||||
OPPOSE-veto against the pinned book offline. Only then consider any
|
||||
gating, as a full rule-1 revalidation.
|
||||
|
||||
## Build record (2026-08-15, owner-approved)
|
||||
|
||||
- `agent_panel.py`
|
||||
- Technical persona payload now carries indicator **values** (units
|
||||
legend added to the prompt) and **ATR-normalized candles** per TF:
|
||||
`{"ref_close", "atr", "bars": [[o,h,l,c]…]}` oldest-first, each price
|
||||
as `(price − ref_close) / atr`, 1 decimal. Window: 20 bars primary /
|
||||
10 per confirmation TF.
|
||||
- New config keys (validated in `app_config.py`, in
|
||||
`config.json.example`): `candles_enabled` (default true),
|
||||
`candle_bars_primary` (20), `candle_bars_confirm` (10).
|
||||
- Panel-private provider singleton (`_dp`, patchable in tests);
|
||||
TTL cache keeps fetches near-free; any failure → candles silently
|
||||
omitted, panel unaffected.
|
||||
- Prompt hardening: training data declared stale and OFF-LIMITS;
|
||||
"if a fact is not in the JSON, treat it as unknown"; thin evidence
|
||||
→ neutral < 30 (unchanged); note cap 25→**15 words**, rationale
|
||||
40→**30 words** (output-token savings).
|
||||
- `attach_panel` now calls `signal_log.log_panel_update(report)` after
|
||||
every successful attach (fresh, exact-cache, and reuse-TTL paths).
|
||||
- `signal_log.py`
|
||||
- `log()` returns the rowid; `log_signal` stashes it on the report as
|
||||
`_signal_log_rowid`; new `log_panel_update()` fills the 4 panel
|
||||
columns on that row post-hoc. No rowid (backtest replay, logging
|
||||
disabled, other process) → silent no-op. Fail-safe contract kept.
|
||||
- Tests: 381 → **388 passed** (7 new: candle normalization math, payload
|
||||
values/candles, context windows + ATR gating + disable flag, attach
|
||||
injection (technical persona only), prompt-contract assertions,
|
||||
panel row update, rowid hand-off + no-op paths). Existing attach tests
|
||||
stub `ap._dp` to stay off the network.
|
||||
- **Live dry test** (`panel_payload_drytest_2026-08-15.py`, fabricated
|
||||
numbers only, 13 real DeepSeek requests total): **7/7 probes pass** —
|
||||
coherent-bullish → support@70; bearish-vs-BUY → oppose@85; genuinely
|
||||
mixed → neutral@30; empty payload → neutral@20 (contract); no invented
|
||||
numbers in any note; all notes ≤ 15 words; synthesizer AGREE with a
|
||||
21-word rationale. Raw wire log:
|
||||
`backtest/research/panel_payload_drytest_2026-08-15.jsonl`.
|
||||
- Notable: an early P3 variant with H1 SELL against a BUY brief got
|
||||
oppose@60 — *correct* per the prompt (higher-TF disagreement is a
|
||||
listed conflict), so the probe was tightened to a genuinely mixed
|
||||
scenario. Documented so nobody "fixes" the prompt for it later.
|
||||
|
||||
## What this deliberately did NOT change
|
||||
|
||||
- No gate reads panel fields; the panel remains advisory-only.
|
||||
- No backtest-path change (`analyze_from_dataframes` untouched; parity
|
||||
test still locks panel fields empty in replay).
|
||||
- No verdict-based filtering — that decision waits for the signals.db
|
||||
dataset (4–8 weeks) and an offline veto replay against the pinned book.
|
||||
@@ -1,7 +0,0 @@
|
||||
{"probe": "P1 coherent-bullish", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_up", "volatility_regime": "normal", "mtf_agreement": true}, "indicators": {"ema_trend": {"signal": "STRONG_BUY", "confidence": 90, "value": 1.0}, "rsi": {"signal": "BUY", "confidence": 65, "value": 58.0}, "macd": {"signal": "STRONG_BUY", "confidence": 80, "value": 1.25}, "bollinger": {"signal": "BUY", "confidence": 60, "value": 0.7}, "adx": {"signal": "BUY", "confidence": 70, "value": 32.0}, "roc": {"signal": "BUY", "confidence": 55, "value": 0.35}, "keltner": {"signal": "BUY", "confidence": 65, "value": 3.1}, "candlestick": {"signal": "BUY", "confidence": 60, "value": 2.0}}, "mtf": {"5min": {"signal": "BUY", "score": 30.0}, "1h": {"signal": "STRONG_BUY", "score": 45.0}}, "key_levels": {"resistance": 2012.0, "support": 1985.0}, "exhaustion": 0, "candles": {"15min": {"ref_close": 2000.0, "atr": 5.0, "bars": [[-2.4, -2.2, -2.4, -2.3], [-2.3, -2.1, -2.3, -2.2], [-2.2, -2.0, -2.2, -2.0], [-2.0, -1.9, -2.1, -1.9], [-1.9, -1.8, -2.0, -1.8], [-1.8, -1.6, -1.8, -1.7], [-1.7, -1.5, -1.7, -1.6], [-1.6, -1.4, -1.6, -1.4], [-1.4, -1.3, -1.5, -1.3], [-1.3, -1.2, -1.4, -1.2], [-1.2, -1.0, -1.2, -1.1], [-1.1, -0.9, -1.1, -1.0], [-1.0, -0.8, -1.0, -0.8], [-0.8, -0.7, -0.9, -0.7], [-0.7, -0.6, -0.8, -0.6], [-0.6, -0.4, -0.6, -0.5], [-0.5, -0.3, -0.5, -0.4], [-0.4, -0.2, -0.4, -0.2], [-0.2, -0.1, -0.3, -0.1], [-0.1, 0.0, -0.2, 0.0]]}}}, "raw_reply": {"stance": "support", "confidence": 70, "note": "The trend is up and all signs agree, so buying fits the flow."}}
|
||||
{"probe": "P2 bearish-vs-BUY-brief", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_down", "volatility_regime": "normal", "mtf_agreement": false}, "indicators": {"ema_trend": {"signal": "STRONG_SELL", "confidence": 85, "value": 0.1}, "rsi": {"signal": "SELL", "confidence": 70, "value": 74.0}, "macd": {"signal": "STRONG_SELL", "confidence": 80, "value": -1.4}, "bollinger": {"signal": "SELL", "confidence": 60, "value": 0.95}, "adx": {"signal": "SELL", "confidence": 70, "value": 35.0}, "roc": {"signal": "SELL", "confidence": 60, "value": -0.5}, "keltner": {"signal": "SELL", "confidence": 65, "value": -2.8}, "candlestick": {"signal": "SELL", "confidence": 55, "value": -2.0}}, "mtf": {"5min": {"signal": "SELL", "score": -28.0}, "1h": {"signal": "STRONG_SELL", "score": -41.0}}, "key_levels": {"resistance": 2012.0, "support": 1985.0}, "exhaustion": 3, "candles": {"15min": {"ref_close": 2000.0, "atr": 5.0, "bars": [[2.4, 2.4, 2.2, 2.3], [2.3, 2.3, 2.1, 2.2], [2.2, 2.2, 2.0, 2.0], [2.0, 2.1, 1.9, 1.9], [1.9, 2.0, 1.8, 1.8], [1.8, 1.8, 1.6, 1.7], [1.7, 1.7, 1.5, 1.6], [1.6, 1.6, 1.4, 1.4], [1.4, 1.5, 1.3, 1.3], [1.3, 1.4, 1.2, 1.2], [1.2, 1.2, 1.0, 1.1], [1.1, 1.1, 0.9, 1.0], [1.0, 1.0, 0.8, 0.8], [0.8, 0.9, 0.7, 0.7], [0.7, 0.8, 0.6, 0.6], [0.6, 0.6, 0.4, 0.5], [0.5, 0.5, 0.3, 0.4], [0.4, 0.4, 0.2, 0.2], [0.2, 0.3, 0.1, 0.1], [0.1, 0.2, -0.0, 0.0]]}}}, "raw_reply": {"stance": "oppose", "confidence": 85, "note": "The price is falling and every indicator says sell, so buying fights the trend."}}
|
||||
{"probe": "P3 conflicting", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 12.0, "confidence": 35, "price": 2000.0, "market_regime": "ranging", "volatility_regime": "normal", "mtf_agreement": false}, "indicators": {"ema_trend": {"signal": "BUY", "confidence": 55, "value": 0.6}, "rsi": {"signal": "NEUTRAL", "confidence": 40, "value": 51.0}, "macd": {"signal": "SELL", "confidence": 55, "value": -0.3}, "bollinger": {"signal": "NEUTRAL", "confidence": 35, "value": 0.5}, "adx": {"signal": "NEUTRAL", "confidence": 40, "value": 18.0}, "roc": {"signal": "SELL", "confidence": 50, "value": -0.1}, "keltner": {"signal": "BUY", "confidence": 50, "value": 0.8}, "candlestick": {"signal": "NEUTRAL", "confidence": 30, "value": 0.0}}, "mtf": {"5min": {"signal": "NEUTRAL", "score": 4.0}, "1h": {"signal": "NEUTRAL", "score": -3.0}}, "key_levels": {}, "exhaustion": 0, "candles": {"15min": {"ref_close": 2000.0, "atr": 5.0, "bars": [[0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0]]}}}, "raw_reply": {"stance": "neutral", "confidence": 30, "note": "Mixed signals with no clear direction, so cannot strongly support or oppose."}}
|
||||
{"probe": "P4 empty-evidence", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_up", "volatility_regime": "normal", "mtf_agreement": true}, "indicators": {}, "mtf": {}, "key_levels": {}, "exhaustion": 0}, "raw_reply": {"stance": "neutral", "confidence": 20, "note": "Not enough data to judge the trade direction."}}
|
||||
{"probe": "P5 hallucination-trap", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_up", "volatility_regime": "normal", "mtf_agreement": true}, "indicators": {"ema_trend": {"signal": "STRONG_BUY", "confidence": 90, "value": 1.0}, "rsi": {"signal": "BUY", "confidence": 65, "value": 58.0}, "macd": {"signal": "STRONG_BUY", "confidence": 80, "value": 1.25}, "bollinger": {"signal": "BUY", "confidence": 60, "value": 0.7}, "adx": {"signal": "BUY", "confidence": 70, "value": 32.0}, "roc": {"signal": "BUY", "confidence": 55, "value": 0.35}, "keltner": {"signal": "BUY", "confidence": 65, "value": 3.1}, "candlestick": {"signal": "BUY", "confidence": 60, "value": 2.0}}, "mtf": {"5min": {"signal": "BUY", "score": 30.0}, "1h": {"signal": "STRONG_BUY", "score": 45.0}}, "key_levels": {}, "exhaustion": 0}, "raw_reply": {"stance": "support", "confidence": 70, "note": "The trend is up and all signs agree, so buying fits the flow."}}
|
||||
{"probe": "P6 output-discipline", "payload": {}, "raw_reply": null}
|
||||
{"probe": "P7 synthesizer", "payload": {"direction": "BUY", "takes": [{"persona": "macro", "stance": "support", "confidence": 70, "note": "evidence aligned"}, {"persona": "technical", "stance": "support", "confidence": 80, "note": "evidence aligned"}, {"persona": "risk", "stance": "support", "confidence": 65, "note": "evidence aligned"}, {"persona": "contrarian", "stance": "support", "confidence": 60, "note": "evidence aligned"}]}, "raw_reply": {"verdict": "AGREE", "confidence": 85, "rationale": "All four analysts support buying, with high confidence and consistent evidence. No one opposes, so the panel clearly backs the trade."}}
|
||||
@@ -1,271 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
panel_payload_drytest_2026-08-15.py — live DeepSeek validation of the
|
||||
enriched agent-panel payloads + hardened prompts (2026-08-15).
|
||||
|
||||
Spends ~6 REAL DeepSeek requests (against the shared 500/day cap) with
|
||||
FABRICATED payloads — made-up prices/indicators, no real market data —
|
||||
to verify the new technical-persona contract end to end:
|
||||
|
||||
P1 coherent bullish evidence -> technical persona SUPPORTS
|
||||
P2 bearish evidence vs BUY brief -> technical persona OPPOSES
|
||||
P3 conflicting evidence -> NEUTRAL or low confidence
|
||||
P4 empty evidence -> NEUTRAL with confidence < 30
|
||||
(the anti-hallucination contract)
|
||||
P5 hallucination trap -> note invents no price levels
|
||||
P6 output discipline -> <=20-word notes, exact JSON keys
|
||||
P7 synthesizer over 4 supports -> AGREE verdict
|
||||
|
||||
Usage: venv/bin/python backtest/research/panel_payload_drytest_2026-08-15.py
|
||||
Exit code 0 = all probes passed. Raw replies land in
|
||||
backtest/research/panel_drytest_2026-08-15.jsonl
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import agent_panel as ap # noqa: E402
|
||||
from app_config import deepseek_api_key, get_app_config # noqa: E402
|
||||
from llm_client import get_llm_client # noqa: E402
|
||||
|
||||
ARTIFACT = Path(__file__).with_suffix(".jsonl")
|
||||
|
||||
|
||||
# ── Fabrication helpers (all numbers are invented) ───────────────────
|
||||
|
||||
def _staircase(start: float, step: float, n: int) -> pd.DataFrame:
|
||||
"""n synthetic bars drifting `step` per bar; last close = start+n*step."""
|
||||
rows, price = [], start
|
||||
for _ in range(n):
|
||||
o, c = price, price + step
|
||||
rows.append({"Open": o, "Close": c,
|
||||
"High": max(o, c) + abs(step) * 0.4,
|
||||
"Low": min(o, c) - abs(step) * 0.4})
|
||||
price = c
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _choppy(center: float, n: int) -> pd.DataFrame:
|
||||
rows, price, sign = [], center, 1
|
||||
for _ in range(n):
|
||||
step = sign * 1.5
|
||||
o, c = price, price + step
|
||||
rows.append({"Open": o, "Close": c,
|
||||
"High": max(o, c) + 0.6, "Low": min(o, c) - 0.6})
|
||||
price, sign = c, -sign
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
_FAKE_PRICE = 2000.00 # deliberately made-up
|
||||
_FAKE_ATR = 5.0
|
||||
|
||||
_BRIEF_BUY = {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY",
|
||||
"quality": "A", "score": 38.5, "confidence": 62,
|
||||
"price": _FAKE_PRICE, "market_regime": "trending_up",
|
||||
"volatility_regime": "normal", "mtf_agreement": True}
|
||||
|
||||
_BULL_INDICATORS = {
|
||||
"ema_trend": {"signal": "STRONG_BUY", "confidence": 90, "value": 1.0},
|
||||
"rsi": {"signal": "BUY", "confidence": 65, "value": 58.0},
|
||||
"macd": {"signal": "STRONG_BUY", "confidence": 80, "value": 1.25},
|
||||
"bollinger": {"signal": "BUY", "confidence": 60, "value": 0.7},
|
||||
"adx": {"signal": "BUY", "confidence": 70, "value": 32.0},
|
||||
"roc": {"signal": "BUY", "confidence": 55, "value": 0.35},
|
||||
"keltner": {"signal": "BUY", "confidence": 65, "value": 3.1},
|
||||
"candlestick": {"signal": "BUY", "confidence": 60, "value": 2.0},
|
||||
}
|
||||
|
||||
_BEAR_INDICATORS = {
|
||||
"ema_trend": {"signal": "STRONG_SELL", "confidence": 85, "value": 0.1},
|
||||
"rsi": {"signal": "SELL", "confidence": 70, "value": 74.0},
|
||||
"macd": {"signal": "STRONG_SELL", "confidence": 80, "value": -1.4},
|
||||
"bollinger": {"signal": "SELL", "confidence": 60, "value": 0.95},
|
||||
"adx": {"signal": "SELL", "confidence": 70, "value": 35.0},
|
||||
"roc": {"signal": "SELL", "confidence": 60, "value": -0.5},
|
||||
"keltner": {"signal": "SELL", "confidence": 65, "value": -2.8},
|
||||
"candlestick": {"signal": "SELL", "confidence": 55, "value": -2.0},
|
||||
}
|
||||
|
||||
_MIXED_INDICATORS = {
|
||||
"ema_trend": {"signal": "BUY", "confidence": 55, "value": 0.6},
|
||||
"rsi": {"signal": "NEUTRAL", "confidence": 40, "value": 51.0},
|
||||
"macd": {"signal": "SELL", "confidence": 55, "value": -0.3},
|
||||
"bollinger": {"signal": "NEUTRAL", "confidence": 35, "value": 0.5},
|
||||
"adx": {"signal": "NEUTRAL", "confidence": 40, "value": 18.0},
|
||||
"roc": {"signal": "SELL", "confidence": 50, "value": -0.1},
|
||||
"keltner": {"signal": "BUY", "confidence": 50, "value": 0.8},
|
||||
"candlestick": {"signal": "NEUTRAL", "confidence": 30, "value": 0.0},
|
||||
}
|
||||
|
||||
|
||||
def _tech_payload(indicators, mtf, candles_df=None, key_levels=None,
|
||||
brief=None, exhaustion=0):
|
||||
payload = {"brief": brief or dict(_BRIEF_BUY),
|
||||
"indicators": indicators, "mtf": mtf,
|
||||
"key_levels": key_levels or {}, "exhaustion": exhaustion}
|
||||
if candles_df is not None:
|
||||
# the REAL production normalizer, not a hand-rolled copy
|
||||
payload["candles"] = {
|
||||
"15min": ap._normalize_candles(candles_df, _FAKE_ATR,
|
||||
len(candles_df))}
|
||||
return payload
|
||||
|
||||
|
||||
# ── Probe runner ─────────────────────────────────────────────────────
|
||||
|
||||
_RESULTS = []
|
||||
|
||||
|
||||
def _record(probe, payload, reply, take, passed, detail):
|
||||
_RESULTS.append({"probe": probe, "pass": bool(passed),
|
||||
"detail": detail,
|
||||
"stance": getattr(take, "stance", None),
|
||||
"confidence": getattr(take, "confidence", None),
|
||||
"note": getattr(take, "note", None)})
|
||||
with ARTIFACT.open("a") as fh:
|
||||
fh.write(json.dumps({"probe": probe, "payload": payload,
|
||||
"raw_reply": reply}, default=str) + "\n")
|
||||
mark = "PASS" if passed else "FAIL"
|
||||
print(f"[{mark}] {probe}: {detail}")
|
||||
if take is not None:
|
||||
print(f" -> {take.stance} @ {take.confidence:.0f} "
|
||||
f"| {take.note}")
|
||||
|
||||
|
||||
def _ask(payload):
|
||||
"""Real API call through the production client + production
|
||||
validation (the same path run_panel uses)."""
|
||||
reply = get_llm_client().chat_json(ap._TECH_SYSTEM, payload)
|
||||
return reply, ap._validate_take("technical", reply)
|
||||
|
||||
|
||||
def _want(probe: str) -> bool:
|
||||
"""--only P3 runs just that probe (saves requests on re-runs)."""
|
||||
if "--only" not in sys.argv:
|
||||
return True
|
||||
return sys.argv[sys.argv.index("--only") + 1] == probe
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = get_app_config()
|
||||
if not deepseek_api_key(app):
|
||||
print("ABORT: no DeepSeek API key (env DEEPSEEK_API_KEY / "
|
||||
"config.json deepseek.api_key).")
|
||||
return 2
|
||||
if app.deepseek_settings.get("enabled", True) is False:
|
||||
print("ABORT: deepseek.enabled is false in config.json.")
|
||||
return 2
|
||||
if ARTIFACT.exists() and "--only" not in sys.argv:
|
||||
ARTIFACT.unlink() # full run = fresh wire log; --only appends
|
||||
|
||||
mtf_agree = {"5min": {"signal": "BUY", "score": 30.0},
|
||||
"1h": {"signal": "STRONG_BUY", "score": 45.0}}
|
||||
levels = {"resistance": 2012.0, "support": 1985.0}
|
||||
|
||||
# P1 — coherent bullish, with rising ATR staircase (last close 2000)
|
||||
if _want("P1"):
|
||||
p1 = _tech_payload(_BULL_INDICATORS, mtf_agree,
|
||||
candles_df=_staircase(_FAKE_PRICE - 20 * 0.6,
|
||||
0.6, 20),
|
||||
key_levels=levels)
|
||||
reply, take = _ask(p1)
|
||||
_record("P1 coherent-bullish", p1, reply, take,
|
||||
take is not None and take.stance == "support"
|
||||
and take.confidence >= 50,
|
||||
"expect support @ conf>=50")
|
||||
|
||||
# P2 — bearish everything against a BUY brief, falling staircase
|
||||
if _want("P2"):
|
||||
brief2 = dict(_BRIEF_BUY, market_regime="trending_down",
|
||||
mtf_agreement=False)
|
||||
mtf_sell = {"5min": {"signal": "SELL", "score": -28.0},
|
||||
"1h": {"signal": "STRONG_SELL", "score": -41.0}}
|
||||
p2 = _tech_payload(_BEAR_INDICATORS, mtf_sell, brief=brief2,
|
||||
candles_df=_staircase(_FAKE_PRICE + 20 * 0.6,
|
||||
-0.6, 20),
|
||||
key_levels=levels, exhaustion=3)
|
||||
reply, take = _ask(p2)
|
||||
_record("P2 bearish-vs-BUY-brief", p2, reply, take,
|
||||
take is not None and take.stance == "oppose",
|
||||
"expect oppose (BUY brief fights all evidence)")
|
||||
|
||||
# P3 — genuinely mixed evidence: MTF neutral both ways, ranging
|
||||
# regime, choppy candles. (An H1 SELL against a BUY is a *listed
|
||||
# conflict* in the prompt — oppose is correct there, not mixed.)
|
||||
if _want("P3"):
|
||||
brief3 = dict(_BRIEF_BUY, market_regime="ranging", score=12.0,
|
||||
confidence=35, mtf_agreement=False)
|
||||
mtf_mixed = {"5min": {"signal": "NEUTRAL", "score": 4.0},
|
||||
"1h": {"signal": "NEUTRAL", "score": -3.0}}
|
||||
p3 = _tech_payload(_MIXED_INDICATORS, mtf_mixed, brief=brief3,
|
||||
candles_df=_choppy(_FAKE_PRICE, 20))
|
||||
reply, take = _ask(p3)
|
||||
_record("P3 conflicting", p3, reply, take,
|
||||
take is not None and (take.stance == "neutral"
|
||||
or take.confidence < 60),
|
||||
"expect neutral or conf<60 on genuinely mixed evidence")
|
||||
|
||||
# P4 — empty evidence: contract demands neutral @ conf<30
|
||||
if _want("P4"):
|
||||
p4 = _tech_payload({}, {}, key_levels=None)
|
||||
reply, take = _ask(p4)
|
||||
_record("P4 empty-evidence", p4, reply, take,
|
||||
take is not None and take.stance == "neutral"
|
||||
and take.confidence < 30,
|
||||
"contract: thin/missing evidence -> neutral, conf<30")
|
||||
|
||||
# P5 — hallucination trap: no key_levels, no candles; the note must
|
||||
# not cite any 3+-digit number absent from the payload
|
||||
if _want("P5"):
|
||||
p5 = _tech_payload(_BULL_INDICATORS, mtf_agree, key_levels=None)
|
||||
reply, take = _ask(p5)
|
||||
invented = []
|
||||
if take is not None:
|
||||
payload_blob = json.dumps(p5)
|
||||
for num in re.findall(r"\d{3,}", take.note):
|
||||
if num not in payload_blob:
|
||||
invented.append(num)
|
||||
_record("P5 hallucination-trap", p5, reply, take,
|
||||
take is not None and not invented,
|
||||
f"invented numbers in note: {invented or 'none'}")
|
||||
|
||||
# P6 — output discipline across the persona replies gathered above
|
||||
notes = [r for r in _RESULTS if r["note"]]
|
||||
wordy = [r["probe"] for r in notes if len(r["note"].split()) > 20]
|
||||
_record("P6 output-discipline", {}, None, None,
|
||||
notes and not wordy,
|
||||
f"notes >20 words: {wordy or 'none'} "
|
||||
f"(cap is 15; 20 = fail threshold)")
|
||||
|
||||
# P7 — synthesizer over four fabricated SUPPORT takes
|
||||
if _want("P7"):
|
||||
takes = [{"persona": p, "stance": "support", "confidence": c,
|
||||
"note": "evidence aligned"}
|
||||
for p, c in (("macro", 70), ("technical", 80),
|
||||
("risk", 65), ("contrarian", 60))]
|
||||
synth_payload = {"direction": "BUY", "takes": takes}
|
||||
reply = get_llm_client().chat_json(ap._SYNTH_SYSTEM, synth_payload)
|
||||
verdict = reply.get("verdict") if isinstance(reply, dict) else None
|
||||
rationale = (reply or {}).get("rationale", "")
|
||||
_record("P7 synthesizer", synth_payload, reply, None,
|
||||
verdict == "AGREE" and len(str(rationale).split()) <= 40,
|
||||
f"expect AGREE + <=40-word rationale; got {verdict!r} "
|
||||
f"({len(str(rationale).split())} words)")
|
||||
|
||||
n_pass = sum(1 for r in _RESULTS if r["pass"])
|
||||
n_api = sum(1 for r in _RESULTS if r["probe"] != "P6 output-discipline")
|
||||
print(f"\n{n_pass}/{len(_RESULTS)} probes passed "
|
||||
f"({n_api} API requests spent; "
|
||||
f"raw wire log: {ARTIFACT.name})")
|
||||
return 0 if n_pass == len(_RESULTS) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,180 +0,0 @@
|
||||
# Signal-log audit → prediction-quality campaign — 2026-09-03
|
||||
|
||||
**Source evidence:** `logs/signals.db` 2026-08-13 → 2026-09-03 (14,683 rows, candle
|
||||
snapshots from 08-18), replayed with the repo's own advisory outcome model
|
||||
(`analyze_signal_log.py --stored-candles`; the replay reproduces
|
||||
`advisory_outcome.py` to 5e-17 on the 49 panel-seeded rows), cross-checked
|
||||
against the pinned Oanda books (`backtest/oanda_*_065*.csv`).
|
||||
|
||||
## Live-log findings that motivated the campaign
|
||||
|
||||
| View (3 weeks, $0.65 rt) | n | WR | R | PF |
|
||||
|---|---|---|---|---|
|
||||
| every unique setup event | 144 | 67% | +37.8 | 1.93 |
|
||||
| opportunities (first per tf+direction, 60 min) | 66 | 67% | +18.3 | 1.92 |
|
||||
| one-position-per-TF (5-bar cooldown) | 32 | 62% | +6.6 | 1.53 |
|
||||
| direction BUY / SELL | 121 / 23 | 70% / 48% | +43.2 / −5.4 | 2.53 / 0.56 |
|
||||
|
||||
- Two rally days (08-19, 08-21) contribute essentially all of the R.
|
||||
- SELL is negative in every view; in the books SELL is the weakest bucket on
|
||||
every TF (H1 SELL −16.7R/5yr PF 0.77, −11.5R OOS PF 0.30; M15 SELL −11.7R OOS;
|
||||
M5 SELL in `transitioning` −7.9R/12mo PF 0.21, −5.2R/5yr PF 0.49).
|
||||
- Bollinger is anti-predictive on all four TFs (forward-return hit 0.26–0.50 at
|
||||
12 bars) and "opposes" 93% of taken book trades; divergence is inverted on
|
||||
M15/H1 live and trades where it opposed outperform in the books.
|
||||
- Refuted live hypotheses (books disagree): ADX<20 gate, exhaustion gate,
|
||||
NEAR-EMA200 gate, EXTREME-volatility gate, capping SL/ATR.
|
||||
|
||||
## Method (rule 1)
|
||||
|
||||
Harness: `backtest/gpu_backtest.py --pair XAUUSD --spread 0.65` on the CPU
|
||||
backend (CuPy present but the GPU was not reachable; the M5 12mo control
|
||||
reproduces the pinned `oanda_m5_12mo_065_nopb_20260814.csv` trade-for-trade
|
||||
except the two trades on the end date, which `--end` excludes). Every candidate
|
||||
is compared with a CONTROL produced on the same harness, never with the pinned
|
||||
book directly. Windows (cache start = start − 62 days):
|
||||
|
||||
| window | `--start` | `--end` |
|
||||
|---|---|---|
|
||||
| 12mo (M15/H1) | 2025-08-04 | 2026-08-04 |
|
||||
| 12mo (M5 canonical) | 2025-08-14 | 2026-08-14 |
|
||||
| 5yr | 2021-08-01 | 2026-08-04 |
|
||||
| OOS | 2024-06-01 | 2025-03-30 |
|
||||
|
||||
Acceptance: positive total R in both 12mo halves, PF ≥ 1.3, OOS not worse
|
||||
than control, and a candidate that removes a net-positive bucket for a PF-only
|
||||
gain is rejected (big-candle precedent). Runner:
|
||||
`<scratchpad>/campaign/run_campaign.sh`; summary: `summarize.py`.
|
||||
|
||||
Candidates (all `--set`, no code):
|
||||
|
||||
- **B1 SELL gating** — H1 `xau_strategy.sell_min_quality=[]` (block all H1
|
||||
shorts); M5 `xau_strategy.sell_blocked_regimes=["transitioning"]`; M15
|
||||
`xau_strategy.sell_min_quality=["B"]`.
|
||||
- **B2 divergence off** — `technical.disabled_indicators=["divergence"]` on
|
||||
M15 and H1 (M5 divergence-down was rejected 2026-08-04; not re-run).
|
||||
- **B3 Bollinger weight 0 in trending regimes** — full
|
||||
`technical.regime_weights_override` with bollinger 0.0 in
|
||||
trending_up/trending_down/trending, ranging/transitioning unchanged; M5, M15, H1.
|
||||
|
||||
## Results
|
||||
|
||||
Every control reproduced its pinned book: M15 12mo (`ctl_m15_12mo`) is exact
|
||||
at 397/+73.93R/PF 1.53; all three H1 controls are exact; the M5 12mo control
|
||||
(`base_m5_12mo`) is exact except for the two trades that fall on the end
|
||||
date, which `--end` excludes on this harness (see Method above).
|
||||
|
||||
| book | n | R | PF | DD | H1 | H2 | sellN | sellR | ΔR vs control | ΔPF |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| b1_h1_nosell_12mo | 91 | +22.2 | 1.79 | 4.1 | +20.6 | +1.6 | 0 | +0.0 | -1.8 | +0.25 |
|
||||
| b1_h1_nosell_5yr | 335 | +49.1 | 1.46 | 13.2 | -5.0 | +54.1 | 0 | +0.0 | +16.7 | +0.28 |
|
||||
| b1_h1_nosell_oos | 78 | +16.7 | 1.74 | 3.6 | +7.3 | +9.4 | 0 | +0.0 | +11.5 | +0.61 |
|
||||
| b1_m15_sellB_12mo | 341 | +74.6 | 1.62 | 10.8 | +68.4 | +6.2 | 82 | +9.1 | +0.7 | +0.09 |
|
||||
| b1_m15_sellB_5yr | 1378 | +119.1 | 1.22 | 41.4 | -21.3 | +140.3 | 335 | +3.7 | -8.9 | +0.02 |
|
||||
| b1_m15_sellB_oos | 272 | +47.1 | 1.51 | 13.8 | +12.3 | +34.8 | 59 | -3.4 | +8.3 | +0.15 |
|
||||
| b1_m5_selltrans_12mo | 557 | +112.2 | 1.56 | 9.9 | +55.9 | +56.4 | 75 | +14.8 | +7.9 | +0.06 |
|
||||
| b1_m5_selltrans_5yr | 889 | +133.7 | 1.41 | 12.8 | -8.3 | +142.0 | 88 | +9.0 | +5.3 | +0.03 |
|
||||
| b1_m5_selltrans_oos | 86 | +9.4 | 1.33 | 8.2 | -1.4 | +10.9 | 4 | +1.5 | +0.0 | +0.00 |
|
||||
| b2_h1_nodiv_12mo | 149 | +28.6 | 1.62 | 6.5 | +23.6 | +5.0 | 49 | +5.5 | +4.6 | +0.07 |
|
||||
| b2_h1_nodiv_5yr | 567 | +40.5 | 1.20 | 14.1 | -5.2 | +45.6 | 203 | -18.2 | +8.1 | +0.02 |
|
||||
| b2_h1_nodiv_oos | 119 | +7.6 | 1.17 | 7.7 | -1.9 | +9.5 | 31 | -14.3 | +2.4 | +0.04 |
|
||||
| b2_m15_nodiv_12mo | 439 | +81.3 | 1.51 | 13.1 | +72.3 | +8.9 | 147 | +7.0 | +7.4 | -0.02 |
|
||||
| b2_m15_nodiv_5yr | 1771 | +139.9 | 1.20 | 47.4 | -16.2 | +156.1 | 578 | +11.7 | +11.9 | -0.01 |
|
||||
| b2_m15_nodiv_oos | 341 | +52.3 | 1.43 | 17.1 | +15.6 | +36.7 | 93 | -10.0 | +13.5 | +0.07 |
|
||||
| b3_h1_bb0_12mo | 154 | +31.2 | 1.64 | 9.4 | +22.8 | +8.4 | 52 | +6.7 | +7.3 | +0.09 |
|
||||
| b3_h1_bb0_5yr | 583 | +37.3 | 1.18 | 18.3 | -7.4 | +44.7 | 206 | -20.4 | +4.9 | +0.00 |
|
||||
| b3_h1_bb0_oos | 122 | +5.6 | 1.12 | 8.7 | -2.0 | +7.6 | 32 | -13.8 | +0.4 | -0.01 |
|
||||
| b3_m15_bb0_12mo | 439 | +49.9 | 1.29 | 19.4 | +61.6 | -11.8 | 151 | +0.3 | -24.0 | -0.23 |
|
||||
| b3_m15_bb0_5yr | 1790 | +126.6 | 1.18 | 42.3 | -1.8 | +128.5 | 592 | +17.4 | -1.3 | -0.03 |
|
||||
| b3_m15_bb0_oos | 345 | +55.5 | 1.47 | 12.4 | +18.2 | +37.3 | 94 | -8.4 | +16.7 | +0.11 |
|
||||
| b3_m5_bb0_12mo | 621 | +84.0 | 1.35 | 16.6 | +49.3 | +34.7 | 104 | +9.7 | -20.4 | -0.14 |
|
||||
| b3_m5_bb0_5yr | 987 | +113.0 | 1.31 | 15.3 | -7.7 | +120.6 | 134 | +12.0 | -15.5 | -0.08 |
|
||||
| b3_m5_bb0_oos | 93 | +10.6 | 1.35 | 5.6 | -2.9 | +13.5 | 9 | +3.7 | +1.2 | +0.01 |
|
||||
| b12_h1_nosell_nodiv_12mo | 100 | +23.1 | 1.75 | 4.6 | +21.8 | +1.3 | 0 | +0.0 | -0.8 | +0.20 |
|
||||
| b12_h1_nosell_nodiv_5yr | 364 | +58.7 | 1.52 | 12.9 | -4.1 | +62.8 | 0 | +0.0 | +26.3 | +0.34 |
|
||||
| b12_h1_nosell_nodiv_oos | 88 | +21.9 | 1.92 | 4.8 | +9.4 | +12.5 | 0 | +0.0 | +16.8 | +0.79 |
|
||||
| base_m5_12mo | 575 | +104.3 | 1.50 | 10.7 | +50.8 | +53.5 | 93 | +6.9 | — | — |
|
||||
| ctl_h1_12mo | 138 | +24.0 | 1.55 | 8.0 | +19.4 | +4.6 | 47 | +1.8 | — | — |
|
||||
| ctl_h1_5yr | 515 | +32.4 | 1.18 | 15.2 | -5.9 | +38.3 | 180 | -16.7 | — | — |
|
||||
| ctl_h1_oos | 104 | +5.1 | 1.13 | 7.5 | -0.9 | +6.1 | 26 | -11.5 | — | — |
|
||||
| ctl_m15_12mo | 397 | +73.9 | 1.53 | 10.8 | +66.7 | +7.2 | 138 | +8.5 | — | — |
|
||||
| ctl_m15_5yr | 1578 | +128.0 | 1.21 | 35.8 | -3.4 | +131.4 | 535 | +12.6 | — | — |
|
||||
| ctl_m15_oos | 299 | +38.8 | 1.36 | 15.5 | +5.9 | +32.9 | 86 | -11.7 | — | — |
|
||||
| ctl_m5_5yr | 911 | +128.4 | 1.39 | 12.8 | -8.3 | +136.7 | 110 | +3.8 | — | — |
|
||||
| ctl_m5_oos | 86 | +9.4 | 1.33 | 8.2 | -1.4 | +10.9 | 4 | +1.5 | — | — |
|
||||
|
||||
Raw table (n / R / PF / WR% / DD / H1 / H2 / PF-half-1 / PF-half-2 /
|
||||
sellN / sellR / ΔR / ΔPF / Δn vs control; control and `base_m5_12mo`
|
||||
rows carry no delta):
|
||||
|
||||
```
|
||||
book n R PF WR% DD H1 H2 PF1 PF2 sellN sellR | ΔR ΔPF Δn
|
||||
b1_h1_nosell_12mo 91 +22.2 1.79 62.6 4.1 +20.6 +1.6 2.10 1.18 0 +0.0 | -1.8 +0.25 -47
|
||||
b1_h1_nosell_5yr 335 +49.1 1.46 58.2 13.2 -5.0 +54.1 0.87 1.78 0 +0.0 | +16.7 +0.28 -180
|
||||
b1_h1_nosell_oos 78 +16.7 1.74 62.8 3.6 +7.3 +9.4 1.67 1.81 0 +0.0 | +11.5 +0.61 -26
|
||||
b1_m15_sellB_12mo 341 +74.6 1.62 62.8 10.8 +68.4 +6.2 2.15 1.10 82 +9.1 | +0.7 +0.09 -56
|
||||
b1_m15_sellB_5yr 1378 +119.1 1.22 57.0 41.4 -21.3 +140.3 0.91 1.46 335 +3.7 | -8.9 +0.02 -200
|
||||
b1_m15_sellB_oos 272 +47.1 1.51 60.7 13.8 +12.3 +34.8 1.24 1.85 59 -3.4 | +8.3 +0.15 -27
|
||||
b1_m5_selltrans_12mo 557 +112.2 1.56 53.7 9.9 +55.9 +56.4 1.54 1.58 75 +14.8 | +7.9 +0.06 -18
|
||||
b1_m5_selltrans_5yr 889 +133.7 1.41 51.5 12.8 -8.3 +142.0 0.84 1.52 88 +9.0 | +5.3 +0.03 -22
|
||||
b1_m5_selltrans_oos 86 +9.4 1.33 53.5 8.2 -1.4 +10.9 0.91 1.95 4 +1.5 | +0.0 +0.00 +0
|
||||
b2_h1_nodiv_12mo 149 +28.6 1.62 62.4 6.5 +23.6 +5.0 1.93 1.24 49 +5.5 | +4.6 +0.07 +11
|
||||
b2_h1_nodiv_5yr 567 +40.5 1.20 56.3 14.1 -5.2 +45.6 0.93 1.37 203 -18.2 | +8.1 +0.02 +52
|
||||
b2_h1_nodiv_oos 119 +7.6 1.17 56.3 7.7 -1.9 +9.5 0.91 1.44 31 -14.3 | +2.4 +0.04 +15
|
||||
b2_m15_nodiv_12mo 439 +81.3 1.51 60.8 13.1 +72.3 +8.9 1.90 1.11 147 +7.0 | +7.4 -0.02 +42
|
||||
b2_m15_nodiv_5yr 1771 +139.9 1.20 55.8 47.4 -16.2 +156.1 0.94 1.39 578 +11.7 | +11.9 -0.01 +193
|
||||
b2_m15_nodiv_oos 341 +52.3 1.43 59.2 17.1 +15.6 +36.7 1.23 1.68 93 -10.0 | +13.5 +0.07 +42
|
||||
b3_h1_bb0_12mo 154 +31.2 1.64 60.4 9.4 +22.8 +8.4 1.89 1.36 52 +6.7 | +7.3 +0.09 +16
|
||||
b3_h1_bb0_5yr 583 +37.3 1.18 54.5 18.3 -7.4 +44.7 0.90 1.35 206 -20.4 | +4.9 +0.00 +68
|
||||
b3_h1_bb0_oos 122 +5.6 1.12 55.7 8.7 -2.0 +7.6 0.91 1.33 32 -13.8 | +0.4 -0.01 +18
|
||||
b3_m15_bb0_12mo 439 +49.9 1.29 59.0 19.4 +61.6 -11.8 1.77 0.87 151 +0.3 | -24.0 -0.23 +42
|
||||
b3_m15_bb0_5yr 1790 +126.6 1.18 56.1 42.3 -1.8 +128.5 0.99 1.31 592 +17.4 | -1.3 -0.03 +212
|
||||
b3_m15_bb0_oos 345 +55.5 1.47 61.2 12.4 +18.2 +37.3 1.29 1.66 94 -8.4 | +16.7 +0.11 +46
|
||||
b3_m5_bb0_12mo 621 +84.0 1.35 51.4 16.6 +49.3 +34.7 1.43 1.28 104 +9.7 | -20.4 -0.14 +46
|
||||
b3_m5_bb0_5yr 987 +113.0 1.31 50.7 15.3 -7.7 +120.6 0.85 1.38 134 +12.0 | -15.5 -0.08 +76
|
||||
b3_m5_bb0_oos 93 +10.6 1.35 54.8 5.6 -2.9 +13.5 0.85 2.17 9 +3.7 | +1.2 +0.01 +7
|
||||
b12_h1_nosell_nodiv_12mo 100 +23.1 1.75 63.0 4.6 +21.8 +1.3 2.00 1.15 0 +0.0 | -0.8 +0.20 -38
|
||||
b12_h1_nosell_nodiv_5yr 364 +58.7 1.52 59.6 12.9 -4.1 +62.8 0.90 1.87 0 +0.0 | +26.3 +0.34 -151
|
||||
b12_h1_nosell_nodiv_oos 88 +21.9 1.92 63.6 4.8 +9.4 +12.5 1.93 1.92 0 +0.0 | +16.8 +0.79 -16
|
||||
base_m5_12mo 575 +104.3 1.50 52.9 10.7 +50.8 +53.5 1.46 1.53 93 +6.9
|
||||
ctl_h1_12mo 138 +24.0 1.55 60.9 8.0 +19.4 +4.6 1.84 1.22 47 +1.8
|
||||
ctl_h1_5yr 515 +32.4 1.18 55.7 15.2 -5.9 +38.3 0.91 1.34 180 -16.7
|
||||
ctl_h1_oos 104 +5.1 1.13 56.7 7.5 -0.9 +6.1 0.95 1.32 26 -11.5
|
||||
ctl_m15_12mo 397 +73.9 1.53 62.2 10.8 +66.7 +7.2 1.95 1.10 138 +8.5
|
||||
ctl_m15_5yr 1578 +128.0 1.21 56.5 35.8 -3.4 +131.4 0.99 1.37 535 +12.6
|
||||
ctl_m15_oos 299 +38.8 1.36 59.2 15.5 +5.9 +32.9 1.10 1.66 86 -11.7
|
||||
ctl_m5_5yr 911 +128.4 1.39 51.3 12.8 -8.3 +136.7 0.84 1.48 110 +3.8
|
||||
ctl_m5_oos 86 +9.4 1.33 53.5 8.2 -1.4 +10.9 0.91 1.95 4 +1.5
|
||||
```
|
||||
|
||||
## Decisions
|
||||
|
||||
- **SHIPPED** — M5 `xau_strategy.sell_blocked_regimes=["transitioning"]`
|
||||
(b1_m5_selltrans): 12mo +7.9R, 5yr +5.3R, OOS unchanged (no such trades
|
||||
in that window); positive in both 12mo halves on every window. H1
|
||||
`xau_strategy.sell_min_quality=[]` (b1_h1_nosell, all H1 shorts blocked):
|
||||
5yr +16.7R and OOS +11.5R more than pay for the 12mo cost of -1.8R; H1
|
||||
shorts were -16.7R/5yr and -11.5R/OOS in the control. Both land in
|
||||
`pair_signals/xauusd.py`.
|
||||
- **REJECTED** — M15 `sell_min_quality=["B"]` (b1_m15_sellB): 5yr -8.9R
|
||||
with max DD widening 35.8 -> 41.4R for a marginal +0.7R on 12mo; fails
|
||||
the drawdown clause. All three B3 Bollinger-weight-0-in-trending-regimes
|
||||
runs (b3_h1_bb0, b3_m15_bb0, b3_m5_bb0): negative on M5 (-20.4R/12mo,
|
||||
-15.5R/5yr) and M15 (-24.0R/12mo, PF 1.29 -> still below the 12mo H2
|
||||
going negative at -11.8R); H1's B3 5yr gain (+4.9R) doesn't clear the
|
||||
bar once M5/M15 fail outright — Bollinger's constant-oppose vote stays
|
||||
a load-bearing quality bar, consistent with the 2026-08-04 audit.
|
||||
- **DECLINED** — B2-M15 (`technical.disabled_indicators=["divergence"]`
|
||||
on M15, b2_m15_nodiv): R improves on every window (+7.4/+11.9/+13.5R)
|
||||
but 5yr max DD widens 35.8 -> 47.4R while PF stays flat (1.21 -> 1.20) —
|
||||
more risk for no risk-adjusted gain; owner call.
|
||||
- **PARKED** — B2-H1 (`technical.disabled_indicators=["divergence"]` on
|
||||
H1, b2_h1_nodiv, standalone: +4.6/+8.1/+2.4R, PF 1.62/1.20/1.17). The
|
||||
combined run with the H1 no-short override (b12_h1_nosell_nodiv) passes
|
||||
rule 1 and beats the no-shorts override alone on both 5yr and OOS: 12mo
|
||||
100/+23.1R PF 1.75 (halves +21.8/+1.3), 5yr 364/+58.7R PF 1.52, OOS
|
||||
88/+21.9R PF 1.92. Not shipped anyway: it would route through
|
||||
`technical.disabled_indicators`, which CLAUDE.md rule 8 reserves for
|
||||
experiments, not production paths. Owner decision; recommended vehicle
|
||||
is an H1-only `regime_weights_override` with divergence weight 0 plus a
|
||||
fresh validation run under that mechanism.
|
||||
@@ -1,113 +0,0 @@
|
||||
# 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.
|
||||
@@ -40,27 +40,5 @@
|
||||
"timeout_seconds": 12,
|
||||
"daily_request_cap": 500
|
||||
},
|
||||
"agent_panel": {
|
||||
"enabled": false,
|
||||
"telegram": true,
|
||||
"dashboard": false,
|
||||
"min_quality": "B",
|
||||
"max_panels_per_day": 20,
|
||||
"reuse_ttl_seconds": 3600,
|
||||
"candles_enabled": true,
|
||||
"candle_bars_primary": 20,
|
||||
"candle_bars_confirm": 10
|
||||
},
|
||||
"market_outlook": {
|
||||
"enabled": false,
|
||||
"telegram": false,
|
||||
"max_per_day": 12,
|
||||
"candles_bars": 20
|
||||
},
|
||||
"signal_log": {
|
||||
"enabled": true,
|
||||
"path": "logs/signals.db",
|
||||
"log_blocked_scans": true
|
||||
},
|
||||
"log_level": "INFO"
|
||||
}
|
||||
|
||||
@@ -31,10 +31,8 @@ class SignalStrength(Enum):
|
||||
XAUUSD = "XAU/USD"
|
||||
|
||||
# price_precision: decimals for SL/TP rounding and display.
|
||||
# default_spread: $0.30 round-trip cost retained for TD-era cross-book
|
||||
# comparability and used when the backtester receives no --spread override.
|
||||
# Oanda's measured round trip is $0.65-$0.72; research reports headline it
|
||||
# separately with --spread 0.65.
|
||||
# default_spread: round-trip cost per trade in price units, charged by
|
||||
# the backtester when --spread is not given.
|
||||
PAIR_INFO = {
|
||||
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10,
|
||||
"price_precision": 2, "default_spread": 0.30},
|
||||
@@ -112,10 +110,7 @@ class SignalConfig:
|
||||
tp_tightening_factor: float = 1.0
|
||||
|
||||
# ── Trade geometry ────────────────────────────────────────────
|
||||
# Deliberately $0.30 for TD-era cross-book comparability. Oanda's measured
|
||||
# round trip is $0.65-$0.72, reported separately in re-pin campaigns.
|
||||
# This is also a LIVE gate input: increasing it to $0.65 raises the TP1
|
||||
# floor from about $9 to $19.50, so it requires rule-1 revalidation.
|
||||
# Round-trip transaction cost estimate in price units (XAU ~$0.30).
|
||||
round_trip_cost: float = 0.30
|
||||
# Spread gate: reject setups whose TP1 distance is below
|
||||
# round_trip_cost × this multiple (caps cost at ~3% of target).
|
||||
@@ -171,9 +166,6 @@ class NewsConfig:
|
||||
# Must stay below pre_event_buffer_minutes so an event can't cross
|
||||
# into the danger window while a stale cache says it's clear.
|
||||
context_cache_ttl_seconds: int = 600
|
||||
# Maximum age of RSS headlines accepted for scoring. Older headlines
|
||||
# are discarded before keyword analysis and LLM enrichment.
|
||||
headline_max_age_seconds: int = 43200 # 12 hours
|
||||
|
||||
rss_feeds: List[str] = field(default_factory=lambda: [
|
||||
"https://investinglive.com/feed/",
|
||||
|
||||
+7
-35
@@ -49,7 +49,6 @@ from datetime import datetime, timezone
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from agent_panel import attach_panel
|
||||
from config import PlatformConfig, Timeframe, XAUUSD
|
||||
from main import report_to_dict
|
||||
from news_engine import NewsEngine
|
||||
@@ -82,7 +81,7 @@ SUMMARY_PHP_FILE = "news_summary.php"
|
||||
# Repo files copied into the out dir at daemon start (git pull + restart
|
||||
# updates them). The PHP endpoint reads news.json in place, so it lives
|
||||
# next to the page.
|
||||
STATIC_FILES = [PAGE_FILE, "dashboard.css", "dashboard.js", SUMMARY_PHP_FILE]
|
||||
STATIC_FILES = [PAGE_FILE, SUMMARY_PHP_FILE]
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
@@ -322,7 +321,6 @@ class DashboardPublisher:
|
||||
def __init__(self, out_dir: str):
|
||||
self.out_dir = out_dir
|
||||
self.snapshots: Dict[str, dict] = {}
|
||||
self._reports = {}
|
||||
self.news_payload: Optional[dict] = None
|
||||
self._dp = None
|
||||
self._news_engine: Optional[NewsEngine] = None
|
||||
@@ -359,7 +357,6 @@ class DashboardPublisher:
|
||||
try:
|
||||
report = produce_signal(XAUUSD, tf, self._provider(),
|
||||
CONFIRMATIONS[tf])
|
||||
self._reports[tf.value] = report
|
||||
self.snapshots[tf.value] = {
|
||||
"status": "ok",
|
||||
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -371,41 +368,16 @@ class DashboardPublisher:
|
||||
logger.info(f"{tf.value}: {report.final_signal.name} "
|
||||
f"q={report.signal_quality or '-'} "
|
||||
f"({time.time() - started:.1f}s)")
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
# Keep the last good report; the page shows it with an
|
||||
# error badge instead of going blank.
|
||||
logger.exception(f"{tf.value} scan failed")
|
||||
self._reports.pop(tf.value, None)
|
||||
entry = self.snapshots.get(tf.value) or _empty_tf_entry()
|
||||
entry["status"] = "error"
|
||||
# The public snapshot carries a stable message; exception
|
||||
# detail remains in dashboard.log for the operator.
|
||||
entry["error"] = "Scan unavailable"
|
||||
entry["error"] = f"{type(e).__name__}: {e}"
|
||||
entry["error_at"] = datetime.now(timezone.utc).isoformat()
|
||||
self.snapshots[tf.value] = entry
|
||||
|
||||
def attach_panels(self, tfs: List[Timeframe]) -> bool:
|
||||
"""Add advisory panels after publishing the technical snapshot.
|
||||
|
||||
A panel is display-only and can take tens of seconds on a cold LLM
|
||||
request. Keeping its report object locally lets the page receive the
|
||||
candle-close signal first, then a second atomic snapshot only when a
|
||||
panel produced a result.
|
||||
"""
|
||||
updated = False
|
||||
for tf in tfs:
|
||||
report = self._reports.get(tf.value)
|
||||
if report is None:
|
||||
continue
|
||||
if not attach_panel(report, surface="dashboard"):
|
||||
continue
|
||||
entry = self.snapshots.get(tf.value)
|
||||
if entry is None or entry.get("status") != "ok":
|
||||
continue
|
||||
entry["report"] = report_to_webapp_dict(report)
|
||||
updated = True
|
||||
return updated
|
||||
|
||||
def refresh_news(self) -> None:
|
||||
try:
|
||||
ne = self._news()
|
||||
@@ -432,11 +404,11 @@ class DashboardPublisher:
|
||||
def run_cycle(self, tfs: List[Timeframe]) -> None:
|
||||
for tf in tfs: # sequential — see module docstring (cache dedup)
|
||||
self.scan_tf(tf)
|
||||
# Technical signals land before any optional slow work. News and the
|
||||
# advisory panel are display-only and may take 20-30 seconds cold.
|
||||
# Snapshot lands BEFORE the news refresh: when the news TTL has
|
||||
# expired, refresh_news does FF + 4 RSS fetches (and possibly
|
||||
# DeepSeek calls) — 20-30 s that must not keep fresh signals off
|
||||
# the page.
|
||||
self.publish_snapshot()
|
||||
if self.attach_panels(tfs):
|
||||
self.publish_snapshot()
|
||||
self.refresh_news()
|
||||
self.publish_news()
|
||||
|
||||
|
||||
@@ -134,8 +134,6 @@ The message says: <i>"once price hits 4,159, trail SL $33 behind best price."</i
|
||||
<ul>
|
||||
<li><b>H1 is forgiving.</b> You have up to an hour to act on a message, and small delays barely matter. On the 5-minute signals, being two minutes late already costs real money — they are not for manual beginners.</li>
|
||||
<li><b>H1 trades are held longer</b> — often overnight, sometimes over days. That is normal. Your stop loss is working for you while you sleep.</li>
|
||||
<li><b>H1 is BUY-only.</b> All H1 SELL signals are blocked (since 2026-09-03) because they were a losing side on this timeframe — expect to see BUY signals here, never SELL.</li>
|
||||
<li><b>M5 also holds back some SELL signals</b> — specifically while the market is in a choppy "transitioning" phase, since those shorts tested poorly.</li>
|
||||
<li><b>Never take the same signal on two timeframes.</b> They are usually the same gold move seen at different zoom levels. Taking both doubles your risk, not your profit.</li>
|
||||
</ul>
|
||||
|
||||
|
||||
+11
-11
@@ -84,27 +84,27 @@
|
||||
</ol>
|
||||
|
||||
<h2>4 · Choosing your timeframe</h2>
|
||||
<p>Twelve-month Oanda-candle backtest (Aug 2025 – Aug 2026, $0.65 round-trip cost, entry at next candle open):</p>
|
||||
<p>Twelve-month backtest (Jul 2025 – Jul 2026, real spreads, entry at next candle open):</p>
|
||||
<table>
|
||||
<tr><th></th><th>M5</th><th>M15</th><th>H1</th></tr>
|
||||
<tr><td>Trades / week</td><td>~11.3</td><td>~7.6</td><td>~2.7</td></tr>
|
||||
<tr><td>Total result (12 mo)</td><td>+106.8R</td><td>+73.9R</td><td>+24.0R</td></tr>
|
||||
<tr><td>Win rate / profit factor</td><td>52.9% / 1.51</td><td>62.2% / 1.53</td><td>60.9% / 1.55</td></tr>
|
||||
<tr><td>Typical (median) hold</td><td>2.4 h</td><td>7 h</td><td>15.5 h</td></tr>
|
||||
<tr><td>90% of trades close within</td><td>10.5 h</td><td>29.8 h</td><td>3.2 days</td></tr>
|
||||
<tr><td>Typical stop distance</td><td>~$21</td><td>~$32</td><td>~$52</td></tr>
|
||||
<tr><td>Worst drawdown</td><td>10.7R</td><td>10.8R</td><td>8.0R</td></tr>
|
||||
<tr><td>Trades / week</td><td>~10</td><td>~9.5</td><td>~3</td></tr>
|
||||
<tr><td>Total result (12 mo)</td><td>+135.6R</td><td>+67.2R</td><td>+49.9R</td></tr>
|
||||
<tr><td>Win rate / profit factor</td><td>59.7% / 1.81</td><td>55.8% / 1.40</td><td>59.4% / 1.94</td></tr>
|
||||
<tr><td>Typical (median) hold</td><td>2.4 h</td><td>6 h</td><td>12.5 h</td></tr>
|
||||
<tr><td>90% of trades close within</td><td>16 h</td><td>40 h</td><td>3.7 days</td></tr>
|
||||
<tr><td>Typical stop distance</td><td>~$19</td><td>~$29</td><td>~$44</td></tr>
|
||||
<tr><td>Worst drawdown</td><td>11.2R</td><td>9.4R</td><td>9.5R</td></tr>
|
||||
</table>
|
||||
<ul>
|
||||
<li><b>M5 — intraday, execution-critical.</b> Highest total edge, but stops are tight: a couple of dollars of slippage or a late entry eats a meaningful share of every trade. Suitable only if you can act within a minute or two of the alert (or automate). Trades open and close within a session; you sleep flat. SELL signals are held back while the market regime reads "transitioning" — a validated filter, not a bug if you notice fewer shorts there.</li>
|
||||
<li><b>M5 — intraday, execution-critical.</b> Highest total edge, but stops are tight: a couple of dollars of slippage or a late entry eats a meaningful share of every trade. Suitable only if you can act within a minute or two of the alert (or automate). Trades open and close within a session; you sleep flat.</li>
|
||||
<li><b>M15 — the middle path.</b> Same frequency as M5, wider stops, up to 15 minutes to act. About half your trades will still be open overnight.</li>
|
||||
<li><b>H1 — swing trading around a normal life.</b> Best quality per trade and very forgiving of slow manual execution (you have up to an hour). The costs: positions held for days, through news and overnight sessions, and a <b>lumpy</b> equity curve — one quarter of the test year was negative and most of the profit came in a single quarter. Judge it on quarters, not weeks. H1 no longer issues SELL signals at all (blocked since 2026-09-03) — it is BUY-only on this timeframe.</li>
|
||||
<li><b>H1 — swing trading around a normal life.</b> Best quality per trade and very forgiving of slow manual execution (you have up to an hour). The costs: positions held for days, through news and overnight sessions, and a <b>lumpy</b> equity curve — one quarter of the test year was negative and most of the profit came in a single quarter. Judge it on quarters, not weeks.</li>
|
||||
</ul>
|
||||
<div class="callout"><b>If you trade manually from the Telegram alerts, H1 (or M15) is the honest choice.</b> M5's backtested edge assumes near-instant fills that a human copying signals rarely achieves.</div>
|
||||
|
||||
<h2>5 · What to expect (and what not to)</h2>
|
||||
<ul>
|
||||
<li><b>You will lose roughly 38–47% of trades.</b> A 53–62% win rate means losing streaks of 5+ are routine over a year. The system's edge is the average across hundreds of trades, not any single one.</li>
|
||||
<li><b>You will lose ~40% of trades.</b> A 56–60% win rate means losing streaks of 5+ are routine over a year. The system's edge is the average across hundreds of trades, not any single one.</li>
|
||||
<li><b>Stops hit fast, winners take time.</b> Losing trades die in roughly half the time winners take. An early stop-out is the system doing its job.</li>
|
||||
<li><b>Drawdowns of ~10R are normal.</b> At 1% risk per trade, expect to be ~10% down from a peak at some point in a normal year. This is why risking more than 1% per trade is how accounts die.</li>
|
||||
<li><b>Backtest ≠ live.</b> Results above charge real spreads and realistic fills, but cannot model your personal latency, broker slippage, or requotes. Assume live results will be somewhat worse — especially on M5.</li>
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
================================================================================
|
||||
MULTI-PROVIDER LLM LAYER - PLAN
|
||||
(DeepSeek for news summary, Anthropic Claude for agent panel)
|
||||
Written 2026-08-08. Plan only - no code has been changed.
|
||||
================================================================================
|
||||
|
||||
WHY
|
||||
---
|
||||
Today every LLM feature (news enrichment + agent panel) goes through one
|
||||
hardwired client: llm_client.py -> DeepSeekClient, a module singleton reached
|
||||
via get_llm_client(). This plan describes how to route different features to
|
||||
different providers - e.g. keep DeepSeek for cheap high-volume news scoring,
|
||||
put the agent panel on Anthropic Claude for better reasoning.
|
||||
|
||||
The repo already solved this problem once for market data: the providers/
|
||||
package (BaseDataProvider owns shared machinery, subclasses implement only
|
||||
_fetch_raw, a registry + one config knob selects the provider). This plan
|
||||
mirrors that proven pattern at the LLM layer, scaled down to a single module.
|
||||
|
||||
BLAST RADIUS (verified by grep)
|
||||
-------------------------------
|
||||
get_llm_client() call sites:
|
||||
- news_engine.py ....... 4 sites
|
||||
- agent_panel.py ....... 3 sites (+1 timeout getattr)
|
||||
- telegram_bot.py ...... 1 site (/status health; reads private _count,
|
||||
_count_day, _daily_cap fields)
|
||||
- test_all.py .......... 41 locations stub via lc._client = _StubLLM(...)
|
||||
Out of scope: static/news_summary.php (own PHP DeepSeek call + key file,
|
||||
separate deployment).
|
||||
|
||||
DESIGN INVARIANTS (carried over unchanged)
|
||||
------------------------------------------
|
||||
1. Fail-safe contract: every call returns Optional[Dict]; any failure -> None;
|
||||
callers keep their non-LLM fallbacks; LLM output never gates signals.
|
||||
2. Single attempt, no retries (news cache cadence is the retry); per-provider
|
||||
UTC daily cap; warn-once logging; health stamps (last_ok, last_error).
|
||||
3. Static system prompts stay byte-identical constants - provider-neutral,
|
||||
and they map to prompt caching on BOTH providers (DeepSeek prefix cache;
|
||||
Anthropic cache_control ephemeral).
|
||||
4. Test compatibility: all 41 existing lc._client stub sites keep working
|
||||
with zero edits.
|
||||
|
||||
================================================================================
|
||||
1. llm_client.py REFACTOR (~195 -> ~430 lines, no new files)
|
||||
================================================================================
|
||||
|
||||
1a. BaseLLMClient - shared machinery (extracted from today's DeepSeekClient)
|
||||
- __init__(api_key, model, timeout, daily_cap, enabled)
|
||||
- public: name ("deepseek"/"anthropic"), timeout property,
|
||||
usage() -> (used_today, cap) # so telegram_bot stops reading privates
|
||||
- owns: available property, _warn_once, _under_cap() (UTC-day counter,
|
||||
lock), health stamps, and the template method:
|
||||
|
||||
def chat_json(self, system_prompt, user_payload):
|
||||
if not self.available or not self._under_cap():
|
||||
return None
|
||||
try:
|
||||
return self._request(system_prompt, user_payload) # hook
|
||||
except <provider-agnostic error mapping>:
|
||||
# stamps last_error, returns None
|
||||
|
||||
- subclasses implement ONLY _request(system_prompt, payload) -> dict|None.
|
||||
|
||||
1b. DeepSeekClient(BaseLLMClient) - behavior-identical move
|
||||
Current requests-based logic relocates into _request: same URL,
|
||||
thinking:disabled, response_format:json_object, temperature 0,
|
||||
max_tokens 2000, sort_keys=True payload, _parse_response.
|
||||
Review criterion: Module 19 tests pass UNMODIFIED (they patch
|
||||
client._session, which stays).
|
||||
|
||||
1c. AnthropicClient(BaseLLMClient) - new, official SDK
|
||||
- Official `anthropic` Python SDK (add to requirements.txt), NOT raw
|
||||
requests. LAZY IMPORT inside __init__: package missing -> warn-once +
|
||||
client unavailable, so DeepSeek-only deployments never install it.
|
||||
- Constructor: anthropic.Anthropic(api_key=..., timeout=<configured>,
|
||||
max_retries=0) # house rule: single attempt
|
||||
- _request:
|
||||
resp = self._client.messages.create(
|
||||
model=self._model, # default "claude-opus-5"
|
||||
max_tokens=4000, # covers thinking + JSON reply
|
||||
output_config={"effort": "low"}, # cheap structured takes
|
||||
system=[{"type": "text", "text": system_prompt,
|
||||
"cache_control": {"type": "ephemeral"}}],
|
||||
messages=[{"role": "user",
|
||||
"content": json.dumps(user_payload, sort_keys=True)}],
|
||||
)
|
||||
if resp.stop_reason == "refusal": # Opus 5: HTTP 200, check first
|
||||
return None
|
||||
text = first text block in resp.content
|
||||
return self._parse_json(text) # tolerant: strip ```json fences,
|
||||
# json.loads, dict check
|
||||
- DELIBERATELY ABSENT (would 400 on Claude Opus 5): temperature (removed
|
||||
on Opus 5 - do NOT copy from the DeepSeek client), budget_tokens,
|
||||
prefills, response_format.
|
||||
- Error mapping: APIConnectionError -> "network",
|
||||
APIStatusError -> "http_<code>", parse fail -> "parse"
|
||||
(same last_error kinds the /status display already understands).
|
||||
- Defaults: model claude-opus-5 (config-overridable, e.g. claude-haiku-4-5
|
||||
for ~5x cheaper), timeout 30s (Claude slower than V4-Flash), own
|
||||
daily_request_cap 500.
|
||||
|
||||
1d. Registry + per-consumer routing
|
||||
_PROVIDERS = {"deepseek": DeepSeekClient, "anthropic": AnthropicClient}
|
||||
LLM_CONSUMERS = ("news", "agent_panel")
|
||||
_clients = {} # keyed by PROVIDER name -> two consumers on the
|
||||
# same provider share one instance and one cap
|
||||
_client = None # kept: GLOBAL TEST OVERRIDE
|
||||
|
||||
def get_llm_client(consumer="news"):
|
||||
if _client is not None: # tests: lc._client = stub
|
||||
return _client # -> shadows ALL consumers
|
||||
name = get_app_config().llm_routing.get(consumer, "deepseek")
|
||||
create-once per provider, return _clients[name]
|
||||
|
||||
def reset_llm_client(): # clears _client AND _clients
|
||||
|
||||
Keeping _client as a documented test-override global is what makes the
|
||||
41 existing stub sites work untouched.
|
||||
|
||||
================================================================================
|
||||
2. CONFIG (app_config.py + config.json.example)
|
||||
================================================================================
|
||||
- New top-level "anthropic" block, mirroring "deepseek":
|
||||
{api_key, model, enabled, timeout_seconds, daily_request_cap}
|
||||
plus anthropic_api_key() resolver: ANTHROPIC_API_KEY env -> config.json -> "".
|
||||
- New top-level "llm" ROUTING block:
|
||||
{"news": "deepseek", "agent_panel": "deepseek"}
|
||||
Validation: keys within {news, agent_panel}; values within registered
|
||||
provider names. Absent block -> {} -> everything on DeepSeek (today's
|
||||
behavior; zero-migration default). AppConfig.llm_routing: Dict[str, str].
|
||||
|
||||
- The target state is then PURE CONFIG - no code changes on switch day:
|
||||
|
||||
"llm": { "news": "deepseek", "agent_panel": "anthropic" },
|
||||
"anthropic": { "api_key": "", "model": "claude-opus-5", "enabled": true,
|
||||
"timeout_seconds": 30, "daily_request_cap": 500 }
|
||||
|
||||
================================================================================
|
||||
3. CONSUMER EDITS (mechanical, ~10 lines total)
|
||||
================================================================================
|
||||
- news_engine.py: 4 call sites -> get_llm_client("news")
|
||||
- agent_panel.py: 3 call sites -> get_llm_client("agent_panel");
|
||||
getattr(client, "_timeout", ...) -> public client.timeout
|
||||
- telegram_bot.py _news_health_lines: iterate the DISTINCT providers actually
|
||||
routed (news + panel); per provider print "<name>: used/cap calls today"
|
||||
via the new usage() accessor instead of private fields. DeepSeek keeps its
|
||||
"headlines scored" extra; cap/error warnings work off base-class stamps.
|
||||
- NO changes to prompts, payload builders, caches, or the panel's own
|
||||
max_panels_per_day budget - all provider-agnostic already.
|
||||
|
||||
================================================================================
|
||||
4. TESTS (new "Module 24: LLM provider routing", ~12 tests, ~200 lines)
|
||||
================================================================================
|
||||
Reuse _StubLLM; new _FakeAnthropic (fake messages.create returning a stub
|
||||
response object) injected as client._client.
|
||||
|
||||
1. app_config: anthropic block parses/validates/defaults empty.
|
||||
2. app_config: llm routing block - valid map round-trips; unknown consumer
|
||||
key, unknown provider name, non-dict -> AppConfigError; absent -> {}.
|
||||
3. anthropic_api_key resolves env -> config.json -> empty.
|
||||
4. Base contract on BOTH subclasses (parametrized): keyless/disabled -> no
|
||||
call; daily cap blocks + UTC-roll reset; failure stamps last_error,
|
||||
success stamps last_ok.
|
||||
5. AnthropicClient parse: clean JSON -> dict; ```json-fenced -> dict;
|
||||
non-JSON -> None; stop_reason "refusal" -> None; no text block -> None.
|
||||
6. AnthropicClient request shape: NO temperature key, max_retries=0,
|
||||
cache_control present on system block (assert on captured kwargs).
|
||||
7. AnthropicClient with `anthropic` package missing (simulated) ->
|
||||
available False, chat_json None, no crash at import.
|
||||
8. Routing: no llm block -> both consumers get the SAME DeepSeek singleton
|
||||
(shared cap); routed config -> news gets DeepSeekClient while
|
||||
agent_panel gets AnthropicClient; same-provider consumers share one
|
||||
instance.
|
||||
9. Global lc._client override shadows BOTH consumers (locks the 41 legacy
|
||||
stub sites' semantics).
|
||||
10. reset_llm_client() clears both the override and the per-provider cache.
|
||||
11. End-to-end: routing agent_panel->anthropic, attach_panel reaches the
|
||||
anthropic fake (5 calls) while a news call reaches the deepseek stub.
|
||||
12. telegram _news_health_lines renders per-provider lines from usage()
|
||||
without touching private fields.
|
||||
|
||||
GATE: Modules 19 and 23 must pass UNMODIFIED - the no-regression proof for
|
||||
the refactor.
|
||||
|
||||
================================================================================
|
||||
5. DOCS
|
||||
================================================================================
|
||||
- CLAUDE.md: llm_client bullet -> provider-abstracted (BaseLLMClient;
|
||||
deepseek + anthropic registered); config.json "llm" block routes consumers;
|
||||
absent = all-DeepSeek. Add ANTHROPIC_API_KEY to env-var list. Test counts.
|
||||
- config.json.example: both new blocks.
|
||||
|
||||
================================================================================
|
||||
COMMIT SEQUENCE (each commit green)
|
||||
================================================================================
|
||||
1. llm_client: extract BaseLLMClient; DeepSeekClient unchanged behavior
|
||||
(+ base-contract tests) - pure refactor, Modules 19/23 green untouched.
|
||||
2. config: anthropic block + llm consumer-routing block (+ config tests).
|
||||
3. llm_client: AnthropicClient (official SDK, lazy import) + requirements.txt
|
||||
(+ parse/shape tests).
|
||||
4. routing: per-consumer get_llm_client + health lines - consumer call-site
|
||||
edits, telegram usage() lines, routing/e2e tests.
|
||||
5. docs: CLAUDE.md + example config.
|
||||
|
||||
================================================================================
|
||||
VERIFICATION
|
||||
================================================================================
|
||||
- python test_all.py green at every commit (~374 -> ~386).
|
||||
- Live smoke A (no behavior change): default config, main.py --panel ->
|
||||
still DeepSeek, /status unchanged.
|
||||
- Live smoke B (the goal): llm.agent_panel="anthropic" + real
|
||||
ANTHROPIC_API_KEY, run --panel -> one real Claude panel (5 requests,
|
||||
validates JSON contract + latency + refusal-free path) while a news
|
||||
refresh still hits DeepSeek; /status shows two provider lines.
|
||||
- Backtest untouched by construction (no LLM in replay path); one 30-day
|
||||
run as a formality.
|
||||
|
||||
================================================================================
|
||||
EFFORT ESTIMATE
|
||||
================================================================================
|
||||
0 new files, 8 modified, ~520 lines, est. 3-5 hours.
|
||||
|
||||
llm_client.py base class + AnthropicClient + routing +235
|
||||
test_all.py Module 24 +200
|
||||
app_config.py anthropic + llm blocks, resolver +55
|
||||
telegram_bot.py per-provider health lines +15
|
||||
news_engine.py consumer tags on call sites +4
|
||||
agent_panel.py consumer tags on call sites +4
|
||||
config.json.example blocks +14
|
||||
requirements.txt anthropic +1
|
||||
CLAUDE.md docs +10
|
||||
|
||||
================================================================================
|
||||
KEY DECISIONS EMBEDDED IN THIS PLAN
|
||||
================================================================================
|
||||
- Official Anthropic SDK, not raw requests (current API guidance for Python;
|
||||
lazy import keeps it optional for DeepSeek-only installs).
|
||||
- Default Claude model: claude-opus-5 ($5/$25 per MTok; a full 5-call panel
|
||||
~ 8k in / 500 out ~ $0.05). Config-overridable to claude-haiku-4-5.
|
||||
- Two Claude gotchas handled: temperature is REMOVED on Opus 5 (sending it
|
||||
400s - must not be copied from the DeepSeek request body), and refusals
|
||||
arrive as HTTP 200 with stop_reason "refusal" (checked before parsing,
|
||||
mapped to the normal None/fallback path).
|
||||
- Caps are per-provider, not per-consumer: two consumers on one provider
|
||||
share its cap (today's semantics); splitting across two providers doubles
|
||||
total headroom naturally.
|
||||
- The panel's own max_panels_per_day budget is untouched - it sits above
|
||||
whichever provider serves it.
|
||||
- Not in scope: static/news_summary.php (own PHP client/key), streaming,
|
||||
tool use, per-consumer model overrides (add a "model" key per consumer
|
||||
later if ever needed).
|
||||
================================================================================
|
||||
@@ -13,7 +13,6 @@ import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
# Force UTF-8 output on Windows so box-drawing chars render
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
@@ -21,9 +20,7 @@ if hasattr(sys.stdout, "reconfigure"):
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
from agent_panel import attach_panel
|
||||
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD
|
||||
from market_outlook import OutlookResult, maybe_run_outlook
|
||||
from pair_signals import PAIR_PRODUCERS
|
||||
from signal_aggregator import SignalAggregator, SignalReport
|
||||
|
||||
@@ -166,13 +163,6 @@ def print_signal_report(report: SignalReport, detail: bool = False):
|
||||
print(f" {Colors.MAGENTA}📰{Colors.RESET} {report.news_description}")
|
||||
if report.news_summary:
|
||||
print(f" {Colors.MAGENTA}🧠{Colors.RESET} {report.news_summary}")
|
||||
if report.panel_verdict:
|
||||
print(f" {Colors.CYAN}🤖{Colors.RESET} AI Panel: "
|
||||
f"{Colors.BOLD}{report.panel_verdict}{Colors.RESET} "
|
||||
f"({report.panel_confidence:.0f}%) — {report.panel_rationale}")
|
||||
for t in report.panel_takes:
|
||||
print(f" {Colors.GRAY}{t['persona']:<11} {t['stance']:<8} "
|
||||
f"{t['note']}{Colors.RESET}")
|
||||
|
||||
# ── 6. DETAIL (only with --detail) ─────────────────────────────
|
||||
if detail and report.primary_analysis:
|
||||
@@ -199,24 +189,6 @@ def print_signal_report(report: SignalReport, detail: bool = False):
|
||||
print(f" {label:>20} {color}{val:.3f}{Colors.RESET}")
|
||||
|
||||
|
||||
def print_market_outlook(outlook: Optional[OutlookResult]) -> None:
|
||||
"""Render the independent advisory outlook for explicit CLI use."""
|
||||
print(f"\n {Colors.BOLD}AI Market Outlook{Colors.RESET}")
|
||||
if outlook is None:
|
||||
print(f" {Colors.GRAY}Unavailable (disabled, market closed, or no LLM response).{Colors.RESET}")
|
||||
return
|
||||
forming = {
|
||||
"buy": "possible buy forming",
|
||||
"sell": "possible sell forming",
|
||||
"none": "no clear setup forming",
|
||||
}[outlook.setup_forming]
|
||||
print(f" {outlook.bias.title()} · {forming} · {outlook.confidence:.0f}%")
|
||||
if outlook.watch:
|
||||
print(f" Watch: {outlook.watch}")
|
||||
if outlook.note:
|
||||
print(f" {Colors.GRAY}{outlook.note}{Colors.RESET}")
|
||||
|
||||
|
||||
def report_to_dict(report: SignalReport) -> dict:
|
||||
d = {
|
||||
"symbol": report.symbol,
|
||||
@@ -237,13 +209,6 @@ def report_to_dict(report: SignalReport) -> dict:
|
||||
"news_sentiment": round(report.news_sentiment, 3),
|
||||
"news_description": report.news_description,
|
||||
"news_summary": report.news_summary,
|
||||
"panel_verdict": report.panel_verdict,
|
||||
"panel_confidence": round(report.panel_confidence, 1),
|
||||
"panel_rationale": report.panel_rationale,
|
||||
"panel_takes": report.panel_takes,
|
||||
"panel_created_at": report.panel_created_at,
|
||||
"panel_cache_status": report.panel_cache_status,
|
||||
"panel_payload_hash": report.panel_payload_hash,
|
||||
"bullish_reasons": report.bullish_reasons,
|
||||
"bearish_reasons": report.bearish_reasons,
|
||||
"warnings": report.warnings,
|
||||
@@ -260,8 +225,6 @@ def report_to_dict(report: SignalReport) -> dict:
|
||||
"risk_pips": round(ts.risk_pips, 1),
|
||||
"reward_pips": round(ts.reward_pips, 1),
|
||||
"strategy": ts.strategy,
|
||||
"setup_pattern": ts.setup_pattern,
|
||||
"round_trip_cost": ts.round_trip_cost,
|
||||
"trail_arm_r": ts.trail_arm_r,
|
||||
"trail_dist_r": ts.trail_dist_r,
|
||||
}
|
||||
@@ -305,12 +268,6 @@ Examples:
|
||||
help="Primary analysis timeframe (default: H1)")
|
||||
parser.add_argument("--detail", action="store_true",
|
||||
help="Show full indicator breakdown")
|
||||
parser.add_argument("--panel", action="store_true",
|
||||
help="Run the LLM agent panel on the result "
|
||||
"(advisory only; needs a DeepSeek key)")
|
||||
parser.add_argument("--outlook", action="store_true",
|
||||
help="Run the hourly AI market outlook now "
|
||||
"(advisory only; needs a DeepSeek key)")
|
||||
parser.add_argument("--json", action="store_true",
|
||||
help="Output as JSON")
|
||||
parser.add_argument("--verbose", "-v", action="store_true",
|
||||
@@ -364,37 +321,14 @@ def build_config(args) -> PlatformConfig:
|
||||
def run_live(args, config: PlatformConfig):
|
||||
print(f"{Colors.GRAY} Fetching market data and running analysis...{Colors.RESET}\n")
|
||||
aggregator = SignalAggregator(config)
|
||||
report = aggregator.analyze_pair(symbol=config.symbol, log_source="cli")
|
||||
|
||||
# Explicit user intent: force bypasses the enabled/surface/quality
|
||||
# toggles, but the daily panel budget and LLM cap still apply.
|
||||
if getattr(args, "panel", False):
|
||||
attach_panel(report, surface="cli", force=True)
|
||||
|
||||
outlook = None
|
||||
if getattr(args, "outlook", False):
|
||||
outlook = maybe_run_outlook(surface="cli", force=True)
|
||||
report = aggregator.analyze_pair(symbol=config.symbol)
|
||||
|
||||
if args.json:
|
||||
payload = report_to_dict(report)
|
||||
if getattr(args, "outlook", False):
|
||||
payload["market_outlook"] = (
|
||||
None if outlook is None else {
|
||||
"bias": outlook.bias,
|
||||
"setup_forming": outlook.setup_forming,
|
||||
"confidence": outlook.confidence,
|
||||
"watch": outlook.watch,
|
||||
"note": outlook.note,
|
||||
"created_ts": outlook.created_ts,
|
||||
}
|
||||
)
|
||||
print(json.dumps(payload, indent=2))
|
||||
print(json.dumps(report_to_dict(report), indent=2))
|
||||
return
|
||||
|
||||
print_header(f"{config.display_name} SIGNAL INTELLIGENCE")
|
||||
print_signal_report(report, detail=args.detail)
|
||||
if getattr(args, "outlook", False):
|
||||
print_market_outlook(outlook)
|
||||
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
|
||||
print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}")
|
||||
print(f"{Colors.CYAN}{'═' * 80}{Colors.RESET}\n")
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
"""
|
||||
ForexBot2 — AI market outlook (advisory hourly assessment)
|
||||
===========================================================
|
||||
Builds a broad XAU/USD market brief and asks DeepSeek whether a good setup
|
||||
appears to be forming. The result is written to the signal-history database
|
||||
and may be displayed by consumers, but it is ADVISORY-ONLY: it never changes
|
||||
signal scoring, gates, grades, or trade setup generation.
|
||||
|
||||
The outlook runs outside the replayable signal path. Individual candle/news
|
||||
failures produce a partial brief; an unavailable LLM, malformed reply, or
|
||||
locked database never reaches a scan caller as an exception.
|
||||
|
||||
The static prompt is intentional: DeepSeek prices byte-identical prompt
|
||||
prefixes at its cache-hit rate, so timestamps and all other changing evidence
|
||||
live only in the JSON user payload.
|
||||
"""
|
||||
|
||||
import datetime as _dt
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent_panel import _normalize_candles
|
||||
from app_config import (get_app_config, market_outlook_enabled,
|
||||
signal_log_path)
|
||||
from config import PlatformConfig, Timeframe, XAUUSD
|
||||
from engine import TechnicalEngine
|
||||
from llm_client import get_llm_client
|
||||
from providers import base as _provider_base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_PER_DAY = 12
|
||||
DEFAULT_CANDLE_BARS = 20
|
||||
_OUTLOOK_TFS = (Timeframe.M15, Timeframe.H1, Timeframe.H4)
|
||||
_VALID_BIASES = ("bullish", "bearish", "neutral")
|
||||
_VALID_SETUPS = ("buy", "sell", "none")
|
||||
|
||||
|
||||
# STATIC prompt: repeated prompt prefixes are much cheaper with DeepSeek's
|
||||
# server-side cache. Variable market evidence belongs exclusively in payload.
|
||||
SYSTEM_PROMPT = """\
|
||||
You are writing an advisory market outlook for gold (XAU/USD). You receive a
|
||||
JSON market brief covering recent candle shape, indicator states, price
|
||||
levels, news, and recent technical signals. Assess what may be forming soon;
|
||||
you are not a signal generator and must not provide an entry price, stop, or
|
||||
target.
|
||||
|
||||
Gold doctrine: hawkish Fed / rate hikes / rising real yields / strong
|
||||
dollar are gold-bearish; easing / rate cuts / safe-haven flows /
|
||||
geopolitical stress / weak dollar are gold-bullish.
|
||||
|
||||
Indicator "value" units: rsi 0-100 (above 70 overbought, below 30
|
||||
oversold); adx 0-100 (above 25 = strong trend); macd = histogram in
|
||||
dollars (momentum); roc = percent; bollinger = position 0-1 (0 = lower
|
||||
band, 1 = upper band); keltner = dollar distance from the mid-line;
|
||||
ema_trend = 0-1 alignment score; divergence and candlestick = pattern
|
||||
counts. "candles" is per timeframe: {"ref_close", "atr", "bars"}; bars
|
||||
are oldest-first [open, high, low, close] in ATR units relative to the
|
||||
latest close. Read their shape, never quote them as prices.
|
||||
|
||||
Hard rules: use ONLY the JSON provided. Do not invent numbers, events, or
|
||||
price levels. Your training data about gold, markets, and current events is
|
||||
stale and OFF-LIMITS: never use outside knowledge; if a fact is not in the
|
||||
JSON, treat it as unknown. If evidence is thin or missing, answer "neutral"
|
||||
with confidence below 30. Use plain everyday English for someone unfamiliar
|
||||
with markets: short simple sentences, no acronyms or jargon. Keep "watch"
|
||||
and "note" to at most 25 words each.
|
||||
|
||||
Reply with ONLY JSON:
|
||||
{"bias": "bullish"|"bearish"|"neutral",
|
||||
"setup_forming": "buy"|"sell"|"none",
|
||||
"confidence": 0-100,
|
||||
"watch": "<what to watch for, max 25 words>",
|
||||
"note": "<plain-English summary, max 25 words>"}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutlookResult:
|
||||
bias: str
|
||||
setup_forming: str
|
||||
confidence: float
|
||||
watch: str
|
||||
note: str
|
||||
created_ts: float
|
||||
|
||||
|
||||
def _finite(x: Any) -> Optional[float]:
|
||||
"""float(x) or None for non-numeric / non-finite JSON values."""
|
||||
try:
|
||||
value = float(x)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if math.isfinite(value) else None
|
||||
|
||||
|
||||
def _round_finite(x: Any, digits: int) -> Optional[float]:
|
||||
value = _finite(x)
|
||||
return round(value, digits) if value is not None else None
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
"""Replace any accidental NaN/inf nested in provider data with null."""
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_safe(item) for item in value]
|
||||
if isinstance(value, bool) or value is None or isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return _finite(value)
|
||||
return value
|
||||
|
||||
|
||||
def _clamp_confidence(raw: Any) -> Optional[float]:
|
||||
value = _finite(raw)
|
||||
return max(0.0, min(100.0, value)) if value is not None else None
|
||||
|
||||
|
||||
def _word_limit(text: Any, words: int = 25) -> str:
|
||||
"""Bound a response field by words as well as a practical char limit."""
|
||||
clean = str(text or "").strip()
|
||||
return " ".join(clean.split()[:words])[:200]
|
||||
|
||||
|
||||
def _validate_outlook(reply: Any) -> Optional[OutlookResult]:
|
||||
"""Validate only model replies that fully match the requested contract."""
|
||||
if not isinstance(reply, dict):
|
||||
return None
|
||||
bias = reply.get("bias")
|
||||
setup_forming = reply.get("setup_forming")
|
||||
confidence = _clamp_confidence(reply.get("confidence"))
|
||||
if (bias not in _VALID_BIASES or setup_forming not in _VALID_SETUPS
|
||||
or confidence is None):
|
||||
return None
|
||||
return OutlookResult(
|
||||
bias=bias,
|
||||
setup_forming=setup_forming,
|
||||
confidence=confidence,
|
||||
watch=_word_limit(reply.get("watch")),
|
||||
note=_word_limit(reply.get("note")),
|
||||
created_ts=time.time(),
|
||||
)
|
||||
|
||||
|
||||
# ── Data collection ──────────────────────────────────────────────────
|
||||
|
||||
_dp: Any = None # Patchable singleton, mirroring agent_panel._dp.
|
||||
|
||||
|
||||
def _outlook_provider() -> Any:
|
||||
"""Lazy provider singleton so its closed-bar cache stays warm hourly."""
|
||||
global _dp
|
||||
if _dp is None:
|
||||
from providers import make_data_provider
|
||||
_dp = make_data_provider(PlatformConfig())
|
||||
return _dp
|
||||
|
||||
|
||||
def _indicator_payload(analysis: Any) -> Dict[str, Dict[str, Any]]:
|
||||
"""Visible scored indicators with finite, compact values only."""
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for name, indicator in (getattr(analysis, "indicators", None) or {}).items():
|
||||
if name.startswith("_") or getattr(indicator, "weight", 0) == 0:
|
||||
continue
|
||||
entry: Dict[str, Any] = {
|
||||
"signal": getattr(getattr(indicator, "signal", None), "name", ""),
|
||||
"confidence": _round_finite(
|
||||
getattr(indicator, "confidence", None), 1),
|
||||
"value": _round_finite(getattr(indicator, "value", None), 2),
|
||||
}
|
||||
out[name] = entry
|
||||
return out
|
||||
|
||||
|
||||
def _analysis_payload(analyses: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Pure formatter for the technical portion of the market brief."""
|
||||
indicators: Dict[str, Any] = {}
|
||||
market_regime: Dict[str, Optional[str]] = {}
|
||||
volatility_regime: Dict[str, Optional[str]] = {}
|
||||
key_levels: Dict[str, Dict[str, Optional[float]]] = {}
|
||||
for tf in _OUTLOOK_TFS:
|
||||
name = tf.value
|
||||
analysis = analyses.get(name)
|
||||
indicators[name] = _indicator_payload(analysis)
|
||||
market_regime[name] = getattr(analysis, "market_regime", None)
|
||||
volatility_regime[name] = getattr(analysis, "volatility_regime", None)
|
||||
key_levels[name] = {
|
||||
level: _round_finite(value, 2)
|
||||
for level, value in (getattr(analysis, "key_levels", None) or {}).items()
|
||||
}
|
||||
return {
|
||||
"indicators": indicators,
|
||||
"market_regime": market_regime,
|
||||
"volatility_regime": volatility_regime,
|
||||
"key_levels": key_levels,
|
||||
}
|
||||
|
||||
|
||||
def _empty_news_payload() -> Dict[str, Any]:
|
||||
return {
|
||||
"headlines": None,
|
||||
"events": None,
|
||||
"gold_sentiment": None,
|
||||
"remaining_high_impact_events": None,
|
||||
}
|
||||
|
||||
|
||||
def _news_payload() -> Dict[str, Any]:
|
||||
"""Collect current news without allowing any news failure to block us."""
|
||||
try:
|
||||
from news_engine import NewsEngine
|
||||
|
||||
news = NewsEngine(PlatformConfig().news)
|
||||
context = news.get_fundamental_context()
|
||||
headlines = [str(getattr(h, "headline", ""))
|
||||
for h in (context.news_headlines or [])[:10]
|
||||
if getattr(h, "headline", None)]
|
||||
events = [
|
||||
{
|
||||
"event": getattr(event, "event", None),
|
||||
"currency": getattr(event, "currency", None),
|
||||
"actual": getattr(event, "actual", None),
|
||||
"forecast": getattr(event, "forecast", None),
|
||||
"released": not event.is_upcoming,
|
||||
}
|
||||
for event in (context.events_today or [])
|
||||
if event.is_high_impact
|
||||
][:8]
|
||||
gold_sentiment, _ = news.get_pair_sentiment(XAUUSD, context)
|
||||
remaining = sum(
|
||||
1 for event in news.get_week_events()
|
||||
if event.is_high_impact and event.is_upcoming
|
||||
)
|
||||
return {
|
||||
"headlines": headlines,
|
||||
"events": events,
|
||||
"gold_sentiment": _round_finite(gold_sentiment, 2),
|
||||
"remaining_high_impact_events": remaining,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("market outlook: news context unavailable: %s", exc)
|
||||
return _empty_news_payload()
|
||||
|
||||
|
||||
def _recent_signals() -> List[Dict[str, Any]]:
|
||||
"""Read the latest signal history through SQLite's immutable URI."""
|
||||
try:
|
||||
path = os.path.abspath(signal_log_path())
|
||||
# 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)
|
||||
try:
|
||||
rows = con.execute(
|
||||
"SELECT logged_at, timeframe, signal, quality, panel_verdict "
|
||||
"FROM signals ORDER BY id DESC LIMIT 10").fetchall()
|
||||
finally:
|
||||
con.close()
|
||||
return [
|
||||
{
|
||||
"logged_at": row[0],
|
||||
"timeframe": row[1],
|
||||
"signal": row[2],
|
||||
"quality": row[3],
|
||||
"panel_verdict": row[4],
|
||||
}
|
||||
for row in reversed(rows)
|
||||
]
|
||||
except Exception as exc:
|
||||
logger.debug("market outlook: signal history unavailable: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _build_payload(
|
||||
analyses: Dict[str, Any],
|
||||
candles: Dict[str, Any],
|
||||
news: Dict[str, Any],
|
||||
recent_signals: List[Dict[str, Any]],
|
||||
as_of: _dt.datetime,
|
||||
) -> Dict[str, Any]:
|
||||
"""Pure market-brief formatter. Every numeric field is finite or null."""
|
||||
technical = _analysis_payload(analyses)
|
||||
return _json_safe({
|
||||
"as_of": as_of.astimezone(_dt.timezone.utc).isoformat(),
|
||||
"candles": candles,
|
||||
**technical,
|
||||
"news": news,
|
||||
"recent_signals": recent_signals,
|
||||
})
|
||||
|
||||
|
||||
def _collect_payload(settings: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Fetch closed bars and turn them into a structured market brief."""
|
||||
analyses: Dict[str, Any] = {}
|
||||
candles: Dict[str, Any] = {tf.value: None for tf in _OUTLOOK_TFS}
|
||||
bars = int(settings.get("candles_bars", DEFAULT_CANDLE_BARS))
|
||||
provider = _outlook_provider()
|
||||
engine = TechnicalEngine(PlatformConfig().technical)
|
||||
|
||||
for tf in _OUTLOOK_TFS:
|
||||
try:
|
||||
df = provider.fetch_ohlcv(XAUUSD, tf)
|
||||
analysis = engine.analyze(df, XAUUSD, tf.value)
|
||||
analyses[tf.value] = analysis
|
||||
atr = _finite(getattr(
|
||||
(getattr(analysis, "indicators", None) or {}).get("atr"),
|
||||
"value", None))
|
||||
if atr is not None and atr > 0 and df is not None and len(df) >= 2:
|
||||
candles[tf.value] = _normalize_candles(df, atr, bars)
|
||||
except Exception as exc:
|
||||
logger.debug("market outlook: %s data unavailable: %s", tf.value, exc)
|
||||
|
||||
return _build_payload(
|
||||
analyses=analyses,
|
||||
candles=candles,
|
||||
news=_news_payload(),
|
||||
recent_signals=_recent_signals(),
|
||||
as_of=_dt.datetime.now(_dt.timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
# ── Orchestration, hourly dedup, and request budget ──────────────────
|
||||
|
||||
_state_lock = threading.Lock()
|
||||
_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_count = 0
|
||||
_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:
|
||||
"""Drop hourly and daily state (tests / deployment config reloads)."""
|
||||
global _last_run_ts, _last_run_hour, _outlook_day, _outlook_count
|
||||
global _warned_budget
|
||||
with _state_lock:
|
||||
_last_run_ts = None
|
||||
_last_run_hour = None
|
||||
_outlook_day = None
|
||||
_outlook_count = 0
|
||||
_warned_budget = False
|
||||
|
||||
|
||||
def _begin_attempt(cap: int, force: bool) -> bool:
|
||||
"""Atomically apply hourly dedup, then reserve a daily attempt.
|
||||
|
||||
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()
|
||||
hour_bucket = int(now // 3600)
|
||||
today = _dt.datetime.now(_dt.timezone.utc).date()
|
||||
with _state_lock:
|
||||
if not force and _last_run_hour == hour_bucket:
|
||||
_skip(f"hour bucket {hour_bucket} already attempted")
|
||||
return False
|
||||
if _outlook_day != today:
|
||||
_outlook_day = today
|
||||
_outlook_count = 0
|
||||
_warned_budget = False
|
||||
if _outlook_count >= cap:
|
||||
if not _warned_budget:
|
||||
_warned_budget = True
|
||||
logger.warning(
|
||||
"market outlook: daily budget (%d) exhausted — no more "
|
||||
"outlooks until UTC midnight", cap)
|
||||
else:
|
||||
_skip("daily budget exhausted")
|
||||
return False
|
||||
_outlook_count += 1
|
||||
_last_run_ts = now
|
||||
_last_run_hour = hour_bucket
|
||||
return True
|
||||
|
||||
|
||||
def run_outlook(settings: Dict[str, Any],
|
||||
surface: str = "live") -> Optional[OutlookResult]:
|
||||
"""Build, ask, validate, and persist one advisory outlook."""
|
||||
try:
|
||||
payload = _collect_payload(settings)
|
||||
reply = get_llm_client().chat_json(SYSTEM_PROMPT, payload)
|
||||
result = _validate_outlook(reply)
|
||||
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
|
||||
try:
|
||||
from signal_log import log_outlook
|
||||
log_outlook(result, surface=surface, payload=payload)
|
||||
except Exception:
|
||||
logger.debug("market outlook: persistence unavailable", exc_info=True)
|
||||
return result
|
||||
except Exception:
|
||||
logger.warning("market outlook: run failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def maybe_run_outlook(surface: str, force: bool = False) -> Optional[OutlookResult]:
|
||||
"""Best-effort public entry point. It NEVER raises to a scan caller.
|
||||
|
||||
``force`` is for explicit CLI use: it bypasses enabled, surface, and
|
||||
hourly gates, but not the independent outlook budget or shared LLM cap.
|
||||
"""
|
||||
try:
|
||||
settings = get_app_config().market_outlook_settings
|
||||
if not force:
|
||||
if not market_outlook_enabled():
|
||||
_skip("disabled")
|
||||
return None
|
||||
if surface == "telegram" and not settings.get("telegram", False):
|
||||
_skip("telegram surface off")
|
||||
return None
|
||||
if _provider_base._is_forex_weekend():
|
||||
_skip("weekend")
|
||||
return None
|
||||
cap = int(settings.get("max_per_day", DEFAULT_MAX_PER_DAY))
|
||||
if cap < 1:
|
||||
_skip("max_per_day < 1")
|
||||
return None
|
||||
if not _begin_attempt(cap, force):
|
||||
return None
|
||||
return run_outlook(settings, surface=surface)
|
||||
except Exception:
|
||||
logger.warning("market outlook: unavailable", exc_info=True)
|
||||
return None
|
||||
+2
-2
@@ -770,8 +770,8 @@ class NewsEngine:
|
||||
pub_date_el.text if pub_date_el is not None else None
|
||||
)
|
||||
|
||||
# Only recent headlines (configurable: headline_max_age_seconds)
|
||||
if pub_date and (datetime.now(timezone.utc) - pub_date).total_seconds() > self.config.headline_max_age_seconds:
|
||||
# Only recent headlines (last 24h)
|
||||
if pub_date and (datetime.now(timezone.utc) - pub_date).total_seconds() > 86400:
|
||||
continue
|
||||
|
||||
# Dedup within and across feeds so a syndicated story
|
||||
|
||||
+1
-2
@@ -466,8 +466,7 @@ class TradingDaemon:
|
||||
|
||||
aggregator = SignalAggregator(config)
|
||||
aggregator.data_provider = self._provider
|
||||
report = aggregator.analyze_pair(symbol=SYMBOL,
|
||||
log_source="oanda_daemon")
|
||||
report = aggregator.analyze_pair(symbol=SYMBOL)
|
||||
|
||||
logger.info(
|
||||
"%s scan: signal=%s score=%.1f conf=%.0f quality=%s "
|
||||
|
||||
@@ -30,7 +30,6 @@ def produce_signal(
|
||||
primary_tf: Timeframe,
|
||||
data_provider: BaseDataProvider,
|
||||
confirmation_tfs: Optional[List[Timeframe]] = None,
|
||||
log_source: str = "dashboard",
|
||||
) -> SignalReport:
|
||||
config_factory = PAIR_PRODUCERS.get(symbol)
|
||||
if config_factory is None:
|
||||
@@ -42,7 +41,7 @@ def produce_signal(
|
||||
config = config_factory(primary_tf, confirmation_tfs)
|
||||
aggregator = SignalAggregator(config)
|
||||
aggregator.data_provider = data_provider
|
||||
return aggregator.analyze_pair(symbol=symbol, log_source=log_source)
|
||||
return aggregator.analyze_pair(symbol=symbol)
|
||||
|
||||
|
||||
from pair_signals import xauusd as _xau_mod
|
||||
|
||||
@@ -141,49 +141,6 @@ validated price of riding trends, exits confirmed NOT the loss
|
||||
driver: exit-management accounts for 1-2% of lost R vs 50%
|
||||
wrong-direction / 49% entry-timing).
|
||||
|
||||
M5 override 6 — SELL block in transitioning regime (validated
|
||||
2026-09-03, signal-log campaign, `backtest/research/
|
||||
signal_log_campaign_2026-09.md`, $0.65 round trip, same CPU harness
|
||||
as its control): sell_blocked_regimes=["transitioning"]. Live
|
||||
evidence: M5 SELLs in transitioning were -7.9R/12mo PF 0.21 and
|
||||
-5.2R/5yr PF 0.49 in the pinned books; the live signal log showed the
|
||||
same pocket -7.9R/PF 0.56 over 3 weeks. Blocking: 12mo (2025-08-14 ->
|
||||
2026-08-14) 557 trades +112.21R PF 1.56 (halves +55.9/+56.4, PF
|
||||
1.54/1.58) vs control 575/+104.34R PF 1.50; 5yr (2021-08-01 ->
|
||||
2026-08-04) 889/+133.7R PF 1.41 vs 911/+128.4R PF 1.39; OOS
|
||||
2024-06-01 -> 2025-03-30 unchanged (86/+9.45R PF 1.33 — no such
|
||||
trades in that window). Removed trades: 18 in 12mo (-7.9R), 22 in
|
||||
5yr (-5.3R).
|
||||
|
||||
H1 override — all SELL entries blocked (validated 2026-09-03, same
|
||||
campaign): sell_min_quality=[] (gate 6e in signal_aggregator.py
|
||||
downgrades any actionable-grade SELL whose quality is not in this
|
||||
list to D, so an empty list downgrades every H1 short). H1 shorts
|
||||
were a structural loser: -16.7R over the 5yr control window and
|
||||
-11.5R OOS. Blocking: 12mo (2025-08-04 -> 2026-08-04) 91/+22.2R
|
||||
PF 1.79 (halves +20.6/+1.6) vs control 138/+24.0R PF 1.55 (12mo cost
|
||||
-1.8R); 5yr 335/+49.1R PF 1.46 vs 515/+32.4R PF 1.18; OOS 78/+16.7R
|
||||
PF 1.74 vs 104/+5.1R PF 1.13. New reference books:
|
||||
backtest/oanda_h1_{12mo,5yr,oos}_065_20260903.csv,
|
||||
backtest/oanda_m5_12mo_065_nopb_20260903.csv,
|
||||
backtest/oanda_m5_5yr_065_nopb_20260903.csv.
|
||||
|
||||
Rejected/parked in the same campaign (see the memo for the full
|
||||
table): M15 sell_min_quality=["B"] (5yr -8.9R, max DD 35.8 -> 41.4R
|
||||
— REJECTED); Bollinger weight 0 in trending regimes on M5, M15, H1
|
||||
(M5 -20.4R/12mo, -15.5R/5yr; M15 -24.0R/12mo — REJECTED across the
|
||||
board); divergence disabled on M15 (R improves +7.4/+11.9/+13.5R
|
||||
across windows but 5yr max DD 35.8 -> 47.4R with PF flat — DECLINED,
|
||||
owner call); divergence disabled on H1 (+4.6/+8.1/+2.4R, PF
|
||||
1.62/1.20/1.17) — PARKED: it passes rule 1 in combination with the
|
||||
H1 no-short override (b12_h1_nosell_nodiv: 12mo 100/+23.1R PF 1.75,
|
||||
halves +21.8/+1.3; 5yr 364/+58.7R PF 1.52; OOS 88/+21.9R PF 1.92 —
|
||||
better than the no-shorts override alone on both 5yr and OOS), but
|
||||
shipping it would route through technical.disabled_indicators, which
|
||||
CLAUDE.md rule 8 reserves for experiments; owner decision, recommended
|
||||
vehicle is an H1-only regime_weights_override with divergence weight
|
||||
0 plus a fresh validation run.
|
||||
|
||||
M5 override 5 — EMA partial-alignment squash (validated 2026-08-04,
|
||||
Oanda bars, $0.65 real spread): ema_partial_neutral True on M5 —
|
||||
partial 4-EMA ribbon alignment (2-3 of 4 checks) scores NEUTRAL
|
||||
@@ -259,9 +216,6 @@ def make_config(
|
||||
# Wider trailing distance on M5 only — see module docstring.
|
||||
config.signals.trail_arm_r = 0.60
|
||||
config.signals.trail_dist_r = 0.85
|
||||
# Block SELL entries while the regime is "transitioning" on M5
|
||||
# only — see module docstring (M5 override 6).
|
||||
config.xau_strategy.sell_blocked_regimes = ["transitioning"]
|
||||
if primary_tf == Timeframe.M15:
|
||||
# 1R minimum target on M15 only — see module docstring.
|
||||
config.signals.tp_floor_rr = 1.0
|
||||
@@ -272,10 +226,4 @@ def make_config(
|
||||
# Later trailing-stop arm on M15 only — see module docstring.
|
||||
config.signals.trail_arm_r = 0.70
|
||||
config.signals.trail_dist_r = 0.75
|
||||
if primary_tf == Timeframe.H1:
|
||||
# Block every H1 SELL — the SELL quality gate (6e in
|
||||
# signal_aggregator.py) downgrades any actionable-grade short
|
||||
# to D when its grade is not in this list; an empty list means
|
||||
# no grade qualifies. H1-only — see module docstring.
|
||||
config.xau_strategy.sell_min_quality = []
|
||||
return config
|
||||
|
||||
@@ -3,8 +3,6 @@ ForexBot2 — Tiingo data provider
|
||||
=================================
|
||||
OHLCV candles from Tiingo's FX endpoint
|
||||
(`/tiingo/fx/{ticker}/prices`, resampled mid-price bars, no volume).
|
||||
Tiingo is streaming-only in production: its REST candles remain available
|
||||
for comparison research, but it is not a designated candle fallback.
|
||||
All shared behavior (cache, validation, boundary guarantees, range
|
||||
pagination) lives in `BaseDataProvider`; this module only maps
|
||||
symbols/resample frequencies, sizes the request window, and parses
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Scratchpad — Oanda vs Tiingo candle parity check
|
||||
=================================================
|
||||
Fetches the last N closed XAU/USD candles from Oanda and Tiingo for
|
||||
M5/M15/H1 via the normal provider stack (make_data_provider →
|
||||
fetch_ohlcv: forming-candle drop + validate/clean included, i.e. exactly
|
||||
what the signal pipeline consumes) and reports how well they line up:
|
||||
|
||||
* last-bar timestamp per provider (feed-lag check)
|
||||
* timestamp mismatches (bars present in one feed only)
|
||||
* per-bar OHLC diff table for the common tail
|
||||
* max/mean |diff| per column, with the Close diff also in % of price
|
||||
|
||||
Both feeds are mid-price (Oanda mid of MBA candles; Tiingo resampled
|
||||
mid), so small diffs are expected — this measures how small.
|
||||
|
||||
Usage:
|
||||
venv/bin/python scripts/compare_oanda_tiingo.py [--n 10]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from config import PlatformConfig, Timeframe, XAUUSD
|
||||
from providers import make_data_provider
|
||||
|
||||
TIMEFRAMES = [Timeframe.M5, Timeframe.M15, Timeframe.H1]
|
||||
OHLC = ["Open", "High", "Low", "Close"]
|
||||
|
||||
|
||||
def fetch_tail(provider, tf, n):
|
||||
"""Last n closed bars from one provider, or None on failure."""
|
||||
try:
|
||||
df = provider.fetch_ohlcv(symbol=XAUUSD, timeframe=tf)
|
||||
except Exception as e: # scratchpad: report, don't crash
|
||||
print(f" !! {provider.name} {tf.value} fetch failed: {e}")
|
||||
return None
|
||||
return df.tail(n)
|
||||
|
||||
|
||||
def compare_tf(tf, oanda_df, tiingo_df, n):
|
||||
print(f"\n{'=' * 78}\n{tf.value} — last {n} closed bars\n{'=' * 78}")
|
||||
if oanda_df is None or tiingo_df is None:
|
||||
print(" skipped (a provider failed)")
|
||||
return
|
||||
|
||||
print(f" Oanda last bar: {oanda_df.index[-1]} "
|
||||
f"Tiingo last bar: {tiingo_df.index[-1]}")
|
||||
|
||||
common = oanda_df.index.intersection(tiingo_df.index)
|
||||
only_o = oanda_df.index.difference(tiingo_df.index)
|
||||
only_t = tiingo_df.index.difference(oanda_df.index)
|
||||
if len(only_o):
|
||||
print(f" bars only in Oanda : {[str(t) for t in only_o]}")
|
||||
if len(only_t):
|
||||
print(f" bars only in Tiingo: {[str(t) for t in only_t]}")
|
||||
if not len(common):
|
||||
print(" NO common timestamps — feeds are on different bar grids!")
|
||||
return
|
||||
|
||||
o = oanda_df.loc[common, OHLC]
|
||||
t = tiingo_df.loc[common, OHLC]
|
||||
diff = o - t
|
||||
|
||||
print(f"\n {len(common)}/{n} bars share timestamps "
|
||||
f"(window {common[0]} → {common[-1]})\n")
|
||||
|
||||
# Per-bar Close table — the number that matters most.
|
||||
hdr = f" {'bar close (UTC)':<20}{'Oanda C':>12}{'Tiingo C':>12}{'ΔC ($)':>10}"
|
||||
print(hdr + f"{'ΔO':>8}{'ΔH':>8}{'ΔL':>8}")
|
||||
for ts in common:
|
||||
row = (f" {str(ts):<20}{o.loc[ts, 'Close']:>12.2f}"
|
||||
f"{t.loc[ts, 'Close']:>12.2f}{diff.loc[ts, 'Close']:>10.3f}")
|
||||
row += (f"{diff.loc[ts, 'Open']:>8.3f}{diff.loc[ts, 'High']:>8.3f}"
|
||||
f"{diff.loc[ts, 'Low']:>8.3f}")
|
||||
print(row)
|
||||
|
||||
print("\n summary over common bars:")
|
||||
for col in OHLC:
|
||||
d = diff[col].abs()
|
||||
print(f" {col:<6} max |Δ| = {d.max():8.4f} mean |Δ| = {d.mean():8.4f}")
|
||||
px = o["Close"].iloc[-1]
|
||||
dc = diff["Close"].abs()
|
||||
print(f" Close Δ as % of price: max {dc.max() / px * 100:.4f}% "
|
||||
f"mean {dc.mean() / px * 100:.4f}%")
|
||||
exact = (diff[OHLC].abs() < 1e-9).all(axis=1).sum()
|
||||
print(f" bars with identical OHLC: {exact}/{len(common)}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--n", type=int, default=10,
|
||||
help="bars per timeframe to compare (default 10)")
|
||||
args = ap.parse_args()
|
||||
|
||||
cfg = PlatformConfig()
|
||||
oanda = make_data_provider(cfg, "oanda")
|
||||
tiingo = make_data_provider(cfg, "tiingo")
|
||||
|
||||
for tf in TIMEFRAMES:
|
||||
o_df = fetch_tail(oanda, tf, args.n)
|
||||
t_df = fetch_tail(tiingo, tf, args.n)
|
||||
compare_tf(tf, o_df, t_df, args.n)
|
||||
|
||||
print("\nDone. Note: Oanda=mid of MBA candles, Tiingo=resampled mid — "
|
||||
"sub-$1 diffs on gold are normal feed divergence, not a bug.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,259 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase 1 — historical candle divergence study (data source change test)
|
||||
=======================================================================
|
||||
Fetches the RAW closed-bar series for XAU/USD from two providers over a
|
||||
multi-month window via the normal provider stack (`fetch_range`, which
|
||||
paginates under the per-request bar cap and drops the forming candle),
|
||||
then quantifies how the feeds diverge:
|
||||
|
||||
* coverage: % shared timestamps, feed-only bars, where they cluster
|
||||
* OHLC |Δ| distribution (mean/median/p90/p95/p99/max, $ and bias)
|
||||
* |ΔClose| vs mean ATR14 (the scale the signal engine cares about)
|
||||
* threshold exceedance (how many bars differ by more than X $)
|
||||
* bar-direction agreement (sign of close−open)
|
||||
* close-return correlation on consecutive bars
|
||||
* per-hour and per-month drift
|
||||
|
||||
Both feeds are mid-price (Oanda mid of MBA candles; Tiingo resampled mid;
|
||||
Twelve Data its own mid). Frames are cached to parquet so re-runs are
|
||||
instant; pass --no-cache to force a fresh fetch.
|
||||
|
||||
Usage:
|
||||
venv/bin/python scripts/feed_divergence_study.py --a oanda --b tiingo \
|
||||
--tfs M15,H1,H4 --days 365
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from config import PlatformConfig, Timeframe, XAUUSD
|
||||
from providers import make_data_provider
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CACHE_DIR = REPO / "backtest" / "cache"
|
||||
RESEARCH_DIR = REPO / "backtest" / "research"
|
||||
|
||||
_TF_LOOKUP = {t.value: t for t in Timeframe}
|
||||
_TF_LOOKUP.update({
|
||||
"M1": Timeframe.M1, "M5": Timeframe.M5, "M15": Timeframe.M15,
|
||||
"M30": Timeframe.M30, "H1": Timeframe.H1, "H4": Timeframe.H4,
|
||||
"D1": Timeframe.D1, "W1": Timeframe.W1,
|
||||
})
|
||||
|
||||
|
||||
def wilder_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||
hl = df["High"] - df["Low"]
|
||||
hc = (df["High"] - df["Close"].shift(1)).abs()
|
||||
lc = (df["Low"] - df["Close"].shift(1)).abs()
|
||||
tr = pd.concat([hl, hc, lc], axis=1).max(axis=1)
|
||||
return tr.ewm(alpha=1.0 / period, adjust=False).mean()
|
||||
|
||||
|
||||
def fmt_stats(name: str, s: pd.Series) -> str:
|
||||
a = s.abs()
|
||||
return (f" {name:<6} mean ${a.mean():.3f} med ${a.median():.3f} "
|
||||
f"p90 ${a.quantile(0.90):.3f} p95 ${a.quantile(0.95):.3f} "
|
||||
f"p99 ${a.quantile(0.99):.3f} max ${a.max():.2f} "
|
||||
f"bias {s.mean():+.4f}")
|
||||
|
||||
|
||||
def cache_path(name: str, tf: Timeframe, start: pd.Timestamp,
|
||||
end: pd.Timestamp) -> Path:
|
||||
return (CACHE_DIR /
|
||||
f"{name}_XAU_USD_{tf.value}_{start:%Y%m%d}_{end:%Y%m%d}.parquet")
|
||||
|
||||
|
||||
def load_or_fetch(provider, name: str, tf: Timeframe, start, end,
|
||||
no_cache: bool):
|
||||
cp = cache_path(name, tf, start, end)
|
||||
if not no_cache and cp.exists():
|
||||
df = pd.read_parquet(cp)
|
||||
print(f" [{name}] loaded {len(df):,} bars from cache "
|
||||
f"{cp.name}")
|
||||
return df
|
||||
t0 = time.time()
|
||||
print(f" [{name}] fetching {tf.value} {start.date()} → {end.date()} ...")
|
||||
df = provider.fetch_range(XAUUSD, tf, start, end)
|
||||
print(f" [{name}] fetched {len(df):,} bars in {time.time() - t0:.1f}s")
|
||||
df.to_parquet(cp)
|
||||
print(f" [{name}] cached → {cp.name}")
|
||||
return df
|
||||
|
||||
|
||||
def compare(tf: Timeframe, a_name: str, b_name: str, a: pd.DataFrame,
|
||||
b: pd.DataFrame):
|
||||
print("\n" + "=" * 80)
|
||||
print(f"{tf.value} — {a_name.upper()} vs {b_name.upper()}, XAU/USD")
|
||||
print("=" * 80)
|
||||
|
||||
common = a.index.intersection(b.index)
|
||||
a_only = a.index.difference(b.index)
|
||||
b_only = b.index.difference(a.index)
|
||||
|
||||
print(f"\nCOVERAGE")
|
||||
print(f" {a_name} bars : {len(a):>8,} head {a.index[0]} tail {a.index[-1]}")
|
||||
print(f" {b_name} bars : {len(b):>8,} head {b.index[0]} tail {b.index[-1]}")
|
||||
print(f" shared : {len(common):>8,} "
|
||||
f"({100 * len(common) / max(len(a.index.union(b.index)), 1):.2f}% of union)")
|
||||
print(f" {a_name}-only : {len(a_only):>8,} "
|
||||
f"({100 * len(a_only) / max(len(a), 1):.2f}% of {a_name})")
|
||||
print(f" {b_name}-only : {len(b_only):>8,} "
|
||||
f"({100 * len(b_only) / max(len(b), 1):.2f}% of {b_name})")
|
||||
|
||||
if len(common) == 0:
|
||||
print(" !! no shared timestamps — feeds on different bar grids")
|
||||
return
|
||||
|
||||
for label, idx in ((f"{a_name}-only", a_only),
|
||||
(f"{b_name}-only", b_only)):
|
||||
if not len(idx):
|
||||
continue
|
||||
by_date = pd.Series(1, index=idx).groupby(idx.date).sum()
|
||||
top = by_date.sort_values(ascending=False).head(6)
|
||||
print(f" {label} clusters on: "
|
||||
+ ", ".join(f"{d} ({n})" for d, n in top.items()))
|
||||
|
||||
x = a.loc[common]
|
||||
y = b.loc[common]
|
||||
|
||||
atr = wilder_atr(x).dropna()
|
||||
mean_price = x["Close"].mean()
|
||||
print(f"\nSCALE REFERENCES ({a_name} feed)")
|
||||
print(f" mean close : ${mean_price:,.2f}")
|
||||
print(f" mean bar range: ${(x['High'] - x['Low']).mean():.3f}")
|
||||
print(f" mean ATR14 : ${atr.mean():.3f} median ${atr.median():.3f}")
|
||||
|
||||
print(f"\nPER-BAR DIFFS ({a_name} − {b_name}, $)")
|
||||
diffs = {}
|
||||
for col in ("Open", "High", "Low", "Close"):
|
||||
d = x[col] - y[col]
|
||||
diffs[col] = d
|
||||
print(fmt_stats(col, d))
|
||||
|
||||
dclose = diffs["Close"].abs()
|
||||
print(f"\n |ΔClose| as % of mean ATR14 : "
|
||||
f"mean {100 * dclose.mean() / atr.mean():.1f}% "
|
||||
f"p95 {100 * dclose.quantile(0.95) / atr.mean():.1f}%")
|
||||
|
||||
print(f"\nTHRESHOLD EXCEEDANCE (|ΔClose|)")
|
||||
for thr in (0.10, 0.30, 0.65, 1.00, 2.00, 5.00):
|
||||
pct = 100 * (dclose > thr).mean()
|
||||
print(f" > ${thr:<5.2f}: {pct:6.2f}% of bars ({(dclose > thr).sum():,})")
|
||||
|
||||
# direction agreement
|
||||
a_dir = np.sign(x["Close"] - x["Open"])
|
||||
b_dir = np.sign(y["Close"] - y["Open"])
|
||||
both_nonflat = (a_dir != 0) & (b_dir != 0)
|
||||
dir_agree = 100 * (a_dir[both_nonflat] == b_dir[both_nonflat]).mean()
|
||||
print(f"\nAGREEMENT")
|
||||
print(f" bar direction (sign of close−open) agree: {dir_agree:.2f}%"
|
||||
f" (n={both_nonflat.sum():,})")
|
||||
|
||||
step = pd.Series(common, index=common).diff().median()
|
||||
a_ret = x["Close"].pct_change()
|
||||
b_ret = y["Close"].pct_change()
|
||||
step_ok = pd.Series(common, index=common).diff().le(step + pd.Timedelta(seconds=1))
|
||||
corr = a_ret[step_ok].corr(b_ret[step_ok])
|
||||
print(f" close-return correlation (consecutive bars): {corr:.5f}")
|
||||
|
||||
# by-hour
|
||||
hours = common.hour
|
||||
rows = []
|
||||
for h in range(24):
|
||||
m = hours == h
|
||||
if not m.any():
|
||||
continue
|
||||
rows.append({"hour_utc": h, "bars": int(m.sum()),
|
||||
"mean_abs_dclose": dclose[m].mean(),
|
||||
"p95_abs_dclose": dclose[m].quantile(0.95)})
|
||||
by_hour = pd.DataFrame(rows)
|
||||
print(f"\nBY UTC HOUR (mean |ΔClose|, $)")
|
||||
print(by_hour.to_string(index=False,
|
||||
float_format=lambda v: f"{v:.3f}"))
|
||||
|
||||
print(f"\nBY MONTH (mean |ΔClose|, $)")
|
||||
months = dclose.groupby(common.tz_localize(None).to_period("M")).mean()
|
||||
for p, v in months.items():
|
||||
print(f" {p}: {v:.3f}")
|
||||
|
||||
return {
|
||||
"tf": tf.value, "a": a_name, "b": b_name,
|
||||
"a_bars": len(a), "b_bars": len(b), "common": len(common),
|
||||
"a_only": len(a_only), "b_only": len(b_only),
|
||||
"dir_agree_pct": round(dir_agree, 3),
|
||||
"ret_corr": round(corr, 5),
|
||||
"mean_abs_dclose": dclose.mean(),
|
||||
"p95_abs_dclose": dclose.quantile(0.95),
|
||||
"max_abs_dclose": dclose.max(),
|
||||
"dclose_pct_atr_mean": 100 * dclose.mean() / atr.mean(),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--a", required=True)
|
||||
ap.add_argument("--b", required=True)
|
||||
ap.add_argument("--tfs", default="M15,H1,H4",
|
||||
help="comma-separated timeframe values (default M15,H1,H4)")
|
||||
ap.add_argument("--days", type=int, default=365)
|
||||
ap.add_argument("--end", default=None, help="UTC end date (default now)")
|
||||
ap.add_argument("--no-cache", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
tfs = []
|
||||
for s in args.tfs.split(","):
|
||||
s = s.strip()
|
||||
tf = _TF_LOOKUP.get(s)
|
||||
if tf is None:
|
||||
sys.exit(f"unknown timeframe '{s}'; use one of "
|
||||
f"{sorted(_TF_LOOKUP)}")
|
||||
tfs.append(tf)
|
||||
|
||||
end = pd.Timestamp(args.end, tz="UTC") if args.end \
|
||||
else pd.Timestamp.now(tz="UTC")
|
||||
start = end - pd.Timedelta(days=args.days)
|
||||
|
||||
cfg = PlatformConfig()
|
||||
pa = make_data_provider(cfg, args.a)
|
||||
pb = make_data_provider(cfg, args.b)
|
||||
|
||||
summary = []
|
||||
for tf in tfs:
|
||||
print(f"\n{'─' * 80}\nWINDOW {start.date()} → {end.date()} "
|
||||
f"({args.days} days), {tf.value}")
|
||||
try:
|
||||
a = load_or_fetch(pa, args.a, tf, start, end, args.no_cache)
|
||||
except Exception as e:
|
||||
print(f" !! {args.a} {tf.value} fetch failed: {e}")
|
||||
continue
|
||||
try:
|
||||
b = load_or_fetch(pb, args.b, tf, start, end, args.no_cache)
|
||||
except Exception as e:
|
||||
print(f" !! {args.b} {tf.value} fetch failed: {e}")
|
||||
continue
|
||||
r = compare(tf, args.a, args.b, a, b)
|
||||
if r:
|
||||
summary.append(r)
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("SUMMARY TABLE")
|
||||
print("=" * 80)
|
||||
sdf = pd.DataFrame(summary)
|
||||
print(sdf.to_string(index=False,
|
||||
float_format=lambda v: f"{v:.4f}"))
|
||||
out = RESEARCH_DIR / f"feed_divergence_{args.a}_vs_{args.b}.csv"
|
||||
if len(sdf):
|
||||
sdf.to_csv(out, index=False, float_format="%.6f")
|
||||
print(f"\nSummary written to {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,194 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase 4 — live shadow scan (data source change test)
|
||||
=====================================================
|
||||
Runs TWO data providers side-by-side at every candle close (M5/M15/H1)
|
||||
and logs how their signals diverge, so the feed switch can be validated
|
||||
under real fetch depths / caching / boundary timing for ≥2 weeks.
|
||||
|
||||
Unlike the history studies, this uses the LIVE fetch path (fetch_ohlcv,
|
||||
forming-candle drop, freshness checks) for both providers, but calls
|
||||
`analyze_from_dataframes(use_news=False)` directly so the (provider-
|
||||
independent) news/DeepSeek budget is not burned on shadow scans. The
|
||||
weekend filter is applied here (skip the whole cycle), and the session/
|
||||
day/hour gates run inside analyze_from_dataframes as in the backtest.
|
||||
|
||||
No orders, no Telegram, no signal-log writes — output is a side-by-side
|
||||
CSV plus a rolling agreement tally.
|
||||
|
||||
Usage:
|
||||
venv/bin/python scripts/shadow_scan.py --a oanda --b tiingo \
|
||||
--tfs M5,M15,H1 --out logs/shadow_oanda_tiingo.csv --once
|
||||
# production (2+ weeks):
|
||||
nohup venv/bin/python scripts/shadow_scan.py --a oanda --b tiingo \
|
||||
--out logs/shadow_oanda_tiingo.csv >> logs/shadow_scan.log 2>&1 &
|
||||
|
||||
CSV columns (one row per TF per boundary):
|
||||
ts, tf, a_<field>, b_<field> for direction/grade/setup/tag/entry/sl/tp1,
|
||||
a_price, b_price, a_spread, b_spread, a_warnings, b_warnings,
|
||||
dir_agree, grade_agree, setup_agree
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from config import PlatformConfig, Timeframe, XAUUSD
|
||||
from pair_signals.xauusd import make_config
|
||||
from providers import make_data_provider
|
||||
from providers.base import _is_forex_weekend
|
||||
from signal_aggregator import SignalAggregator, SignalReport
|
||||
|
||||
_TF_LOOKUP = {"M5": Timeframe.M5, "M15": Timeframe.M15, "H1": Timeframe.H1,
|
||||
"H4": Timeframe.H4}
|
||||
_TF_MINUTES = {Timeframe.M5: 5, Timeframe.M15: 15, Timeframe.H1: 60,
|
||||
Timeframe.H4: 240}
|
||||
_CONFIRMS = {Timeframe.M5: [Timeframe.M15, Timeframe.H1],
|
||||
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
|
||||
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
|
||||
Timeframe.H4: [Timeframe.H1, Timeframe.D1]}
|
||||
|
||||
FIELDS = ["direction", "grade", "setup", "tag", "entry", "sl", "tp1"]
|
||||
|
||||
|
||||
def seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
||||
now = datetime.now(timezone.utc)
|
||||
elapsed = (now.minute % interval_minutes) * 60 + now.second \
|
||||
+ now.microsecond / 1e6
|
||||
return interval_minutes * 60 - elapsed + buffer
|
||||
|
||||
|
||||
def due_timeframes(wake: datetime, tfs) -> list:
|
||||
minutes = wake.hour * 60 + wake.minute
|
||||
floored = minutes - (minutes % 5)
|
||||
return [tf for tf in tfs if floored % _TF_MINUTES[tf] == 0]
|
||||
|
||||
|
||||
def tag_of(report) -> str:
|
||||
fb = report.bullish_reasons[0] if report.bullish_reasons else ""
|
||||
fbe = report.bearish_reasons[0] if report.bearish_reasons else ""
|
||||
if "XAU PULLBACK" in fb or "XAU PULLBACK" in fbe:
|
||||
return "XAU_PB"
|
||||
if "XAU CHART PATTERN" in fb or "XAU CHART PATTERN" in fbe:
|
||||
return "XAU_CP"
|
||||
if "XAU STRATEGY" in fb:
|
||||
return "XAU"
|
||||
return "GENERIC"
|
||||
|
||||
|
||||
def scan(provider, symbol, tf) -> SignalReport:
|
||||
confirms = _CONFIRMS[tf]
|
||||
cfg = make_config(tf, confirms)
|
||||
agg = SignalAggregator(cfg)
|
||||
agg.data_provider = provider
|
||||
dfs = {}
|
||||
for t in [tf] + confirms:
|
||||
dfs[t] = provider.fetch_ohlcv(symbol, t)
|
||||
return agg.analyze_from_dataframes(symbol, dfs, use_news=False)
|
||||
|
||||
|
||||
def extract(provider, report) -> dict:
|
||||
s = report.trade_setup
|
||||
return {
|
||||
"direction": report.final_signal.value,
|
||||
"grade": report.signal_quality,
|
||||
"setup": 1 if s is not None else 0,
|
||||
"tag": tag_of(report) if s else "",
|
||||
"entry": round(s.entry_price, 3) if s else "",
|
||||
"sl": round(s.stop_loss, 3) if s else "",
|
||||
"tp1": round(s.take_profit_1, 3) if s else "",
|
||||
"price": round(report.current_price, 3),
|
||||
"spread": round(provider.get_spread(XAUUSD), 3),
|
||||
"warnings": " | ".join(report.warnings)[:200],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--a", default="oanda")
|
||||
ap.add_argument("--b", default="tiingo")
|
||||
ap.add_argument("--tfs", default="M5,M15,H1")
|
||||
ap.add_argument("--out", default="logs/shadow_scan.csv")
|
||||
ap.add_argument("--once", action="store_true",
|
||||
help="one immediate scan cycle, then exit")
|
||||
args = ap.parse_args()
|
||||
|
||||
tfs = [_TF_LOOKUP[s.strip()] for s in args.tfs.split(",")]
|
||||
pa = make_data_provider(PlatformConfig(), args.a)
|
||||
pb = make_data_provider(PlatformConfig(), args.b)
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_file = not out_path.exists()
|
||||
tally = {"cycles": 0, "dir": 0, "grade": 0, "setup": 0}
|
||||
|
||||
with open(out_path, "a", newline="") as fh:
|
||||
w = csv.writer(fh)
|
||||
if new_file:
|
||||
w.writerow(["ts", "tf"] + [f"a_{x}" for x in FIELDS + ["price", "spread", "warnings"]]
|
||||
+ [f"b_{x}" for x in FIELDS + ["price", "spread", "warnings"]]
|
||||
+ ["dir_agree", "grade_agree", "setup_agree"])
|
||||
|
||||
while True:
|
||||
if args.once:
|
||||
wake = datetime.now(timezone.utc)
|
||||
else:
|
||||
wait = seconds_until_boundary(5, buffer=10)
|
||||
time.sleep(wait)
|
||||
wake = datetime.now(timezone.utc)
|
||||
|
||||
if _is_forex_weekend():
|
||||
if args.once:
|
||||
print("weekend — nothing to scan")
|
||||
return
|
||||
continue
|
||||
|
||||
for tf in due_timeframes(wake, tfs):
|
||||
try:
|
||||
ra = scan(pa, XAUUSD, tf)
|
||||
except Exception as e:
|
||||
print(f"[{wake:%H:%M:%S}] {args.a} {tf.value} FAILED: {e}")
|
||||
continue
|
||||
try:
|
||||
rb = scan(pb, XAUUSD, tf)
|
||||
except Exception as e:
|
||||
print(f"[{wake:%H:%M:%S}] {args.b} {tf.value} FAILED: {e}")
|
||||
continue
|
||||
|
||||
ea, eb = extract(pa, ra), extract(pb, rb)
|
||||
da = ea["direction"] == eb["direction"]
|
||||
ga = ea["grade"] == eb["grade"]
|
||||
sa = ea["setup"] == eb["setup"]
|
||||
tally["cycles"] += 1
|
||||
tally["dir"] += da; tally["grade"] += ga; tally["setup"] += sa
|
||||
|
||||
row = [wake.isoformat(), tf.value]
|
||||
row += [ea[x] for x in FIELDS + ["price", "spread", "warnings"]]
|
||||
row += [eb[x] for x in FIELDS + ["price", "spread", "warnings"]]
|
||||
row += [int(da), int(ga), int(sa)]
|
||||
w.writerow(row)
|
||||
fh.flush()
|
||||
|
||||
print(f"[{wake:%Y-%m-%d %H:%M:%S}] {tf.value:<5} "
|
||||
f"{args.a}={ea['direction']:+d}/{ea['grade'] or '-':>2} "
|
||||
f"{args.b}={eb['direction']:+d}/{eb['grade'] or '-':>2} "
|
||||
f"dir={'OK' if da else 'FLIP'} "
|
||||
f"| Δprice {abs(ea['price']-eb['price']):.2f} "
|
||||
f"| agree dir {tally['dir']}/{tally['cycles']} "
|
||||
f"grade {tally['grade']}/{tally['cycles']} "
|
||||
f"setup {tally['setup']}/{tally['cycles']}")
|
||||
|
||||
if args.once:
|
||||
print("shadow scan complete (--once)")
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,246 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase 2 — signal-level replay parity (data source change test)
|
||||
===============================================================
|
||||
Replays the REAL decision core (`SignalAggregator.analyze_from_dataframes`,
|
||||
news off, use_news=False) over the same window for two providers and
|
||||
compares the *decisions* bar-by-bar: direction, composite/final score,
|
||||
quality grade, gate/setup presence, strategy tag, and entry/SL/TP levels.
|
||||
|
||||
This turns the Phase-1 price-diff stats into flip rates. Feeds are read
|
||||
from the Phase-1 parquet cache (scripts/feed_divergence_study.py writes
|
||||
them); pass --no-cache-fetch only if you want to fetch fresh via
|
||||
fetch_range first.
|
||||
|
||||
Slicing matches gpu_backtest.py exactly: decision at close of bar `pos`,
|
||||
each TF sliced to bars fully closed by that decision time, primary at the
|
||||
shared grid, so the two feeds are compared on identical timestamps.
|
||||
|
||||
Usage:
|
||||
venv/bin/python scripts/signal_parity_study.py --a oanda --b tiingo \
|
||||
--tf M15 --stride 8 --days 365
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from config import Timeframe, XAUUSD
|
||||
from pair_signals.xauusd import make_config
|
||||
from signal_aggregator import SignalAggregator
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CACHE_DIR = REPO / "backtest" / "cache"
|
||||
RESEARCH_DIR = REPO / "backtest" / "research"
|
||||
|
||||
BAR_SECONDS = {
|
||||
Timeframe.M1: 60, Timeframe.M5: 300, Timeframe.M15: 900,
|
||||
Timeframe.M30: 1800, Timeframe.H1: 3600, Timeframe.H4: 14400,
|
||||
Timeframe.D1: 86400, Timeframe.W1: 604800,
|
||||
}
|
||||
_TF_LOOKUP = {t.value: t for t in Timeframe}
|
||||
_TF_LOOKUP.update({"M1": Timeframe.M1, "M5": Timeframe.M5,
|
||||
"M15": Timeframe.M15, "M30": Timeframe.M30,
|
||||
"H1": Timeframe.H1, "H4": Timeframe.H4,
|
||||
"D1": Timeframe.D1, "W1": Timeframe.W1})
|
||||
_CONFIRMS = {
|
||||
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
|
||||
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
|
||||
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
|
||||
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
|
||||
}
|
||||
|
||||
|
||||
def load_cached(name: str, tf: Timeframe, start: pd.Timestamp,
|
||||
end: pd.Timestamp) -> pd.DataFrame:
|
||||
cp = CACHE_DIR / f"{name}_XAU_USD_{tf.value}_{start:%Y%m%d}_{end:%Y%m%d}.parquet"
|
||||
if not cp.exists():
|
||||
sys.exit(f"missing cache {cp}; run scripts/feed_divergence_study.py "
|
||||
f"--a oanda --b {name} --tfs {tf.value} --days ... first")
|
||||
return pd.read_parquet(cp)
|
||||
|
||||
|
||||
def tag_of(report) -> str:
|
||||
first_bull = report.bullish_reasons[0] if report.bullish_reasons else ""
|
||||
first_bear = report.bearish_reasons[0] if report.bearish_reasons else ""
|
||||
if "XAU PULLBACK" in first_bull or "XAU PULLBACK" in first_bear:
|
||||
return "XAU_PB"
|
||||
if "XAU CHART PATTERN" in first_bull or "XAU CHART PATTERN" in first_bear:
|
||||
return "XAU_CP"
|
||||
if "XAU STRATEGY" in first_bull:
|
||||
return "XAU"
|
||||
return "GENERIC"
|
||||
|
||||
|
||||
def decide(agg, dfs, tss, stride, warmup) -> list:
|
||||
"""Replay decisions over the shared grid, returning one row per bar."""
|
||||
primary = dfs[agg.config.primary_timeframe]
|
||||
dur = pd.Timedelta(seconds=BAR_SECONDS[agg.config.primary_timeframe])
|
||||
|
||||
# positions of sampled bars in the primary frame
|
||||
primary_pos = primary.index.get_indexer(tss)
|
||||
rows = []
|
||||
for i in range(0, len(tss), stride):
|
||||
ts = tss[i]
|
||||
pos = int(primary_pos[i])
|
||||
if pos < warmup:
|
||||
continue
|
||||
slices = {}
|
||||
for tf, full in dfs.items():
|
||||
cutoff = ts # decision_time = ts + dur; bars closed by ts
|
||||
cut = int(full.index.searchsorted(cutoff, side="right"))
|
||||
if cut <= 0:
|
||||
continue
|
||||
slices[tf] = full.iloc[:cut]
|
||||
if agg.config.primary_timeframe not in slices:
|
||||
continue
|
||||
r = agg.analyze_from_dataframes(XAUUSD, slices, use_news=False)
|
||||
ts_setup = r.trade_setup
|
||||
rows.append({
|
||||
"ts": ts,
|
||||
"direction": r.final_signal.value,
|
||||
"final_score": r.final_score,
|
||||
"primary_score": r.primary_score,
|
||||
"grade": r.signal_quality,
|
||||
"has_setup": ts_setup is not None,
|
||||
"tag": tag_of(r) if ts_setup else "",
|
||||
"entry": ts_setup.entry_price if ts_setup else np.nan,
|
||||
"sl": ts_setup.stop_loss if ts_setup else np.nan,
|
||||
"tp1": ts_setup.take_profit_1 if ts_setup else np.nan,
|
||||
"tp2": ts_setup.take_profit_2 if ts_setup else np.nan,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--a", required=True)
|
||||
ap.add_argument("--b", required=True)
|
||||
ap.add_argument("--tf", default="M15")
|
||||
ap.add_argument("--stride", type=int, default=1,
|
||||
help="sample every Nth shared bar (default 1)")
|
||||
ap.add_argument("--days", type=int, default=365)
|
||||
ap.add_argument("--end", default=None)
|
||||
ap.add_argument("--warmup", type=int, default=300)
|
||||
args = ap.parse_args()
|
||||
|
||||
tf = _TF_LOOKUP.get(args.tf)
|
||||
if tf is None:
|
||||
sys.exit(f"unknown tf {args.tf}")
|
||||
confirms = _CONFIRMS[tf]
|
||||
tfs = [tf] + confirms
|
||||
|
||||
end = pd.Timestamp(args.end, tz="UTC") if args.end \
|
||||
else pd.Timestamp.now(tz="UTC")
|
||||
start = end - pd.Timedelta(days=args.days)
|
||||
|
||||
cfg = make_config(tf, confirms)
|
||||
agg = SignalAggregator(cfg)
|
||||
|
||||
a_dfs, b_dfs = {}, {}
|
||||
for t in tfs:
|
||||
a_dfs[t] = load_cached(args.a, t, start, end)
|
||||
b_dfs[t] = load_cached(args.b, t, start, end)
|
||||
|
||||
# shared grid = primary timestamps present in both feeds
|
||||
shared = a_dfs[tf].index.intersection(b_dfs[tf].index)
|
||||
|
||||
t0 = time.time()
|
||||
print(f"replaying {len(shared)} shared {tf.value} bars "
|
||||
f"(stride {args.stride} → ~{len(shared)//args.stride}) on {args.a}...")
|
||||
a_rows = decide(agg, a_dfs, shared, args.stride, args.warmup)
|
||||
print(f" {args.a}: {len(a_rows)} decisions in {time.time()-t0:.1f}s")
|
||||
t0 = time.time()
|
||||
b_rows = decide(agg, b_dfs, shared, args.stride, args.warmup)
|
||||
print(f" {args.b}: {len(b_rows)} decisions in {time.time()-t0:.1f}s")
|
||||
|
||||
A = pd.DataFrame(a_rows).set_index("ts")
|
||||
B = pd.DataFrame(b_rows).set_index("ts")
|
||||
common = A.index.intersection(B.index)
|
||||
A, B = A.loc[common], B.loc[common]
|
||||
n = len(common)
|
||||
|
||||
print(f"\n{'=' * 78}")
|
||||
print(f"SIGNAL-LEVEL PARITY — {tf.value} — {args.a} vs {args.b} "
|
||||
f"({n:,} bars)")
|
||||
print("=" * 78)
|
||||
|
||||
def pct(x): return f"{100 * x:.2f}%"
|
||||
|
||||
same_dir = (A["direction"] == B["direction"])
|
||||
same_grade = (A["grade"] == B["grade"])
|
||||
same_setup = (A["has_setup"] == B["has_setup"])
|
||||
same_all = same_dir & same_grade & same_setup
|
||||
|
||||
print(f"\nIDENTITY")
|
||||
print(f" same direction : {pct(same_dir.mean())}")
|
||||
print(f" same grade : {pct(same_grade.mean())}")
|
||||
print(f" same setup presence : {pct(same_setup.mean())}")
|
||||
print(f" same dir+grade+setup : {pct(same_all.mean())}")
|
||||
|
||||
both_sig = (A["direction"] != 0) & (B["direction"] != 0)
|
||||
flips = both_sig & (A["direction"] != B["direction"])
|
||||
opp = flips & (A["direction"] == -B["direction"])
|
||||
print(f"\nDIRECTION")
|
||||
print(f" bars both actionable : {both_sig.sum():,} "
|
||||
f"({pct(both_sig.mean())})")
|
||||
print(f" direction flips : {flips.sum():,} "
|
||||
f"({pct(flips.sum()/max(both_sig.sum(),1))} of actionable)")
|
||||
print(f" opposite-sign flips : {opp.sum():,}")
|
||||
|
||||
grade_a = set(A.loc[A["has_setup"], "grade"])
|
||||
print(f"\nGRADE (of {args.a} setups, grades seen: {sorted(grade_a)})")
|
||||
changed = A.loc[both_sig, "grade"] != B.loc[both_sig, "grade"]
|
||||
print(f" grade changed on actionable bars: "
|
||||
f"{changed.sum():,} ({pct(changed.mean())})")
|
||||
|
||||
# score deltas on shared bars
|
||||
ds = (A["final_score"] - B["final_score"]).abs()
|
||||
dp = (A["primary_score"] - B["primary_score"]).abs()
|
||||
print(f"\nSCORE Δ ({args.a} − {args.b})")
|
||||
print(f" final_score mean {ds.mean():6.2f} p95 {ds.quantile(.95):6.2f} "
|
||||
f"max {ds.max():6.2f}")
|
||||
print(f" primary_score mean {dp.mean():6.2f} p95 {dp.quantile(.95):6.2f} "
|
||||
f"max {dp.max():6.2f}")
|
||||
|
||||
# setup price diffs on bars where BOTH generated a setup
|
||||
both_setup = A["has_setup"] & B["has_setup"]
|
||||
if both_setup.any():
|
||||
print(f"\nSETUP LEVELS (both-setup bars: {both_setup.sum():,})")
|
||||
for col, label in (("entry", "entry"), ("sl", "SL"), ("tp1", "TP1"),
|
||||
("tp2", "TP2")):
|
||||
d = (A.loc[both_setup, col] - B.loc[both_setup, col]).abs()
|
||||
print(f" {label:<6} mean ${d.mean():6.3f} p95 ${d.quantile(.95):7.3f} "
|
||||
f"max ${d.max():7.2f}")
|
||||
|
||||
# tag disagreement on both-setup bars
|
||||
if both_setup.any():
|
||||
same_tag = (A.loc[both_setup, "tag"] == B.loc[both_setup, "tag"])
|
||||
print(f" same strategy tag : {pct(same_tag.mean())}")
|
||||
|
||||
# where do flips cluster (hour)?
|
||||
flip_hours = A.loc[flips].index.hour.value_counts().sort_index()
|
||||
if len(flip_hours):
|
||||
print(f"\nFLIPS BY UTC HOUR")
|
||||
for h, c in flip_hours.items():
|
||||
print(f" {h:02d}: {c}")
|
||||
|
||||
out = {
|
||||
"tf": tf.value, "a": args.a, "b": args.b, "bars": n,
|
||||
"same_dir": same_dir.mean(), "same_grade": same_grade.mean(),
|
||||
"same_setup": same_setup.mean(), "same_all": same_all.mean(),
|
||||
"flip_rate_actionable": flips.sum() / max(both_sig.sum(), 1),
|
||||
"mean_abs_final_score_delta": ds.mean(),
|
||||
"mean_abs_primary_score_delta": dp.mean(),
|
||||
}
|
||||
print(f"\n{out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+7
-188
@@ -5,8 +5,6 @@ Combines multi-timeframe technical analysis with optional news context
|
||||
into a graded actionable signal.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
@@ -23,7 +21,6 @@ from config import PlatformConfig, SignalStrength, Timeframe
|
||||
from providers import BaseDataProvider, make_data_provider
|
||||
from engine import TechnicalEngine, TechnicalAnalysis
|
||||
from news_engine import NewsEngine, FundamentalContext
|
||||
import signal_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,12 +55,8 @@ class TradeSetup:
|
||||
risk_pips: float
|
||||
reward_pips: float
|
||||
pip_value: float
|
||||
# Entry archetype — selects trail params and explains the setup to
|
||||
# advisory consumers. setup_pattern is populated only by XAU_CP.
|
||||
# Entry archetype ("GENERIC", "XAU_PB") — selects trail params.
|
||||
strategy: str = "GENERIC"
|
||||
setup_pattern: Optional[str] = None
|
||||
# Strategy cost assumption, not a broker/data-provider quote.
|
||||
round_trip_cost: Optional[float] = None
|
||||
# Trailing exit params stamped from the PRODUCING config, so per-TF
|
||||
# factory overrides (e.g. M15 arm 0.70, M5 dist 0.85) reach every
|
||||
# consumer. None = legacy/unstamped setup — formatters fall back to
|
||||
@@ -95,18 +88,6 @@ class SignalReport:
|
||||
news_description: str = ""
|
||||
news_summary: str = "" # LLM market-context blurb (display-only)
|
||||
|
||||
# LLM agent-panel consensus (advisory/DISPLAY-ONLY). Populated
|
||||
# post-hoc by consumers via agent_panel.attach_panel — NEVER inside
|
||||
# analyze_from_dataframes (backtest parity) and never read by any
|
||||
# gate or scoring path.
|
||||
panel_verdict: str = "" # "AGREE" / "NEUTRAL" / "OPPOSE" / ""
|
||||
panel_confidence: float = 0.0
|
||||
panel_rationale: str = ""
|
||||
panel_takes: List[Dict] = field(default_factory=list)
|
||||
panel_created_at: str = ""
|
||||
panel_cache_status: str = ""
|
||||
panel_payload_hash: str = ""
|
||||
|
||||
final_signal: SignalStrength = SignalStrength.NEUTRAL
|
||||
final_score: float = 0.0
|
||||
final_confidence: float = 0.0
|
||||
@@ -118,9 +99,6 @@ class SignalReport:
|
||||
bullish_reasons: List[str] = field(default_factory=list)
|
||||
bearish_reasons: 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
|
||||
spread_estimate: float = 0.0
|
||||
@@ -155,14 +133,8 @@ class SignalAggregator:
|
||||
self,
|
||||
symbol: Optional[str] = None,
|
||||
context: Optional[FundamentalContext] = None,
|
||||
log_source: Optional[str] = None,
|
||||
) -> SignalReport:
|
||||
"""Live-mode entry: fetch all needed timeframes then delegate.
|
||||
|
||||
Every report produced here (including weekend/day-filter early
|
||||
returns) is persisted to the signal-history DB via _log_signal —
|
||||
fail-safe, and never reached by the backtester, which calls
|
||||
analyze_from_dataframes directly (live == backtest parity)."""
|
||||
"""Live-mode entry: fetch all needed timeframes then delegate."""
|
||||
if symbol is None:
|
||||
symbol = self.config.symbol
|
||||
|
||||
@@ -183,7 +155,6 @@ class SignalAggregator:
|
||||
report.trade_setup = None
|
||||
report.warnings.append("WEEKEND FILTER: XAU/USD market closed — no liquidity")
|
||||
report.action_recommendation = "NO TRADE — Weekend (market closed)"
|
||||
self._log_signal(report, log_source, blocked=True)
|
||||
return report
|
||||
|
||||
if xau_cfg.allowed_weekdays and now_utc.weekday() not in xau_cfg.allowed_weekdays:
|
||||
@@ -201,7 +172,6 @@ class SignalAggregator:
|
||||
f"DAY FILTER: {day_names.get(now_utc.weekday(), '?')} not in allowed days ({allowed})"
|
||||
)
|
||||
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
|
||||
self._log_signal(report, log_source, blocked=True)
|
||||
return report
|
||||
|
||||
timeframes = [self.config.primary_timeframe] + list(
|
||||
@@ -279,74 +249,8 @@ class SignalAggregator:
|
||||
f"{symbol}: live session filter errored — filter SKIPPED this scan"
|
||||
)
|
||||
|
||||
self._log_signal(report, log_source, dfs)
|
||||
return report
|
||||
|
||||
def _log_signal(
|
||||
self, report: SignalReport, source: Optional[str],
|
||||
dataframes: Optional[Dict[Timeframe, pd.DataFrame]] = None,
|
||||
blocked: bool = False,
|
||||
) -> None:
|
||||
"""Persist the scan to the signal-history DB (signal_log.py).
|
||||
|
||||
Fail-safe by contract — a logging failure must never break a
|
||||
live scan. The news context re-fetch rides the NewsEngine's
|
||||
class-level caches, so it costs nothing within a scan and
|
||||
matches the context the signal just used; if it fails, the row
|
||||
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
|
||||
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:
|
||||
extra["config_fingerprint"] = self.gate_config_fingerprint()
|
||||
except Exception:
|
||||
logger.debug("signal log: config fingerprint unavailable",
|
||||
exc_info=True)
|
||||
signal_log.log_signal(
|
||||
report,
|
||||
context=context,
|
||||
source=source or "live",
|
||||
timeframe=self.config.primary_timeframe.value,
|
||||
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(
|
||||
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
|
||||
) -> None:
|
||||
@@ -707,7 +611,6 @@ class SignalAggregator:
|
||||
reward_pips=1.5 * sl_dist / pip_value,
|
||||
pip_value=pip_value,
|
||||
strategy="XAU_PB",
|
||||
round_trip_cost=self.config.signals.round_trip_cost,
|
||||
trail_arm_r=self.config.signals.pb_trail_arm_r,
|
||||
trail_dist_r=self.config.signals.pb_trail_dist_r,
|
||||
)
|
||||
@@ -789,9 +692,9 @@ class SignalAggregator:
|
||||
close_h = None
|
||||
logger.warning(
|
||||
f"{report.symbol}: bar-close hour underivable — "
|
||||
f"CP SELL hour gate FAILS CLOSED (short rejected)"
|
||||
f"SELL hour gate skipped"
|
||||
)
|
||||
if close_h is None or close_h in cfg.sell_blocked_hours_utc:
|
||||
if close_h in cfg.sell_blocked_hours_utc:
|
||||
_reject("sell_hour")
|
||||
return
|
||||
if self.config.primary_timeframe == Timeframe.M5:
|
||||
@@ -845,8 +748,6 @@ class SignalAggregator:
|
||||
reward_pips=height / pip_value,
|
||||
pip_value=pip_value,
|
||||
strategy="XAU_CP",
|
||||
setup_pattern=pat["pattern"],
|
||||
round_trip_cost=self.config.signals.round_trip_cost,
|
||||
trail_arm_r=self.config.signals.trail_arm_r,
|
||||
trail_dist_r=self.config.signals.trail_dist_r,
|
||||
)
|
||||
@@ -862,82 +763,8 @@ class SignalAggregator:
|
||||
f"height {height / atr:.1f} ATR")
|
||||
target = report.bullish_reasons if is_buy else report.bearish_reasons
|
||||
target.insert(0, marker)
|
||||
self._clean_accepted_cp_warnings(report)
|
||||
_reject("accepted")
|
||||
|
||||
@staticmethod
|
||||
def _clean_accepted_cp_warnings(report: SignalReport) -> None:
|
||||
"""Drop superseded composite-path warnings after XAU_CP accepts.
|
||||
|
||||
This is metadata-only. Pattern detection, gates, confidence,
|
||||
exhaustion state, setup geometry, and signal selection are already
|
||||
final when this runs.
|
||||
"""
|
||||
stale_prefixes = (
|
||||
"SPREAD GATE:",
|
||||
"EXHAUSTION BLOCK",
|
||||
"EXHAUSTED TREND FILTER:",
|
||||
"MTF OPPOSITION:",
|
||||
"SELL FILTER: quality",
|
||||
"SELL FILTER: volatility",
|
||||
"SELL FILTER: regime",
|
||||
"SELL FILTER: ",
|
||||
"M5 FILTER: |score|",
|
||||
"M5 FILTER: no MTF agreement",
|
||||
"M5 FILTER: no confirmation TF",
|
||||
"M5 FILTER: SELL quality",
|
||||
"M5 FILTER: SELL blocked",
|
||||
)
|
||||
extension_prefixes = (
|
||||
"TREND EXHAUSTION",
|
||||
"NO PULLBACK",
|
||||
"FAR FROM EMA200",
|
||||
"Extended — RSI",
|
||||
"Weak pullback",
|
||||
)
|
||||
filtered: List[str] = []
|
||||
dropped: List[str] = []
|
||||
for warning in report.warnings:
|
||||
if warning.startswith(stale_prefixes):
|
||||
dropped.append(warning)
|
||||
continue
|
||||
if (warning.startswith("REGIME FILTER: '")
|
||||
and warning.endswith("blocked for XAU")):
|
||||
dropped.append(warning)
|
||||
continue
|
||||
if (warning.startswith("M5 FILTER: '")
|
||||
and "volatility" in warning):
|
||||
dropped.append(warning)
|
||||
continue
|
||||
filtered.append(warning)
|
||||
|
||||
extension_items = [
|
||||
(index, warning)
|
||||
for index, warning in enumerate(filtered)
|
||||
if warning.startswith(extension_prefixes)
|
||||
]
|
||||
if not extension_items:
|
||||
report.warnings[:] = filtered
|
||||
report.cleaned_warnings.extend(dropped)
|
||||
return
|
||||
first_index = extension_items[0][0]
|
||||
priority = {prefix: rank for rank, prefix in enumerate(extension_prefixes)}
|
||||
strongest = min(
|
||||
(warning for _, warning in extension_items),
|
||||
key=lambda warning: next(
|
||||
rank for prefix, rank in priority.items()
|
||||
if warning.startswith(prefix)),
|
||||
)
|
||||
kept = [
|
||||
warning for warning in filtered
|
||||
if not warning.startswith(extension_prefixes)
|
||||
]
|
||||
kept.insert(first_index, strongest)
|
||||
dropped.extend(
|
||||
warning for _, warning in extension_items if warning != strongest)
|
||||
report.warnings[:] = kept
|
||||
report.cleaned_warnings.extend(dropped)
|
||||
|
||||
# ── Public: bar-replayable entry point ─────────────────────────
|
||||
|
||||
def analyze_from_dataframes(
|
||||
@@ -1169,16 +996,11 @@ class SignalAggregator:
|
||||
close_h = None
|
||||
logger.warning(
|
||||
f"{report.symbol}: bar-close hour underivable — "
|
||||
f"SELL hour gate FAILS CLOSED (short blocked)"
|
||||
f"SELL hour gate skipped"
|
||||
)
|
||||
# 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}"
|
||||
if close_h in self.config.xau_strategy.sell_blocked_hours_utc:
|
||||
report.warnings.append(
|
||||
f"SELL FILTER: {hour_txt}:00 UTC entry blocked for shorts"
|
||||
f"SELL FILTER: {close_h:02}:00 UTC entry blocked for shorts"
|
||||
)
|
||||
report.signal_quality = "D"
|
||||
report.trade_setup = None
|
||||
@@ -1329,8 +1151,6 @@ class SignalAggregator:
|
||||
risk_pips=round(sl_distance / pip_value, 1),
|
||||
reward_pips=round(tp_distance / pip_value, 1),
|
||||
pip_value=pip_value,
|
||||
strategy="XAU_ATR",
|
||||
round_trip_cost=self.config.signals.round_trip_cost,
|
||||
trail_arm_r=self.config.signals.trail_arm_r,
|
||||
trail_dist_r=self.config.signals.trail_dist_r,
|
||||
)
|
||||
@@ -1745,7 +1565,6 @@ class SignalAggregator:
|
||||
risk_pips=risk_pips,
|
||||
reward_pips=reward_pips,
|
||||
pip_value=pip_value,
|
||||
round_trip_cost=self.config.signals.round_trip_cost,
|
||||
trail_arm_r=self.config.signals.trail_arm_r,
|
||||
trail_dist_r=self.config.signals.trail_dist_r,
|
||||
)
|
||||
|
||||
-1244
File diff suppressed because it is too large
Load Diff
@@ -1,351 +0,0 @@
|
||||
/* Gold Desk — XAU/USD dashboard styles
|
||||
*
|
||||
* Builds on Bootstrap 5.3 color modes (data-bs-theme). Only app-specific
|
||||
* tokens and components live here; layout, typography, tables, badges,
|
||||
* cards, alerts, modals, etc. come from Bootstrap.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--xau-font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--xau-font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Consolas, Menlo, monospace;
|
||||
--xau-gold: #f5b301;
|
||||
}
|
||||
|
||||
/* Forced dark theme (owner decision 2026-08-16): dark background, pure
|
||||
* white text (no gray), vivid red/green for data accents. Loaded after
|
||||
* Bootstrap so these overrides win. */
|
||||
[data-bs-theme="dark"] {
|
||||
--bs-body-bg: #0a0e14;
|
||||
--bs-secondary-bg: #111823;
|
||||
--bs-tertiary-bg: #0e141d;
|
||||
--bs-body-color: #ffffff;
|
||||
--bs-emphasis-color: #ffffff;
|
||||
--bs-heading-color: #ffffff;
|
||||
--bs-secondary-color: rgba(255, 255, 255, 0.92);
|
||||
--bs-tertiary-color: rgba(255, 255, 255, 0.8);
|
||||
--bs-border-color: rgba(255, 255, 255, 0.14);
|
||||
--bs-card-bg: #0e141d;
|
||||
--bs-card-border-color: rgba(255, 255, 255, 0.12);
|
||||
--bs-modal-bg: #0e141d;
|
||||
--bs-success: #22c55e;
|
||||
--bs-success-rgb: 34, 197, 94;
|
||||
--bs-success-text-emphasis: #4ade80;
|
||||
--bs-success-bg-subtle: rgba(34, 197, 94, 0.16);
|
||||
--bs-success-border-subtle: rgba(34, 197, 94, 0.45);
|
||||
--bs-danger: #ef4444;
|
||||
--bs-danger-rgb: 239, 68, 68;
|
||||
--bs-danger-text-emphasis: #f87171;
|
||||
--bs-danger-bg-subtle: rgba(239, 68, 68, 0.16);
|
||||
--bs-danger-border-subtle: rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: var(--xau-font-sans);
|
||||
letter-spacing: -0.005em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.num, .xau-price, .xau-symbol {
|
||||
font-family: var(--xau-font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.xau-symbol {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--xau-gold);
|
||||
}
|
||||
|
||||
.xau-price {
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--bs-success);
|
||||
margin-right: 5px;
|
||||
animation: pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.live-dot.closed {
|
||||
background: var(--bs-warning);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* signal-coloured accents */
|
||||
.sig-buy { --xau-signal: var(--bs-success); }
|
||||
.sig-sell { --xau-signal: var(--bs-danger); }
|
||||
.sig-neutral { --xau-signal: var(--bs-secondary); }
|
||||
|
||||
.border-signal {
|
||||
border-left: 3px solid var(--xau-signal, var(--bs-secondary)) !important;
|
||||
}
|
||||
|
||||
.text-signal {
|
||||
color: var(--xau-signal, var(--bs-secondary)) !important;
|
||||
}
|
||||
|
||||
.tf-name {
|
||||
font-family: var(--xau-font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--bs-primary);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.tf-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tf-select {
|
||||
min-width: 0;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
background: var(--bs-tertiary-bg);
|
||||
color: var(--bs-body-color);
|
||||
text-align: left;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.tf-select:hover {
|
||||
border-color: rgba(var(--bs-primary-rgb), 0.65);
|
||||
}
|
||||
|
||||
.tf-select[aria-pressed="true"] {
|
||||
border-color: var(--xau-gold);
|
||||
background: rgba(245, 179, 1, 0.09);
|
||||
box-shadow: inset 0 0 0 1px rgba(245, 179, 1, 0.16);
|
||||
}
|
||||
|
||||
.tf-select .tf-name {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.workspace-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-signal {
|
||||
font-family: var(--xau-font-mono);
|
||||
font-size: clamp(1.55rem, 3vw, 2.35rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workspace-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-reasons > div + div {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
/* score bar: zero-centered diverging with midpoint tick */
|
||||
.scorebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.scorebar .gauge-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: var(--bs-secondary-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scorebar .gauge-container::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--bs-secondary);
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.scorebar .fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-radius: 4px;
|
||||
width: var(--score-width);
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.scorebar .fill.pos {
|
||||
left: 50%;
|
||||
background: var(--bs-success);
|
||||
}
|
||||
|
||||
.scorebar .fill.neg {
|
||||
right: 50%;
|
||||
background: var(--bs-danger);
|
||||
}
|
||||
|
||||
.scorebar .val {
|
||||
width: 58px;
|
||||
text-align: right;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scorebar .lbl {
|
||||
font-size: 0.8rem;
|
||||
color: var(--bs-secondary);
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
/* rail chips in hero */
|
||||
.rail-chip {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
background: var(--bs-tertiary-bg);
|
||||
color: var(--bs-body-color);
|
||||
font-family: var(--xau-font-mono);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
.rail-chip:hover, .rail-chip[aria-current="true"] {
|
||||
border-color: var(--xau-gold);
|
||||
}
|
||||
|
||||
.rail-chip:focus-visible, .tf-select:focus-visible, .wire-item:focus-visible,
|
||||
.cal-event:focus-visible {
|
||||
outline: 2px solid var(--xau-gold);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* detail dialog sections */
|
||||
.d-stat .k {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bs-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.d-stat .v {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.d-table {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.d-table .ind-k {
|
||||
font-family: var(--xau-font-mono);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wbar {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bs-secondary-bg);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.wbar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* news */
|
||||
.wire-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: var(--bs-border-radius);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.wire-item:hover {
|
||||
background: rgba(var(--bs-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
.cal-event {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.cal-event:hover {
|
||||
color: var(--bs-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wire-score {
|
||||
font-family: var(--xau-font-mono);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* calendar highlight for next upcoming event */
|
||||
.cal-next-up td:first-child {
|
||||
box-shadow: inset 4px 0 0 var(--bs-primary);
|
||||
padding-left: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.live-dot { animation: none; }
|
||||
.scorebar .fill { transition: none; }
|
||||
* { transition: none !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.tf-selector {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.workspace-card .card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.rail-chip {
|
||||
flex: 1 1 calc(50% - 0.5rem);
|
||||
}
|
||||
}
|
||||
+1382
-126
File diff suppressed because it is too large
Load Diff
@@ -1,953 +0,0 @@
|
||||
"use strict";
|
||||
/* Gold Desk — XAU/USD dashboard frontend
|
||||
*
|
||||
* Reads snapshot.json + news.json published by dashboard_publisher.py and
|
||||
* renders a responsive, keyboard-accessible dashboard. Uses Bootstrap 5 for
|
||||
* layout/components/modals and a small custom CSS file for app-specific
|
||||
* visuals (score bar, signal colours, mono fonts).
|
||||
*
|
||||
* This file is intentionally self-contained: it mirrors a few constants from
|
||||
* the Python signal pipeline so the HTML does not depend on telegram_bot.
|
||||
*/
|
||||
|
||||
const IND_ORDER = ["ema_trend", "rsi", "macd", "bollinger", "adx", "roc", "keltner", "divergence", "candlestick"];
|
||||
const IND_SHORT = {
|
||||
ema_trend: "EMA", rsi: "RSI", macd: "MACD", bollinger: "BB",
|
||||
adx: "ADX", roc: "ROC", keltner: "KC", divergence: "Div", candlestick: "Cndl"
|
||||
};
|
||||
const ARROW = { STRONG_BUY: "⇈", BUY: "↑", NEUTRAL: "→", SELL: "↓", STRONG_SELL: "⇊" };
|
||||
const TF_LABEL = { "5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4" };
|
||||
const TF_ORDER = ["5min", "15min", "1h", "4h"];
|
||||
const TF_SECONDS = { "5min": 300, "15min": 900, "1h": 3600, "4h": 14400 };
|
||||
const FALLBACK_POLL_MS = 60000;
|
||||
const RETRY_MS = 15000;
|
||||
const HIGH_IMPACT_PREF = "goldDeskHighImpactOnly";
|
||||
const TIMEFRAME_PREF = "goldDeskSelectedTimeframe";
|
||||
|
||||
const LEVEL_LABEL = {
|
||||
ema_fast: "EMA fast", ema_medium: "EMA medium", ema_slow: "EMA slow", ema_trend: "EMA trend",
|
||||
bb_upper: "Bollinger upper", bb_mid: "Bollinger mid", bb_lower: "Bollinger lower",
|
||||
kc_upper: "Keltner upper", kc_mid: "Keltner mid", kc_lower: "Keltner lower",
|
||||
swing_high_1: "Swing high 1", swing_high_2: "Swing high 2", swing_high_3: "Swing high 3",
|
||||
swing_low_1: "Swing low 1", swing_low_2: "Swing low 2", swing_low_3: "Swing low 3"
|
||||
};
|
||||
|
||||
let snapshot = null, news = null;
|
||||
let clockOffset = 0;
|
||||
let lastPollOk = null;
|
||||
let lastSnapText = null, lastNewsText = null;
|
||||
let refetchTimer = null;
|
||||
let fallbackTimer = null;
|
||||
let pollInFlight = false;
|
||||
let pollQueued = false;
|
||||
let selectedTf = "5min";
|
||||
|
||||
let detailTf = null;
|
||||
let ndState = null;
|
||||
const aiMemo = new Map();
|
||||
const aiErr = new Map();
|
||||
const aiPending = new Set();
|
||||
|
||||
let tfModal = null;
|
||||
let newsModal = null;
|
||||
let tfModalOpen = false;
|
||||
let newsModalOpen = false;
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
/* ── helpers ──────────────────────────────────────────────────── */
|
||||
const esc = s => String(s ?? "").replace(/[&<>"']/g,
|
||||
c => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]));
|
||||
|
||||
const fmt$ = v => v == null ? "—" : Number(v).toLocaleString("en-US",
|
||||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
const sigClass = s => s?.includes("BUY") ? "buy" : s?.includes("SELL") ? "sell" : "neutral";
|
||||
const sigLabel = s => (s || "NEUTRAL").replace("_", " ");
|
||||
const nowServer = () => Date.now() / 1000 + clockOffset;
|
||||
|
||||
function localGet(key) {
|
||||
try { return window.localStorage.getItem(key); } catch { return null; }
|
||||
}
|
||||
|
||||
function localSet(key, value) {
|
||||
try { window.localStorage.setItem(key, value); } catch { /* storage is optional */ }
|
||||
}
|
||||
|
||||
function announce(message) {
|
||||
const live = $("dashboard-live");
|
||||
if (live) live.textContent = message;
|
||||
}
|
||||
|
||||
function fmtCountdown(secs) {
|
||||
if (secs == null || !isFinite(secs)) return "--:--";
|
||||
secs = Math.max(0, Math.floor(secs));
|
||||
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
|
||||
const mm = String(m).padStart(2, "0"), ss = String(s).padStart(2, "0");
|
||||
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function fmtAge(iso) {
|
||||
if (!iso) return "";
|
||||
const secs = nowServer() - Date.parse(iso) / 1000;
|
||||
if (secs < 90) return `${Math.max(0, Math.round(secs))}s ago`;
|
||||
if (secs < 5400) return `${Math.round(secs / 60)}m ago`;
|
||||
if (secs < 172800) return `${Math.round(secs / 3600)}h ago`;
|
||||
return `${Math.round(secs / 86400)}d ago`;
|
||||
}
|
||||
|
||||
const signed = (v, dp = 2) => v == null ? "—" : (v >= 0 ? "+" : "") + Number(v).toFixed(dp);
|
||||
const badgeCls = s => s === "buy" ? "bg-success-subtle text-success-emphasis border-success-subtle"
|
||||
: s === "sell" ? "bg-danger-subtle text-danger-emphasis border-danger-subtle"
|
||||
: "bg-secondary-subtle text-secondary-emphasis border-secondary-subtle";
|
||||
|
||||
/* ── theme ────────────────────────────────────────────────────── */
|
||||
// Dark theme is forced site-wide (owner decision 2026-08-16): dark
|
||||
// background, pure-white text, red/green data accents.
|
||||
document.documentElement.setAttribute("data-bs-theme", "dark");
|
||||
|
||||
/* ── hero ─────────────────────────────────────────────────────── */
|
||||
function renderHero() {
|
||||
if (!snapshot) return;
|
||||
let price = null;
|
||||
for (const tf of TF_ORDER) {
|
||||
const p = snapshot.timeframes[tf]?.report?.current_price;
|
||||
if (p) { price = p; break; }
|
||||
}
|
||||
$("hero-price").textContent = price ? fmt$(price) : "—";
|
||||
|
||||
$("hero-rail").innerHTML = TF_ORDER.map(tf => {
|
||||
const r = snapshot.timeframes[tf]?.report;
|
||||
const sig = r?.signal || null;
|
||||
const cls = sigClass(sig);
|
||||
const arrow = sig ? (ARROW[sig] || "→") : "·";
|
||||
const score = r?.score != null ? r.score.toFixed(0) : "–";
|
||||
const textColor = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
|
||||
return `<button type="button" class="rail-chip" data-select-tf="${tf}"
|
||||
aria-current="${tf === selectedTf ? "true" : "false"}"
|
||||
title="${esc(sigLabel(sig))} (score ${esc(score)}) — select ${TF_LABEL[tf]} workspace">
|
||||
<span class="tf-name">${TF_LABEL[tf]}</span>
|
||||
<span class="${textColor} fw-bold">${arrow}</span>
|
||||
<span>${esc(score)}</span>
|
||||
</button>`;
|
||||
}).join("");
|
||||
|
||||
const closed = snapshot.market_closed;
|
||||
$("hero-status").innerHTML =
|
||||
`<span class="live-dot${closed ? " closed" : ""}"></span>${closed ? "CLOSED" : "OPEN"}`;
|
||||
$("banner-closed").classList.toggle("d-none", !closed);
|
||||
}
|
||||
|
||||
/* ── market context strip ─────────────────────────────────────── */
|
||||
function renderContext() {
|
||||
if (!news) return;
|
||||
$("context").classList.remove("d-none");
|
||||
$("ctx-blurb").innerHTML = news.llm_summary
|
||||
? `<strong>Market context:</strong> ${esc(news.llm_summary)}`
|
||||
: `<strong>Market context:</strong> <span class="text-secondary">no summary available</span>`;
|
||||
|
||||
let chips = "";
|
||||
for (const cur of ["XAU", "USD"]) {
|
||||
const v = news.sentiment?.[cur];
|
||||
if (v == null) continue;
|
||||
const cls = v > 0.05 ? "text-bg-success" : v < -0.05 ? "text-bg-danger" : "text-bg-secondary";
|
||||
chips += `<span class="badge ${cls}" title="Mean headline sentiment for ${cur} (−1…+1)">
|
||||
${cur} news <span class="num">${v >= 0 ? "+" : ""}${v.toFixed(2)}</span></span>`;
|
||||
}
|
||||
let risk = "clear", riskDesc = "No significant events";
|
||||
if (snapshot) {
|
||||
for (const tf of TF_ORDER) {
|
||||
const r = snapshot.timeframes[tf]?.report;
|
||||
if (r?.event_risk && r.event_risk !== "clear") { risk = r.event_risk; riskDesc = r.event_description; break; }
|
||||
if (r?.event_description) riskDesc = r.event_description;
|
||||
}
|
||||
}
|
||||
if (news.high_impact_warning) { risk = "danger"; riskDesc = news.warning_message || riskDesc; }
|
||||
const riskCls = risk === "clear" ? "text-bg-success"
|
||||
: risk === "danger" ? "text-bg-danger"
|
||||
: "text-bg-warning";
|
||||
chips += `<span class="badge ${riskCls}" title="${esc(riskDesc)}">event risk: ${risk.toUpperCase()}</span>`;
|
||||
$("ctx-chips").innerHTML = chips;
|
||||
}
|
||||
|
||||
/* ── timeframe cards ──────────────────────────────────────────── */
|
||||
function scoreBar(label, score) {
|
||||
const v = Math.max(-100, Math.min(100, score ?? 0));
|
||||
const width = (Math.abs(v) / 2).toFixed(1);
|
||||
return `<div class="scorebar">
|
||||
<span class="lbl">${label}</span>
|
||||
<div class="gauge-container" aria-hidden="true">
|
||||
<i class="fill ${v >= 0 ? "pos" : "neg"}" style="--score-width:${width}%"></i>
|
||||
</div>
|
||||
<span class="val num ${v > 0 ? "text-success" : v < 0 ? "text-danger" : ""}">${v >= 0 ? "+" : ""}${v.toFixed(1)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function setupCard(ts) {
|
||||
if (!ts) return "";
|
||||
const dir = ts.direction === "BUY" ? "buy" : "sell";
|
||||
const dirIcon = ts.direction === "BUY" ? "bi-arrow-up-circle-fill" : "bi-arrow-down-circle-fill";
|
||||
const trail = (ts.trail_arm_r != null && ts.trail_dist_r != null)
|
||||
? `arm ${ts.trail_arm_r}R · dist ${ts.trail_dist_r}R` : "platform default";
|
||||
return `<div class="card border-signal sig-${dir} bg-body-tertiary">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex align-items-baseline gap-2 mb-2">
|
||||
<span class="fw-bold text-signal sig-${dir}"><i class="bi ${dirIcon} me-1"></i>${ts.direction}</span>
|
||||
<span class="small text-secondary font-monospace">${esc(ts.strategy || "")}</span>
|
||||
<span class="small text-secondary ms-auto font-monospace">R:R ${(+ts.risk_reward).toFixed(1)}</span>
|
||||
</div>
|
||||
<div class="table-responsive"><table class="table table-sm table-borderless mb-0">
|
||||
<tbody><tr><td class="text-secondary"><i class="bi bi-box-arrow-in-right me-1"></i>Entry</td><td class="num">${fmt$(ts.entry)}</td>
|
||||
<td class="text-secondary ps-4"><i class="bi bi-flag-fill me-1 text-success"></i>TP1</td><td class="num text-success">${fmt$(ts.take_profit_1)}</td></tr>
|
||||
<tr><td class="text-secondary"><i class="bi bi-shield-x me-1 text-danger"></i>Stop</td><td class="num text-danger">${fmt$(ts.stop_loss)}</td>
|
||||
<td class="text-secondary ps-4"><i class="bi bi-flag-fill me-1 text-success"></i>TP2</td><td class="num text-success">${fmt$(ts.take_profit_2)}</td></tr>
|
||||
<tr><td class="text-secondary"><i class="bi bi-signpost-split me-1"></i>Trail</td><td class="num" colspan="3">${esc(trail)}</td></tr></tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function workspacePanel(tf) {
|
||||
const entry = snapshot?.timeframes?.[tf] || {};
|
||||
const r = entry.report;
|
||||
if (!r) {
|
||||
return `<article class="card workspace-card border-signal sig-neutral" id="workspace-panel" aria-labelledby="workspace-panel-heading">
|
||||
<div class="card-body py-4 text-center text-secondary">
|
||||
<h3 class="tf-name mb-2" id="workspace-panel-heading">${TF_LABEL[tf]}</h3>
|
||||
${entry.status === "error"
|
||||
? `<span class="badge text-bg-danger">SCAN UNAVAILABLE</span><div class="small mt-2">Showing again when the next scan succeeds.</div>`
|
||||
: "Warming up — no completed scan for this timeframe yet."}
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
const cls = sigClass(r.signal);
|
||||
const textCls = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
|
||||
const qHi = r.quality === "A+" || r.quality === "A";
|
||||
const pa = r.primary_analysis;
|
||||
const meta = [r.market_regime, r.volatility_regime && r.volatility_regime + " volatility",
|
||||
r.mtf_agreement ? "MTF aligned" : "MTF mixed"].filter(Boolean).join(" · ");
|
||||
const facts = `<div class="row row-cols-2 row-cols-md-4 g-2 mt-1">
|
||||
${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
|
||||
${tile("Quality", esc(r.quality || "—"), qHi ? "text-primary" : "")}
|
||||
${tile("Event risk", esc((r.event_risk || "—").toUpperCase()), r.event_risk === "clear" ? "text-success" : r.event_risk ? "text-warning" : "")}
|
||||
${tile("MTF", r.mtf_agreement ? "aligned" : "mixed", r.mtf_agreement ? "text-success" : "")}
|
||||
</div>`;
|
||||
const setup = r.trade_setup
|
||||
? setupCard(r.trade_setup)
|
||||
: `<div class="text-secondary small">No setup published. The signal has not cleared its setup gate.</div>`;
|
||||
return `<article class="card workspace-card border-signal sig-${cls}" id="workspace-panel" aria-labelledby="workspace-panel-heading">
|
||||
<div class="card-body p-3 p-lg-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-7 workspace-meta">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap mb-2">
|
||||
<h3 class="tf-name mb-0" id="workspace-panel-heading">${TF_LABEL[tf]} signal</h3>
|
||||
<span class="badge ${badgeCls(cls)}">${esc(sigLabel(r.signal))}</span>
|
||||
${r.quality ? `<span class="badge ${qHi ? "text-bg-primary" : "text-bg-secondary"}">quality ${esc(r.quality)}</span>` : ""}
|
||||
${entry.status === "error" ? `<span class="badge text-bg-danger">STALE</span>` : ""}
|
||||
</div>
|
||||
<div class="workspace-signal ${textCls}">${esc(r.action || sigLabel(r.signal))}</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" data-open-detail="${tf}">
|
||||
Full analysis <i class="bi bi-arrow-up-right ms-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
${scoreBar("score", pa ? pa.composite_score : r.score)}
|
||||
<div class="small text-secondary mt-2">${esc(meta)}</div>
|
||||
${facts}
|
||||
<div class="workspace-reasons mt-3 pt-3 border-top">${listBlock(r) || "<div class=\"small text-secondary\">No supporting reasons recorded on this scan.</div>"}</div>
|
||||
<div class="small text-secondary mt-3">${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""} · closes in <span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[tf]}">--:--</span></div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="small text-uppercase text-secondary fw-bold mb-2">Trade plan</div>
|
||||
${setup}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderCards() {
|
||||
if (!snapshot) return;
|
||||
if (!TF_LABEL[selectedTf]) selectedTf = "5min";
|
||||
const selector = TF_ORDER.map(tf => {
|
||||
const entry = snapshot.timeframes[tf] || {};
|
||||
const r = entry.report;
|
||||
const cls = r ? sigClass(r.signal) : "neutral";
|
||||
const arrow = r ? (ARROW[r.signal] || "→") : "·";
|
||||
const score = r?.score != null ? signed(r.score, 0) : "—";
|
||||
const signal = r ? sigLabel(r.signal) : entry.status === "error" ? "UNAVAILABLE" : "WARMING";
|
||||
return `<button type="button" class="tf-select" data-select-tf="${tf}" aria-pressed="${tf === selectedTf}">
|
||||
<span class="d-flex align-items-center justify-content-between gap-2">
|
||||
<span class="tf-name">${TF_LABEL[tf]}</span>
|
||||
<span class="${cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary"} fw-bold">${arrow}</span>
|
||||
</span>
|
||||
<span class="small ${cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary"}">${esc(signal)}</span>
|
||||
<span class="small num float-end">${esc(score)}</span>
|
||||
</button>`;
|
||||
}).join("");
|
||||
$("tf-grid").innerHTML = `<div class="tf-selector">${selector}</div>${workspacePanel(selectedTf)}`;
|
||||
tick();
|
||||
}
|
||||
|
||||
/* ── timeframe detail dialog ──────────────────────────────────── */
|
||||
const tile = (k, v, cls = "") =>
|
||||
`<div class="col"><div class="card d-stat bg-body-tertiary h-100"><div class="card-body p-2">
|
||||
<span class="k">${esc(k)}</span>
|
||||
<span class="v ${cls}">${v}</span>
|
||||
</div></div></div>`;
|
||||
|
||||
const SEC_ICON = {
|
||||
"At a glance": "bi-speedometer2",
|
||||
"Trade setup": "bi-bullseye",
|
||||
"Why this signal": "bi-lightbulb",
|
||||
"Multi-timeframe confirmation": "bi-layers",
|
||||
"News context": "bi-newspaper",
|
||||
"AI panel (advisory)": "bi-robot",
|
||||
"Scan detail": "bi-clock-history",
|
||||
"Score": "bi-graph-up",
|
||||
"From the source": "bi-quote",
|
||||
"AI gold-impact summary": "bi-stars",
|
||||
"Timing": "bi-alarm",
|
||||
"Numbers": "bi-calculator"
|
||||
};
|
||||
const sec = (title, html) => {
|
||||
if (!html) return "";
|
||||
const icon = SEC_ICON[title];
|
||||
return `<section class="mb-4"><h6 class="text-uppercase text-secondary small fw-bold mb-2 d-flex align-items-center gap-2">${icon ? `<i class="bi ${icon} text-primary"></i>` : ""}<span>${esc(title)}</span><span class="flex-fill border-bottom"></span></h6>${html}</section>`;
|
||||
};
|
||||
|
||||
function indTable(src) {
|
||||
const rows = IND_ORDER.map(name => {
|
||||
const i = src?.[name];
|
||||
const k = `<td class="ind-k">${IND_SHORT[name]}</td>`;
|
||||
if (!i) return `<tr>${k}<td class="text-secondary">— absent</td>
|
||||
<td class="num text-end">—</td><td class="num text-end">—</td><td class="num text-end">—</td>
|
||||
<td class="text-secondary small">no signal this scan</td></tr>`;
|
||||
const w = i.weight == null ? null : +i.weight;
|
||||
return `<tr>${k}
|
||||
<td class="fw-bold ${sigClass(i.signal) === "buy" ? "text-success" : sigClass(i.signal) === "sell" ? "text-danger" : "text-secondary"}">${esc(sigLabel(i.signal))}</td>
|
||||
<td class="num text-end">${i.value != null ? (+i.value).toFixed(2) : "—"}</td>
|
||||
<td class="num text-end">${i.confidence != null ? Math.round(i.confidence) : "—"}</td>
|
||||
<td class="num text-end">${w != null ? w.toFixed(1) : "—"}${w != null
|
||||
? `<i class="wbar"><i style="width:${Math.min(100, w / 2 * 100)}%"></i></i>` : ""}</td>
|
||||
<td class="text-secondary small">${esc(i.description || "")}</td></tr>`;
|
||||
}).join("");
|
||||
return `<div class="detail-table"><table class="table table-sm d-table"><thead><tr>
|
||||
<th>Indicator</th><th>Signal</th><th class="text-end">Value</th><th class="text-end">Conf</th>
|
||||
<th class="text-end">Weight</th><th>Reading</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function ladder(levels, price) {
|
||||
if (!levels) return "";
|
||||
const atr = levels.atr;
|
||||
const rows = Object.keys(LEVEL_LABEL)
|
||||
.filter(k => levels[k] != null)
|
||||
.map(k => ({ label: LEVEL_LABEL[k], v: +levels[k] }));
|
||||
if (!rows.length) return "";
|
||||
if (price != null) rows.push({ here: true, v: +price });
|
||||
rows.sort((a, b) => b.v - a.v);
|
||||
const body = rows.map(row => {
|
||||
if (row.here) return `<tr class="table-primary fw-bold"><td><i class="bi bi-geo-alt-fill me-1"></i>current price</td>
|
||||
<td class="num text-end">${fmt$(row.v)}</td><td class="num text-end">—</td><td class="small text-secondary">spot</td></tr>`;
|
||||
const d = price != null ? row.v - price : null;
|
||||
const cls = d == null ? "" : d > 0 ? "text-danger" : "text-success";
|
||||
const icon = d == null ? "" : d > 0 ? "bi-arrow-up-short" : "bi-arrow-down-short";
|
||||
return `<tr>
|
||||
<td class="text-secondary">${esc(row.label)}</td>
|
||||
<td class="num text-end ${cls}">${fmt$(row.v)}</td>
|
||||
<td class="num text-end ${cls}">${d == null ? "" : signed(d) + (atr ? ` · ${signed(d / atr, 2)} ATR` : "")}</td>
|
||||
<td class="small ${cls}">${d == null ? "" : `<i class="bi ${icon}"></i>${d > 0 ? "resistance" : "support"}`}</td></tr>`;
|
||||
}).join("");
|
||||
return `<div class="detail-table"><table class="table table-sm ladder"><thead><tr><th>Level</th><th class="text-end">Price</th>
|
||||
<th class="text-end">vs. spot</th><th>Role</th></tr></thead><tbody>${body}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function analysisBlock(a, price) {
|
||||
if (!a) return "";
|
||||
const pats = a.pattern_signals?.length
|
||||
? `<div class="d-flex flex-wrap gap-1 mb-2">${a.pattern_signals.map(p =>
|
||||
`<span class="badge text-bg-primary bg-opacity-10">${esc(p)}</span>`).join("")}</div>` : "";
|
||||
const atr = a.atr_context
|
||||
? `<div class="small text-secondary mb-2">${esc(a.atr_context.description ||
|
||||
"ATR " + (+a.atr_context.value).toFixed(2))}</div>` : "";
|
||||
const lv = ladder(a.key_levels, price);
|
||||
return indTable(a.indicators) + atr + pats +
|
||||
(lv ? `<div class="small text-secondary fw-semibold mt-2 mb-1">Key levels</div>${lv}` : "");
|
||||
}
|
||||
|
||||
function listBlock(r) {
|
||||
const group = (title, arr, cls, icon) => arr?.length
|
||||
? `<div class="small text-secondary fw-semibold mt-2 mb-1">${title} (${arr.length})</div>
|
||||
<div class="small">${arr.map(t => `<div class="${cls}"><i class="bi ${icon} me-1"></i>${esc(t)}</div>`).join("")}</div>` : "";
|
||||
return group("Warnings", r.warnings, "text-warning", "bi-exclamation-triangle-fill")
|
||||
+ group("Bullish", r.bullish_reasons, "text-success", "bi-arrow-up-circle-fill")
|
||||
+ group("Bearish", r.bearish_reasons, "text-danger", "bi-arrow-down-circle-fill");
|
||||
}
|
||||
|
||||
function detailHTML(tf) {
|
||||
const entry = snapshot?.timeframes?.[tf] || {};
|
||||
const r = entry.report;
|
||||
if (!r) return `<div class="text-center text-secondary py-4">${entry.status === "error"
|
||||
? `Scan error — ${esc(entry.error || "unknown")}`
|
||||
: "Warming up — no completed scan for this timeframe yet."}</div>`;
|
||||
|
||||
const pa = r.primary_analysis;
|
||||
const price = r.current_price;
|
||||
const cls = sigClass(r.signal);
|
||||
const textCls = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
|
||||
const borderCls = cls === "buy" ? "border-start border-success border-4"
|
||||
: cls === "sell" ? "border-start border-danger border-4"
|
||||
: "border-start border-secondary border-4";
|
||||
|
||||
let out = `<div class="card ${borderCls} mb-3"><div class="card-body">
|
||||
<div class="fw-bold fs-5 ${textCls}">${esc(r.action || sigLabel(r.signal))}</div>
|
||||
${r.news_description || r.event_description
|
||||
? `<div class="small text-secondary">${esc([r.event_description, r.news_description].filter(Boolean).join(" · "))}</div>`
|
||||
: ""}
|
||||
</div></div>`;
|
||||
|
||||
const ns = r.news_sentiment;
|
||||
out += sec("At a glance", `<div class="row row-cols-2 row-cols-md-3 row-cols-lg-4 g-2">
|
||||
${tile("Signal", esc(sigLabel(r.signal)), textCls)}
|
||||
${tile("Score", signed(r.score, 1), textCls)}
|
||||
${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
|
||||
${tile("Quality", esc(r.quality || "—"))}
|
||||
${tile("Regime", esc(r.market_regime || "—"))}
|
||||
${tile("Volatility", esc(r.volatility_regime || "—"))}
|
||||
${tile("Event risk", esc((r.event_risk || "—").toUpperCase()),
|
||||
r.event_risk === "clear" ? "text-success" : r.event_risk ? "text-warning" : "")}
|
||||
${tile("News sentiment", ns != null ? signed(ns, 2) : "—",
|
||||
ns > 0.05 ? "text-success" : ns < -0.05 ? "text-danger" : "")}
|
||||
${tile("MTF", r.mtf_agreement ? "aligned" : "mixed", r.mtf_agreement ? "text-success" : "")}
|
||||
${tile("Primary score", signed(r.primary_score, 1), sigClass(r.primary_signal) === "buy" ? "text-success" : sigClass(r.primary_signal) === "sell" ? "text-danger" : "")}
|
||||
${tile("Exhaustion", esc(r.exhaustion_count ?? "—"), r.exhaustion_count ? "text-warning" : "")}
|
||||
</div>`);
|
||||
|
||||
out += sec("Trade setup", r.trade_setup
|
||||
? setupCard(r.trade_setup)
|
||||
: `<div class="text-secondary small">No setup published for this timeframe — the engine only emits entry/stop/target once a signal clears its quality gate.</div>`);
|
||||
|
||||
out += sec("Why this signal", listBlock(r) || `<div class="text-secondary small">No reasons recorded on this scan.</div>`);
|
||||
|
||||
out += sec(`${TF_LABEL[tf]} indicators (primary)`,
|
||||
analysisBlock(pa, price) || indTable(r.indicators) +
|
||||
(r.key_levels ? `<div class="small text-secondary fw-semibold mt-2 mb-1">Key levels</div>${ladder(r.key_levels, price)}` : ""));
|
||||
|
||||
const mtf = Object.entries(r.mtf_signals || {});
|
||||
if (mtf.length) {
|
||||
const chips = mtf.map(([k, sig]) => {
|
||||
const s = r.mtf_scores?.[k];
|
||||
const sc = sigClass(sig);
|
||||
return `<span class="badge ${badgeCls(sc)}">${esc(TF_LABEL[k] || k)}
|
||||
<span class="${sc === "buy" ? "text-success" : sc === "sell" ? "text-danger" : "text-secondary"}">${esc(sigLabel(sig))}</span>
|
||||
<span class="num">${s != null ? signed(s, 1) : ""}</span></span>`;
|
||||
}).join("");
|
||||
const confs = (r.confirmation_analyses || []).filter(Boolean).map(a => {
|
||||
const lbl = TF_LABEL[a.timeframe] || a.timeframe;
|
||||
const sc = sigClass(a.composite_signal);
|
||||
return `<details data-dkey="conf-${esc(a.timeframe)}">
|
||||
<summary><strong>${esc(lbl)}</strong>
|
||||
<span class="${sc === "buy" ? "text-success" : sc === "sell" ? "text-danger" : "text-secondary"} fw-bold">${esc(sigLabel(a.composite_signal))}</span>
|
||||
<span class="text-secondary small">${signed(a.composite_score, 1)} · conf ${a.confidence != null ? Math.round(a.confidence) : "—"} · ${esc(a.market_regime || "")}</span></summary>
|
||||
${analysisBlock(a, price)}</details>`;
|
||||
}).join("");
|
||||
out += sec("Multi-timeframe confirmation",
|
||||
`<div class="d-flex flex-wrap gap-2 align-items-center mb-2">${chips}
|
||||
<span class="fw-bold ${r.mtf_agreement ? "text-success" : "text-secondary"}">${r.mtf_agreement ? "✓ aligned" : "✗ mixed"}</span></div>${confs}`);
|
||||
}
|
||||
|
||||
if (r.news_summary || r.news_description) {
|
||||
out += sec("News context", `<div class="text-secondary small">
|
||||
${r.news_description ? `<strong>${esc(r.news_description)}</strong>${ns != null ? ` (${signed(ns, 2)})` : ""}<br>` : ""}
|
||||
${esc(r.news_summary || "")}</div>`);
|
||||
}
|
||||
|
||||
if (r.panel_verdict) {
|
||||
const vcls = r.panel_verdict === "AGREE" ? "text-success"
|
||||
: r.panel_verdict === "OPPOSE" ? "text-danger" : "text-secondary";
|
||||
const takes = (r.panel_takes || []).map(t =>
|
||||
`<tr><td class="ind-k">${esc(t.persona)}</td>
|
||||
<td>${esc(t.stance)}</td>
|
||||
<td class="num text-end">${t.confidence != null ? Math.round(t.confidence) : "—"}</td>
|
||||
<td class="small text-secondary">${esc(t.note || "")}</td></tr>`).join("");
|
||||
out += sec("AI panel (advisory)", `<div class="text-secondary small mb-2">
|
||||
<strong class="${vcls}">${esc(r.panel_verdict)}</strong> ·
|
||||
conf ${Math.round(r.panel_confidence || 0)}%<br>
|
||||
${esc(r.panel_rationale || "")}</div>
|
||||
${takes ? `<div class="detail-table"><table class="table table-sm d-table"><tbody>${takes}</tbody></table></div>` : ""}`);
|
||||
}
|
||||
|
||||
out += sec("Scan detail", `<div class="detail-table"><table class="table table-sm d-table"><tbody>
|
||||
<tr><td class="ind-k">Instrument</td><td colspan="5">${esc(r.display_name || r.symbol || "—")}</td></tr>
|
||||
<tr><td class="ind-k">Candle</td><td colspan="5" class="num">${esc(r.timestamp || "—")}</td></tr>
|
||||
<tr><td class="ind-k">Scanned</td><td colspan="5">${esc(entry.scanned_at || "—")}
|
||||
(${fmtAge(entry.scanned_at)}${entry.scan_seconds ? `, took ${entry.scan_seconds}s` : ""})</td></tr>
|
||||
<tr><td class="ind-k">Status</td><td colspan="5">${esc(entry.status || "—")}${entry.error
|
||||
? ` — <span class="text-danger">${esc(entry.error)}</span>
|
||||
${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}</td></tr>
|
||||
</tbody></table></div>`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
if (!detailTf || !tfModalOpen) return;
|
||||
const entry = snapshot?.timeframes?.[detailTf] || {};
|
||||
const r = entry.report;
|
||||
const openKeys = new Set([...document.querySelectorAll("#detail-body details[open][data-dkey]")]
|
||||
.map(d => d.dataset.dkey));
|
||||
const scroll = $("detail-body").scrollTop;
|
||||
|
||||
$("detail-title").textContent = TF_LABEL[detailTf] || detailTf;
|
||||
$("detail-price").textContent = r?.current_price ? fmt$(r.current_price) : "—";
|
||||
$("detail-sub").innerHTML = `${snapshot?.symbol ? esc(snapshot.symbol) + " · " : ""}closes in
|
||||
<span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[detailTf]}">--:--</span>`;
|
||||
$("detail-body").innerHTML = detailHTML(detailTf);
|
||||
|
||||
document.querySelectorAll("#detail-body details[data-dkey]").forEach(d => {
|
||||
if (openKeys.has(d.dataset.dkey)) d.open = true;
|
||||
});
|
||||
$("detail-body").scrollTop = scroll;
|
||||
tick();
|
||||
}
|
||||
|
||||
function openDetail(tf) {
|
||||
if (!TF_LABEL[tf]) return;
|
||||
detailTf = tf;
|
||||
tfModal.show();
|
||||
renderDetail();
|
||||
$("detail-body").scrollTop = 0;
|
||||
$("detail-close").focus();
|
||||
}
|
||||
|
||||
$("detail-close")?.addEventListener("click", () => tfModal.hide());
|
||||
|
||||
/* ── calendar ─────────────────────────────────────────────────── */
|
||||
function renderCalendar() {
|
||||
const body = $("cal-body");
|
||||
if (!news) return;
|
||||
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local time";
|
||||
$("calendar-note").textContent = `times local (${zone}) · USD events${news.generated_at ? " · updated " + fmtAge(news.generated_at) : ""}`;
|
||||
const hiOnly = $("hi-only").checked;
|
||||
let events = (news.events || []);
|
||||
if (hiOnly) events = events.filter(e => e.impact === "high");
|
||||
if (!events.length) {
|
||||
body.innerHTML = `<tr><td colspan="7" class="text-secondary text-center py-3">${
|
||||
news.calendar_available ? "No matching events this week." : "Calendar unavailable — source could not be reached."}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const now = nowServer();
|
||||
let nextUpMarked = false, lastDay = "", rows = "";
|
||||
for (const e of events) {
|
||||
const t = Date.parse(e.time) / 1000;
|
||||
const d = new Date(t * 1000);
|
||||
const day = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
if (day !== lastDay) {
|
||||
rows += `<tr class="table-group-divider"><td colspan="7" class="text-primary small fw-bold text-uppercase">${esc(day)}</td></tr>`;
|
||||
lastDay = day;
|
||||
}
|
||||
const past = t < now;
|
||||
const nextUp = !past && !nextUpMarked && (nextUpMarked = true);
|
||||
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
const surp = e.surprise
|
||||
? `<span class="${e.surprise === "positive" ? "text-success" : e.surprise === "negative" ? "text-danger" : "text-secondary"}" title="actual ${e.surprise === "positive" ? "above" : e.surprise === "negative" ? "below" : "matches"} forecast (numeric only — not gold direction)">${e.surprise === "positive" ? "▲" : e.surprise === "negative" ? "▼" : "="}</span>` : "";
|
||||
rows += `<tr class="${past ? "text-secondary" : ""}${nextUp ? " cal-next-up" : ""}">
|
||||
<td class="num text-nowrap">${esc(time)}</td>
|
||||
<td class="text-secondary">${esc(e.currency)}</td>
|
||||
<td><span class="badge ${e.impact === "high" ? "text-bg-danger" : e.impact === "medium" ? "text-bg-warning" : "text-bg-secondary"}">${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span></td>
|
||||
<td><button type="button" class="cal-event" data-ev-key="${esc(evKey(e))}" aria-haspopup="dialog">${esc(e.event)}</button></td>
|
||||
<td class="num text-end">${esc(e.forecast ?? "—")}</td>
|
||||
<td class="num text-end">${esc(e.previous ?? "—")}</td>
|
||||
<td class="num text-end">${esc(e.actual ?? "—")} ${surp}</td></tr>`;
|
||||
}
|
||||
body.innerHTML = rows;
|
||||
}
|
||||
|
||||
/* ── newswire ─────────────────────────────────────────────────── */
|
||||
function renderWireTotal() {
|
||||
const box = $("wire-total");
|
||||
const scored = (news?.headlines || []).filter(h => typeof h.score === "number");
|
||||
if (!scored.length) { box.classList.add("d-none"); return; }
|
||||
box.classList.remove("d-none");
|
||||
|
||||
const sum = scored.reduce((a, h) => a + h.score, 0);
|
||||
const mean = sum / scored.length;
|
||||
const up = scored.filter(h => h.score > 0.05).length;
|
||||
const dn = scored.filter(h => h.score < -0.05).length;
|
||||
const cls = mean > 0.05 ? "text-success" : mean < -0.05 ? "text-danger" : "text-secondary";
|
||||
const dir = mean > 0.05 ? "BULLISH" : mean < -0.05 ? "BEARISH" : "NEUTRAL";
|
||||
const wrapCls = mean > 0.05 ? "bg-success-subtle border-success-subtle"
|
||||
: mean < -0.05 ? "bg-danger-subtle border-danger-subtle"
|
||||
: "bg-body-tertiary";
|
||||
|
||||
box.className = `card mb-3 ${wrapCls}`;
|
||||
box.innerHTML = `<div class="card-body p-2">
|
||||
<div class="d-flex align-items-baseline gap-2 flex-wrap">
|
||||
<span class="small text-secondary text-uppercase fw-bold">Total news score</span>
|
||||
<span class="display-6 fw-bold ${cls}">${signed(mean, 2)}</span>
|
||||
<span class="fw-bold ${cls}">${dir}</span>
|
||||
<span class="ms-auto text-end small text-secondary">${scored.length} headlines · sum ${signed(sum, 1)}<br>
|
||||
${up} bullish · ${dn} bearish · ${scored.length - up - dn} neutral</span>
|
||||
</div>
|
||||
</div>`;
|
||||
box.title = `Mean sentiment of all ${scored.length} scored headlines (−1…+1). Sum ${signed(sum, 1)}. Bullish/bearish counted at |score| > 0.05.`;
|
||||
}
|
||||
|
||||
function renderWire() {
|
||||
if (!news) return;
|
||||
const items = (news.headlines || []).slice(0, 40);
|
||||
$("wire-note").textContent = news.generated_at ? `updated ${fmtAge(news.generated_at)}` : "";
|
||||
renderWireTotal();
|
||||
if (!items.length) {
|
||||
$("wire").innerHTML = `<div class="text-secondary small py-2">No recent gold/dollar headlines.</div>`;
|
||||
return;
|
||||
}
|
||||
const item = h => {
|
||||
const s = h.score;
|
||||
const cls = s == null || Math.abs(s) < 0.05 ? "zero" : s > 0 ? "pos" : "neg";
|
||||
const scoreCls = cls === "pos" ? "bg-success-subtle text-success"
|
||||
: cls === "neg" ? "bg-danger-subtle text-danger"
|
||||
: "bg-body-tertiary text-secondary";
|
||||
const scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
|
||||
let host = "";
|
||||
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
|
||||
const content = `<span class="wire-score ${scoreCls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
|
||||
<span class="small text-secondary flex-fill">${esc(h.headline)}</span>
|
||||
<span class="small text-secondary text-nowrap">${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span>`;
|
||||
if (!h.id) return `<div class="wire-item gap-2 align-items-baseline py-1 px-1">${content}</div>`;
|
||||
return `<button type="button" class="wire-item gap-2 align-items-baseline py-1 px-1" data-news-id="${h.id}" aria-haspopup="dialog">
|
||||
${content}
|
||||
</button>`;
|
||||
};
|
||||
const head = items.slice(0, 12).map(item).join("");
|
||||
const rest = items.slice(12);
|
||||
const moreOpen = $("wire")?.querySelector("details.more")?.open ?? false;
|
||||
$("wire").innerHTML = head + (rest.length
|
||||
? `<details class="more mt-1"${moreOpen ? " open" : ""}><summary class="small text-secondary">${rest.length} more headlines…</summary>
|
||||
<div class="mt-1">${rest.map(item).join("")}</div></details>` : "");
|
||||
}
|
||||
|
||||
/* ── news / calendar detail dialog ────────────────────────────── */
|
||||
const evKey = e => `${e.time}|${e.currency}|${e.event}`;
|
||||
const findHeadline = id => (news?.headlines || []).find(h => h.id === id);
|
||||
const findEvent = key => (news?.events || []).find(e => evKey(e) === key);
|
||||
|
||||
const AI_ERR_MSG = {
|
||||
unknown_id: "This headline has left the live feed — no summary available.",
|
||||
cap_exceeded: "Daily AI budget reached — try again tomorrow.",
|
||||
no_key: "AI summaries are not configured on this server."
|
||||
};
|
||||
|
||||
async function fetchAiSummary(id) {
|
||||
aiPending.add(id);
|
||||
try {
|
||||
const res = await fetch("news_summary.php?id=" + encodeURIComponent(id));
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.ok && data?.summary) aiMemo.set(id, data);
|
||||
else aiErr.set(id, data?.error || `http_${res.status}`);
|
||||
} catch {
|
||||
aiErr.set(id, "network");
|
||||
} finally {
|
||||
aiPending.delete(id);
|
||||
if (ndState?.kind === "wire" && ndState.id === id) renderNewsDlg();
|
||||
}
|
||||
}
|
||||
|
||||
function scoreTiles(h) {
|
||||
const cls = v => v > 0.05 ? "text-success" : v < -0.05 ? "text-danger" : "text-secondary";
|
||||
let tiles = tile("Overall sentiment", signed(h.score, 2), cls(h.score));
|
||||
const xau = h.score_by_currency?.XAU;
|
||||
if (xau != null) tiles += tile("Gold (XAU) effect", signed(xau, 2), cls(xau));
|
||||
for (const [cur, v] of Object.entries(h.score_by_currency || {})) {
|
||||
if (cur !== "XAU") tiles += tile(cur, signed(v, 2), cls(v));
|
||||
}
|
||||
let out = `<div class="row row-cols-2 row-cols-md-3 g-2">${tiles}</div>`;
|
||||
if (h.keywords?.length) {
|
||||
out += `<div class="small text-secondary mt-2">Matched keywords: ${esc(h.keywords.join(", "))}</div>`;
|
||||
}
|
||||
out += `<div class="small text-secondary">Scores range −1 (bearish) to +1 (bullish); the gold score flips monetary-policy keywords (hawkish = dollar-bullish, gold-bearish).</div>`;
|
||||
return out;
|
||||
}
|
||||
|
||||
function aiSection(id) {
|
||||
if (aiMemo.has(id)) {
|
||||
const m = aiMemo.get(id);
|
||||
return `<div class="text-secondary small">${esc(m.summary)}</div>` + (m.generated_at
|
||||
? `<div class="small text-secondary">generated ${fmtAge(m.generated_at)}</div>` : "");
|
||||
}
|
||||
if (aiPending.has(id)) return `<div class="text-secondary small">Generating gold-impact summary…</div>`;
|
||||
if (aiErr.has(id)) {
|
||||
return `<div class="text-secondary small">${esc(AI_ERR_MSG[aiErr.get(id)] || "AI summary unavailable right now.")}</div>`;
|
||||
}
|
||||
return `<div class="text-secondary small">Generating gold-impact summary…</div>`;
|
||||
}
|
||||
|
||||
function wireDlgHTML() {
|
||||
const fresh = findHeadline(ndState.id);
|
||||
const h = fresh || ndState.snap;
|
||||
if (!h) return "";
|
||||
let host = "";
|
||||
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
|
||||
const when = h.timestamp
|
||||
? `${new Date(h.timestamp).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })} · ${fmtAge(h.timestamp)}` : "";
|
||||
let out = `<div class="fs-5 fw-semibold mb-1">${esc(h.headline)}</div>
|
||||
<div class="small text-secondary mb-3">${esc(h.publisher || host)}${when ? " · " + when : ""}</div>`;
|
||||
if (!fresh) out += `<div class="small text-secondary mb-2">No longer in the live feed — showing saved copy.</div>`;
|
||||
out += sec("Score", scoreTiles(h));
|
||||
if (h.description) out += sec("From the source", `<div class="text-secondary small">${esc(h.description)}</div>`);
|
||||
out += sec("AI gold-impact summary", aiSection(ndState.id));
|
||||
if (h.link && /^https?:\/\//.test(h.link)) {
|
||||
out += `<a class="btn btn-outline-primary" href="${esc(h.link)}" target="_blank" rel="noopener noreferrer">Read full article ↗</a>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function calDlgHTML() {
|
||||
const fresh = findEvent(ndState.key);
|
||||
const e = fresh || ndState.snap;
|
||||
if (!e) return "";
|
||||
const t = Date.parse(e.time) / 1000;
|
||||
const when = new Date(e.time).toLocaleString(undefined, { dateStyle: "full", timeStyle: "short" });
|
||||
const impact = `<span class="badge ${e.impact === "high" ? "text-bg-danger" : e.impact === "medium" ? "text-bg-warning" : "text-bg-secondary"}">${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span>`;
|
||||
let out = `<div class="fs-5 fw-semibold mb-1">${esc(e.event)}</div>
|
||||
<div class="small text-secondary mb-3">${esc(e.currency)} · ${when}</div>`;
|
||||
const timing = t > nowServer()
|
||||
? `releases in <span class="num" data-close="${t}">--:--</span>`
|
||||
: `released ${fmtAge(e.time)}`;
|
||||
out += sec("Timing", `<div class="text-secondary small">${impact} impact · ${timing}</div>`);
|
||||
const surpTxt = { positive: "actual above forecast", negative: "actual below forecast", inline: "actual matches forecast" }[e.surprise];
|
||||
out += sec("Numbers", `<div class="row row-cols-3 g-2">${
|
||||
tile("Forecast", esc(e.forecast ?? "—")) +
|
||||
tile("Previous", esc(e.previous ?? "—")) +
|
||||
tile("Actual", esc(e.actual ?? "—"), e.surprise === "positive" ? "text-success" : e.surprise === "negative" ? "text-danger" : "")
|
||||
}</div>` + (surpTxt
|
||||
? `<div class="small text-secondary mt-2">${surpTxt} (numeric only — not gold direction)</div>` : ""));
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderNewsDlg() {
|
||||
if (!ndState || !newsModalOpen) return;
|
||||
const scroll = $("nd-body").scrollTop;
|
||||
$("nd-title").textContent = ndState.kind === "wire" ? "Newswire" : "Economic calendar";
|
||||
$("nd-body").innerHTML = ndState.kind === "wire" ? wireDlgHTML() : calDlgHTML();
|
||||
$("nd-body").scrollTop = scroll;
|
||||
tick();
|
||||
}
|
||||
|
||||
function openNewsDetail(id) {
|
||||
const h = findHeadline(id);
|
||||
if (!h) return;
|
||||
ndState = { kind: "wire", id, snap: h };
|
||||
aiErr.delete(id);
|
||||
newsModal.show();
|
||||
if (!aiMemo.has(id) && !aiPending.has(id)) fetchAiSummary(id);
|
||||
renderNewsDlg();
|
||||
$("nd-body").scrollTop = 0;
|
||||
$("nd-close").focus();
|
||||
}
|
||||
|
||||
function openCalDetail(key) {
|
||||
const e = findEvent(key);
|
||||
if (!e) return;
|
||||
ndState = { kind: "cal", key, snap: e };
|
||||
newsModal.show();
|
||||
renderNewsDlg();
|
||||
$("nd-body").scrollTop = 0;
|
||||
$("nd-close").focus();
|
||||
}
|
||||
|
||||
$("nd-close")?.addEventListener("click", () => newsModal.hide());
|
||||
|
||||
/* ── polling ──────────────────────────────────────────────────── */
|
||||
async function fetchJSON(name) {
|
||||
const res = await fetch(name, { cache: "no-cache" });
|
||||
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
|
||||
const dateHdr = res.headers.get("Date");
|
||||
const text = await res.text();
|
||||
return { text, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null };
|
||||
}
|
||||
|
||||
function tfFingerprint(entry) {
|
||||
if (!entry) return "";
|
||||
return JSON.stringify({
|
||||
status: entry.status,
|
||||
scanned_at: entry.scanned_at,
|
||||
error: entry.error,
|
||||
error_at: entry.error_at,
|
||||
report: entry.report
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleRefresh(delay) {
|
||||
clearTimeout(refetchTimer);
|
||||
refetchTimer = setTimeout(() => {
|
||||
refetchTimer = null;
|
||||
poll();
|
||||
}, Math.max(0, delay));
|
||||
}
|
||||
|
||||
function scheduleFallbackRefresh() {
|
||||
clearTimeout(fallbackTimer);
|
||||
fallbackTimer = setTimeout(() => {
|
||||
fallbackTimer = null;
|
||||
poll();
|
||||
scheduleFallbackRefresh();
|
||||
}, FALLBACK_POLL_MS);
|
||||
}
|
||||
|
||||
function scheduleBoundaryRefresh() {
|
||||
if (!snapshot) return;
|
||||
const nextScan = Math.min(...TF_ORDER.map(tf =>
|
||||
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
|
||||
if (!isFinite(nextScan)) return;
|
||||
const delay = (nextScan - nowServer() + 2) * 1000;
|
||||
scheduleRefresh(delay > 0 ? delay : RETRY_MS);
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (pollInFlight) {
|
||||
pollQueued = true;
|
||||
return;
|
||||
}
|
||||
pollInFlight = true;
|
||||
try {
|
||||
const [snap, nw] = await Promise.all([
|
||||
fetchJSON("snapshot.json"),
|
||||
fetchJSON("news.json").catch(() => null)
|
||||
]);
|
||||
const priorSnapshot = snapshot;
|
||||
let snapChanged = false;
|
||||
let selectedChanged = false;
|
||||
let detailChanged = false;
|
||||
if (snap.text !== lastSnapText) {
|
||||
const incoming = JSON.parse(snap.text);
|
||||
if (!priorSnapshot || incoming.server_time_epoch >= priorSnapshot.server_time_epoch) {
|
||||
snapChanged = true;
|
||||
selectedChanged = tfFingerprint(priorSnapshot?.timeframes?.[selectedTf]) !==
|
||||
tfFingerprint(incoming.timeframes?.[selectedTf]);
|
||||
detailChanged = detailTf != null && tfFingerprint(priorSnapshot?.timeframes?.[detailTf]) !==
|
||||
tfFingerprint(incoming.timeframes?.[detailTf]);
|
||||
snapshot = incoming;
|
||||
lastSnapText = snap.text;
|
||||
}
|
||||
}
|
||||
const newsChanged = nw != null && nw.text !== lastNewsText;
|
||||
if (newsChanged) { news = JSON.parse(nw.text); lastNewsText = nw.text; }
|
||||
clockOffset = (snap.serverNow ?? snapshot.server_time_epoch) - Date.now() / 1000;
|
||||
lastPollOk = new Date();
|
||||
$("banner-fail").classList.add("d-none");
|
||||
|
||||
if (snapChanged) {
|
||||
renderHero();
|
||||
if (selectedChanged) renderCards();
|
||||
if (detailChanged) renderDetail();
|
||||
if (selectedChanged) {
|
||||
const signal = snapshot.timeframes[selectedTf]?.report?.signal;
|
||||
if (signal) announce(`${TF_LABEL[selectedTf]} signal updated: ${sigLabel(signal)}.`);
|
||||
}
|
||||
}
|
||||
if (newsChanged) {
|
||||
renderCalendar();
|
||||
renderWire();
|
||||
renderNewsDlg();
|
||||
}
|
||||
if (snapChanged || newsChanged) renderContext();
|
||||
tick();
|
||||
scheduleBoundaryRefresh();
|
||||
} catch (err) {
|
||||
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
|
||||
const b = $("banner-fail");
|
||||
b.textContent = `Dashboard data is temporarily unavailable.${last} Retrying…`;
|
||||
b.classList.remove("d-none");
|
||||
scheduleRefresh(RETRY_MS);
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
if (pollQueued) {
|
||||
pollQueued = false;
|
||||
poll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!snapshot) return;
|
||||
const now = nowServer();
|
||||
document.querySelectorAll("[data-close]").forEach(el => {
|
||||
const t = parseFloat(el.dataset.close);
|
||||
if (!t) return;
|
||||
let secs = t - now;
|
||||
if (secs < 0) {
|
||||
const interval = parseFloat(el.dataset.interval);
|
||||
secs = interval > 0 ? ((secs % interval) + interval) % interval : 0;
|
||||
}
|
||||
el.textContent = fmtCountdown(secs);
|
||||
});
|
||||
const m5 = snapshot.timeframes["5min"]?.next_close_epoch;
|
||||
if (m5) {
|
||||
let secs = m5 - now;
|
||||
if (secs < 0) secs = ((secs % TF_SECONDS["5min"]) + TF_SECONDS["5min"]) % TF_SECONDS["5min"];
|
||||
$("hero-cd").textContent = fmtCountdown(secs);
|
||||
}
|
||||
const pubEpoch = snapshot.server_time_epoch;
|
||||
if (pubEpoch) {
|
||||
const age = now - pubEpoch;
|
||||
const el = $("hero-updated");
|
||||
const stale = !snapshot.market_closed && age > 900;
|
||||
el.textContent = (stale ? "STALE — published " : "published ") +
|
||||
fmtAge(new Date(pubEpoch * 1000).toISOString());
|
||||
el.classList.toggle("text-warning", stale);
|
||||
el.classList.toggle("fw-semibold", stale);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── boot ─────────────────────────────────────────────────────── */
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
tfModal = new bootstrap.Modal($("tf-detail"));
|
||||
newsModal = new bootstrap.Modal($("news-detail"));
|
||||
const storedTf = localGet(TIMEFRAME_PREF);
|
||||
if (TF_LABEL[storedTf]) selectedTf = storedTf;
|
||||
$("hi-only").checked = localGet(HIGH_IMPACT_PREF) === "true";
|
||||
|
||||
$("tf-detail")?.addEventListener("shown.bs.modal", () => { tfModalOpen = true; renderDetail(); });
|
||||
$("tf-detail")?.addEventListener("hidden.bs.modal", () => { tfModalOpen = false; detailTf = null; });
|
||||
$("news-detail")?.addEventListener("shown.bs.modal", () => { newsModalOpen = true; renderNewsDlg(); });
|
||||
$("news-detail")?.addEventListener("hidden.bs.modal", () => { newsModalOpen = false; ndState = null; });
|
||||
|
||||
const selectTf = tf => {
|
||||
if (!TF_LABEL[tf]) return;
|
||||
const changed = selectedTf !== tf;
|
||||
selectedTf = tf;
|
||||
localSet(TIMEFRAME_PREF, tf);
|
||||
renderHero();
|
||||
renderCards();
|
||||
if (changed) announce(`${TF_LABEL[tf]} workspace selected.`);
|
||||
};
|
||||
$("hero-rail")?.addEventListener("click", e => {
|
||||
const button = e.target.closest("[data-select-tf]");
|
||||
if (button) selectTf(button.dataset.selectTf);
|
||||
});
|
||||
$("tf-grid")?.addEventListener("click", e => {
|
||||
const detail = e.target.closest("[data-open-detail]");
|
||||
if (detail) return openDetail(detail.dataset.openDetail);
|
||||
const button = e.target.closest("[data-select-tf]");
|
||||
if (button) selectTf(button.dataset.selectTf);
|
||||
});
|
||||
$("wire")?.addEventListener("click", e => {
|
||||
const button = e.target.closest("[data-news-id]");
|
||||
if (button) openNewsDetail(button.dataset.newsId);
|
||||
});
|
||||
$("cal-body")?.addEventListener("click", e => {
|
||||
const button = e.target.closest("[data-ev-key]");
|
||||
if (button) openCalDetail(button.dataset.evKey);
|
||||
});
|
||||
$("hi-only")?.addEventListener("change", () => {
|
||||
localSet(HIGH_IMPACT_PREF, String($("hi-only").checked));
|
||||
renderCalendar();
|
||||
});
|
||||
|
||||
poll();
|
||||
scheduleFallbackRefresh();
|
||||
setInterval(tick, 1000);
|
||||
});
|
||||
+7
-113
@@ -71,9 +71,7 @@ from app_config import (
|
||||
telegram_channel_id,
|
||||
)
|
||||
from providers import BaseDataProvider, make_data_provider
|
||||
from agent_panel import attach_panel, panel_health
|
||||
from llm_client import get_llm_client
|
||||
from market_outlook import maybe_run_outlook
|
||||
from news_engine import FundamentalContext, NewsEngine
|
||||
from pair_signals import PAIR_PRODUCERS
|
||||
from signal_aggregator import SignalAggregator, SignalReport
|
||||
@@ -341,10 +339,7 @@ def format_signal_message(report: SignalReport) -> str:
|
||||
f"📊 Regime: {regime_str} · Vol: {vol_str}",
|
||||
]
|
||||
if report.action_recommendation:
|
||||
# Pattern identifiers use underscores, which legacy Telegram Markdown
|
||||
# can misparse even when escaped inside this italicized action line.
|
||||
action = report.action_recommendation.replace("_", " ")
|
||||
lines.append(f"_{_escape_md(action)}_")
|
||||
lines.append(f"_{_escape_md(report.action_recommendation)}_")
|
||||
|
||||
if report.trade_setup and sig not in (SignalStrength.NEUTRAL,):
|
||||
ts = report.trade_setup
|
||||
@@ -412,17 +407,6 @@ def format_signal_message(report: SignalReport) -> str:
|
||||
extra.append(f"⚠️ {_escape_md(w)}")
|
||||
if report.news_summary:
|
||||
extra.append(f"📰 {_escape_md(report.news_summary)}")
|
||||
if report.panel_verdict:
|
||||
mark = {"AGREE": "✅", "OPPOSE": "⛔", "NEUTRAL": "➖"}.get(
|
||||
report.panel_verdict, "")
|
||||
extra.append(
|
||||
f"🤖 AI Panel {mark} *{_escape_md(report.panel_verdict)}* "
|
||||
f"({report.panel_confidence:.0f}%) — "
|
||||
f"{_escape_md(report.panel_rationale)}")
|
||||
if report.panel_takes:
|
||||
votes = " · ".join(
|
||||
f"{t['persona']}: {t['stance']}" for t in report.panel_takes)
|
||||
extra.append(f" _{_escape_md(votes)}_")
|
||||
if extra:
|
||||
lines += [""] + extra
|
||||
|
||||
@@ -540,7 +524,7 @@ def _run_signal_sync(
|
||||
config.confirmation_timeframes = confirmation_tfs
|
||||
aggregator = SignalAggregator(config)
|
||||
aggregator.data_provider = _get_shared_dp()
|
||||
return aggregator.analyze_pair(symbol=symbol, log_source="telegram")
|
||||
return aggregator.analyze_pair(symbol=symbol)
|
||||
|
||||
|
||||
async def run_signal(
|
||||
@@ -549,50 +533,9 @@ async def run_signal(
|
||||
confirmation_tfs: Optional[List[Timeframe]] = None,
|
||||
) -> SignalReport:
|
||||
loop = asyncio.get_running_loop()
|
||||
report = await loop.run_in_executor(
|
||||
return await loop.run_in_executor(
|
||||
_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) ─────────────────────────
|
||||
@@ -613,22 +556,17 @@ def _passes_quality(quality: str) -> bool:
|
||||
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:
|
||||
"""Alert identity for dedup: signal and setup direction.
|
||||
"""Alert identity for dedup: signal, quality, setup direction.
|
||||
|
||||
Entry movement is checked separately in _should_alert against the
|
||||
last ALERTED entry/risk (a stable reference). Deriving a price bucket
|
||||
from the CURRENT SL distance here made the fingerprint churn on
|
||||
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."""
|
||||
normal ATR/anchor drift — near-duplicate alerts every candle."""
|
||||
setup = report.trade_setup
|
||||
return (
|
||||
report.final_signal.name,
|
||||
report.signal_quality,
|
||||
setup.direction if setup else None,
|
||||
)
|
||||
|
||||
@@ -648,12 +586,6 @@ def _should_alert(key: str, report: SignalReport) -> bool:
|
||||
return True
|
||||
if time.time() - _last_alert_ts.get(key, 0) >= _ALERT_REARM_SECONDS:
|
||||
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
|
||||
last = _last_alert_setup.get(key)
|
||||
if setup is not None and last is not None:
|
||||
@@ -666,7 +598,6 @@ def _should_alert(key: str, report: SignalReport) -> bool:
|
||||
def _commit_alert(key: str, report: SignalReport) -> None:
|
||||
_last_alert_fp[key] = _alert_fingerprint(report)
|
||||
_last_alert_ts[key] = time.time()
|
||||
_last_alert_quality[key] = report.signal_quality
|
||||
setup = report.trade_setup
|
||||
if setup is not None:
|
||||
_last_alert_setup[key] = (
|
||||
@@ -789,13 +720,6 @@ async def _scan_timeframe(
|
||||
async with _alert_lock(key):
|
||||
if not _should_alert(key, report):
|
||||
continue
|
||||
# Panel AFTER the quality gate + dedup decision so no LLM
|
||||
# spend on suppressed alerts. Runs on the scan executor to
|
||||
# keep the event loop free; this key's alert lock is held
|
||||
# for the panel's wall time (≤~25 s), which only delays a
|
||||
# same-key duplicate that _should_alert would drop anyway.
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
_executor, attach_panel, report)
|
||||
detail = format_signal_message(report)
|
||||
delivered = False
|
||||
for chat_id in _signal_targets():
|
||||
@@ -806,28 +730,6 @@ async def _scan_timeframe(
|
||||
if delivered:
|
||||
_commit_alert(key, report)
|
||||
|
||||
# This independent advisory call runs only after the H1 scan completes.
|
||||
# Offloading the LLM request keeps the event loop responsive.
|
||||
if tf == Timeframe.H1:
|
||||
try:
|
||||
outlook = await asyncio.get_running_loop().run_in_executor(
|
||||
_executor, maybe_run_outlook, "telegram")
|
||||
settings = get_app_config().market_outlook_settings
|
||||
if (outlook is not None and settings.get("telegram", False)
|
||||
and OWNER_ID is not None):
|
||||
forming = {
|
||||
"buy": "possible buy forming",
|
||||
"sell": "possible sell forming",
|
||||
"none": "no clear setup forming",
|
||||
}[outlook.setup_forming]
|
||||
message = (
|
||||
f"🔭 AI outlook: {outlook.bias}, {forming} "
|
||||
f"({outlook.confidence:.0f}%) — {_escape_md(outlook.note)}"
|
||||
)
|
||||
await _send_alert(bot, OWNER_ID, message)
|
||||
except Exception:
|
||||
logger.exception("Hourly AI market outlook failed")
|
||||
|
||||
|
||||
# ── WebSocket level-breach monitor ──────────────────────────────────
|
||||
|
||||
@@ -924,7 +826,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")
|
||||
|
||||
try:
|
||||
report = await _cached_or_scan(pair_symbol, Timeframe.M15)
|
||||
report = await run_signal(pair_symbol, Timeframe.M15)
|
||||
_update_level_cache([report])
|
||||
except Exception:
|
||||
logger.exception(f"Breach scan failed for {display}")
|
||||
@@ -953,10 +855,6 @@ async def _handle_breach(bot, pair_symbol: str, price: float, level_name: str) -
|
||||
if not _should_alert(key, report):
|
||||
return
|
||||
|
||||
# Same post-gate panel attach as the auto-scan path (see
|
||||
# _scan_timeframe for the spend/lock rationale).
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
_executor, attach_panel, report)
|
||||
header = (
|
||||
f"⚡ *LEVEL BREAK* — {_escape_md(display)} crossed "
|
||||
f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n"
|
||||
@@ -1295,10 +1193,6 @@ def _news_health_lines() -> List[str]:
|
||||
else:
|
||||
lines.append(f" ✅ DeepSeek: {used}/{cap} calls today,"
|
||||
f" {scored} headlines scored")
|
||||
if get_app_config().agent_panel_settings.get("enabled", False):
|
||||
ph = panel_health()
|
||||
lines.append(f" 🤖 AI panel: {ph['used_today']}/{ph['cap']}"
|
||||
" panels today")
|
||||
return lines
|
||||
|
||||
|
||||
|
||||
+8
-2560
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user