Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 887aaed6f2 | |||
| 7e8ed754ff | |||
| 9769a0c84d | |||
| 964239e67d | |||
| 5e3758bd53 | |||
| c146fffb5b | |||
| 38c81251e8 | |||
| a7c2bfc7da | |||
| 7be2231eb3 | |||
| 0b604f1ad0 | |||
| 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,7 +11,7 @@ Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtest
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
python test_all.py # QA suite: 406 tests, 24 modules, no network (mocked)
|
||||
python test_all.py # QA suite: 374 tests, 23 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 telegram_bot.py # the bot (refuses to boot without credentials)
|
||||
@@ -29,13 +29,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. 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). 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`.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -59,10 +59,8 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
|
||||
- **`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, etc.; evidence in the docstring). A test enforces factory ≡ inline-config equivalence. Confirmation map: M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1].
|
||||
- **`signal_log.py` / `advisory_outcome.py`** — SQLite signal-history logger (stdlib only). Hooked into `SignalAggregator.analyze_pair` only (the live entry point — the backtester never writes here, preserving parity), so every live scan from every surface (Telegram `telegram`, CLI `cli`, Oanda daemon `oanda_daemon`, dashboard `dashboard`) lands one row in `logs/signals.db`: signal/grade/price, MTF + trade-setup + reasons JSON, full per-TF indicator snapshots, the last 14 closed OHLCV candles per fetched TF, and the news at scan time (event risk + calendar events + scored headlines JSON, re-fetched via the NewsEngine class-level caches). Config.json `signal_log` block (`enabled`, `path`; env `SIGNAL_LOG_ENABLED`/`SIGNAL_LOG_PATH`). **Fail-safe by contract** — a logging failure can never break a scan. Panel fields arrive post-hoc: `log_signal` stashes the rowid and `agent_panel.attach_panel` writes verdict + creation/cache provenance + payload hash onto the same row. A versioned advisory-only outcome then advances from later stored closed-candle snapshots using next-open reanchoring, close-only trailing, continuity checks that allow only scheduled weekend/maintenance closures, a 300-bar timeout, and both $0.30/$0.65 cost views; it is explicitly a cleaned-mid evidence model, not account execution or raw-path backtest P&L. No rowid means silent no-op.
|
||||
- **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). Optional DeepSeek enrichment via `llm_client.py` (requests-only, no SDK): batched headline sentiment overriding keyword scores, gold-direction on release surprises, and a market-summary blurb (`SignalReport.news_summary`) — **display/warning-only, keyword fallback on any failure, inert without a key**; prompts are static module constants (DeepSeek prefix-cache pricing) and requests send `thinking: disabled` (V4-Flash reasoning otherwise blows the timeout). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`) — the LLM does not change that.
|
||||
- **`agent_panel.py`** — ai-hedge-fund-style advisory panel: 4 DeepSeek persona calls (macro/technical/risk/contrarian, concurrent on a panel-private pool) + 1 synthesizer producing an **ADVISORY-ONLY** consensus (`AGREE/NEUTRAL/OPPOSE` + confidence + rationale) written onto `SignalReport.panel_*` fields. Attached **post-hoc** by consumers (`telegram_bot` alert path, `main --panel`, `dashboard_publisher`) — never runs inside `analyze_from_dataframes` (backtest parity, test-locked) and no gate reads its fields. Persona payloads are role-filtered: macro gets high-impact events plus only code-scored directional news; technical gets strategy/pattern, primary indicators, MTF, levels, and ATR-normalized candles; risk gets setup strategy, strategy cost assumption, trailing semantics, volatility/events/warnings (never the median candle range as spread); contrarian gets directional news, setup, timestamped latest-closed price, indicators, reasons, and warnings. Spend is bounded by exact cache, full-evidence-identity TTL reuse, and `max_panels_per_day`; changed evidence never inherits a direction-only cached verdict. Verdict provenance logs creation time, `MISS`/`EXACT_HIT`/`TTL_REUSE`, and a SHA-256 payload hash. Prompts forbid outside knowledge and outputs remain short.
|
||||
- **`market_outlook.py`** — hourly DeepSeek market brief for XAU/USD, using ATR-normalized M15/H1/H4 candles, visible indicator states, levels, news, and immutable recent signal history. It returns a written **ADVISORY-ONLY** bias/setup-forming assessment and persists it independently in `outlooks` within `logs/signals.db` together with the news, indicator/regime/level, and candle evidence sent to the model. It may be sent post-scan by Telegram or requested with `main.py --outlook`; it never imports or runs inside the signal path. A lock enforces hourly dedup plus its own UTC-day cap (default 12), and provider/news failures leave a safe partial brief while LLM/database failures degrade to no outlook.
|
||||
- **`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. Static prompts (prefix caching); spend bounded by an exact per-bar cache, a reuse TTL keyed (symbol, TF, direction) so persisting signals don't re-spend every bar, and a `max_panels_per_day` UTC budget (default 20 ≈ ≤100 requests) on top of the shared 500/day client cap. `<2` valid persona takes skips the synthesizer; any failure degrades to no panel, never a changed signal.
|
||||
- **`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`.
|
||||
@@ -70,7 +68,7 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
|
||||
|
||||
## 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,15 +80,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.
|
||||
|
||||
@@ -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)
|
||||
+108
-495
@@ -25,39 +25,17 @@ layers bound the spend on top of that cap:
|
||||
|
||||
- ``max_panels_per_day`` (config, default 20) — a UTC-day budget of
|
||||
panel *attempts*, mirroring the client's request cap.
|
||||
- A reuse TTL (default 3600 s) keyed on the complete reviewed evidence
|
||||
payload, ignoring only the bar timestamp. Changed price, setup, news,
|
||||
warnings, indicators, or candles always trigger a fresh review (subject
|
||||
to the daily budget); genuinely identical evidence can be re-served.
|
||||
- A reuse TTL (default 3600 s) keyed on (symbol, timeframe, direction):
|
||||
a persisting signal re-serves its cached verdict across bars instead
|
||||
of re-spending. Without this, one sticky M5 dashboard signal would
|
||||
burn 5 calls per 5-minute bar — 1,440/day against the 500 cap.
|
||||
|
||||
Prompts are STATIC module constants (DeepSeek prefix-cache pricing —
|
||||
see news_engine.py); all variable content travels in the user payload.
|
||||
|
||||
Payload enrichment (2026-08-15/23, owner-approved, advisory-only): the
|
||||
technical persona receives strategy/pattern context, raw indicator VALUES
|
||||
(with a units legend in the prompt), and ATR-NORMALIZED candles per timeframe
|
||||
(code does the arithmetic — raw prices are the model's weakest input;
|
||||
see backtest/research/llm_news_eval_2026-08-06.md). Sized by config
|
||||
``candle_bars_primary`` (default 20) / ``candle_bars_confirm``
|
||||
(default 10); ``candles_enabled: false`` reverts to the pre-enrichment
|
||||
payload. Prompts forbid outside/training-data knowledge and cap notes
|
||||
at 15 words / rationales at 30 (output tokens are the pricey side).
|
||||
|
||||
Macro and contrarian receive only code-scored directional news; contrarian
|
||||
also receives the timestamped closed-market snapshot, complete setup, and
|
||||
visible indicators. Risk receives the stamped trailing policy and strategy
|
||||
cost assumption, never the candle range mislabeled as a spread.
|
||||
|
||||
Verdict logging writes the verdict, creation time, cache provenance, payload
|
||||
hash, and a pending deterministic advisory outcome onto the scan's signals.db
|
||||
row via signal_log's rowid hand-off. No field is read by live behavior.
|
||||
"""
|
||||
|
||||
import datetime as _dt
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
@@ -66,7 +44,6 @@ from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||
|
||||
from app_config import get_app_config
|
||||
from config import Timeframe
|
||||
from llm_client import DEFAULT_TIMEOUT, get_llm_client
|
||||
|
||||
if TYPE_CHECKING: # type hints only — keep the coupling one-way
|
||||
@@ -78,8 +55,6 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_MIN_QUALITY = "B"
|
||||
DEFAULT_MAX_PANELS_PER_DAY = 20
|
||||
DEFAULT_REUSE_TTL_SECONDS = 3600.0
|
||||
DEFAULT_CANDLE_BARS_PRIMARY = 20
|
||||
DEFAULT_CANDLE_BARS_CONFIRM = 10
|
||||
|
||||
# Quality ranks for the min_quality gate. Deliberately a local copy —
|
||||
# telegram_bot's ranking is private and dashboard_publisher must not
|
||||
@@ -103,11 +78,9 @@ _TAKE_CONTRACT = """\
|
||||
Your review is advisory research on an already-generated signal; you are
|
||||
one voice on a panel, not the decision maker.
|
||||
Hard rules: use ONLY the data in the JSON you receive — do not invent
|
||||
numbers, events, or price levels. Your training data about gold, the
|
||||
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 the
|
||||
evidence your role needs is thin or missing, answer "neutral" with
|
||||
confidence below 30, unless your role's instructions say otherwise.
|
||||
numbers, events, or price levels. If the evidence your role needs is
|
||||
thin or missing, answer "neutral" with confidence below 30, unless your
|
||||
role's instructions say otherwise.
|
||||
Confidence scale (0-100): 80-100 strong one-sided evidence; 50-79 solid
|
||||
case with some doubt; 30-49 mixed evidence; below 30 little basis to
|
||||
judge.
|
||||
@@ -118,7 +91,7 @@ not "H4"; describe what an indicator MEANS ("the price has climbed
|
||||
unusually fast"), never its name or number.
|
||||
Reply with ONLY JSON:
|
||||
{"stance": "support"|"oppose"|"neutral", "confidence": <0-100>,
|
||||
"note": "<max 15 words>"}
|
||||
"note": "<max 25 words>"}
|
||||
"stance" says whether YOUR analysis supports the proposed trade
|
||||
direction, opposes it, or is genuinely mixed; "confidence" is how sure
|
||||
you are of your stance; "note" is your single most important point.
|
||||
@@ -128,11 +101,9 @@ _MACRO_SYSTEM = """\
|
||||
You are the macro analyst on a gold (XAU/USD) trade-review panel. You
|
||||
receive JSON: a proposed trade brief ("brief": direction, timeframe,
|
||||
quality, price, regime) plus the current fundamental picture — recent
|
||||
directional "headlines", today's high-impact "events" (released: true
|
||||
means the number is out), and optional aggregate "sentiment" for gold.
|
||||
Neutral and unrelated headlines were removed by code. Each remaining
|
||||
headline has a code-computed "gold_influence" and score; trust that
|
||||
direction and do not reinterpret it from the wording alone.
|
||||
"headlines", today's high-impact "events" (released: true means the
|
||||
number is out), a news "sentiment" score for gold, and an optional
|
||||
"news_summary" blurb.
|
||||
Judge ONE thing: does the macro backdrop support the proposed direction?
|
||||
Weigh released surprises over stale headlines; an imminent unreleased
|
||||
high-impact event is a reason for lower confidence, not a direction.
|
||||
@@ -154,32 +125,15 @@ and a soft jobs report -> {"stance": "support", "confidence": 75,
|
||||
_TECH_SYSTEM = """\
|
||||
You are the technical analyst on a gold (XAU/USD) trade-review panel.
|
||||
You receive JSON: a proposed trade brief ("brief") plus the technical
|
||||
evidence — the setup "strategy" and optional "setup_pattern",
|
||||
per-"indicators" signal/confidence/value, multi-timeframe "mtf" signals
|
||||
and scores, "key_levels" versus the current price, an "exhaustion"
|
||||
count of consecutive same-direction bars flagged by the engine, and
|
||||
optionally "candles".
|
||||
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, values may extend outside the bands); keltner =
|
||||
dollar distance from the mid-line; ema_trend = -1 to +1 alignment score;
|
||||
divergence and candlestick = pattern counts. RSI is interpreted in the
|
||||
supplied market regime, not as an automatic reversal at 70/30.
|
||||
"candles" is per timeframe: {"ref_close", "atr", "bars"} — bars are
|
||||
oldest-first, each [open, high, low, close] expressed in ATR units
|
||||
relative to ref_close: 0.0 = the latest close, +1.0 = one ATR above it,
|
||||
-2.0 = two ATR below it. Read the SHAPE (drift, swings, wicks); never
|
||||
quote these as prices.
|
||||
evidence — per-"indicators" signal/confidence, multi-timeframe
|
||||
"mtf" signals and scores, "key_levels" versus the current price, and an
|
||||
"exhaustion" count of consecutive same-direction bars flagged by the
|
||||
engine.
|
||||
Judge ONE thing: is the technical picture internally coherent for the
|
||||
proposed direction? Look for indicators fighting each other, the signal
|
||||
fighting the regime (e.g. a BUY in trending_down), higher timeframes
|
||||
disagreeing, price pressed into a key level, an exhausted move being
|
||||
chased, or the candle path contradicting the indicator readings.
|
||||
For XAU_CP, the named completed reversal/continuation pattern is the
|
||||
setup trigger and may lead lagging indicators; indicator lag alone is
|
||||
not sufficient reason to oppose it. Judge whether the pattern and the
|
||||
remaining evidence are coherent together.
|
||||
disagreeing, price pressed into a key level, or an exhausted move being
|
||||
chased.
|
||||
Stance rules:
|
||||
- support: the indicators, higher timeframes, and regime mostly agree
|
||||
with the proposed direction and none of the conflicts above appear.
|
||||
@@ -197,22 +151,19 @@ BUY and exhaustion 0 -> {"stance": "support", "confidence": 70,
|
||||
_RISK_SYSTEM = """\
|
||||
You are the risk manager on a gold (XAU/USD) trade-review panel. You
|
||||
receive JSON: a proposed trade brief ("brief") plus the risk facts —
|
||||
the trade "setup" (strategy, optional setup pattern, entry, stop,
|
||||
advisory targets, risk, strategy cost assumption, and trailing policy),
|
||||
the "volatility" regime, engine "warnings", and calendar "event_risk".
|
||||
When trailing.enabled is true, TP1/TP2 are reference levels rather than
|
||||
hard exits: profit can run beyond them, and the stop starts ratcheting
|
||||
after arm_r using distance_r. Never evaluate that setup as a static TP1
|
||||
trade. "round_trip_cost" is a strategy assumption, not a broker or data-
|
||||
provider quote.
|
||||
the trade "setup" (entry, stop_loss, take_profit_1/2, risk_reward,
|
||||
risk_dollars), the "volatility" regime, engine "warnings", the calendar
|
||||
"event_risk" level, and the assumed "spread".
|
||||
Judge ONE thing: is this setup's risk acceptable as constructed? Check
|
||||
stop distance versus volatility, cost as a fraction of risk, trailing
|
||||
geometry, and event landmines about to hit.
|
||||
reward-to-risk adequacy, stop distance versus the volatility regime
|
||||
(a tight stop in extreme vol dies to noise; a huge stop in low vol is
|
||||
oversized), spread as a fraction of the stop, and event landmines about
|
||||
to hit.
|
||||
Stance rules:
|
||||
- support: the trailing opportunity justifies the risk, the stop fits
|
||||
- support: the likely reward clearly justifies the risk, the stop fits
|
||||
the current volatility, and no event landmine is imminent.
|
||||
- oppose: a stop/trailing policy mismatched to volatility, strategy cost
|
||||
eating a big share of the stop, an imminent
|
||||
- oppose: reward too thin for the risk, a stop mismatched to the
|
||||
volatility, spread eating a big share of the stop, an imminent
|
||||
high-impact event — or no setup at all (a missing setup means the
|
||||
signal graded too low for one; oppose unless the rest is
|
||||
exceptional).
|
||||
@@ -220,21 +171,17 @@ Stance rules:
|
||||
attractive.
|
||||
Example: RR 1.4 with volatility extreme and a high-impact event due ->
|
||||
{"stance": "oppose", "confidence": 65,
|
||||
"note": "The stop and trailing plan do not suit wild price swings before major news."}
|
||||
Example: trailing enabled with normal volatility, low cost versus risk,
|
||||
and no events due ->
|
||||
"note": "The possible profit is small for such wild price swings, and big economic news is due soon."}
|
||||
Example: RR 2.4 with normal volatility and no events due ->
|
||||
{"stance": "support", "confidence": 70,
|
||||
"note": "The stop and trailing plan fit calm conditions, with low costs and no major news."}
|
||||
"note": "The possible profit is more than double the possible loss, and conditions look calm."}
|
||||
""" + _TAKE_CONTRACT
|
||||
|
||||
_CONTRARIAN_SYSTEM = """\
|
||||
You are the contrarian on a gold (XAU/USD) trade-review panel — the
|
||||
designated skeptic. You receive JSON: a proposed trade brief ("brief"),
|
||||
the timestamped latest-closed-bar "market_snapshot", the complete trade
|
||||
"setup" and strategy context, visible primary-timeframe "indicators",
|
||||
filtered directional "news", the engine's bullish/bearish reasons, and
|
||||
its warnings. Neutral and unrelated news was removed by code; trust each
|
||||
headline's code-computed gold_influence instead of reinterpreting it.
|
||||
the engine's own "bullish_reasons" and "bearish_reasons", its
|
||||
"warnings", and an optional "news_summary".
|
||||
Your job: construct the strongest honest case AGAINST the proposed
|
||||
trade, then score how damaging that case is. Use "oppose" only when the
|
||||
counter-case is genuinely strong, "neutral" when it is real but modest,
|
||||
@@ -251,11 +198,9 @@ JSON: the proposed trade "direction" and the panel's "takes" — one per
|
||||
analyst ("persona", "stance", "confidence", "note").
|
||||
Weigh the takes into ONE consensus on whether to act on the signal:
|
||||
"AGREE" (the panel backs the trade), "OPPOSE" (the panel is against it),
|
||||
or "NEUTRAL" (genuinely split or low conviction). The risk manager and
|
||||
contrarian have intentionally defensive roles; their objections are not
|
||||
automatic vetoes. Do not count repeated versions of the same warning as
|
||||
independent evidence. Weigh role relevance, evidence quality, and
|
||||
confidence rather than averaging blindly.
|
||||
or "NEUTRAL" (genuinely split or low conviction). Weigh a high-
|
||||
confidence oppose from any single analyst seriously — one strong veto
|
||||
can outweigh two lukewarm supports. Do not average blindly.
|
||||
Base the verdict and rationale ONLY on the takes given — do not add
|
||||
outside analysis.
|
||||
Confidence scale (0-100): 80-100 the panel clearly leans one way; 50-79
|
||||
@@ -268,7 +213,7 @@ possible profit is too small and the price has already climbed
|
||||
unusually fast. Only the news backdrop supports buying."
|
||||
Reply with ONLY JSON:
|
||||
{"verdict": "AGREE"|"NEUTRAL"|"OPPOSE", "confidence": <0-100>,
|
||||
"rationale": "<max 30 words>"}
|
||||
"rationale": "<max 40 words>"}
|
||||
"""
|
||||
|
||||
|
||||
@@ -289,21 +234,10 @@ class PanelVerdict:
|
||||
rationale: str
|
||||
takes: List[PersonaTake] = field(default_factory=list)
|
||||
created_ts: float = 0.0
|
||||
payload_hash: str = ""
|
||||
|
||||
|
||||
# ── Payload builders (pure functions; llm_client json-dumps sorted) ──
|
||||
|
||||
def _finite(x: Any) -> Optional[float]:
|
||||
"""float(x) or None for non-numeric / non-finite (NaN/inf breaks the
|
||||
strict-JSON wire format the client sends)."""
|
||||
try:
|
||||
f = float(x)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return f if math.isfinite(f) else None
|
||||
|
||||
|
||||
def _direction(report: "SignalReport") -> Optional[str]:
|
||||
"""BUY/SELL the panel should review; None = nothing to review."""
|
||||
if report.trade_setup is not None:
|
||||
@@ -331,190 +265,50 @@ def _brief(report: "SignalReport") -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _primary_indicator_payload(report: "SignalReport") -> Dict[str, Dict[str, Any]]:
|
||||
indicators: Dict[str, Dict[str, Any]] = {}
|
||||
analysis = report.primary_analysis
|
||||
if analysis is None:
|
||||
return indicators
|
||||
for name, ind in analysis.indicators.items():
|
||||
if name.startswith("_") or ind.weight == 0:
|
||||
continue
|
||||
entry: Dict[str, Any] = {
|
||||
"signal": ind.signal.name,
|
||||
"confidence": round(ind.confidence),
|
||||
}
|
||||
value = _finite(getattr(ind, "value", None))
|
||||
if value is not None:
|
||||
entry["value"] = round(value, 2)
|
||||
indicators[name] = entry
|
||||
return indicators
|
||||
|
||||
|
||||
def _strategy_context(report: "SignalReport") -> Dict[str, Any]:
|
||||
setup = report.trade_setup
|
||||
code = getattr(setup, "strategy", None) if setup is not None else None
|
||||
meanings = {
|
||||
"XAU_ATR": "gold ATR setup produced by the scored technical signal",
|
||||
"XAU_PB": "trend-continuation pullback to EMA21 or Keltner mid-line",
|
||||
"XAU_CP": "completed classical chart-pattern entry",
|
||||
"GENERIC": "swing and ATR setup produced by the scored technical signal",
|
||||
}
|
||||
out: Dict[str, Any] = {
|
||||
"code": code or "NO_SETUP",
|
||||
"meaning": meanings.get(code, "no executable trade setup was produced"),
|
||||
}
|
||||
pattern = getattr(setup, "setup_pattern", None) if setup is not None else None
|
||||
if pattern:
|
||||
out["setup_pattern"] = str(pattern)
|
||||
return out
|
||||
|
||||
|
||||
def _trade_setup_payload(report: "SignalReport") -> Optional[Dict[str, Any]]:
|
||||
setup = report.trade_setup
|
||||
if setup is None:
|
||||
return None
|
||||
entry = _finite(getattr(setup, "entry_price", None))
|
||||
stop = _finite(getattr(setup, "stop_loss", None))
|
||||
risk = abs(entry - stop) if entry is not None and stop is not None else None
|
||||
cost = _finite(getattr(setup, "round_trip_cost", None))
|
||||
arm = _finite(getattr(setup, "trail_arm_r", None))
|
||||
distance = _finite(getattr(setup, "trail_dist_r", None))
|
||||
out: Dict[str, Any] = {
|
||||
"direction": getattr(setup, "direction", None),
|
||||
"strategy": _strategy_context(report),
|
||||
"entry": round(entry, 2) if entry is not None else None,
|
||||
"stop_loss": round(stop, 2) if stop is not None else None,
|
||||
"take_profit_1": round(float(setup.take_profit_1), 2),
|
||||
"take_profit_2": round(float(setup.take_profit_2), 2),
|
||||
"risk_reward_to_advisory_tp1": round(float(setup.risk_reward), 2),
|
||||
"risk_price_distance": round(risk, 2) if risk is not None else None,
|
||||
"round_trip_cost": round(cost, 2) if cost is not None else None,
|
||||
"trailing": {
|
||||
"enabled": bool(arm is not None and arm > 0),
|
||||
"arm_r": round(arm, 2) if arm is not None else None,
|
||||
"distance_r": round(distance, 2) if distance is not None else None,
|
||||
"tp1_tp2_are_advisory": bool(arm is not None and arm > 0),
|
||||
},
|
||||
}
|
||||
analysis = report.primary_analysis
|
||||
atr = _finite((analysis.key_levels or {}).get("atr")) \
|
||||
if analysis is not None else None
|
||||
if atr is not None and atr > 0 and risk is not None:
|
||||
out["atr"] = round(atr, 2)
|
||||
out["stop_distance_atr"] = round(risk / atr, 2)
|
||||
if risk and cost is not None:
|
||||
out["cost_to_risk_pct"] = round(100.0 * cost / risk, 2)
|
||||
return out
|
||||
|
||||
|
||||
def _market_snapshot(report: "SignalReport") -> Dict[str, Any]:
|
||||
return {
|
||||
"price": round(report.current_price, 2),
|
||||
"bar_timestamp": report.timestamp,
|
||||
"basis": "latest_closed_candle",
|
||||
}
|
||||
|
||||
|
||||
def _headline_gold_score(headline: Any) -> Optional[float]:
|
||||
"""Deterministic XAU/USD influence from an already-scored headline."""
|
||||
by_currency = getattr(headline, "sentiment_by_currency", None) or {}
|
||||
xau = _finite(by_currency.get("XAU"))
|
||||
usd = _finite(by_currency.get("USD"))
|
||||
if xau is not None or usd is not None:
|
||||
score = (xau or 0.0) - (usd or 0.0)
|
||||
else:
|
||||
mentioned = set(getattr(headline, "currencies_mentioned", None) or [])
|
||||
raw = _finite(getattr(headline, "sentiment_score", None))
|
||||
if raw is None:
|
||||
return None
|
||||
if "XAU" in mentioned and "USD" in mentioned:
|
||||
return None # pair direction is ambiguous without per-currency scores
|
||||
if "XAU" in mentioned:
|
||||
score = raw
|
||||
elif "USD" in mentioned:
|
||||
score = -raw
|
||||
else:
|
||||
return None
|
||||
return max(-1.0, min(1.0, score)) if math.isfinite(score) else None
|
||||
|
||||
|
||||
def _directional_news_payload(
|
||||
context: Optional["FundamentalContext"], limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
if context is None:
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for headline in context.news_headlines:
|
||||
score = _headline_gold_score(headline)
|
||||
if score is None or abs(score) < 0.1:
|
||||
continue
|
||||
timestamp = getattr(headline, "timestamp", None)
|
||||
out.append({
|
||||
"headline": str(getattr(headline, "headline", "")),
|
||||
"published_at": (timestamp.isoformat()
|
||||
if hasattr(timestamp, "isoformat") else str(timestamp or "")),
|
||||
"gold_influence": "bullish" if score > 0 else "bearish",
|
||||
"score": round(score, 3),
|
||||
})
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _aggregate_sentiment_payload(report: "SignalReport") -> Optional[Dict[str, Any]]:
|
||||
score = _finite(report.news_sentiment)
|
||||
if score is None or abs(score) < 0.1:
|
||||
return None
|
||||
return {
|
||||
"gold_influence": "bullish" if score > 0 else "bearish",
|
||||
"score": round(score, 3),
|
||||
}
|
||||
|
||||
|
||||
def _macro_payload(report: "SignalReport",
|
||||
context: Optional["FundamentalContext"]) -> Dict[str, Any]:
|
||||
headlines: List[str] = []
|
||||
events: List[Dict[str, Any]] = []
|
||||
if context is not None:
|
||||
headlines = [h.headline for h in context.news_headlines[:10]]
|
||||
events = [
|
||||
{"event": e.event, "currency": e.currency,
|
||||
"time": e.time.isoformat(), "actual": e.actual,
|
||||
"forecast": e.forecast, "previous": e.previous,
|
||||
"surprise": e.surprise,
|
||||
"minutes_until": round(e.minutes_until, 1),
|
||||
"actual": e.actual, "forecast": e.forecast,
|
||||
"released": not e.is_upcoming}
|
||||
for e in context.events_today if e.is_high_impact
|
||||
][:8]
|
||||
payload: Dict[str, Any] = {
|
||||
return {
|
||||
"brief": _brief(report),
|
||||
"headlines": _directional_news_payload(context),
|
||||
"headlines": headlines,
|
||||
"events": events,
|
||||
"sentiment": round(report.news_sentiment, 3),
|
||||
"news_summary": report.news_summary,
|
||||
}
|
||||
sentiment = _aggregate_sentiment_payload(report)
|
||||
if sentiment is not None:
|
||||
payload["sentiment"] = sentiment
|
||||
return payload
|
||||
|
||||
|
||||
def _technical_payload(report: "SignalReport",
|
||||
candles: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
def _technical_payload(report: "SignalReport") -> Dict[str, Any]:
|
||||
indicators: Dict[str, Dict[str, Any]] = {}
|
||||
key_levels: Dict[str, float] = {}
|
||||
analysis = report.primary_analysis
|
||||
if analysis is not None:
|
||||
# Same visibility filter as main.report_to_dict: private series
|
||||
# ride as _-prefixed pseudo-indicators, weight-0 = context-only.
|
||||
indicators = {
|
||||
name: {"signal": ind.signal.name,
|
||||
"confidence": round(ind.confidence)}
|
||||
for name, ind in analysis.indicators.items()
|
||||
if not name.startswith("_") and ind.weight != 0
|
||||
}
|
||||
key_levels = {k: round(v, 2) for k, v in analysis.key_levels.items()}
|
||||
payload: Dict[str, Any] = {
|
||||
return {
|
||||
"brief": _brief(report),
|
||||
"strategy": _strategy_context(report),
|
||||
"indicators": _primary_indicator_payload(report),
|
||||
"indicators": indicators,
|
||||
"mtf": {tf: {"signal": sig.name,
|
||||
"score": round(report.mtf_scores.get(tf, 0.0), 1)}
|
||||
for tf, sig in report.mtf_signals.items()},
|
||||
"key_levels": key_levels,
|
||||
"exhaustion": report.exhaustion_count,
|
||||
}
|
||||
if candles:
|
||||
payload["candles"] = candles
|
||||
return payload
|
||||
|
||||
|
||||
def _risk_payload(report: "SignalReport") -> Dict[str, Any]:
|
||||
@@ -524,103 +318,32 @@ def _risk_payload(report: "SignalReport") -> Dict[str, Any]:
|
||||
"warnings": list(report.warnings),
|
||||
"event_risk": report.event_risk,
|
||||
"event_description": report.event_description,
|
||||
"spread": round(report.spread_estimate, 2),
|
||||
}
|
||||
ts = report.trade_setup
|
||||
if ts is not None:
|
||||
payload["setup"] = {
|
||||
"direction": ts.direction,
|
||||
"entry": round(ts.entry_price, 2),
|
||||
"stop_loss": round(ts.stop_loss, 2),
|
||||
"take_profit_1": round(ts.take_profit_1, 2),
|
||||
"take_profit_2": round(ts.take_profit_2, 2),
|
||||
"risk_reward": round(ts.risk_reward, 2),
|
||||
"risk_dollars": round(ts.risk_pips * ts.pip_value, 2),
|
||||
}
|
||||
setup = _trade_setup_payload(report)
|
||||
if setup is not None:
|
||||
payload["setup"] = setup
|
||||
return payload
|
||||
|
||||
|
||||
def _contrarian_payload(
|
||||
report: "SignalReport", context: Optional["FundamentalContext"] = None
|
||||
) -> Dict[str, Any]:
|
||||
def _contrarian_payload(report: "SignalReport") -> Dict[str, Any]:
|
||||
return {
|
||||
"brief": _brief(report),
|
||||
"market_snapshot": _market_snapshot(report),
|
||||
"setup": _trade_setup_payload(report),
|
||||
"indicators": _primary_indicator_payload(report),
|
||||
"news": _directional_news_payload(context),
|
||||
"bullish_reasons": report.bullish_reasons[:3],
|
||||
"bearish_reasons": report.bearish_reasons[:3],
|
||||
"warnings": list(report.warnings),
|
||||
"news_summary": report.news_summary,
|
||||
}
|
||||
|
||||
|
||||
# ── Candle context (ATR-normalized; technical persona only) ─────────
|
||||
#
|
||||
# Raw prices are the LLM's weakest input modality (measured in-repo:
|
||||
# numeric-comparison misreads, backtest/research/llm_news_eval_2026-08-06.md),
|
||||
# so code does the arithmetic and the model reads a normalized SHAPE:
|
||||
# each bar's [o,h,l,c] as offsets from the latest close in ATR units.
|
||||
# A panel-private provider singleton keeps the TTL cache warm across
|
||||
# attaches; any failure degrades to "no candles", never a failed panel.
|
||||
|
||||
_TF_BY_VALUE = {tf.value: tf for tf in Timeframe}
|
||||
|
||||
# Patchable for tests (mirrors the llm_client._client stub pattern).
|
||||
_dp: Any = None
|
||||
|
||||
|
||||
def _panel_provider() -> Any:
|
||||
global _dp
|
||||
if _dp is None:
|
||||
from config import PlatformConfig
|
||||
from providers import make_data_provider
|
||||
_dp = make_data_provider(PlatformConfig())
|
||||
return _dp
|
||||
|
||||
|
||||
def _normalize_candles(df: Any, atr: float, bars: int) -> Dict[str, Any]:
|
||||
"""Last `bars` closed candles as ATR-unit offsets from the final
|
||||
close: 0.0 = latest close, +1.0 = one ATR above it. Oldest-first."""
|
||||
tail = df.tail(bars)
|
||||
ref = float(tail["Close"].iloc[-1])
|
||||
out = []
|
||||
for _, row in tail.iterrows():
|
||||
out.append([round((float(row["Open"]) - ref) / atr, 1),
|
||||
round((float(row["High"]) - ref) / atr, 1),
|
||||
round((float(row["Low"]) - ref) / atr, 1),
|
||||
round((float(row["Close"]) - ref) / atr, 1)])
|
||||
return {"ref_close": round(ref, 2), "atr": round(float(atr), 2),
|
||||
"bars": out}
|
||||
|
||||
|
||||
def _candle_context(report: "SignalReport",
|
||||
settings: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Best-effort {"<tf>": _normalize_candles(...)} per analysis TF;
|
||||
None = omit the key from the payload. NEVER raises."""
|
||||
try:
|
||||
if not settings.get("candles_enabled", True):
|
||||
return None
|
||||
confirmations = list(report.confirmation_analyses or [])
|
||||
analyses = [report.primary_analysis] + confirmations
|
||||
counts = [int(settings.get("candle_bars_primary",
|
||||
DEFAULT_CANDLE_BARS_PRIMARY))] + [
|
||||
int(settings.get("candle_bars_confirm",
|
||||
DEFAULT_CANDLE_BARS_CONFIRM))
|
||||
] * len(confirmations)
|
||||
dp = _panel_provider()
|
||||
out: Dict[str, Any] = {}
|
||||
for analysis, n in zip(analyses, counts):
|
||||
if analysis is None:
|
||||
continue
|
||||
atr = _finite(getattr(
|
||||
(analysis.indicators or {}).get("atr"), "value", None))
|
||||
if not atr or atr <= 0:
|
||||
continue
|
||||
tf = _TF_BY_VALUE.get(analysis.timeframe)
|
||||
if tf is None:
|
||||
continue
|
||||
df = dp.fetch_ohlcv(report.symbol, tf)
|
||||
if df is None or len(df) < 2:
|
||||
continue
|
||||
out[analysis.timeframe] = _normalize_candles(df, atr, n)
|
||||
return out or None
|
||||
except Exception as e:
|
||||
logger.debug("agent panel: candle context unavailable: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# ── Orchestration ────────────────────────────────────────────────────
|
||||
|
||||
# Panel-private pool: the 4 persona calls run concurrently WITHOUT
|
||||
@@ -660,52 +383,8 @@ def _ask_persona(persona: str, system: str,
|
||||
return _validate_take(persona, reply)
|
||||
|
||||
|
||||
def _panel_jobs(
|
||||
report: "SignalReport",
|
||||
context: Optional["FundamentalContext"],
|
||||
candles: Optional[Dict[str, Any]],
|
||||
) -> Tuple[Tuple[str, str, Dict[str, Any]], ...]:
|
||||
return (
|
||||
("macro", _MACRO_SYSTEM, _macro_payload(report, context)),
|
||||
("technical", _TECH_SYSTEM, _technical_payload(report, candles)),
|
||||
("risk", _RISK_SYSTEM, _risk_payload(report)),
|
||||
("contrarian", _CONTRARIAN_SYSTEM,
|
||||
_contrarian_payload(report, context)),
|
||||
)
|
||||
|
||||
|
||||
def _panel_payload_hash(
|
||||
jobs: Tuple[Tuple[str, str, Dict[str, Any]], ...],
|
||||
ignore_bar_timestamp: bool = False,
|
||||
) -> str:
|
||||
client = get_llm_client()
|
||||
personas = []
|
||||
for persona, system, payload in jobs:
|
||||
payload_copy = json.loads(json.dumps(payload, allow_nan=False))
|
||||
if ignore_bar_timestamp and persona == "contrarian":
|
||||
snapshot = payload_copy.get("market_snapshot")
|
||||
if isinstance(snapshot, dict):
|
||||
snapshot.pop("bar_timestamp", None)
|
||||
personas.append({
|
||||
"persona": persona, "system": system, "payload": payload_copy,
|
||||
})
|
||||
envelope = {
|
||||
"contract": "agent-panel-input-v2",
|
||||
"model": getattr(client, "_model", client.__class__.__name__),
|
||||
"personas": personas,
|
||||
"synthesizer_system": _SYNTH_SYSTEM,
|
||||
}
|
||||
raw = json.dumps(
|
||||
envelope, sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=True, allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return "sha256:" + hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def run_panel(report: "SignalReport",
|
||||
context: Optional["FundamentalContext"] = None,
|
||||
candles: Optional[Dict[str, Any]] = None,
|
||||
jobs: Optional[Tuple[Tuple[str, str, Dict[str, Any]], ...]] = None,
|
||||
) -> Optional[PanelVerdict]:
|
||||
"""Run 4 personas + synthesizer; None = no verdict (fail-safe).
|
||||
|
||||
@@ -717,8 +396,12 @@ def run_panel(report: "SignalReport",
|
||||
direction = _direction(report)
|
||||
if direction is None:
|
||||
return None
|
||||
jobs = jobs or _panel_jobs(report, context, candles)
|
||||
payload_hash = _panel_payload_hash(jobs)
|
||||
jobs = (
|
||||
("macro", _MACRO_SYSTEM, _macro_payload(report, context)),
|
||||
("technical", _TECH_SYSTEM, _technical_payload(report)),
|
||||
("risk", _RISK_SYSTEM, _risk_payload(report)),
|
||||
("contrarian", _CONTRARIAN_SYSTEM, _contrarian_payload(report)),
|
||||
)
|
||||
futures = [_pool.submit(_ask_persona, name, system, payload)
|
||||
for name, system, payload in jobs]
|
||||
# The HTTP timeout inside the client bounds each call; the margin
|
||||
@@ -749,7 +432,7 @@ def run_panel(report: "SignalReport",
|
||||
rationale = str(reply.get("rationale", "")).strip()[:240]
|
||||
return PanelVerdict(verdict=verdict, confidence=confidence,
|
||||
rationale=rationale, takes=takes,
|
||||
created_ts=time.time(), payload_hash=payload_hash)
|
||||
created_ts=time.time())
|
||||
|
||||
|
||||
# ── Cache, budget, attach ────────────────────────────────────────────
|
||||
@@ -757,14 +440,13 @@ def run_panel(report: "SignalReport",
|
||||
_state_lock = threading.Lock()
|
||||
# Exact result per reviewed bar: repeated attaches on the same signal
|
||||
# (re-scan, multi-consumer) are free.
|
||||
_cache: "OrderedDict[Tuple[str, str, str, str, str], PanelVerdict]" = OrderedDict()
|
||||
_cache: "OrderedDict[Tuple[str, str, str, str], PanelVerdict]" = OrderedDict()
|
||||
_CACHE_MAX = 32
|
||||
# Reuse only when the full prompt + evidence payload is byte-equivalent.
|
||||
# Direction-only reuse served stale warnings/setups to materially changed
|
||||
# signals; the daily panel budget remains the hard spend bound.
|
||||
_last_by_payload: Dict[str, PanelVerdict] = {}
|
||||
_inflight: Dict[str, threading.Event] = {}
|
||||
# Per-process UTC-day budget of panel attempts (mirrors llm_client._under_cap).
|
||||
# Reuse layer: a persisting signal (same symbol/tf/direction, newer bar)
|
||||
# re-serves its last verdict for reuse_ttl_seconds instead of re-spending
|
||||
# 5 requests per bar (see module docstring for the M5 dashboard math).
|
||||
_last_by_dir: Dict[Tuple[str, str, str], PanelVerdict] = {}
|
||||
# UTC-day budget of panel attempts (mirrors llm_client._under_cap).
|
||||
_panel_day: Optional[_dt.date] = None
|
||||
_panel_count: int = 0
|
||||
_warned_budget = False
|
||||
@@ -775,10 +457,7 @@ def reset_panel_state() -> None:
|
||||
global _panel_day, _panel_count, _warned_budget
|
||||
with _state_lock:
|
||||
_cache.clear()
|
||||
_last_by_payload.clear()
|
||||
for event in _inflight.values():
|
||||
event.set()
|
||||
_inflight.clear()
|
||||
_last_by_dir.clear()
|
||||
_panel_day = None
|
||||
_panel_count = 0
|
||||
_warned_budget = False
|
||||
@@ -814,33 +493,13 @@ def _under_panel_budget(cap: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _write_fields(
|
||||
report: "SignalReport", v: PanelVerdict, cache_status: str
|
||||
) -> None:
|
||||
def _write_fields(report: "SignalReport", v: PanelVerdict) -> None:
|
||||
report.panel_verdict = v.verdict
|
||||
report.panel_confidence = float(v.confidence)
|
||||
report.panel_rationale = v.rationale
|
||||
report.panel_takes = [
|
||||
{"persona": t.persona, "stance": t.stance,
|
||||
"confidence": t.confidence, "note": t.note} for t in v.takes]
|
||||
report.panel_created_at = _dt.datetime.fromtimestamp(
|
||||
v.created_ts, tz=_dt.timezone.utc).isoformat()
|
||||
report.panel_cache_status = cache_status
|
||||
report.panel_payload_hash = v.payload_hash
|
||||
|
||||
|
||||
def _log_panel_verdict(report: "SignalReport") -> None:
|
||||
"""Persist the verdict onto the scan's signals.db row (fail-safe).
|
||||
|
||||
The row is inserted inside analyze_pair — BEFORE the panel attaches
|
||||
post-hoc — so panel columns arrive via this update. A report that was
|
||||
never logged (backtest replay, logging disabled) is a silent no-op.
|
||||
"""
|
||||
try:
|
||||
from signal_log import log_panel_update
|
||||
log_panel_update(report)
|
||||
except Exception:
|
||||
logger.debug("agent panel: verdict logging failed", exc_info=True)
|
||||
|
||||
|
||||
def attach_panel(report: "SignalReport",
|
||||
@@ -875,6 +534,27 @@ def attach_panel(report: "SignalReport",
|
||||
if direction is None:
|
||||
return False
|
||||
|
||||
analysis = report.primary_analysis
|
||||
tf = analysis.timeframe if analysis else ""
|
||||
exact_key = (report.symbol, tf, report.timestamp, direction)
|
||||
dir_key = (report.symbol, tf, direction)
|
||||
ttl = float(settings.get("reuse_ttl_seconds",
|
||||
DEFAULT_REUSE_TTL_SECONDS))
|
||||
with _state_lock:
|
||||
hit = _cache.get(exact_key)
|
||||
if hit is None:
|
||||
prior = _last_by_dir.get(dir_key)
|
||||
if prior is not None and time.time() - prior.created_ts < ttl:
|
||||
hit = prior
|
||||
if hit is not None:
|
||||
_write_fields(report, hit)
|
||||
return True
|
||||
|
||||
cap = int(settings.get("max_panels_per_day",
|
||||
DEFAULT_MAX_PANELS_PER_DAY))
|
||||
if not _under_panel_budget(cap):
|
||||
return False
|
||||
|
||||
if context is None:
|
||||
# Best-effort; the news engine's class-level 600 s caches make
|
||||
# this nearly free when warm in both bot and dashboard.
|
||||
@@ -887,83 +567,16 @@ def attach_panel(report: "SignalReport",
|
||||
logger.debug("agent panel: news context unavailable: %s", e)
|
||||
context = None
|
||||
|
||||
candles = _candle_context(report, settings)
|
||||
jobs = _panel_jobs(report, context, candles)
|
||||
payload_hash = _panel_payload_hash(jobs)
|
||||
reuse_hash = _panel_payload_hash(jobs, ignore_bar_timestamp=True)
|
||||
analysis = report.primary_analysis
|
||||
tf = analysis.timeframe if analysis else ""
|
||||
exact_key = (report.symbol, tf, report.timestamp, direction,
|
||||
payload_hash)
|
||||
ttl = float(settings.get("reuse_ttl_seconds",
|
||||
DEFAULT_REUSE_TTL_SECONDS))
|
||||
inflight_key = reuse_hash if ttl > 0 else payload_hash
|
||||
hit = None
|
||||
cache_status = ""
|
||||
leader = False
|
||||
event: Optional[threading.Event] = None
|
||||
with _state_lock:
|
||||
hit = _cache.get(exact_key)
|
||||
if hit is not None:
|
||||
cache_status = "EXACT_HIT"
|
||||
elif ttl > 0:
|
||||
prior = _last_by_payload.get(reuse_hash)
|
||||
if prior is not None and time.time() - prior.created_ts < ttl:
|
||||
hit = prior
|
||||
cache_status = "TTL_REUSE"
|
||||
if hit is None:
|
||||
event = _inflight.get(inflight_key)
|
||||
if event is None:
|
||||
event = threading.Event()
|
||||
_inflight[inflight_key] = event
|
||||
leader = True
|
||||
if hit is not None:
|
||||
_write_fields(report, hit, cache_status)
|
||||
_log_panel_verdict(report)
|
||||
return True
|
||||
|
||||
if not leader:
|
||||
# A successful leader can spend one client timeout on personas
|
||||
# and another on synthesis; followers must cover both phases.
|
||||
client_timeout = getattr(
|
||||
get_llm_client(), "_timeout", DEFAULT_TIMEOUT)
|
||||
timeout = 2.0 * client_timeout + 12.0
|
||||
if event is None or not event.wait(timeout=timeout):
|
||||
return False
|
||||
with _state_lock:
|
||||
hit = _cache.get(exact_key)
|
||||
cache_status = "EXACT_HIT"
|
||||
if hit is None and ttl > 0:
|
||||
hit = _last_by_payload.get(reuse_hash)
|
||||
cache_status = "TTL_REUSE"
|
||||
if hit is None:
|
||||
return False
|
||||
_write_fields(report, hit, cache_status)
|
||||
_log_panel_verdict(report)
|
||||
return True
|
||||
|
||||
try:
|
||||
cap = int(settings.get("max_panels_per_day",
|
||||
DEFAULT_MAX_PANELS_PER_DAY))
|
||||
if not _under_panel_budget(cap):
|
||||
return False
|
||||
|
||||
verdict = run_panel(report, context, candles=candles, jobs=jobs)
|
||||
verdict = run_panel(report, context)
|
||||
if verdict is None:
|
||||
return False
|
||||
_write_fields(report, verdict, "MISS")
|
||||
_write_fields(report, verdict)
|
||||
with _state_lock:
|
||||
_cache[exact_key] = verdict
|
||||
while len(_cache) > _CACHE_MAX:
|
||||
_cache.popitem(last=False)
|
||||
_last_by_payload[reuse_hash] = verdict
|
||||
_log_panel_verdict(report)
|
||||
_last_by_dir[dir_key] = verdict
|
||||
return True
|
||||
finally:
|
||||
with _state_lock:
|
||||
current = _inflight.pop(inflight_key, None)
|
||||
if current is not None:
|
||||
current.set()
|
||||
except Exception:
|
||||
logger.exception("agent panel: attach failed — signal unaffected")
|
||||
return False
|
||||
|
||||
+1
-88
@@ -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", "agent_panel"}
|
||||
_KNOWN_OANDA = {
|
||||
"account_id", "api_token", "base_url", "lot_size",
|
||||
"quality_gate", "max_hold_bars",
|
||||
@@ -98,21 +97,11 @@ _KNOWN_DEEPSEEK = {
|
||||
_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"}
|
||||
_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."""
|
||||
@@ -134,9 +123,6 @@ class AppConfig:
|
||||
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"
|
||||
|
||||
|
||||
@@ -318,45 +304,8 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
|
||||
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")
|
||||
app.signal_log_settings = dict(slog)
|
||||
|
||||
level = raw.get("log_level", app.log_level)
|
||||
if level not in _VALID_LOG_LEVELS:
|
||||
raise AppConfigError(
|
||||
@@ -456,42 +405,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_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
-14
@@ -46,20 +46,7 @@
|
||||
"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"
|
||||
"reuse_ttl_seconds": 3600
|
||||
},
|
||||
"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).
|
||||
|
||||
+14
-34
@@ -82,7 +82,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 +322,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 +358,13 @@ class DashboardPublisher:
|
||||
try:
|
||||
report = produce_signal(XAUUSD, tf, self._provider(),
|
||||
CONFIRMATIONS[tf])
|
||||
self._reports[tf.value] = report
|
||||
# Advisory AI panel, post-hoc and display-only. Off unless
|
||||
# config.json agent_panel.dashboard is true. The reuse-TTL
|
||||
# cache + max_panels_per_day budget bound the spend against
|
||||
# this process's own DeepSeek cap (a persisting M5 signal
|
||||
# re-serves its cached verdict instead of re-running every
|
||||
# bar); a fresh panel adds ≤~25 s before this TF publishes.
|
||||
attach_panel(report, surface="dashboard")
|
||||
self.snapshots[tf.value] = {
|
||||
"status": "ok",
|
||||
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -371,41 +376,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,10 +412,10 @@ 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.
|
||||
self.publish_snapshot()
|
||||
if self.attach_panels(tfs):
|
||||
# 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()
|
||||
self.refresh_news()
|
||||
self.publish_news()
|
||||
|
||||
@@ -84,16 +84,16 @@
|
||||
</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.</li>
|
||||
@@ -104,7 +104,7 @@
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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"):
|
||||
@@ -23,7 +22,6 @@ if hasattr(sys.stderr, "reconfigure"):
|
||||
|
||||
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
|
||||
|
||||
@@ -199,24 +197,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,
|
||||
@@ -241,9 +221,6 @@ def report_to_dict(report: SignalReport) -> dict:
|
||||
"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 +237,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,
|
||||
}
|
||||
@@ -308,9 +283,6 @@ Examples:
|
||||
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 +336,19 @@ 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")
|
||||
report = aggregator.analyze_pair(symbol=config.symbol)
|
||||
|
||||
# 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)
|
||||
|
||||
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,410 +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())
|
||||
uri = f"file:{path}?immutable=1"
|
||||
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
|
||||
_outlook_day: Optional[_dt.date] = None
|
||||
_outlook_count = 0
|
||||
_warned_budget = False
|
||||
|
||||
|
||||
def reset_outlook_state() -> None:
|
||||
"""Drop hourly and daily state (tests / deployment config reloads)."""
|
||||
global _last_run_ts, _outlook_day, _outlook_count, _warned_budget
|
||||
with _state_lock:
|
||||
_last_run_ts = 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."""
|
||||
global _last_run_ts, _outlook_day, _outlook_count, _warned_budget
|
||||
now = time.time()
|
||||
today = _dt.datetime.now(_dt.timezone.utc).date()
|
||||
with _state_lock:
|
||||
if not force and _last_run_ts is not None and now - _last_run_ts < 3600:
|
||||
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)
|
||||
return False
|
||||
_outlook_count += 1
|
||||
_last_run_ts = now
|
||||
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)
|
||||
result = _validate_outlook(get_llm_client().chat_json(SYSTEM_PROMPT, payload))
|
||||
if result is None:
|
||||
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():
|
||||
return None
|
||||
if surface == "telegram" and not settings.get("telegram", False):
|
||||
return None
|
||||
if _provider_base._is_forex_weekend():
|
||||
return None
|
||||
cap = int(settings.get("max_per_day", DEFAULT_MAX_PER_DAY))
|
||||
if cap < 1 or 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
|
||||
+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
|
||||
|
||||
@@ -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,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,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()
|
||||
+2
-116
@@ -21,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__)
|
||||
|
||||
@@ -56,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
|
||||
@@ -101,9 +96,6 @@ class SignalReport:
|
||||
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
|
||||
@@ -150,14 +142,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
|
||||
|
||||
@@ -178,7 +164,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)
|
||||
return report
|
||||
|
||||
if xau_cfg.allowed_weekdays and now_utc.weekday() not in xau_cfg.allowed_weekdays:
|
||||
@@ -196,7 +181,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)
|
||||
return report
|
||||
|
||||
timeframes = [self.config.primary_timeframe] + list(
|
||||
@@ -274,34 +258,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,
|
||||
) -> 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."""
|
||||
context = None
|
||||
try:
|
||||
context = self._get_news_engine().get_fundamental_context()
|
||||
except Exception:
|
||||
logger.debug("signal log: news context unavailable",
|
||||
exc_info=True)
|
||||
signal_log.log_signal(
|
||||
report,
|
||||
context=context,
|
||||
source=source or "live",
|
||||
timeframe=self.config.primary_timeframe.value,
|
||||
dataframes=dataframes,
|
||||
)
|
||||
|
||||
def _apply_regime_filter_live(
|
||||
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
|
||||
) -> None:
|
||||
@@ -662,7 +620,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,
|
||||
)
|
||||
@@ -800,8 +757,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,
|
||||
)
|
||||
@@ -817,74 +772,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] = []
|
||||
for warning in report.warnings:
|
||||
if warning.startswith(stale_prefixes):
|
||||
continue
|
||||
if (warning.startswith("REGIME FILTER: '")
|
||||
and warning.endswith("blocked for XAU")):
|
||||
continue
|
||||
if (warning.startswith("M5 FILTER: '")
|
||||
and "volatility" in 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
|
||||
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)
|
||||
report.warnings[:] = kept
|
||||
|
||||
# ── Public: bar-replayable entry point ─────────────────────────
|
||||
|
||||
def analyze_from_dataframes(
|
||||
@@ -1271,8 +1160,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,
|
||||
)
|
||||
@@ -1687,7 +1574,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,
|
||||
)
|
||||
|
||||
-697
@@ -1,697 +0,0 @@
|
||||
"""
|
||||
signal_log.py — SQLite signal-history logger
|
||||
============================================
|
||||
|
||||
Persists every live scan's SignalReport (signal, grade, price at scan
|
||||
time, news at scan time) to a SQLite database so the full decision
|
||||
history can be queried later.
|
||||
|
||||
Hooked ONLY into SignalAggregator.analyze_pair (the live entry point);
|
||||
the backtester calls analyze_from_dataframes directly and therefore
|
||||
never writes here (live == backtest parity — CLAUDE.md rule 4).
|
||||
|
||||
Panel fields arrive POST-HOC: the row is inserted during analyze_pair
|
||||
(before consumers attach the advisory panel), so log_signal stashes the
|
||||
rowid on the report and agent_panel.attach_panel calls
|
||||
log_panel_update() to fill the verdict, takes, creation/cache provenance,
|
||||
payload hash, and advisory-outcome seed on that same row. Later closed-candle
|
||||
snapshots advance the hypothetical next-open/trailing outcome. A report
|
||||
without a rowid (backtest replay, logging disabled) is a silent no-op.
|
||||
|
||||
Deployment settings come from the config.json "signal_log" block via
|
||||
app_config (env vars SIGNAL_LOG_ENABLED / SIGNAL_LOG_PATH win):
|
||||
|
||||
"signal_log": {"enabled": true, "path": "logs/signals.db"}
|
||||
|
||||
Fail-safe contract: logging must NEVER break a scan. Every public entry
|
||||
point catches all exceptions and downgrades them to a warning log line.
|
||||
Report/context objects are duck-typed (no imports from signal_aggregator
|
||||
or news_engine) so this module stays at the bottom of the import graph.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import app_config
|
||||
from advisory_outcome import (
|
||||
advance_outcome,
|
||||
canonical_r,
|
||||
initial_outcome,
|
||||
timeframe_value,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
logged_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
timeframe TEXT NOT NULL,
|
||||
signal TEXT NOT NULL,
|
||||
score REAL,
|
||||
confidence REAL,
|
||||
quality TEXT,
|
||||
action TEXT,
|
||||
price REAL,
|
||||
spread REAL,
|
||||
market_regime TEXT,
|
||||
volatility_regime TEXT,
|
||||
mtf_json TEXT,
|
||||
setup_json TEXT,
|
||||
reasons_json TEXT,
|
||||
warnings_json TEXT,
|
||||
event_risk TEXT,
|
||||
event_description TEXT,
|
||||
news_sentiment REAL,
|
||||
news_description TEXT,
|
||||
news_summary TEXT,
|
||||
events_json TEXT,
|
||||
headlines_json TEXT,
|
||||
indicators_json TEXT,
|
||||
candles_json TEXT,
|
||||
panel_verdict TEXT,
|
||||
panel_confidence REAL,
|
||||
panel_rationale TEXT,
|
||||
panel_takes_json TEXT,
|
||||
panel_created_at TEXT,
|
||||
panel_cache_status TEXT,
|
||||
panel_payload_hash TEXT,
|
||||
advisory_outcome_status TEXT,
|
||||
advisory_r_multiple REAL,
|
||||
advisory_outcome_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_signals_sym_tf_ts
|
||||
ON signals (symbol, timeframe, logged_at);
|
||||
CREATE TABLE IF NOT EXISTS outlooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
logged_at TEXT NOT NULL,
|
||||
surface TEXT NOT NULL,
|
||||
bias TEXT,
|
||||
setup_forming TEXT,
|
||||
confidence REAL,
|
||||
watch TEXT,
|
||||
note TEXT,
|
||||
news_json TEXT,
|
||||
indicators_json TEXT,
|
||||
candles_json TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
# Additive migration for columns introduced after the original schema.
|
||||
# Idempotent — skips columns already present.
|
||||
_ADDITIVE_SIGNAL_COLS = (
|
||||
("panel_verdict", "TEXT"),
|
||||
("panel_confidence", "REAL"),
|
||||
("panel_rationale", "TEXT"),
|
||||
("panel_takes_json", "TEXT"),
|
||||
("indicators_json", "TEXT"),
|
||||
("candles_json", "TEXT"),
|
||||
("panel_created_at", "TEXT"),
|
||||
("panel_cache_status", "TEXT"),
|
||||
("panel_payload_hash", "TEXT"),
|
||||
("advisory_outcome_status", "TEXT"),
|
||||
("advisory_r_multiple", "REAL"),
|
||||
("advisory_outcome_json", "TEXT"),
|
||||
)
|
||||
|
||||
_ADDITIVE_OUTLOOK_COLS = (
|
||||
("news_json", "TEXT"),
|
||||
("indicators_json", "TEXT"),
|
||||
("candles_json", "TEXT"),
|
||||
)
|
||||
|
||||
_INSERT = """
|
||||
INSERT INTO signals (
|
||||
logged_at, source, symbol, timeframe, signal, score, confidence,
|
||||
quality, action, price, spread, market_regime, volatility_regime,
|
||||
mtf_json, setup_json, reasons_json, warnings_json,
|
||||
event_risk, event_description, news_sentiment, news_description,
|
||||
news_summary, events_json, headlines_json,
|
||||
indicators_json, candles_json,
|
||||
panel_verdict, panel_confidence, panel_rationale, panel_takes_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
_OUTLOOK_INSERT = """
|
||||
INSERT INTO outlooks (
|
||||
logged_at, surface, bias, setup_forming, confidence, watch, note,
|
||||
news_json, indicators_json, candles_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
|
||||
def _finite(x: Any) -> Optional[float]:
|
||||
"""float(x) or None for non-numeric / non-finite values (NaN/inf —
|
||||
SQLite has no NaN and strict JSON rejects it)."""
|
||||
try:
|
||||
f = float(x)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return f if math.isfinite(f) else None
|
||||
|
||||
|
||||
def _iso(ts: Any) -> Optional[str]:
|
||||
if ts is None:
|
||||
return None
|
||||
try:
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
return ts.astimezone(timezone.utc).isoformat()
|
||||
except (AttributeError, ValueError):
|
||||
return str(ts)
|
||||
|
||||
|
||||
def _dumps(obj: Any) -> str:
|
||||
return json.dumps(obj, default=str, allow_nan=False)
|
||||
|
||||
|
||||
# ── Serializers (duck-typed; deliberately NOT imported from ──
|
||||
# ── dashboard_publisher — the dashboard's hard independence rule) ──
|
||||
|
||||
def _event_to_dict(ev: Any) -> Dict[str, Any]:
|
||||
try:
|
||||
surprise = ev.surprise
|
||||
except Exception:
|
||||
surprise = None
|
||||
return {
|
||||
"time": _iso(getattr(ev, "time", None)),
|
||||
"currency": getattr(ev, "currency", None),
|
||||
"impact": getattr(ev, "impact", None),
|
||||
"event": getattr(ev, "event", None),
|
||||
"forecast": getattr(ev, "forecast", None),
|
||||
"previous": getattr(ev, "previous", None),
|
||||
"actual": getattr(ev, "actual", None),
|
||||
"surprise": surprise,
|
||||
}
|
||||
|
||||
|
||||
def _headline_to_dict(h: Any) -> Dict[str, Any]:
|
||||
return {
|
||||
"headline": getattr(h, "headline", None),
|
||||
"source": getattr(h, "source", None),
|
||||
"timestamp": _iso(getattr(h, "timestamp", None)),
|
||||
"score": _finite(getattr(h, "sentiment_score", None)),
|
||||
"score_by_currency": {
|
||||
str(currency): _finite(score)
|
||||
for currency, score in (
|
||||
getattr(h, "sentiment_by_currency", None) or {}).items()
|
||||
},
|
||||
"currencies": list(getattr(h, "currencies_mentioned", None) or []),
|
||||
"keywords": list(getattr(h, "keywords", None) or []),
|
||||
"link": getattr(h, "link", None),
|
||||
"description": getattr(h, "description", None),
|
||||
"publisher": getattr(h, "publisher", None),
|
||||
}
|
||||
|
||||
|
||||
def _context_news_json(context: Any) -> "tuple[Optional[str], Optional[str]]":
|
||||
"""(events_json, headlines_json) from a FundamentalContext, or
|
||||
(None, None) when no context was captured."""
|
||||
if context is None:
|
||||
return None, None
|
||||
events = {
|
||||
"high_impact_warning": bool(
|
||||
getattr(context, "high_impact_warning", False)),
|
||||
"warning_message": getattr(context, "warning_message", "") or "",
|
||||
"calendar_available": bool(
|
||||
getattr(context, "calendar_available", False)),
|
||||
"sentiment_by_currency": dict(
|
||||
getattr(context, "news_sentiment", None) or {}),
|
||||
"events_today": [
|
||||
_event_to_dict(e)
|
||||
for e in getattr(context, "events_today", None) or []
|
||||
],
|
||||
"events_upcoming_1h": [
|
||||
_event_to_dict(e)
|
||||
for e in getattr(context, "events_upcoming_1h", None) or []
|
||||
],
|
||||
}
|
||||
headlines = [
|
||||
_headline_to_dict(h)
|
||||
for h in getattr(context, "news_headlines", None) or []
|
||||
]
|
||||
return _dumps(events), _dumps(headlines)
|
||||
|
||||
|
||||
def _key_str(k: Any) -> str:
|
||||
return getattr(k, "value", str(k))
|
||||
|
||||
|
||||
def _analysis_to_dict(analysis: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Compact, JSON-safe snapshot of one TechnicalAnalysis."""
|
||||
if analysis is None:
|
||||
return None
|
||||
indicators = {}
|
||||
for name, indicator in (
|
||||
getattr(analysis, "indicators", None) or {}).items():
|
||||
if str(name).startswith("_"):
|
||||
continue
|
||||
signal = getattr(indicator, "signal", None)
|
||||
indicators[str(name)] = {
|
||||
"signal": getattr(signal, "name", str(signal)),
|
||||
"value": _finite(getattr(indicator, "value", None)),
|
||||
"confidence": _finite(getattr(indicator, "confidence", None)),
|
||||
"weight": _finite(getattr(indicator, "weight", None)),
|
||||
"description": getattr(indicator, "description", "") or "",
|
||||
}
|
||||
composite = getattr(analysis, "composite_signal", None)
|
||||
return {
|
||||
"timestamp": str(getattr(analysis, "timestamp", "")),
|
||||
"composite_signal": getattr(composite, "name", str(composite)),
|
||||
"composite_score": _finite(
|
||||
getattr(analysis, "composite_score", None)),
|
||||
"confidence": _finite(getattr(analysis, "confidence", None)),
|
||||
"market_regime": getattr(analysis, "market_regime", None),
|
||||
"volatility_regime": getattr(analysis, "volatility_regime", None),
|
||||
"key_levels": {
|
||||
str(name): _finite(value)
|
||||
for name, value in (
|
||||
getattr(analysis, "key_levels", None) or {}).items()
|
||||
},
|
||||
"pattern_signals": list(
|
||||
getattr(analysis, "pattern_signals", None) or []),
|
||||
"indicators": indicators,
|
||||
}
|
||||
|
||||
|
||||
def _report_indicators_json(report: Any) -> Optional[str]:
|
||||
analyses = [getattr(report, "primary_analysis", None)]
|
||||
analyses.extend(getattr(report, "confirmation_analyses", None) or [])
|
||||
snapshot = {}
|
||||
for analysis in analyses:
|
||||
if analysis is None:
|
||||
continue
|
||||
timeframe = str(getattr(analysis, "timeframe", ""))
|
||||
if timeframe:
|
||||
snapshot[timeframe] = _analysis_to_dict(analysis)
|
||||
return _dumps(snapshot) if snapshot else None
|
||||
|
||||
|
||||
def _candles_json(dataframes: Any, limit: int = 14) -> Optional[str]:
|
||||
"""Last closed OHLCV bars per fetched timeframe, oldest first."""
|
||||
snapshot = {}
|
||||
for timeframe, df in (dataframes or {}).items():
|
||||
if df is None or getattr(df, "empty", True):
|
||||
continue
|
||||
bars = []
|
||||
for timestamp, row in df.tail(limit).iterrows():
|
||||
bars.append({
|
||||
"timestamp": _iso(timestamp),
|
||||
"open": _finite(row.get("Open")),
|
||||
"high": _finite(row.get("High")),
|
||||
"low": _finite(row.get("Low")),
|
||||
"close": _finite(row.get("Close")),
|
||||
"volume": _finite(row.get("Volume")),
|
||||
})
|
||||
if bars:
|
||||
snapshot[_key_str(timeframe)] = bars
|
||||
return _dumps(snapshot) if snapshot else None
|
||||
|
||||
|
||||
def _report_row(
|
||||
report: Any,
|
||||
context: Any,
|
||||
source: str,
|
||||
timeframe: str,
|
||||
dataframes: Any = None,
|
||||
) -> tuple:
|
||||
mtf_signals = getattr(report, "mtf_signals", None) or {}
|
||||
mtf_json = _dumps({
|
||||
"agreement": bool(getattr(report, "mtf_agreement", False)),
|
||||
"signals": {_key_str(k): getattr(v, "name", str(v))
|
||||
for k, v in mtf_signals.items()},
|
||||
"scores": {_key_str(k): _finite(v)
|
||||
for k, v in (getattr(report, "mtf_scores", None)
|
||||
or {}).items()},
|
||||
})
|
||||
|
||||
setup = getattr(report, "trade_setup", None)
|
||||
setup_json = None
|
||||
if setup is not None:
|
||||
setup_json = _dumps({
|
||||
"direction": getattr(setup, "direction", None),
|
||||
"entry": _finite(getattr(setup, "entry_price", None)),
|
||||
"stop_loss": _finite(getattr(setup, "stop_loss", None)),
|
||||
"take_profit_1": _finite(getattr(setup, "take_profit_1", None)),
|
||||
"take_profit_2": _finite(getattr(setup, "take_profit_2", None)),
|
||||
"risk_reward": _finite(getattr(setup, "risk_reward", None)),
|
||||
"risk_pips": _finite(getattr(setup, "risk_pips", None)),
|
||||
"reward_pips": _finite(getattr(setup, "reward_pips", None)),
|
||||
"strategy": getattr(setup, "strategy", None),
|
||||
"setup_pattern": getattr(setup, "setup_pattern", None),
|
||||
"round_trip_cost": _finite(
|
||||
getattr(setup, "round_trip_cost", None)),
|
||||
"trail_arm_r": _finite(getattr(setup, "trail_arm_r", None)),
|
||||
"trail_dist_r": _finite(getattr(setup, "trail_dist_r", None)),
|
||||
})
|
||||
|
||||
reasons_json = _dumps({
|
||||
"bullish": list(getattr(report, "bullish_reasons", None) or []),
|
||||
"bearish": list(getattr(report, "bearish_reasons", None) or []),
|
||||
})
|
||||
warnings_json = _dumps(list(getattr(report, "warnings", None) or []))
|
||||
events_json, headlines_json = _context_news_json(context)
|
||||
|
||||
signal = getattr(report, "final_signal", None)
|
||||
panel_takes = getattr(report, "panel_takes", None) or []
|
||||
panel_takes_json = _dumps(panel_takes) if panel_takes else None
|
||||
return (
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
source,
|
||||
str(getattr(report, "symbol", "")),
|
||||
timeframe,
|
||||
getattr(signal, "name", str(signal)),
|
||||
_finite(getattr(report, "final_score", None)),
|
||||
_finite(getattr(report, "final_confidence", None)),
|
||||
getattr(report, "signal_quality", "") or "",
|
||||
getattr(report, "action_recommendation", "") or "",
|
||||
_finite(getattr(report, "current_price", None)),
|
||||
_finite(getattr(report, "spread_estimate", None)),
|
||||
getattr(report, "market_regime", "") or "",
|
||||
getattr(report, "volatility_regime", "") or "",
|
||||
mtf_json,
|
||||
setup_json,
|
||||
reasons_json,
|
||||
warnings_json,
|
||||
getattr(report, "event_risk", "") or "",
|
||||
getattr(report, "event_description", "") or "",
|
||||
_finite(getattr(report, "news_sentiment", None)),
|
||||
getattr(report, "news_description", "") or "",
|
||||
getattr(report, "news_summary", "") or "",
|
||||
events_json,
|
||||
headlines_json,
|
||||
_report_indicators_json(report),
|
||||
_candles_json(dataframes),
|
||||
getattr(report, "panel_verdict", None),
|
||||
_finite(getattr(report, "panel_confidence", None)),
|
||||
getattr(report, "panel_rationale", None),
|
||||
panel_takes_json,
|
||||
)
|
||||
|
||||
|
||||
class SignalLogger:
|
||||
"""Thread-safe SQLite writer for signal history. One instance per
|
||||
database path; safe to share across the bot's executor threads and
|
||||
the daemon's thread pool."""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self._path = path
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
self._conn = sqlite3.connect(path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._lock = threading.Lock()
|
||||
with self._lock:
|
||||
self._conn.executescript(_SCHEMA)
|
||||
self._migrate_signal_columns()
|
||||
self._migrate_outlook_columns()
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self._path
|
||||
|
||||
def _migrate_signal_columns(self) -> None:
|
||||
"""Add newer signal columns to pre-existing tables (idempotent)."""
|
||||
existing = {r[1] for r in self._conn.execute("PRAGMA table_info(signals)")}
|
||||
for name, col_type in _ADDITIVE_SIGNAL_COLS:
|
||||
if name not in existing:
|
||||
try:
|
||||
self._conn.execute(
|
||||
f"ALTER TABLE signals ADD COLUMN {name} {col_type}")
|
||||
except sqlite3.OperationalError as exc:
|
||||
# Another dashboard/bot process may have migrated the
|
||||
# shared database after our PRAGMA snapshot.
|
||||
if "duplicate column name" not in str(exc).lower():
|
||||
raise
|
||||
self._conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_signals_advisory_pending"
|
||||
" ON signals (symbol, advisory_outcome_status, id)")
|
||||
self._conn.commit()
|
||||
|
||||
def _migrate_outlook_columns(self) -> None:
|
||||
"""Add snapshot columns to pre-existing outlook tables."""
|
||||
existing = {
|
||||
r[1] for r in self._conn.execute("PRAGMA table_info(outlooks)")
|
||||
}
|
||||
for name, col_type in _ADDITIVE_OUTLOOK_COLS:
|
||||
if name not in existing:
|
||||
try:
|
||||
self._conn.execute(
|
||||
f"ALTER TABLE outlooks ADD COLUMN {name} {col_type}")
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "duplicate column name" not in str(exc).lower():
|
||||
raise
|
||||
self._conn.commit()
|
||||
|
||||
def log(
|
||||
self,
|
||||
report: Any,
|
||||
context: Any = None,
|
||||
source: str = "live",
|
||||
timeframe: str = "",
|
||||
dataframes: Any = None,
|
||||
) -> int:
|
||||
"""Insert one scan row; returns the rowid (panel-update key)."""
|
||||
row = _report_row(report, context, source, timeframe, dataframes)
|
||||
with self._lock:
|
||||
cur = self._conn.execute(_INSERT, row)
|
||||
self._conn.commit()
|
||||
rowid = int(cur.lastrowid)
|
||||
try:
|
||||
self.resolve_pending(str(row[2]), row[25])
|
||||
except Exception:
|
||||
logger.warning("signal_log: advisory outcome resolver failed",
|
||||
exc_info=True)
|
||||
return rowid
|
||||
|
||||
def update_panel(
|
||||
self,
|
||||
rowid: int,
|
||||
verdict: str,
|
||||
confidence: Optional[float],
|
||||
rationale: str,
|
||||
takes_json: str,
|
||||
*,
|
||||
created_at: Optional[str] = None,
|
||||
cache_status: Optional[str] = None,
|
||||
payload_hash: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Fill panel provenance and initialize its advisory outcome."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT setup_json, indicators_json, timeframe,"
|
||||
" advisory_outcome_status FROM signals WHERE id=?",
|
||||
(rowid,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
advisory_status = row[3]
|
||||
advisory_json = None
|
||||
if advisory_status is None:
|
||||
setup = json.loads(row[0]) if row[0] else None
|
||||
indicators = json.loads(row[1]) if row[1] else None
|
||||
advisory_status, state = initial_outcome(
|
||||
setup, indicators, row[2])
|
||||
advisory_json = _dumps(state)
|
||||
self._conn.execute(
|
||||
"UPDATE signals SET panel_verdict=?, panel_confidence=?,"
|
||||
" panel_rationale=?, panel_takes_json=?,"
|
||||
" panel_created_at=COALESCE(?, panel_created_at),"
|
||||
" panel_cache_status=COALESCE(?, panel_cache_status),"
|
||||
" panel_payload_hash=COALESCE(?, panel_payload_hash),"
|
||||
" advisory_outcome_status=COALESCE(advisory_outcome_status, ?),"
|
||||
" advisory_outcome_json=COALESCE(advisory_outcome_json, ?)"
|
||||
" WHERE id=?",
|
||||
(verdict, confidence, rationale, takes_json,
|
||||
created_at, cache_status, payload_hash,
|
||||
advisory_status, advisory_json, rowid))
|
||||
self._conn.commit()
|
||||
|
||||
def resolve_pending(
|
||||
self, symbol: str, candles_json: Optional[str], limit: int = 100
|
||||
) -> int:
|
||||
"""Advance pending advisory outcomes from a new candle snapshot."""
|
||||
if not candles_json:
|
||||
return 0
|
||||
snapshots = json.loads(candles_json)
|
||||
if not isinstance(snapshots, dict):
|
||||
return 0
|
||||
changed = 0
|
||||
with self._lock:
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
rows = self._conn.execute(
|
||||
"SELECT id, timeframe, advisory_outcome_json FROM signals"
|
||||
" WHERE symbol=? AND advisory_outcome_status='PENDING'"
|
||||
" ORDER BY id",
|
||||
(symbol,),
|
||||
).fetchall()
|
||||
eligible = 0
|
||||
for rowid, timeframe, state_json in rows:
|
||||
bars = timeframe_value(snapshots, timeframe)
|
||||
if not bars or not state_json:
|
||||
continue
|
||||
if eligible >= max(1, int(limit)):
|
||||
break
|
||||
eligible += 1
|
||||
state = json.loads(state_json)
|
||||
before = _dumps(state)
|
||||
state = advance_outcome(state, bars)
|
||||
after = _dumps(state)
|
||||
if after == before:
|
||||
continue
|
||||
status = state.get("status", "UNRESOLVABLE")
|
||||
self._conn.execute(
|
||||
"UPDATE signals SET advisory_outcome_status=?,"
|
||||
" advisory_r_multiple=?, advisory_outcome_json=?"
|
||||
" WHERE id=? AND advisory_outcome_status='PENDING'",
|
||||
(status, canonical_r(state), after, rowid),
|
||||
)
|
||||
changed += 1
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
return changed
|
||||
|
||||
def log_outlook(self, outlook: Any, surface: str,
|
||||
payload: Any = None) -> int:
|
||||
"""Insert one advisory market outlook; returns its rowid."""
|
||||
payload = payload if isinstance(payload, dict) else {}
|
||||
technical = {
|
||||
key: payload.get(key)
|
||||
for key in ("indicators", "market_regime",
|
||||
"volatility_regime", "key_levels")
|
||||
if key in payload
|
||||
}
|
||||
row = (
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
surface,
|
||||
getattr(outlook, "bias", None),
|
||||
getattr(outlook, "setup_forming", None),
|
||||
_finite(getattr(outlook, "confidence", None)),
|
||||
getattr(outlook, "watch", None),
|
||||
getattr(outlook, "note", None),
|
||||
_dumps(payload.get("news")) if "news" in payload else None,
|
||||
_dumps(technical) if technical else None,
|
||||
_dumps(payload.get("candles")) if "candles" in payload else None,
|
||||
)
|
||||
with self._lock:
|
||||
cur = self._conn.execute(_OUTLOOK_INSERT, row)
|
||||
self._conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._conn.close()
|
||||
|
||||
|
||||
# ── Module-level entry point (config-driven, fail-safe) ───────────────
|
||||
|
||||
_loggers: Dict[str, SignalLogger] = {}
|
||||
_loggers_lock = threading.Lock()
|
||||
|
||||
|
||||
def log_signal(
|
||||
report: Any,
|
||||
context: Any = None,
|
||||
source: str = "live",
|
||||
timeframe: str = "",
|
||||
dataframes: Any = None,
|
||||
) -> None:
|
||||
"""Persist one scan if the signal_log config block enables it.
|
||||
Stashes the rowid on the report (``_signal_log_rowid``) so a post-hoc
|
||||
panel attach can update this same row. NEVER raises — a logging
|
||||
failure must not break a live scan."""
|
||||
try:
|
||||
if not app_config.signal_log_enabled():
|
||||
return
|
||||
path = app_config.signal_log_path()
|
||||
with _loggers_lock:
|
||||
lg = _loggers.get(path)
|
||||
if lg is None:
|
||||
lg = SignalLogger(path)
|
||||
_loggers[path] = lg
|
||||
rowid = lg.log(report, context=context, source=source,
|
||||
timeframe=timeframe, dataframes=dataframes)
|
||||
try:
|
||||
report._signal_log_rowid = rowid
|
||||
except Exception:
|
||||
pass # exotic report object — panel update just won't happen
|
||||
except Exception:
|
||||
logger.warning("signal_log: failed to persist signal",
|
||||
exc_info=True)
|
||||
|
||||
|
||||
def log_panel_update(report: Any) -> None:
|
||||
"""Write post-hoc panel fields onto the row logged for this scan.
|
||||
|
||||
Matches on the private rowid hand-off from log_signal; a report that
|
||||
was never logged (backtest replay, logging disabled, logger in
|
||||
another process) is a silent no-op. NEVER raises."""
|
||||
try:
|
||||
rowid = getattr(report, "_signal_log_rowid", None)
|
||||
if rowid is None or not app_config.signal_log_enabled():
|
||||
return
|
||||
verdict = getattr(report, "panel_verdict", "") or ""
|
||||
if not verdict:
|
||||
return
|
||||
path = app_config.signal_log_path()
|
||||
with _loggers_lock:
|
||||
lg = _loggers.get(path)
|
||||
if lg is None:
|
||||
return
|
||||
lg.update_panel(
|
||||
int(rowid), verdict,
|
||||
_finite(getattr(report, "panel_confidence", None)),
|
||||
getattr(report, "panel_rationale", "") or "",
|
||||
_dumps(getattr(report, "panel_takes", None) or []),
|
||||
created_at=getattr(report, "panel_created_at", "") or None,
|
||||
cache_status=getattr(report, "panel_cache_status", "") or None,
|
||||
payload_hash=getattr(report, "panel_payload_hash", "") or None)
|
||||
except Exception:
|
||||
logger.warning("signal_log: failed to persist panel verdict",
|
||||
exc_info=True)
|
||||
|
||||
|
||||
def log_outlook(outlook: Any, surface: str = "live",
|
||||
payload: Any = None) -> None:
|
||||
"""Persist one advisory outlook when signal logging is enabled.
|
||||
|
||||
Outlook persistence is deliberately independent of a signal row: the
|
||||
feature runs on its own hourly cadence and must never affect a scan.
|
||||
"""
|
||||
try:
|
||||
if not app_config.signal_log_enabled():
|
||||
return
|
||||
path = app_config.signal_log_path()
|
||||
with _loggers_lock:
|
||||
lg = _loggers.get(path)
|
||||
if lg is None:
|
||||
lg = SignalLogger(path)
|
||||
_loggers[path] = lg
|
||||
lg.log_outlook(outlook, surface, payload=payload)
|
||||
except Exception:
|
||||
logger.warning("signal_log: failed to persist market outlook",
|
||||
exc_info=True)
|
||||
|
||||
|
||||
def reset_loggers_for_tests() -> None:
|
||||
"""Close and drop all cached loggers (test isolation)."""
|
||||
with _loggers_lock:
|
||||
for lg in _loggers.values():
|
||||
try:
|
||||
lg.close()
|
||||
except Exception:
|
||||
pass
|
||||
_loggers.clear()
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+1430
-114
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);
|
||||
});
|
||||
+2
-28
@@ -73,7 +73,6 @@ from app_config import (
|
||||
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 +340,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
|
||||
@@ -540,7 +536,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(
|
||||
@@ -753,28 +749,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 ──────────────────────────────────
|
||||
|
||||
|
||||
+19
-1273
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user