Compare commits

...

2 Commits

Author SHA1 Message Date
Harwinder Sekhon 0396970d29 Big-candle gate (default-OFF) + M5 relaxation campaign + --set tooling
Gate 6h (big-candle entry gate): blocks all XAU entries (core, XAU_PB,
XAU_CP) when the primary TF's last 24h contains a bar with range >= K x
prior-bar ATR. Ships default-OFF: validation rejected every threshold
(K 3-6 on M5/M15 12mo) — removed trades were net-POSITIVE at every cell;
M5 quiet-window trades are the only losing bucket. Config knobs
big_candle_atr_multiple/lookback_hours, --big-candle backtest flag,
Module 17 tests.

M5 relaxation SHIPPED (validated 2026-07-30, Oanda bars, $0.30 spread):
m5_min_abs_score 30->25, m5_min_confirmation_score 10->5, hour-21 UTC
unblocked M5-only. Beats control in ALL 6 calendar years of the 5yr
replay (+230.9R/2319 PF 1.26 DD 21.3 vs +168.0R/2048 PF 1.21 DD 27.0),
12mo +107.4R PF 1.36 vs +84.6R PF 1.33, OOS +26.1R vs +17.2R, $0.70
spread +86.9R vs +66.4R. M15: every relaxation retested negative —
config unchanged. Declined-but-open: M5 hour-22 block, M15 hour-16
unblock (both fail 5yr/OOS). Evidence in the factory docstring.

Tooling: generic --set PATH=JSON research override in gpu_backtest
(typo-proof, logged); diagnose_filters filter_tags refreshed for gates
6d-6i with a staleness-guard test (Module 18); research scripts
hour_analysis/burst_profile/book_diff. Suite: 268 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jqHpmNYmwYMowF6bPZyeD
2026-07-30 11:30:20 -04:00
Harwinder Sekhon a46aa9ccd9 Rebuild CLAUDE.md: lean architecture reference
Replaces the deleted exhaustive knob-by-knob reference with a compact
guide: commands, config precedence, pipeline architecture, rules for
changes, and current-state notes (Oanda feed, TD-pinned books).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jqHpmNYmwYMowF6bPZyeD
2026-07-30 11:30:07 -04:00
10 changed files with 680 additions and 400 deletions
+59 -364
View File
@@ -1,381 +1,76 @@
# ForexBot2 — Codebase Reference
# CLAUDE.md
Signal-intelligence platform for **XAU/USD (gold — live, validated)**. It fetches OHLCV from the active data provider (**Oanda v20 since 2026-07-28**; Twelve Data and Tiingo stay registered as fallbacks — config.json `data_providers.active` switches), scores 9 technical indicators per timeframe with regime-adaptive weights, checks multi-timeframe agreement and news/calendar risk, grades the signal (A+…F), and generates a full trade setup (entry, SL, TP1/TP2, trailing plan). Delivery: Telegram bot (auto-scanner + subscriber channel) and a CLI. A realism-focused backtester replays history through the *exact same* signal code.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file is the authoritative developer/agent reference. The human-facing docs live in `docs/` (`user_guide.html` + `beginner_guide.html`, and their PDF builds) — keep them in sync when signal behavior changes. `backtest/instructions_for_backtest.txt` lists what every backtest report must record.
## What this is
**Read this whole file before changing signal logic.** Every numeric threshold below was either backtested or is load-bearing for a backtested result. Rules for changes are at the bottom ("Rules for making changes").
Signal-intelligence platform for **XAU/USD (gold — the only supported pair)**. It fetches OHLCV from the active data provider (**Oanda v20 since 2026-07-28**; Twelve Data and Tiingo remain registered fallbacks), scores 9 technical indicators per timeframe with regime-adaptive weights, checks multi-timeframe agreement and news risk, grades the signal (A+…F), and generates a full trade setup (entry, SL, TP1/TP2, trailing plan). Delivery: Telegram bot, CLI, and an Oanda practice-account auto-execution daemon. A realism-focused backtester replays history through the *exact same* signal code.
---
Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtested or is load-bearing for a backtested result. Validation evidence lives in the factory/module docstrings and in `~/.claude/projects/.../memory/`.
## Repository map
## Commands
| Path | Lines | Purpose |
|------|-------|---------|
| `config.py` | ~374 | All config dataclasses, `PAIR_INFO` registry, per-pair strategy configs. **`PlatformConfig` defaults ARE the tuned XAU config.** |
| `app_config.py` | ~290 | config.json loader/validation, env-precedence resolvers, repo-root `.env` loader (setdefault at import), `apply_pair_overrides` deployment overlay |
| `config.json.example` | 24 | committed schema template; real gitignored config.json holds secrets + deployment settings |
| `providers/base.py` | ~400 | `BaseDataProvider`: shared cache, validation/cleaning, freshness + boundary guarantees, `fetch_range` pagination, streaming stubs. Subclasses implement only `_fetch_raw` |
| `providers/twelve_data.py` | ~240 | `TwelveDataProvider` (alias `ForexDataProvider`): TD SDK fetch, rate-limit (55 credits/min), WS quote streaming |
| `providers/oanda.py` | ~190 | `OandaDataProvider`: v20 REST MBA candles, real bid/ask spread, date-window fetch |
| `providers/tiingo.py` | ~200 | `TiingoDataProvider`: FX endpoint mid-price candles (no volume, no spread — see Tiingo note), named errors for every failure shape |
| `providers/__init__.py` | ~55 | `PROVIDER_REGISTRY` + `make_data_provider` (config.json `data_providers.active` selects) |
| `data_provider.py` / `oanda_provider.py` | ~25 | back-compat shims re-exporting from `providers/` |
| `engine.py` | ~1173 | `TechnicalEngine`: computes all indicators on one DataFrame → `TechnicalAnalysis` (composite score, regime, key levels) |
| `chart_patterns.py` | ~280 | Chart-pattern detectors for the XAU_CP archetype (pure functions, ATR-unit geometry, first-close-beyond triggers) |
| `signal_aggregator.py` | ~1447 | `SignalAggregator`: MTF analysis, news integration, gates/penalties, quality grade, trade-setup generation → `SignalReport` |
| `news_engine.py` | ~680 | Economic calendar (Forex Factory JSON + MyFXBook RSS fallback) + keyword RSS sentiment |
| `telegram_bot.py` | ~1259 | Async Telegram bot: candle-boundary auto-scanner, WebSocket level-breach monitor, owner lock, subscriber channel |
| `pair_signals/__init__.py` | 52 | `PAIR_PRODUCERS` registry + `produce_signal()` |
| `pair_signals/xauusd.py` | 71 | XAU config factory + per-timeframe overrides (docstring = validation record) |
| `main.py` | ~348 | CLI: `python main.py --pair XAUUSD --timeframe H1 --detail/--json` |
| `test_all.py` | ~4100 | QA suite: **253 tests in 16 modules**, no live API needed. Must stay green. |
| `backtest/gpu_backtest.py` | ~2231 | Bar-replay backtester (CuPy→NumPy fallback) using the real `SignalAggregator` |
| `backtest/diagnose_filters.py` | 609 | Filter-diagnosis tooling |
| `backtest/research/` | — | Calendar research infra (`ff_calendar_scraper.py`, `calendar_store.py`) + result CSVs |
| `backtest/*.csv` | — | Verification trade books from past tuning campaigns (`xau_*.csv`, …). **Gitignored, local-only** — used for manual before/after comparison, not by tests |
| `backtest/cache/` | — | Parquet OHLCV cache (raw bars) |
| `logs/telegram.log` | — | Bot log (3-hour rotation × 240 backups = 30-day retention) |
```bash
python test_all.py # QA suite: 268 tests, 18 modules, no network (mocked)
python main.py --pair XAUUSD --timeframe H1 --detail # CLI signal report (--json for full dump)
python telegram_bot.py # the bot (refuses to boot without credentials)
python oanda_daemon.py # auto-execution daemon on Oanda practice account
---
# Backtests
python backtest/gpu_backtest.py --pair XAUUSD --days 365
python backtest/gpu_backtest.py --pair XAUUSD --start 2025-01-01 --end 2025-06-01 \
--primary-tf 15min --confirm-tfs 1h,4h
# key flags: --spread --quality A+,A,B,C --strategies --workers --max-hold-bars
# --warmup-bars --cooldown-bars --no-cache --export-csv out.csv --detail
```
## Pair status
**`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`.
- **XAU/USD**: the tuned, validated product — and the ONLY supported pair. All defaults in `PlatformConfig`/`XauStrategyConfig` are its tuned values.
- **EUR/USD**: REMOVED from the codebase (2026-07-16, branch `xau-only-chart-patterns`). It never had a validated edge: the 2026-07-11/12 verdict found no configuration passing validation on M5, M15, or H1 at a 1-pip round-trip cost; root cause was cost-to-volatility (EUR M15 ATR ≈ 10 pips → 1-pip spread taxes ~10% of ATR; gold's ATR/spread ratio is several times larger). Calendar-surprise and H1-hybrid avenues were also tested and closed. The full EUR implementation and verdict history live in git history (last commit with EUR support: `7d3fd95`). Do not re-add EUR without new data (tick fills or H4/D1 horizons).
Python 3.12; a `venv/` exists in-repo. Dependencies in `requirements.txt` (numpy, pandas, requests, twelvedata, python-telegram-bot ≥20, websockets).
---
## Configuration & credentials
## End-to-end pipeline (what happens on every scan)
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), `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`.
## Architecture
### End-to-end pipeline (every scan)
```
Twelve Data REST (+ WebSocket ticks in the bot)
→ data_provider.fetch_ohlcv() per timeframe: throttle, fetch, drop forming candle, validate/clean, cache
→ engine.TechnicalEngine.analyze() per timeframe: 9 scored indicators + ATR context → regime → composite score
providers/<active>.fetch_ohlcv() per TF: throttle, fetch, drop forming candle, validate/clean, cache
→ engine.TechnicalEngine.analyze() per TF: 9 scored indicators + ATR context → regime → composite score
→ signal_aggregator.analyze_pair() live entry point (weekend/day filters, fetch, staleness)
└─ analyze_from_dataframes() replayable core: MTF agreement → news → bonuses/penalties →
final signal + quality grade → gates (session, SELL, M5, regime) →
trade setup (3 generators) → reasons
→ SignalReport consumed by telegram_bot.py, main.py, backtest/gpu_backtest.py
final signal + quality grade → gates → trade setup → reasons
→ SignalReport consumed by telegram_bot.py, main.py, oanda_daemon.py, backtester
```
Signal currency everywhere is the `SignalStrength` enum: `STRONG_BUY=2, BUY=1, NEUTRAL=0, SELL=-1, STRONG_SELL=-2`.
---
## config.py — every knob
### PAIR_INFO (market constants; key is the canonical symbol `"XAU/USD"`)
| Field | XAU/USD | Meaning |
|---|---|---|
| `pip_value` | 0.10 | price units per pip |
| `price_precision` | 2 | decimals for SL/TP rounding & display |
| `default_spread` | 0.30 | round-trip cost charged by the backtester when `--spread` not given |
### TechnicalConfig (indicator parameters)
EMA 9/21/50/200 (`ema_mode`: `"4ema"` default or `"2ema"` = 50/200 only); RSI 14 (OB 70 / OS 30 / neutral zone 40–60; `rsi_mode`: `"regime_aware"` default or `"classic"`); MACD 12/26/9 (`macd_as_pillar=True` — MACD is a scored indicator, not just divergence input); BB 20/2.0σ; ATR 14; ADX 14 (strong ≥25, very strong ≥50); divergence lookback 40; swing-S/R lookback 50; ROC period 10, threshold 1.0%; Keltner: EMA 20 mid ± 1.5 × ATR(10); ADX slope lookback 5, "emerging" threshold 3.0; `disabled_indicators` list (removes the pillar from scoring; for MACD the histogram is still computed — divergence needs it); `regime_weights_override` dict (replaces the gold regime-multiplier table for experiments).
### SignalConfig (scoring & trade geometry — XAU-calibrated)
| Field | Default | Meaning |
|---|---|---|
| `mtf_agreement_threshold` | 2 | timeframes (of primary + confirmations) that must agree |
| `min_rr_ratio` | 0.5 | reject setups below this RR |
| `tp_tightening_factor` | 1.0 | TP scale factor. **Leave at 1.0**: 0.8 was re-tested 2026-07-08 with spread+gap fills and nearly halved total R |
| `round_trip_cost` | 0.30 | XAU $ round trip |
| `min_tp_cost_multiple` | 30.0 | spread gate: TP1 distance must be ≥ cost × this (= $9 on XAU). Validated: sub-$10 targets lose |
| `min_sl_pips` | 80.0 | SL floor in pips ($8 on XAU) |
| `tp_floor_rr` | 0.5 | TP1 floor as fraction of SL distance (M15 factory overrides to 1.0) |
| `trail_arm_r` / `trail_dist_r` | 0.6 / 0.75 | trailing exit: once favorable excursion ≥ arm×risk, stop trails dist×risk behind best price (bar-close updates). TP1 becomes advisory. Validated on 12mo M5: +105R vs +48R static. 0 disables. **M15 factory overrides arm to 0.70** (2026-07-21 grid: +210.7R vs +175.7R/5yr, OOS +57.4 vs +45.4; DD clause waived by owner); **M5 factory overrides dist to 0.85** (same-day grid: +231.2R vs +218.0R/5yr, OOS +14.5 vs +11.1, DD flat; dist 1.0 rejected at the OOS gate — see factory docstring) |
| `pb_trail_arm_r` / `pb_trail_dist_r` | 0.8 / 0.75 | trailing params for XAU_PB pullback trades (tighter stops → arm later) |
### NewsConfig
Tracked currencies `["USD","XAU"]`; pre-event buffer 30 min, post-event 15 min (45 min after a *surprise* print); `event_risk_caps_signals=False` — **XAU default: event risk warns but does NOT cap confidence** (2026-07-11 audit: the caps suppressed trades worth +4.6..+7.2R/yr; XAU trades near USD releases outperform); `sentiment_affects_signals=False` — **headline sentiment is informational only** (warnings still emitted; the +5 alignment bonus and 10-pt `news_conflict` penalty are gated off, so live decisions are pure technical, matching backtests). Context cache TTL 600 s (must stay < pre-event buffer). 4 RSS feeds (investinglive, FXStreet, Google-News gold query, CNBC economy).
### XauStrategyConfig (all XAU gates; `enabled=True`)
| Field | Default | Notes |
|---|---|---|
| `blocked_hours_utc` | [0,1,2,10,11,13,16,21] | bar-close hours never traded. M5 factory appends 9 (post-London-open whipsaw, −28.9R/143 over 6 yr) |
| `allowed_weekdays` | [0,1,2,3,4,6] | Mon–Fri + Sun (Sydney open). `weekend_filter=True`: Fri 22:00 → Sun 22:00 UTC closed |
| `min_atr_threshold` | 1.0 | $1 ATR floor for swing-based setups |
| `v2_sl_atr_multiplier` | 2.0 | SL ATR floor in the generic setup generator |
| `sl_atr_multiplier` / `tp_atr_multiplier` | 2.0 / 1.0 | used by the XAU ATR setup generator (A+/A only) |
| `sell_min_quality` | ["A+","A","B"] | SELLs below B are blocked |
| `sell_vol_regimes` | ["high","extreme"] | SELLs only in these volatility regimes (quiet shorts fade vs gold's macro uptrend) |
| `blocked_regimes` / `sell_blocked_regimes` | [] / [] | per-TF factory opt-ins. M15 factory sets `blocked_regimes=["trending"]` (mixed regime: strong ADX, EMAs unaligned — lost −6.8R/24) |
| `sell_blocked_hours_utc` | [] | bar-CLOSE UTC hours where SELLs are blocked (BUYs unaffected). Per-TF factory opt-in: M15 sets [15, 17] (NY-session shorts, −33.5R/165 over 5 yr, negative 6/6 years both halves; BUYs there +34.3R) |
| M5 gates | | `m5_require_mtf=True`; `m5_min_abs_score=30`; `m5_min_confirmation_score=10`; `m5_sell_max_quality=["B","C"]` (A-grade M5 SELLs enter at dip extremes and lose); `m5_blocked_vol_regimes=["high"]` (chop transition — 61% WR, negative total); `m5_sell_min_rsi=25` |
| M5 pullback (XAU_PB) | | `m5_pullback_enabled=True`; min score 15 (lower than gate 1 BY DESIGN — mid-pullback scores weaker); ADX ≥ 20; RSI zones BUY (35,62) / SELL (38,65); retrace depth 0.8–6.0 ATR from the 40-bar swing extreme; band touch within 3 bars. Validated +38.3R/254 trades on 12mo |
| Chart patterns (XAU_CP) | `cp_enabled=False` | `cp_*` fields: pattern list, lookback 72, swing window 3, recency 20, double tolerance 0.5 ATR, height 1.5–8 ATR, H&S head ≥0.5 ATR / shoulder tol 1.0 ATR, flag pole ≥3 ATR in ≤12 bars, flag 3–12 bars retracing 10–50%. Textbook defaults, NOT tuned. **Default OFF** — M5 factory enables BUY-only (`inv_hs`, `double_bottom`, `bull_flag`); M15/H1/H4 remain disabled (all failed OOS validation 2026-07-16). Bearish patterns (`hs_top`, `double_top`, `bear_flag`) dropped: SELL CP lost −3.21R on OOS, −4.52R on M15 5yr |
### FilterConfig (`config.backtest` — despite the name it applies LIVE too)
`session_filter=True`, window 03:00–23:00 UTC, `blocked_hours_utc` mirrors XAU's list (non-XAU pairs read this copy), `allowed_weekdays` Mon–Fri+Sun. `regime_filter=False` (optional H4-ADX≥25 gate, off).
### PlatformConfig (top level)
`symbol="XAU/USD"`, primary TF H1, confirmations [M15, H4], `data_lookback_days=60`, `scan_pairs=[XAUUSD]` (mirrors config.json `scanning.pairs` — see below), `scan_timeframes=[M5,M15,H1,H4]`, `ws_cooldown_seconds=300`.
Per-pair base blocked hours plus `scan_pairs`/`scan_timeframes` overlay from config.json via `app_config.apply_pair_overrides`, applied in the pair factory BEFORE per-TF deltas. Credentials, alert quality, and log level never touch `PlatformConfig` — `telegram_bot.py` and `data_provider.py` read them directly from `app_config.get_app_config()` and its resolvers. `scanning.pairs` in config.json is the deployment switch for what the bot scans — `PlatformConfig.scan_pairs` just carries the resolved value.
---
## engine.py — TechnicalEngine
**Input contract:** pandas DataFrame, UTC tz-aware DatetimeIndex, oldest-first, columns `Open High Low Close Volume` (capitalized). `< 30` bars → empty NEUTRAL result. EMA200 needs ≥ 200 bars (otherwise EMA-trend indicator degrades gracefully).
**Compute order matters:** ATR first (EMA-ribbon and candlestick proximity use it) → EMA → RSI → BB → ADX → MACD (always computed, even if not a pillar — divergence needs the histogram) → ROC → Keltner (needs BB levels + MACD histogram) → divergence → structure S/R → candlesticks → regime classification → regime-aware RSI reinterpretation → composite.
Results land in `TechnicalAnalysis.indicators: Dict[str, IndicatorSignal]` (fields: name, value, signal, confidence 0-100, description, weight). Private series ride on the result object as underscore attrs (`_rsi_series`, `_macd_histogram`, `_adx_slope`, `_kc_squeeze`, `_kc_squeeze_released`) and are skipped by the composite scorer and all formatters (skip rule: name starts with `_` or `weight == 0`).
### The 9 scored indicators (+ ATR as context)
| Key | Base weight | Signal logic (exact) |
|---|---|---|
| `ema_trend` | 1.0 | 4-EMA mode: alignment score over checks (price>EMA9, 9>21, 21>50, 50>200), normalized to [-1,1]. >0.6 STRONG_BUY (conf ≤90), >0.2 BUY, mirrored sells, else NEUTRAL (40). Fast/medium cross ±10 conf. Ribbon (9/21/50 spread < 1 ATR) = "COMPRESSED"; compressed→expanded = +10 conf. Emits `ema_fast/medium/slow/trend` key levels |
| `rsi` | 1.5 | Wilder RSI (ewm α=1/14). Classic pass first: ≥70 SELL, ≤30 BUY, ≥60 BUY-momentum (45), ≤40 SELL-momentum (45), else NEUTRAL (30). **Then overwritten by regime reinterpretation** (below) when `rsi_mode="regime_aware"` |
| `macd` | 1.5 | Histogram = MACD line − signal line. Cross above 0 → BUY (conf 55+); accelerating (hist>prev>0) → STRONG_BUY; mirrored down; positive-but-fading / negative-but-fading → NEUTRAL (35); flat → NEUTRAL (30). Confidence scaled by |hist| vs its 20-bar mean |
| `bollinger` | 1.5 | Position = (close−lower)/(upper−lower). >1.0 SELL (65), <0.0 BUY (65), >0.8 SELL (45), <0.2 BUY (45), else NEUTRAL (30). Squeeze = width < 0.75 × 20-bar avg width → +10 conf. Emits `bb_upper/lower/mid` |
| `adx` | 0.5 | Wilder ADX/DI (ties zero BOTH DMs — fixes a systematic bearish nudge). ADX ≥ 25 → BUY/SELL by DI dominance, else NEUTRAL. Conf 40/65/80 by ADX band. ADX slope (5-bar diff): <25 with slope > 3.0 → "TREND EMERGING" +15 conf; also strengthening/weakening adjustments. Sets `_adx_slope` |
| `roc` | 1.5 | 10-period %change. Positive & rising → BUY (STRONG if >2× threshold=2%); negative & falling → SELL (zero-crosses land in these branches by definition — a separate cross branch was provably dead and removed 2026-07-23); else NEUTRAL. **Directionally redundant with MACD/Keltner/EMA (0.8–0.94 corr) but its weight is load-bearing: zeroing it backtested 13R WORSE — composite thresholds are calibrated with it. Do not remove without retuning thresholds** |
| `keltner` | 1.5 | EMA20 mid ± 1.5×ATR(10). TTM squeeze = BB fully inside KC. Squeeze released (was→not) + MACD hist sign → STRONG_BUY/STRONG_SELL (80); squeeze active → NEUTRAL (60); close above/below channel → BUY/SELL continuation (65); inside → NEUTRAL (30). Emits `kc_upper/lower/mid` |
| `divergence` | 1.5 | RSI + MACD-hist vs price over 40 bars. Swing points (window 3) must align within ±2 bars to pair up; last two pairs: price LL + indicator HL = bullish, price HH + indicator LH = bearish. Any found → BUY/SELL/NEUTRAL-if-both, conf 70, value = count |
| `candlestick` | 1.0 | Last bar: Doji (body<10% range), Hammer/Hanging Man (lower wick>2×body), Shooting Star/Inverted Hammer, Engulfing (vs prev bar), Marubozu (body>85%). Majority vote → signal, conf 50; +20 if pattern sits within 0.5 ATR of a swing S/R level |
| `atr` | **0.0** (context) | Wilder ATR. Volatility regime from percentile vs last 50 ATR values: >80 extreme, >60 high, >30 normal, else low. Emits `atr`, `atr_pct` |
Structure S/R (not scored): swing highs/lows (window 3) over `swing_sr_lookback` (min 120 bars on 1min/5min TFs); up to 3 above and 3 below stored as `swing_high_1..3` / `swing_low_1..3` key levels.
### Market regime classification (exact rules)
- ADX ≥ 25 **and** EMA-trend signal bullish → `trending_up`; bearish → `trending_down`; EMA mixed → **`trending`** (the "mixed" regime — momentum without directional structure; treated as direction-UNKNOWN everywhere; a past bullish-bias bug here cost −6.8R and is fixed)
- else ADX slope > 3.0 → `transitioning` (trend birth, ~5–10 bars before ADX crosses 25)
- else → `ranging`
### Regime-aware RSI reinterpretation (`rsi_mode="regime_aware"`, the default — classic mode backtested worse for gold)
| Regime | RSI zones |
|---|---|
| trending_up | 38–55 STRONG_BUY 75 ("pullback zone, ideal entry"); 30–38 BUY 60; 55–65 BUY 50; >65 NEUTRAL ("extended, wait"); <30 NEUTRAL ("possible reversal") |
| trending_down | mirrored: 45–62 STRONG_SELL 75; 62–70 SELL 60; 35–45 SELL 50; <35 / >70 NEUTRAL |
| transitioning / trending (mixed) | ONLY extremes: ≥70 SELL 50, ≤30 BUY 50, else NEUTRAL 30 |
| ranging | ≥70 SELL 70, ≤30 BUY 70, ≥60 SELL 45, ≤40 BUY 45, else NEUTRAL |
### Composite score
For each scored indicator: `effective_weight = base_weight × regime_multiplier`; contribution = `signal.value × effective_weight × confidence/100`. Composite = `(Σ contributions / Σ effective_weights) × 50`, clamped to ±100. Confidence = weight-averaged indicator confidence. Composite signal: ≥40 STRONG_BUY, ≥15 BUY, ≤−40 STRONG_SELL, ≤−15 SELL, else NEUTRAL.
**Gold regime multipliers** (defaults in `_compute_composite`; `technical.regime_weights_override` replaces the whole dict):
| Indicator | trending_up/down/trending | ranging | transitioning |
|---|---|---|---|
| ema_trend | 2.0 | 0.3 | 1.5 |
| rsi | 1.0 | 1.5 | 1.0 |
| macd | 2.0 | 1.5 | 2.5 |
| bollinger | 0.5 | 1.5 | 1.0 |
| keltner | 1.5 | 2.0 | 2.5 |
| roc | 1.5 | 0.5 | 2.0 |
| candlestick | 0.8 | 1.0 | 1.0 |
| divergence | 1.0 | 2.5 | 2.0 |
| adx | 0.8 | 0.5 | 1.0 |
---
## signal_aggregator.py — SignalAggregator
Two entry points:
- **`analyze_pair(symbol)`** — LIVE: applies XAU weekend + allowed-weekday filters on wall clock, fetches all TFs (primary + confirmations), flags stale data (primary stale → signal suppressed to NEUTRAL/F), delegates to `analyze_from_dataframes`, then applies the live session check on the **bar-CLOSE time** (bar timestamp + candle duration): outside 03:00–23:00 or in blocked hours → NO TRADE. This is the exact predicate the backtester replays, so live == backtest.
- **`analyze_from_dataframes(symbol, dfs, context, use_news)`** — replayable core; treats each df as the visible history at decision time (caller must slice; no weekend filter here — the backtester applies it).
### Pipeline inside `analyze_from_dataframes` (numbered as in the source)
1. Primary TF analysis → seeds report (score, confidence, regime, vol regime, price, timestamp = last bar's index label).
2. Confirmation TF analyses → `mtf_signals` / `mtf_scores` keyed by TF string ("5min", "1h", …).
3. **MTF agreement**: count BUY-ish vs SELL-ish across ALL TFs (primary included); agreement = count ≥ 2 **in the primary's direction** (two bullish confirms cannot "confirm" a bearish primary).
4. News (if `use_news`): event risk (`clear`/`caution`/`danger`) + pair sentiment (−1..+1). Backtests pass `use_news=False`.
5. Spread estimate = median(High−Low) of primary df.
6. **`_compute_final_signal`** (bonuses → event cap → penalties → thresholds → grade):
- Bonuses: MTF agreement → +10 conf and `score = 0.6×primary + 0.4×mean(all TF scores)`; news sentiment aligned (|sent|>0.2) → +5 (gated by `news.sentiment_affects_signals`, False by default); in a *directional* trend, RSI in pullback zone → +5, BB position favorable → +5 (mixed `trending` regime gets NO directional bonus).
- Event risk `danger`: warning always; confidence hard-capped at 30 ONLY if `news.event_risk_caps_signals` (False by default — XAU-tuned).
- **Penalty system (diminishing, replaces old additive stacking):** collect, sort by size desc, apply with decay ×1.0, ×0.5, ×0.25…, total capped at 30. Penalties: `mtf_disagreement` 15; `news_conflict` 10 (gated by `sentiment_affects_signals`, off by default — warning still emitted); `event_caution` 15 (config-gated); `counter_trend` 15; `extreme_vol` 10; `ema200_near` 5 (price within 0.5% of EMA200 — env `FB2_EMA_LO` overrides); `ema200_far` 5 (>2.5%); `exhaustion` 8 (price >3.5 ATR beyond the FARTHEST of the 3 tracked swing levels — fires earlier than a nearest-swing rule would; calibrated as-is in every validated book); `no_pullback` 8 (>2.5 ATR from EMA21 in trend) / `weak_pullback` 5 (>2.0); `rsi_extended` 5 (RSI>65 with BUY / <35 with SELL in any strong-ADX regime, both directions in mixed `trending`); `bb_overextended` 8 (BB pos >0.85 BUY / <0.15 SELL).
- **Exhaustion hard block**: ≥2 exhaustion-tagged penalties ({exhaustion, no_pullback, rsi_extended, bb_overextended} → `report.exhaustion_count`) AND momentum fading (ROC sign against score, or MACD signal against score) → confidence = 0.
- Confidence clamped [0, 95]. **Final signal**: score ≥35 & conf ≥55 STRONG_BUY; ≥25 & ≥50 BUY; mirrored sells; else NEUTRAL.
- **Quality grade**: |score|≥40 & conf≥65 & MTF → **A+**; ≥33 & ≥55 & MTF → **A**; ≥28 & ≥50 → **B**; ≥25 & ≥45 → **C**; ≥15 → **D**; else **F**.
7. Post-grade gates, in order:
- **6b session filter** (bar-label hour + weekday; XAU reads `xau_strategy.blocked_hours_utc`/`allowed_weekdays`, others read `config.backtest.*`) → NEUTRAL/F.
- **6c transitioning boost**: regime `transitioning` and grade B/C/D → A if |score|≥30 & conf≥50 & MTF; else B if |score|≥25 & conf≥45 (early trend entries).
- **6d exhausted-trend cap**: trending regime + exhaustion_count ≥2 + grade C/D → D, setup killed.
- **6e XAU SELL quality gate**: sell-direction & grade actionable (A+..C) & not in `sell_min_quality` → D. The actionable guard is load-bearing: without it a session-blocked F report was *upgraded* to D, re-opening the M5 pullback path (its entry check is `!= "F"`).
- **6f MTF opposition**: ≥2 TFs pointing against the trade direction → D (never trade against consensus).
- **6g XAU SELL volatility gate**: sell-direction & vol regime not in {high, extreme} → D.
- **6g2 XAU regime gates**: regime in `blocked_regimes` (M15: mixed `trending`) → D; sell-direction & regime in `sell_blocked_regimes` → D.
- **6g3 XAU SELL hour gate**: sell-direction & bar-CLOSE hour (label + candle duration — the entry hour under next-open fills) in `sell_blocked_hours_utc` → D. Per-TF opt-in; M15 factory blocks 15/17 UTC.
- **6i XAU M5 noise gates** (`_apply_m5_filters`, primary TF = M5 only): (1) |score| ≥ 30; (2) MTF agreement required; (3) ≥1 confirmation TF with score ≥ ±10 in trade direction (NEUTRAL confirms don't count); (4) SELL grade must be B/C (A-grade M5 shorts lose −0.017R/trade); (5) vol regime `high` blocked; (6) SELL blocked when RSI < 25. Each failure → D, setup killed.
8. **Trade setup**: XAU + grade A+/A → `_generate_xau_atr_trade_setup` first; any pair + grade A+/A/B/C and no setup yet → `_generate_trade_setup` (swing-based).
9. Reasons: every non-neutral indicator description → `bullish_reasons`/`bearish_reasons` (the backtester tags strategy from reasons[0] markers).
10. **XAU_PB pullback** (M5 XAU only, `m5_pullback_enabled`, no setup yet, grade ≠ F): `_try_m5_pullback` — see below.
11. **XAU_CP chart pattern** (any TF, XAU only, `cp_enabled`, no setup yet, NOT session/day-blocked — weak-composite grade F is allowed BY DESIGN, reversals complete on weak composites): `_try_chart_pattern` — see below.
### Trade setup generators (exact geometry)
**A. `_generate_xau_atr_trade_setup`** (XAU, grade A+/A, ATR>0): TP quality scale A+ 2.0 / A 1.5 (A with exhaustion≥2 → 1.0). TP1 = entry ± `tp_atr_multiplier`(1.0)×scale×ATR; TP2 = 1.5×that; SL = entry ∓ 2.0×ATR. Gates: RR ≥ 0.5; TP1 distance ≥ $9 (spread gate). Prices rounded to 2 dp; pip 0.10 hardcoded — **this generator is XAU-only by construction**.
**B. `_generate_trade_setup`** (swing-based, any pair, grade A+..C): XAU requires ATR ≥ $1 (LOW ATR FILTER). Per-side:
- SL anchor = nearest swing low below price (BUY) / swing high above (SELL) ∓/± `sl_swing_buffer`×ATR (1.0); fallback (no swing) = (1+buffer)×ATR. SL distance floored at max(ATR × sl_atr_floor, min_sl_pips×pip) — XAU: max(2.0×ATR, $8).
- TP1 = nearest swing beyond entry ± 0.3 ATR if it's within `tp_swing_cap` ATR (2.5) AND ≥ SL×RR-floor; else entry ± max(`tp_mult`×ATR, SL×RR-floor). tp_mult 1.5, RR-floor = max(min_rr 0.5, tp_floor_rr — 1.0 on M15).
- TP2 = second swing if within 5 ATR, else entry ± max((tp_mult+1.5)×ATR, SL×floor×1.5); forced beyond TP1 by ≥1 ATR.
- Gates: RR ≥ min_rr; spread gate TP1 distance ≥ cost×multiple ($9 on XAU).
**C. `_try_m5_pullback` (XAU_PB)**: detection = EMA alignment ≥3 of 4 checks (9>21, 21>50, 50>200, close>200 — mirrored for SELL), ADX ≥ 20, price touched max(EMA21, KC-mid) within last 3 bars, resumption bar closed back in trend direction beyond EMA9, retrace depth 0.8–6.0 ATR from the 40-bar extreme, RSI in pullback zone (BUY 35–62 / SELL 38–65). Then gates: directional score ≥ 15; ≥1 confirmation TF ≥ ±10; not against MTF consensus; vol regime gates (same as breakout path). Geometry: SL = pullback 8-bar extreme ∓ 0.5 ATR (min $8); TP1 = 1.5×risk, TP2 = 2.5×risk; spread gate $9. Sets `strategy="XAU_PB"`, grade min B, marker `"XAU PULLBACK STRATEGY: …"` at reasons[0]. Rejection funnel counters in class attr `SignalAggregator.pb_funnel`.
**D. `_try_chart_pattern` (XAU_CP)**: detection in `chart_patterns.py` — 6 classical patterns (double_bottom/double_top, inv_hs/hs_top, bull_flag/bear_flag), swing-point based (strict local extrema, window `cp_swing_window`=3), all thresholds in ATR units, scanned over the last `cp_lookback`=72 closed bars. Trigger = FIRST close beyond the neckline/boundary since the pattern formed (one breakout → one signal, no repaint); reversal patterns are invalidated by a new extreme beyond the pattern. No composite-score gate BY DESIGN. Gates: MTF non-opposition (6f mirror); SELL patterns respect `sell_vol_regimes`, `sell_blocked_hours_utc` (bar-close hour), M5 RSI floor; M5 blocks `m5_blocked_vol_regimes` both ways; ATR ≥ $1. Geometry: SL = pattern anchor ∓ 0.5 ATR (min $8); TP1 = measured move (pattern height), TP2 = 1.5×height; RR ≥ 0.5; spread gate $9. Sets `strategy="XAU_CP"` (global trailing params), grade min B, marker `"XAU CHART PATTERN: …"` at reasons[0]. Funnel in `SignalAggregator.cp_funnel`.
`TradeSetup` fields: pair, direction, entry, SL, TP1, TP2, RR, risk/reward pips, pip_value, `strategy` ("GENERIC", "XAU_PB", or "XAU_CP" — selects trailing params; XAU_CP uses the global pair), `trail_arm_r`/`trail_dist_r` (stamped from the PRODUCING config at generation so per-TF factory overrides reach formatters; `None` = legacy unstamped → display falls back to platform defaults).
---
## news_engine.py — NewsEngine
- **Calendar**: Forex Factory free JSON (`nfs.faireconomy.media/ff_calendar_thisweek.json`), 3 retries, honors 429 Retry-After with class-level backoff (≤600 s); fallback MyFXBook RSS. Never fabricates events — unavailable calendar = risk checks skipped that cycle.
- **Caches are class-level** (a fresh NewsEngine is built per scan): calendar + headlines, TTL = 600 s on success, 60 s on empty result; lock held across fetch so concurrent scans share one request.
- **Event risk** (`get_event_risk_level`): high-impact event for base/quote within 30 min → `danger`; within 60 min → `caution`; already-released high-impact → `caution` for 15 min after (45 min if actual ≠ forecast — "surprise"). `EconomicEvent.surprise` compares parsed numeric actual vs forecast (handles "%", "K/M/B" suffixes); direction is numeric-only (doesn't know if higher is bullish).
- **Sentiment**: keyword sets over headlines from 4 RSS feeds, deduped by title (Google News " - Publisher" suffix stripped), last 24 h only. Per-currency scores; **`GOLD_INVERSE_KEYWORDS`** (rate hike/hawkish/tightening/rate cut/dovish/easing/stimulus) flip sign for XAU (hawkish = dollar-bullish = gold-bearish). Currency detection via compiled word-boundary-ish regexes (bare "us"/"eu"/"uk" deliberately excluded). Pair sentiment = base − quote.
---
## providers/ — data-provider abstraction (refactored 2026-07-25)
One class per provider, all subclassing **`providers/base.py BaseDataProvider`**, which owns every behavior the signal pipeline depends on — defined ONCE (a test enforces that neither subclass overrides shared methods):
- **In-memory cache**: key `{symbol}_{tf}`, TTL = candle duration (M5 300 s … H4 14400 s); a cached frame is evicted and refetched if it fails the freshness check **or the boundary check** (`_has_latest_closed_bar`: the frame must contain the most recent fully closed bar, weekend-exempt — a wall-clock TTL alone would serve mid-candle snapshots at the next boundary and scans would run one bar behind). Fresh fetches enforce the same boundary guarantee: a response missing the just-closed bar (feed latency at the boundary) gets one grace retry after `FRESH_FETCH_RETRY_SECONDS` (3 s), then proceeds with a loud warning. Cache is lock-protected (one provider is shared across the bot's thread pool).
- **`_drop_forming_candle`**: some feeds include the current partial candle; the scanner fires seconds after boundary, so the last bar is dropped if its close time is in the future. Without this every indicator would read a seconds-old stub and signals would repaint. (No-op on feeds that only return complete candles, e.g. Oanda.)
- **Freshness** (`is_data_fresh`, public): last bar older than 3× candle duration → stale (warns; live path suppresses signal if the PRIMARY TF is stale). Weekends exempt (Fri 22:00→Sun 22:00 UTC).
- **`_validate_and_clean`** (public wrapper `validate_and_clean`): drop all-NaN rows → ffill (limit 3) → drop NaN closes → sort + dedupe (keep last) → drop future timestamps (>now+5 min) → repair OHLC inconsistencies (High=max, Low=min of O/C/existing) → remove >10σ return outliers (spike bar only — the opposite-direction snap-back bar right after it is kept, else every spike punched a two-bar hole) → warn if < ~210 bars.
- **`fetch_range(symbol, tf, start, end)`**: paginated RAW date-range fetch under `MAX_BARS_PER_REQUEST` (5000) — the backtester's `fetch_history` calls this, so ANY registered provider can feed the backtester.
- `outputsize = max(300, min(days×bars_per_day+20, 5000))`; `CANDLE_SECONDS`/`BARS_PER_DAY` tables live on the base class (the aggregator's bar-close clock reads `BaseDataProvider.CANDLE_SECONDS`).
- **Streaming capability (optional)**: `supports_streaming` flag + async generator `iter_ticks(symbols, on_connect)` yielding `(canonical_symbol, price)` for one connection's lifetime; the caller owns reconnect/backoff. Errors: each provider raises its own `DATA_ERROR`, always a subclass of `DataIntegrityError` — catch the base type provider-agnostically.
**A subclass implements only `_fetch_raw(symbol, tf, outputsize, start=None, end=None)`** (normalized frame: capitalized OHLCV, UTC tz-aware index named "Datetime", oldest-first) plus symbol/interval maps and optional capabilities. To add a provider: one module in `providers/`, one `PROVIDER_REGISTRY` entry in `providers/__init__.py`, one config.json `data_providers` block. The live feed is selected by config.json `data_providers.active` (registration alone changes nothing).
- **`providers/twelve_data.py` `TwelveDataProvider`** (alias `ForexDataProvider`): TD SDK (`TDClient.time_series(...).as_pandas()`), symbols normalized to `"XAU/USD"` form; lowercase→capitalized columns, newest-first→oldest-first; **rate limit** 55 credits/min sliding window (`_throttle`, lock-protected, blocks with sleep). Implements streaming (`ws.twelvedata.com` quotes WS, 10 s heartbeat, `_parse_ws_price`).
- **`providers/oanda.py` `OandaDataProvider`**: v20 REST MBA candles (complete-only), real bid/ask spread via `get_spread` override; credentials default to the config.json `oanda` block so the registry can build it config-only (explicit `api_token`/`base_url` args still accepted — the daemon passes them). Date-window fetches send `from`/`to` and omit `count` (v20 rejects the combination).
- **`providers/tiingo.py` `TiingoDataProvider`**: `/tiingo/fx/{ticker}/prices` resampled mid-price candles; no volume (filled 0.0), W1 unmapped (raises). Tiingo takes date windows, not bar counts — live fetches derive `startDate` from the requested count × candle duration × 1.6 weekend pad. Auth in the header (key never in URLs/logs). Every failure shape raises `TiingoDataError` with a named cause (429 quota, 401/403 auth, empty response, malformed candle, missing key). **Candles only** — `get_spread` stays on the base default; the free quote endpoint's indicative spread must not be re-added (see Tiingo spread-monitor removal note).
- **`data_provider.py` / `oanda_provider.py` are back-compat shims** re-exporting from `providers/`; `ForexDataProvider` is the historical name of the TD provider.
- **No silent failures**: provider errors all subclass `DataIntegrityError` and carry a named cause; the aggregator logs per-TF fetch failures and degrades the report (missing primary → NEUTRAL/F "data unavailable"); `make_data_provider` raises on an unknown `active` name; keyless providers warn at construction and raise at fetch.
---
## pair_signals/ — per-pair factories (ALWAYS use these, never bare PlatformConfig for a pair)
`PAIR_PRODUCERS: Dict[symbol, factory]`; factory signature `make_config(primary_tf, confirmation_tfs=None) -> PlatformConfig`. `produce_signal(symbol, tf, data_provider)` builds config → aggregator → `analyze_pair`. Registered: XAU/USD.
Inside `make_config`, call order is: `PlatformConfig()` defaults → `app_config.apply_pair_overrides(config)` (stamps config.json's non-credential deployment settings onto the config: base `xau_strategy`/`backtest` `blocked_hours_utc`, `scan_pairs`, `scan_timeframes`) → the validated per-TF deltas below. The overlay runs first so e.g. the M5 hour-9 append lands on top of the JSON base, not the other way around.
**A bare `PlatformConfig()` silently runs any pair with XAU defaults — always go through the factory** (the bot, CLI, and backtester all do).
Default confirmation map (same in the factory, the bot, and backtest usage): M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1]. (Exception: `main.py`'s CLI map uses M5→[M1,M15] — a CLI-only quirk.)
**xauusd.py**: `PlatformConfig()` defaults + per-TF overrides (each validated; full evidence in the module docstring):
- M5: append hour 9 UTC to blocked hours (6-yr replay: +207.1R vs +178.3R; M15-and-up keep hour 9 — it's positive there). Enable BUY-only chart patterns (`cp_enabled=True`, `cp_patterns=["inv_hs","double_bottom","bull_flag"]`): 12mo +41.8R PF 1.60 (train/val both positive), 5yr +65.2R PF 1.48; bearish CP dropped (OOS −3.21R). M15/H1 CP remain disabled (all failed OOS).
- M5: `signals.trail_dist_r = 0.85` (wider trail leash, arm stays 0.60; validated 2026-07-21 5-yr grid: +231.2R PF 1.30 vs +218.0R PF 1.29, halves and OOS better, DD flat; dist 1.0 had the best in-sample total but FAILED the OOS gate. XAU_PB keeps pb_trail 0.8/0.75; opposite finding vs M15's later-arm — per-TF divergence again).
- M15: `signals.tp_floor_rr = 1.0` (1R minimum targets: +116.6R vs +105.4R, better in train/val and 2024 OOS; **hurts M5** — per-TF only) and `xau_strategy.blocked_regimes = ["trending"]` (mixed regime block: +65.7R vs +59.0R). A transitioning-SELL block was tested and REJECTED (bull-market artifact — failed 2024 OOS).
- M15: `xau_strategy.sell_blocked_hours_utc = [15, 17]` (SELL-only hour block, validated 2026-07-16 on the 5-yr replay: +176.7R PF 1.31 vs +144.9R PF 1.22, better in all 6 years, both 12-mo halves, and 2024-06→2025-03 OOS; M5 cross-TF pocket confirms; BUYs at those hours are +34.3R so `blocked_hours_utc` would be wrong).
- M15: `signals.trail_arm_r = 0.70` (trail arms later; validated 2026-07-21, 5-yr grid arm 0.3-0.9 × dist 0.5-1.0: +210.7R PF 1.35 WR 57.8% vs +175.7R PF 1.30 WR 53.9% at the 0.6 default; both halves and OOS better; 5yr max-DD clause 33.9R vs 29.1R waived by owner. Earlier-arm cells uniformly worse. M5 keeps 0.6).
- The factory must stay equivalent to inline `PlatformConfig(symbol=XAUUSD, …)` + overrides — **enforced by a test in test_all.py**.
---
## telegram_bot.py — the bot
Run: `python telegram_bot.py` (credentials resolve env `TELEGRAM_TOKEN`/`TELEGRAM_CHAT_ID`/`TELEGRAM_CHANNEL_ID` → config.json `telegram.*` → refuses to boot; see Environment variables).
- **Owner lock**: every command/button is wrapped in `_owner_only` — only `TELEGRAM_CHAT_ID` gets responses; others get a refusal. The bot is the owner's control panel; the product is the channel.
- **Subscriber channel** (`TELEGRAM_CHANNEL_ID`, `-100…`; `""` disables): auto-scan signal alerts + breach alerts also post there (bot needs channel-admin). Monetization = Telegram Stars subscription invite links; Telegram handles membership, the bot has zero payment code. The channel feed keeps running even when the owner toggles DM auto-scan off (`_feed_active()` = DM chats OR channel).
- **Auto-scan**: boot-starts if `TELEGRAM_CHAT_ID` set (initial M15 scan populates the level cache first). Candle-boundary loops for M5/M15/H1/H4, firing at boundary + 10 s. Each scan runs all `scan_pairs` concurrently, updates the level cache, then broadcasts.
- **Alert gating**: quality threshold (`_alert_min_quality`, default "C", menu cycles C→B→A) + fingerprint dedup per (symbol, TF) on (signal, quality, direction) with 1-hour re-arm — a persisting identical signal doesn't re-send every candle, but a setup whose entry moved ≥ half the last-ALERTED risk distance (new pullback/swing anchor; compared against the stored last-alert entry/risk, a stable reference immune to per-candle ATR drift) re-alerts. The fingerprint is committed only **after** a message reaches ≥1 target (`_commit_alert`), so a failed Telegram send doesn't consume the alert.
- **WebSocket monitor**: the provider owns the connection/protocol (`BaseDataProvider.iter_ticks` streaming capability — TD implements it; heartbeat + message parsing live in `providers/twelve_data.py`); the bot owns exponential reconnect (≤60 s) + breach dispatch, and disables the monitor with a log line if the active provider has `supports_streaming=False`. On tick crossing any cached key level (`swing_high/low_1..3`, `ema_trend`, `bb_upper/lower`, `kc_upper/lower`; cache TTL 1 h) → immediate M15 rescan → "LEVEL BREAK" alert if actionable+changed (same gates as auto-scan), 5-min per-pair cooldown.
- **Commands**: `/start` (menu), `/scan` (buttons: per pair — M5/M15/H1/H4 + Scan All TFs), `/autoscan` (toggle DM copy), `/status` (loops, WS, live prices, level cache, cooldowns), `/cache` (age per pair/TF), `/clearcache`, `/help`.
- **Tiingo spread monitor: REMOVED 2026-07-23** (shipped 2026-07-22, removed next day). Tiingo's free `/tiingo/fx/top` publishes a two-tier *indicative* spread for gold — exactly $0.10 in liquid hours and exactly $0.70 in the 21:00–00:00 UTC rollover window / news moments (verified: 273-sample production log + live probes; only those two values ever occur). Quotes are mid ± a posted increment, not executable depth, so the feature could never validate the $0.30 backtest cost assumption. Do not re-add a spread display from Tiingo's free FX feed.
- Signal messages (redesigned 2026-07-24, `format_signal_message`): verdict header first, then trade setup, then a condensed one-line indicator summary (`EMA↑ RSI→ …`, `_indicator_summary`) + warnings. The setup block includes an **informational 100-pip TP line** (`_INFO_TP_PIPS`, display-only — entry ± 100 pips, ≈R vs stop; NOT part of the backtested exit model) and the **trailing-stop management plan** (arm price + trail distance, read from the `trail_arm_r`/`trail_dist_r` stamped on the setup by the producing per-TF config). Risk disclaimer on every outbound message.
- Blocking scan work runs in `ThreadPoolExecutor(max_workers=4)`; one shared `ForexDataProvider` across all scans (cache reuse).
- **Logging (hardened 2026-07-22)**: root-logger handlers (`force=True`) → console + `logs/telegram.log` (3-hour rotation, 240 backups = 30 days), full-date timestamps; every module logger (data_provider, news_engine, websockets, telegram.ext) lands in the same file. Failure surfaces all covered: candle loops catch-and-continue per cycle (one bad scan can't kill a loop), breach tasks log exceptions via done-callback, PTB `add_error_handler` registered, and `sys.excepthook`/`threading.excepthook` (installed in `main()`) route uncaught crashes to the log. WS monitor keeps its exponential-backoff reconnect + `logger.exception`.
---
## main.py — CLI
`python main.py [--pair XAUUSD] [--timeframe M5|M15|M30|H1|H4|D1] [--detail] [--json] [--verbose]`. Uses the pair factory, renders a compact terminal report (decision, setup, context, top-3 reasons per side, warnings) or full JSON via `report_to_dict`.
---
## backtest/gpu_backtest.py — the backtester
Replays history bar-by-bar through the **real `SignalAggregator.analyze_from_dataframes`** — same gates, same setup math. News is disabled (`use_news=False`): backtests are pure technical.
**Realism model (all validated fixes — do not regress):**
- Entry at the **next bar's open** after the signal bar; SL/TP distances preserved relative to actual entry. Entries are **re-anchored to the raw price path** (`_reanchor_entries_to_raw`): if cleaning dropped a >10σ spike bar right after the signal bar, the trade enters at the spike bar's open, not the bar after it — skipping it was a favorable bias.
- Round-trip `--spread` subtracted from every trade's PnL before computing R (default = pair's `default_spread`: XAU $0.30). Results before 2026-07-08 (+59.5R baseline era) charged no spread — not comparable.
- Trade resolution runs against the **RAW price path** (`fetch_history(clean=False)`): the parquet cache stores raw bars; cleaning (which drops >10σ news spikes that hit stops in reality) is applied only to the signal-side data. Caches written before this fix contain cleaned data — refetch to restore spike bars.
- Same-bar SL+TP tie → SL first (conservative). Gap through SL/TP → filled at the bar open.
- **Trailing exits** (default on, from `signals.trail_arm_r/trail_dist_r`; XAU_PB trades carry per-trade 0.8/0.75 overrides): stop ratchets at bar close only, so a new stop can only fill from the next bar (conservative). Exit reasons: SL / TRAIL / TP1 (static mode) / TIMEOUT / NO_DATA.
- Session + weekend + weekday filters evaluate the **decision time = bar close** (`ts + primary_duration`) — exactly matching the live bot's wall-clock check.
- The optional H4-ADX regime filter (`config.backtest.regime_filter`, default off) is applied in the replay by calling the live `_apply_regime_filter_live` method itself under the same guard, and the replay loads/slices the regime-filter TF whenever the gate is on (like live `analyze_pair`) — previously M5/M15 primaries silently no-opped the filter because H4 wasn't in their confirm maps. Live and backtest cannot diverge if it's enabled (2026-07-21 parity fix; the unregistered-pair config fallback also applies the config.json overlay now).
- Confirmation TFs are sliced to bars **fully closed** by decision time (no look-ahead).
**Performance:** `precompute_indicators` builds every indicator series once per TF (CuPy on GPU or NumPy — check the log line `Backend: CuPy GPU` vs `NumPy CPU fallback`; CuPy needs the `nvidia-*-cu12` pip wheels or it silently falls back). `GPUTechnicalEngine` subclasses the live engine and overrides only the `_compute_*` methods with cache lookups — outputs are byte-identical to the live engine (regression-tested). Bar replay: pure per-bar evaluation fans out over forked worker processes (`--workers`, 0 = auto: cores−2 capped 12; <5000 bars forces 1); the entry **cooldown (5 bars) is the only sequential rule**, applied in an ordered pass afterwards — parallel and sequential books are identical. ~300 bars/s/core, ~1700 bars/s at 10 workers.
**Strategy tags** (from reasons[0] markers): `XAU_PB`, `XAU_CP`, `GENERIC` (plus legacy `XAU` recognized). `--strategies` filters the book by tag. `--cp` force-enables the chart-pattern entry for candidate validation; `--cp-patterns` narrows the detector set.
**CLI:**
```bash
python backtest/gpu_backtest.py --pair XAUUSD --days 365
python backtest/gpu_backtest.py --pair XAUUSD --start 2025-01-01 --end 2025-06-01 --primary-tf 15min --confirm-tfs 1h,4h
# key flags: --spread --quality A+,A,B,C --strategies --workers --max-hold-bars 300
# --warmup-bars 250 --cooldown-bars 5 --no-cache --export-csv out.csv --detail
```
Report: summary metrics (total R, PF, Sharpe on R, max DD, MFE/MAE), breakdowns by quality/strategy/regime, trade log, and per-trade reasoning dumps (indicator-by-indicator agreement at entry — required by `instructions_for_backtest.txt`). `--export-csv` flattens every indicator's signal/value/agreement into columns.
**Comparability note (2026-07-16):** the parity/realism fixes of that date (EMA-ribbon port to the GPU engine, exact-pandas Bollinger precompute, raw-path entry re-anchoring, per-TF D1 warmup, live confirm-map CLI default, outlier snap-back retention) all shift books slightly vs earlier runs. Books produced before these fixes are not directly comparable — re-run the baseline before any before/after tuning comparison.
Data: paginated fetches via `provider.fetch_range` (5000-bar chunks; works with any registered provider) cached as parquet in `backtest/cache/` keyed by **provider**/symbol/TF/window (bars differ between feeds — Oanda/TD divergence study; `twelve_data` keeps the legacy un-prefixed filename so pinned-book caches stay valid); warmup pad ≥60 days before the window for EMA200, extended per-TF so slow confirmation TFs get ≥210 bars at window start (D1 gets ~299 days — 62 days gave only ~61 bars: no EMA200, unlike live's 300-bar outputsize floor). `--confirm-tfs` defaults to the pair factory's live confirmation map (pass it explicitly to override); a hardcoded default here previously made the standard invocation backtest a different MTF setup than the bot scans.
---
## test_all.py — QA suite
`python test_all.py` — **253 tests, 16 modules** (config, data_provider, engine, news_engine, signal_aggregator, main, telegram_bot, integration, pair_signals registry + engine-bug regressions, 2026-07-16 audit-fix regressions, chart patterns, app_config, .env loader + bot resilience, Oanda provider/executor/daemon, provider abstraction). No network needed (mocked). Uses a custom `@test("name")` decorator that runs immediately (NOT pytest — don't grep for `def test_`). Exit code non-zero on failure. Includes: the xauusd-factory ≡ inline-config equivalence test, engine bug-fix regression tests (Module 9b), a live-vs-GPU engine parity sweep incl. the EMA-ribbon path (Module 11), config.json loading/precedence/validation coverage (Module 13), and `.env` loader + bot-resilience coverage (Module 14). **Any signal-logic change must keep this green and should add tests.**
---
## Environment variables
Precedence for every credential/deployment setting: **env var → config.json → none**. A repo-root `.env` file (gitignored) is loaded at `app_config` import via `os.environ.setdefault` — it behaves like real env vars except a real shell export always wins. Hardcoded fallbacks (previously in `config.py` / `telegram_bot.py`) were **REMOVED 2026-07-21**; the bot now refuses to boot (`telegram_bot.main`) and data fetches fail with a named error (`data_provider`) if a secret resolves to neither source. See `app_config.py` and `config.json.example`.
| Variable | Purpose | Default |
|---|---|---|
| `TWELVE_DATA_API_KEY` | market data key (twelve_data only) | none — falls through to config.json `data_providers.twelve_data.api_key`, then fails fast |
| `TIINGO_API_KEY` | market data key (tiingo only) | none — falls through to config.json `data_providers.tiingo.api_key`, then fails fast |
| `TELEGRAM_TOKEN` | bot token | none — falls through to config.json `telegram.token`, then refuses to boot |
| `TELEGRAM_CHAT_ID` | owner chat id (boot auto-scan + owner lock) | none — falls through to config.json `telegram.chat_id`, then refuses to boot |
| `TELEGRAM_CHANNEL_ID` | subscriber channel (`-100…`); `""` disables the feed | none — falls through to config.json `telegram.channel_id` |
| `FB2_EMA_LO` | EMA200 "near" penalty threshold in % | `0.5` |
Dependencies (`requirements.txt`): numpy, pandas, requests, twelvedata, python-telegram-bot ≥20, websockets. Python 3.12. A `venv/` exists in-repo.
---
## Code conventions
- Type hints on public methods; dataclasses for all config and outputs.
- `SignalStrength` (−2..+2) is the universal signal currency.
- Key levels dict keys: `swing_high_*`, `swing_low_*`, `ema_*`, `bb_*`, `kc_*`, `atr`, `atr_pct`.
- Private per-indicator series ride on `TechnicalAnalysis` as `_underscore` attributes; anything `_`-prefixed or weight-0 is skipped by scorers/formatters.
- UTC everywhere; DataFrames indexed by tz-aware `Datetime`, oldest-first, capitalized OHLCV columns.
- Logging via module loggers; the bot adds a rotating file handler.
---
## Rules for making changes (READ BEFORE EDITING SIGNAL LOGIC)
1. **Never change a tuned value without re-validation.** The acceptance rule used throughout: positive total R in BOTH the train and validation halves of a ≥12-month window, PF ≥ 1.3, at realistic cost (XAU $0.30 round trip), with trailing exits and next-open fills. For any marginal edge additionally re-test on the 2024-06→2025-03 out-of-sample window — within-year splits alone have approved configs that failed OOS.
2. **Per-timeframe overrides live in the pair factory** (`pair_signals/*.py`), never in global defaults — the same knob has opposite effects on different TFs (proven twice: `tp_floor_rr`, hour-9 block). Document the validation evidence in the factory docstring.
3. **Pair isolation is a hard invariant.** XAU gates in the aggregator are display-gated (`report.display_name == "XAU/USD"`) so a future pair port cannot inherit gold-calibrated logic by accident.
4. **Live == backtest parity**: session/weekend/hour filters must evaluate bar-CLOSE time in both paths; `xauusd.make_config` must stay equivalent to the inline config the backtester/bot would build (test-enforced). If you add a live gate, add it to the replay path (or `analyze_from_dataframes`) in the same commit.
5. **ROC stays weighted** (see engine table) unless you retune composite thresholds and re-validate — removing it lost 13R.
6. **Backtester realism knobs are results-critical**: raw-path resolution, spread charging, next-open entry, SL-first ties, close-only trail ratchet. Any "optimization" that touches these invalidates comparability with the pinned books (`backtest/*.csv`, memory notes).
7. **Don't zero/disable indicators via config in production paths** — `disabled_indicators` exists for experiments; composite thresholds assume the full set.
8. **The forming candle must stay dropped** and analysis must only ever see closed bars, or live signals repaint.
9. Keep `test_all.py` green (`python test_all.py`); extend it for every new gate/knob.
10. Dead/legacy config fields were removed 2026-07-21 (`MarketSession`, `is_jpy_pair`, `candle_buffer_seconds`, the unused `SignalConfig`/`XauStrategyConfig` legacy knobs). Do not add config fields that nothing reads; delete dead fields only with test updates.
Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz-aware, oldest-first, capitalized OHLCV columns. Everything runs on **closed bars only** — the forming candle is dropped, and session/hour filters evaluate **bar-CLOSE time** (bar label + candle duration) identically in live and backtest.
### Layers
- **`providers/`** — one class per provider subclassing `BaseDataProvider` (`providers/base.py`), which owns everything the pipeline depends on: TTL+boundary cache, forming-candle drop, freshness check, validate/clean, paginated `fetch_range` (feeds the backtester from any provider), optional tick streaming. Subclasses implement only `_fetch_raw` + symbol/interval maps. Registry + `make_data_provider` in `providers/__init__.py`; config.json `data_providers.active` selects the live feed. `data_provider.py`/`oanda_provider.py` are back-compat shims. A test enforces that subclasses don't override shared base methods. Tiingo is candles-only — do not re-add spread display from its free FX feed (two-tier indicative quotes, removed 2026-07-23).
- **`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. `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].
- **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`).
- **`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), WebSocket level-breach monitor (auto-disabled when the active provider has `supports_streaming=False` — Oanda does not stream). 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, trail ratchets at bar close exactly once per bar. Accepted divergence: one position total.
- **`backtest/gpu_backtest.py`** — bar-by-bar replay through the real `analyze_from_dataframes` (news off). Realism model (do not regress): next-bar-open entries re-anchored to the raw price path, round-trip `--spread` charged per trade, trade resolution on RAW bars (cleaning applies only to signal-side data), same-bar SL+TP tie → SL first, gap fills at bar open, trail ratchet at bar close only. Indicators are precomputed once per TF (CuPy GPU or NumPy fallback — check the `Backend:` log line); replay fans out over worker processes and the 5-bar cooldown is applied in an ordered pass afterwards, so parallel and sequential books are identical. Parquet cache in `backtest/cache/` keyed by provider/symbol/TF/window. `backtest/*.csv` are gitignored pinned trade books from past campaigns — used for manual before/after comparison. `backtest/instructions_for_backtest.txt` lists what every backtest report must record.
## Rules for making changes (read before editing signal logic)
1. **Never change a tuned value without re-validation.** Acceptance rule: positive total R in BOTH train and validation halves of a ≥12-month window, PF ≥ 1.3, at realistic cost (XAU $0.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.
5. **ROC stays weighted** even though it's correlated with MACD/Keltner/EMA — zeroing it backtested 13R worse; composite thresholds are calibrated with it.
6. **Backtester realism knobs are results-critical** (raw-path resolution, spread charging, next-open entry, SL-first ties, close-only trail). Touching them invalidates comparability with all pinned books.
7. **The forming candle must stay dropped** — live signals repaint otherwise.
8. Don't add config fields nothing reads; don't disable indicators in production paths (`disabled_indicators` is for experiments only).
9. EUR/USD was removed 2026-07-16 after a no-edge verdict (cost-to-volatility; last commit with EUR support: `7d3fd95`). Do not re-add without new data. BTC/ETH work lives on unmerged branches only.
## Current state notes (2026-07-30)
- 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.
+44 -24
View File
@@ -79,34 +79,54 @@ def classify_bar(
"""
filters_hit: List[str] = []
# Scan warnings for known filter tags
filter_tags = {
"H4 RSI GATE": "H4_RSI_GATE",
"H1 MACD GATE": "H1_MACD_GATE",
"RSI ZONE GATE": "RSI_ZONE_GATE",
"XAU BUY FILTER": "XAU_BUY_FILTER",
"SESSION FILTER": "SESSION_FILTER",
"DAY FILTER": "DAY_FILTER",
"LOW ATR FILTER": "LOW_ATR_FILTER",
"SELL SUPPRESSED": "SELL_SUPPRESSED",
"EXHAUSTION": "EXHAUSTION_PENALTY",
"TREND EXHAUSTION": "TREND_EXHAUSTION",
"NO PULLBACK": "NO_PULLBACK_PENALTY",
"Weak pullback": "WEAK_PULLBACK_PENALTY",
"Extended entry": "EXTENDED_ENTRY_PENALTY",
"Counter-trend": "COUNTER_TREND_PENALTY",
"EXTREME volatility":"EXTREME_VOL_PENALTY",
"NEAR EMA200": "NEAR_EMA200_PENALTY",
"FAR FROM EMA200": "FAR_EMA200_PENALTY",
"EVENT RISK": "EVENT_RISK",
"REGIME FILTER": "REGIME_FILTER",
"Timeframes CONFLICT": "MTF_CONFLICT",
}
# Known filter tags, matched against the exact warning strings the
# aggregator emits (refreshed 2026-07-30 for gates 6d-6i). ORDER
# MATTERS: most-specific prefix first, and each warning maps to at
# most ONE tag (break on first hit) — substring families like
# "M5 FILTER: SELL quality" vs "M5 FILTER" and "TREND EXHAUSTION"
# vs "EXHAUSTION" would otherwise double-count.
filter_tags = [
# M5 noise gates (6i, _apply_m5_filters)
("M5 FILTER: |score|", "M5_SCORE"),
("M5 FILTER: no MTF agreement", "M5_MTF"),
("M5 FILTER: no confirmation TF", "M5_CONF"),
("M5 FILTER: SELL quality", "M5_SELL_QUALITY"),
("M5 FILTER: SELL blocked — RSI", "M5_SELL_RSI"),
("M5 FILTER: '", "M5_VOL"),
("M5 FILTER", "M5_OTHER"),
# XAU SELL gates (6e/6g/6g2/6g3)
("SELL FILTER: quality", "SELL_QUALITY"),
("SELL FILTER: volatility", "SELL_VOL"),
("SELL FILTER: regime", "SELL_REGIME"),
("UTC entry blocked for shorts", "SELL_HOUR"),
# Hard gates
("MTF OPPOSITION", "MTF_OPPOSITION"),
("EXHAUSTED TREND FILTER", "EXHAUSTED_TREND"),
("BIG CANDLE FILTER", "BIG_CANDLE"),
("WEEKEND FILTER", "WEEKEND_FILTER"),
("SESSION FILTER", "SESSION_FILTER"),
("DAY FILTER", "DAY_FILTER"),
("LOW ATR FILTER", "LOW_ATR_FILTER"),
("REGIME FILTER", "REGIME_FILTER"),
# Soft penalties / context warnings
("TREND EXHAUSTION", "TREND_EXHAUSTION"),
("EXHAUSTION", "EXHAUSTION_PENALTY"),
("NO PULLBACK", "NO_PULLBACK_PENALTY"),
("Weak pullback", "WEAK_PULLBACK_PENALTY"),
("Extended — RSI", "RSI_EXTENDED_PENALTY"),
("Counter-trend", "COUNTER_TREND_PENALTY"),
("EXTREME volatility", "EXTREME_VOL_PENALTY"),
("NEAR EMA200", "NEAR_EMA200_PENALTY"),
("FAR FROM EMA200", "FAR_EMA200_PENALTY"),
("EVENT RISK", "EVENT_RISK"),
("Timeframes CONFLICT", "MTF_CONFLICT"),
]
for w in report.warnings:
for tag, name in filter_tags.items():
for tag, name in filter_tags:
if tag in w:
filters_hit.append(name)
break
# Determine outcome
non_neutral = report.final_signal not in (SignalStrength.NEUTRAL,)
+53
View File
@@ -30,6 +30,7 @@ Usage:
from __future__ import annotations
import argparse
import json
import logging
import multiprocessing as mp
import os
@@ -2117,6 +2118,19 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
p.add_argument("--cp-patterns", default=None,
help="Comma-separated pattern subset for --cp (e.g. "
"double_bottom,bull_flag). Default: all detectors.")
p.add_argument("--big-candle", type=float, default=None,
help="Force xau_strategy.big_candle_atr_multiple=K for this "
"run (no entries while the lookback window contains a "
"bar with range >= K x prior ATR) — for validating the "
"candidate.")
p.add_argument("--big-candle-hours", type=float, default=None,
help="Override big_candle_lookback_hours for --big-candle "
"runs (default 24).")
p.add_argument("--set", action="append", default=[], metavar="PATH=JSON",
help="Override any PlatformConfig leaf by dotted path; the "
"value is JSON-parsed for typing (research knob; "
"repeatable). e.g. --set xau_strategy.m5_min_abs_score=25 "
"--set 'xau_strategy.sell_min_quality=[\"A+\",\"A\",\"B\",\"C\"]'")
p.add_argument("--no-cache", action="store_true", help="Skip parquet cache (force API fetch)")
p.add_argument("--spread", type=float, default=None,
help="Round-trip cost in price units per trade (default: pair's "
@@ -2148,6 +2162,39 @@ def _resolve_tf(s: str) -> Timeframe:
raise SystemExit(f"Unknown timeframe '{s}'. Valid: {[t.value for t in Timeframe]}")
def _apply_config_overrides(cfg: PlatformConfig, overrides: List[str]) -> None:
"""Apply repeatable --set PATH=JSON research overrides onto the config.
Unknown paths are a hard error: dataclass instances silently accept
setattr of any name, so a typo'd path would otherwise produce a
no-op run that looks like 'the relaxation had no effect'. Every
applied override is logged — the run log is the config audit trail
required by instructions_for_backtest.txt.
"""
for item in overrides:
path, sep, raw = item.partition("=")
if not sep:
raise SystemExit(f"--set expects PATH=VALUE, got '{item}'")
try:
value = json.loads(raw)
except json.JSONDecodeError:
value = raw # bare-string convenience ("oanda" etc.)
obj = cfg
parts = path.strip().split(".")
for attr in parts[:-1]:
if not hasattr(obj, attr):
raise SystemExit(f"--set: unknown config path '{path}'")
obj = getattr(obj, attr)
leaf = parts[-1]
if not hasattr(obj, leaf):
raise SystemExit(f"--set: unknown config path '{path}'")
old = getattr(obj, leaf)
if hasattr(old, "__dataclass_fields__"):
raise SystemExit(f"--set: '{path}' is a config section, not a leaf")
setattr(obj, leaf, value)
logger.info(f"--set override: {path} = {value!r} (was {old!r})")
def main(argv: Optional[List[str]] = None) -> int:
args = _parse_args(argv)
@@ -2197,6 +2244,12 @@ def main(argv: Optional[List[str]] = None) -> int:
platform_cfg.xau_strategy.cp_patterns = [
s.strip() for s in args.cp_patterns.split(",") if s.strip()
]
if args.big_candle is not None:
platform_cfg.xau_strategy.big_candle_atr_multiple = args.big_candle
if args.big_candle_hours is not None:
platform_cfg.xau_strategy.big_candle_lookback_hours = args.big_candle_hours
if args.set:
_apply_config_overrides(platform_cfg, args.set)
confirm_tfs = list(platform_cfg.confirmation_timeframes)
end = (
+86
View File
@@ -0,0 +1,86 @@
"""Trade-book diff: relaxed/gated run vs its control.
Usage:
python backtest/research/book_diff.py CONTROL.csv CANDIDATE.csv BAR_MINUTES
Decision numbers are RUN-LEVEL (ΔtotalR, ΔPF, ΔmaxDD, Δtrades) — never
the added-trade bucket alone: the 5-bar entry cooldown means new
entries displace existing ones. The added/removed decomposition and the
per-decision-hour histogram of added trades are diagnostics.
Shifted trades = removed+added pairs in the same direction within
±5 bars (cooldown displacement, not a genuinely new/lost signal).
"""
import sys
import pandas as pd
def _metrics(df):
r = df["r_multiple"]
gains, losses = r[r > 0].sum(), -r[r <= 0].sum()
cum = r.cumsum()
return dict(trades=len(df), total_r=r.sum(),
pf=gains / max(losses, 1e-9),
wr=(r > 0).mean() * 100 if len(df) else 0.0,
max_dd=(cum.cummax() - cum).max() if len(cum) else 0.0)
def diff_books(control_csv, candidate_csv, bar_minutes, split="2026-01-29"):
ctrl, cand = pd.read_csv(control_csv), pd.read_csv(candidate_csv)
for df in (ctrl, cand):
df["_t"] = pd.to_datetime(df["entry_time"], utc=True, format="mixed")
df["_key"] = df["entry_time"].astype(str) + "|" + df["direction"]
added = cand[~cand["_key"].isin(ctrl["_key"])].copy()
removed = ctrl[~ctrl["_key"].isin(cand["_key"])].copy()
# cooldown displacement: same-direction removed/added within ±5 bars
tol = pd.Timedelta(minutes=5 * bar_minutes)
shifted = 0
rem_pool = removed.copy()
for _, a in added.iterrows():
m = rem_pool[(rem_pool["direction"] == a["direction"])
& ((rem_pool["_t"] - a["_t"]).abs() <= tol)]
if len(m):
shifted += 1
rem_pool = rem_pool.drop(m.index[0])
mc, mk = _metrics(ctrl), _metrics(cand)
split_ts = pd.Timestamp(split, tz="UTC")
halves = (cand[cand["_t"] < split_ts]["r_multiple"].sum(),
cand[cand["_t"] >= split_ts]["r_multiple"].sum())
hour_hist = None
if len(added):
added["dec_hour"] = (added["_t"]
- pd.Timedelta(minutes=bar_minutes)).dt.hour
hour_hist = added.groupby("dec_hour")["r_multiple"] \
.agg(["count", "sum"]).round(2)
return dict(
control=mc, candidate=mk,
delta_r=mk["total_r"] - mc["total_r"],
delta_pf=mk["pf"] - mc["pf"],
delta_dd=mk["max_dd"] - mc["max_dd"],
delta_trades=mk["trades"] - mc["trades"],
added_n=len(added), added_r=added["r_multiple"].sum(),
removed_n=len(removed), removed_r=removed["r_multiple"].sum(),
shifted=shifted, halves=halves, added_by_hour=hour_hist,
)
if __name__ == "__main__":
d = diff_books(sys.argv[1], sys.argv[2], int(sys.argv[3]))
c, k = d["control"], d["candidate"]
print(f"control : {c['trades']:4d} trades {c['total_r']:+7.1f}R "
f"PF {c['pf']:.2f} WR {c['wr']:.1f}% DD {c['max_dd']:.1f}")
print(f"candidate: {k['trades']:4d} trades {k['total_r']:+7.1f}R "
f"PF {k['pf']:.2f} WR {k['wr']:.1f}% DD {k['max_dd']:.1f}")
print(f"ΔR {d['delta_r']:+.1f} ΔPF {d['delta_pf']:+.2f} "
f"ΔDD {d['delta_dd']:+.1f} Δtrades {d['delta_trades']:+d}")
print(f"added {d['added_n']} ({d['added_r']:+.1f}R) "
f"removed {d['removed_n']} ({d['removed_r']:+.1f}R) "
f"shifted≈{d['shifted']}")
print(f"candidate halves: H1 {d['halves'][0]:+.1f}R H2 {d['halves'][1]:+.1f}R")
if d["added_by_hour"] is not None:
print("\nadded trades by decision hour:")
print(d["added_by_hour"].to_string())
+42
View File
@@ -0,0 +1,42 @@
"""Burst-candle frequency by UTC hour — the empirical "hedge fund hours".
Usage:
python backtest/research/burst_profile.py PARQUET_GLOB
Per UTC bar-close hour of the given bars: % of bars whose range is
>= K x the PRIOR bar's Wilder ATR(14), for K in {3,4,5}; mean bar
range; and mean signed drift over the 6 bars following a >=4x burst
(does price continue or snap back?). Analysis tooling only.
"""
import glob
import sys
import pandas as pd
def burst_table(parquet_glob: str, bar_minutes: int = 5):
files = sorted(glob.glob(parquet_glob))
if not files:
raise SystemExit(f"no parquet matches {parquet_glob}")
df = pd.concat([pd.read_parquet(f) for f in files]).sort_index()
df = df[~df.index.duplicated(keep="last")]
h, l, c = df["High"], df["Low"], df["Close"]
tr = pd.concat([h - l, (h - c.shift()).abs(), (l - c.shift()).abs()],
axis=1).max(axis=1)
atr = tr.ewm(alpha=1 / 14, min_periods=14, adjust=False).mean()
ratio = (h - l) / atr.shift(1)
close_hour = (df.index + pd.Timedelta(minutes=bar_minutes)).hour
fwd6 = c.shift(-6) - c # signed move over the 6 bars after this one
out = pd.DataFrame({
"bars": ratio.groupby(close_hour).size(),
**{f"pct_ge_{k}x": (ratio >= k).groupby(close_hour).mean() * 100
for k in (3, 4, 5)},
"mean_range": (h - l).groupby(close_hour).mean(),
"post4x_drift6": fwd6[ratio >= 4].groupby(close_hour[ratio >= 4]).mean(),
})
return out.round(3)
if __name__ == "__main__":
print(burst_table(sys.argv[1],
int(sys.argv[2]) if len(sys.argv) > 2 else 5).to_string())
+40
View File
@@ -0,0 +1,40 @@
"""Per-hour P&L breakdown of a backtest trade book.
Usage:
python backtest/research/hour_analysis.py BOOK.csv BAR_MINUTES
Reports, per UTC hour (both entry hour and DECISION hour = entry_time
minus one bar, the clock blocked_hours_utc evaluates): trade count,
total R, mean R, win rate, profit factor. Also a per-(hour, direction)
table. Analysis tooling only — never imported by the live code.
"""
import sys
import pandas as pd
def hour_tables(book_csv: str, bar_minutes: int):
df = pd.read_csv(book_csv)
et = pd.to_datetime(df["entry_time"], utc=True, format="mixed")
df["entry_hour"] = et.dt.hour
df["decision_hour"] = (et - pd.Timedelta(minutes=bar_minutes)).dt.hour
def table(group_cols):
g = df.groupby(group_cols)["r_multiple"]
out = g.agg(count="count", total_r="sum", mean_r="mean",
wr=lambda r: (r > 0).mean() * 100)
out["pf"] = g.apply(
lambda r: r[r > 0].sum() / max(-r[r <= 0].sum(), 1e-9))
return out.round(2)
return (table("decision_hour"), table(["decision_hour", "direction"]),
table("entry_hour"))
if __name__ == "__main__":
book, mins = sys.argv[1], int(sys.argv[2])
by_dec, by_dec_dir, by_ent = hour_tables(book, mins)
print(f"\n── {book} · by DECISION hour (bar-close clock) ──")
print(by_dec.to_string())
print(f"\n── by (decision hour, direction) ──")
print(by_dec_dir.to_string())
+18 -2
View File
@@ -203,9 +203,16 @@ class XauStrategyConfig:
# M5 signals are noisy; require stronger confirmation before
# generating trade setups. Without these, M5 is breakeven
# (+0.01R/trade) with 21% drawdown.
# Relaxed 2026-07-30 (30→25, 10→5) in the relaxation campaign:
# the combined config (with the M5 hour-21 unblock) beat control
# in ALL 6 calendar years of the 5yr Oanda replay (+230.9R vs
# +168.0R, PF 1.26 vs 1.21, max DD 21.3 vs 27.0), 12mo +107.4R
# vs +84.6R PF 1.36 both halves positive, OOS 2024-06→2025-03
# +26.1R vs +17.2R, and at $0.70 spread +86.9R vs +66.4R.
# Score floors below 25 are vacuous (grade C requires |score|≥25).
m5_require_mtf: bool = True
m5_min_abs_score: float = 30.0
m5_min_confirmation_score: float = 10.0
m5_min_abs_score: float = 25.0
m5_min_confirmation_score: float = 5.0
# A-grade M5 SELLs enter at the extreme of temporary dips and
# lose (-0.017R/trade on 163 trades) — maximum conviction at
# the worst entry. Cap SELL quality so A-grade can't trigger
@@ -271,6 +278,15 @@ class XauStrategyConfig:
cp_flag_max_bars: int = 12
cp_flag_retrace: tuple = (0.1, 0.5) # flag depth as fraction of pole
# ── Big-candle entry gate ──────────────────────────────────────
# When the recent window on the scan's own primary TF contains an
# abnormally large candle (bar range >= K x the PRIOR bar's ATR —
# institutional burst; price may reverse violently), no XAU entry
# of ANY kind may fire (core, XAU_PB, XAU_CP). 0.0 = disabled.
# Per-TF factories opt in only after the standard validation rule.
big_candle_atr_multiple: float = 0.0
big_candle_lookback_hours: float = 24.0
@dataclass
class FilterConfig:
+23 -4
View File
@@ -99,6 +99,24 @@ in 5/7 calendar years including winning years (2022 -9.2R). Blocking:
12mo book is unchanged (+139.0R vs +139.3R). M15-and-up keep hour 9:
it is POSITIVE there (M15 6yr +1.7R, M15 OOS +8.6R), so the block is
M5-only, same per-timeframe pattern as the M15 overrides.
M5 override 2 — relaxation campaign (validated 2026-07-30, Oanda
bars, $0.30 spread, trailing exits): UNBLOCK hour 21 UTC on M5 only,
combined with the config.py M5 gate relaxations (m5_min_abs_score
30->25, m5_min_confirmation_score 10->5). The combined config beat
the pre-relaxation control in ALL 6 calendar years of the 5yr replay
2021-07-30 -> 2026-07-30 (+230.9R/2319 PF 1.26 DD 21.3 vs
+168.0R/2048 PF 1.21 DD 27.0), on 12mo (+107.4R PF 1.36, halves
+68.6/+38.8 vs +84.6R PF 1.33), on OOS 2024-06-01 -> 2025-03-30
(+26.1R vs +17.2R), and at $0.70 spread (+86.9R vs +66.4R). Hour-21
trades alone: +28.0R/22, positive every year — overturning the old
block per the full-evidence rule. Hour 21 stays BLOCKED on M15+
(12mo M15 unblock tested -0.3R). Declined-but-open from the same
campaign: an M5 hour-22 block (+4.8R on 12mo — the burst-heavy
"institutional" hour — but -3.2R on 5yr and -1.1R on OOS: regime-
specific); M15 hour-16 unblock (+0.8R/+57 trades 12mo but -18.3R
DD +34% on 5yr); every SELL-gate/vol/TP-size relaxation retested
negative (see memory notes for the full grid).
"""
from typing import Optional, List
@@ -126,10 +144,11 @@ def make_config(
confirmation_tfs or _CONFIRMATIONS.get(primary_tf, [Timeframe.M15, Timeframe.H4])
)
if primary_tf == Timeframe.M5:
# Block hour 9 UTC on M5 only — see module docstring.
config.xau_strategy.blocked_hours_utc = (
config.xau_strategy.blocked_hours_utc + [9]
)
# Block hour 9 UTC on M5 only; UNBLOCK hour 21 on M5 only
# (positive every year of the 5yr replay) — see docstring.
config.xau_strategy.blocked_hours_utc = [
h for h in config.xau_strategy.blocked_hours_utc if h != 21
] + [9]
# BUY-only chart patterns on M5 — see module docstring.
config.xau_strategy.cp_enabled = True
config.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"]
+67 -2
View File
@@ -306,6 +306,44 @@ class SignalAggregator:
return None
return float(adx.iloc[-1])
def _detect_big_candle(self, primary_df: pd.DataFrame) -> Optional[tuple]:
"""Largest abnormal candle in the recent primary-TF window.
Returns (timestamp, ratio) of the biggest bar whose range is
>= big_candle_atr_multiple x the PRIOR bar's ATR within the last
big_candle_lookback_hours of primary-TF bars, else None. The
prior-bar ATR (shift 1) matters: a huge candle inflates the
Wilder ATR immediately, so comparing against the concurrent ATR
under-detects the very bars this gate exists to catch.
Pure pandas on the caller-sliced df, so the live path and the
backtest replay (which calls analyze_from_dataframes on prefix
slices) compute bit-identical answers. The tail slice keeps the
per-call cost bounded (~n + 42 rows); min_periods NaNs and short
histories degrade to "no trip".
"""
cfg = self.config.xau_strategy
bar_secs = BaseDataProvider.CANDLE_SECONDS[self.config.primary_timeframe]
n = max(1, int(cfg.big_candle_lookback_hours * 3600 // bar_secs))
period = self.config.technical.atr_period
tail = primary_df.iloc[-(n + 3 * period):]
high, low, close = tail["High"], tail["Low"], tail["Close"]
tr = pd.concat(
[high - low,
(high - close.shift()).abs(),
(low - close.shift()).abs()],
axis=1,
).max(axis=1)
atr = tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
ratio = (high - low) / atr.shift(1)
window = ratio.iloc[-n:]
if window.empty:
return None
max_ratio = window.max()
if pd.isna(max_ratio) or max_ratio < cfg.big_candle_atr_multiple:
return None
return window.idxmax(), float(max_ratio)
def _apply_m5_filters(self, report: SignalReport) -> None:
"""Extra selectivity for M5 — noisy TF needs stronger confirmation."""
if report.signal_quality not in ("A+", "A", "B", "C"):
@@ -957,6 +995,30 @@ class SignalAggregator:
report.signal_quality = "D"
report.trade_setup = None
# ── 6h. Big-candle entry gate (opt-in via per-TF factories) ──
# An abnormally large candle in the recent window = institutional
# burst; price may reverse violently, so NO entry of any kind may
# fire. Detection runs for ANY grade: the warning is the
# suppression channel for the XAU_PB path (which accepts D) and
# the XAU_CP path (which accepts even F). The D-downgrade itself
# touches actionable grades only — F stays F (same convention as
# 6e).
if (self.config.xau_strategy.enabled
and report.display_name == "XAU/USD"
and self.config.xau_strategy.big_candle_atr_multiple > 0):
big_candle = self._detect_big_candle(primary_df)
if big_candle is not None:
bc_ts, bc_ratio = big_candle
report.warnings.append(
f"BIG CANDLE FILTER: {bc_ratio:.1f}x ATR bar at {bc_ts} "
f"within "
f"{self.config.xau_strategy.big_candle_lookback_hours:g}h "
f"— no entries"
)
if report.signal_quality in ("A+", "A", "B", "C"):
report.signal_quality = "D"
report.trade_setup = None
# ── 6i. M5 noise gate (gold-tuned thresholds — XAU only) ──
if (self.config.primary_timeframe == Timeframe.M5
and report.display_name == "XAU/USD"):
@@ -985,7 +1047,9 @@ class SignalAggregator:
and report.display_name == "XAU/USD"
and self.config.xau_strategy.m5_pullback_enabled
and report.trade_setup is None
and report.signal_quality != "F"):
and report.signal_quality != "F"
and not any(w.startswith("BIG CANDLE FILTER")
for w in report.warnings)):
self._try_m5_pullback(report, primary_df, pip_value)
# ── 10. Chart-pattern entry (XAU_CP) ────────────────────────
@@ -1002,7 +1066,8 @@ class SignalAggregator:
and self.config.xau_strategy.cp_enabled
and report.trade_setup is None
and not any(w.startswith(("SESSION FILTER", "DAY FILTER",
"WEEKEND FILTER"))
"WEEKEND FILTER",
"BIG CANDLE FILTER"))
for w in report.warnings)):
self._try_chart_pattern(report, primary_df, pip_value)
+248 -4
View File
@@ -2000,10 +2000,11 @@ def _():
inline_cfg.signals.trail_dist_r = 0.75
if tf == Timeframe.M5:
# Documented per-TF overrides (see xauusd.py docstring):
# hour 9 UTC blocked + BUY-only chart patterns on M5 only.
inline_cfg.xau_strategy.blocked_hours_utc = (
inline_cfg.xau_strategy.blocked_hours_utc + [9]
)
# hour 9 UTC blocked, hour 21 UNBLOCKED (2026-07-30
# relaxation campaign) + BUY-only chart patterns on M5 only.
inline_cfg.xau_strategy.blocked_hours_utc = [
h for h in inline_cfg.xau_strategy.blocked_hours_utc if h != 21
] + [9]
inline_cfg.xau_strategy.cp_enabled = True
inline_cfg.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"]
inline_cfg.signals.trail_arm_r = 0.60
@@ -4309,6 +4310,249 @@ def _():
assert td != oa, "providers must not share cache files"
# ══════════════════════════════════════════════════════════════════════
# MODULE 17: Big-candle entry gate (gate 6h)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 17: big-candle entry gate ═══")
def _inject_big_bar(df, pos=-250, mult=8.0):
"""Copy df and widen one bar to ~mult x the median range (a burst
candle) at position `pos` — far enough back not to disturb the
near-window indicators (RSI/BB/ATR 14-20 bars, swing S/R 120)."""
out = df.copy()
atr_est = (out["High"] - out["Low"]).median()
ts = out.index[pos]
out.loc[ts, "High"] = out.loc[ts, "Open"] + mult * atr_est
out.loc[ts, "Low"] = out.loc[ts, "Open"] - 0.5 * atr_est
out.loc[ts, "Close"] = out.loc[ts, "Open"] + mult * 0.9 * atr_est
return out
def _bc_agg(k=3.0, tf=Timeframe.M5, confirms=(Timeframe.M15, Timeframe.H1)):
from pair_signals.xauusd import make_config as xau_make_config
cfg = xau_make_config(tf, list(confirms))
cfg.xau_strategy.big_candle_atr_multiple = k
return SignalAggregator(cfg)
# Deterministic ACTIONABLE fixture: grade A BUY with a GENERIC setup
# through the factory M5 config at an unblocked hour (Wed 14:55 UTC),
# huge bar pre-injected 250 bars back (verified by seed search).
def _bc_actionable_dfs():
end = "2026-07-08 14:55"
primary = _inject_big_bar(make_xau_bars(end=end, drift=0.8, seed=0))
m15 = make_xau_bars(end="2026-07-08 14:45", freq="15min", drift=0.8, seed=100)
h1 = make_xau_bars(end="2026-07-08 14:00", freq="60min", drift=0.8, seed=200)
return {Timeframe.M5: primary, Timeframe.M15: m15, Timeframe.H1: h1}
@test("BC detect: quiet fixture → None; injected 8x bar → (its ts, ratio ≥ 3)")
def _():
agg = _bc_agg(k=3.0)
quiet = make_xau_bars(end="2026-07-08 14:55")
assert agg._detect_big_candle(quiet) is None, \
"quiet bars must not trip the detector"
spiked = _inject_big_bar(quiet, pos=-250)
hit = agg._detect_big_candle(spiked)
assert hit is not None, "injected burst bar not detected"
ts, ratio = hit
assert ts == spiked.index[-250], f"wrong offender bar: {ts}"
assert ratio >= 3.0, f"ratio {ratio} below threshold"
@test("BC detect: burst bar as LAST row still trips (prior-ATR shift)")
def _():
# The huge bar inflates the concurrent Wilder ATR; the shift(1)
# denominator keeps the very newest burst detectable.
agg = _bc_agg(k=3.0)
spiked = _inject_big_bar(make_xau_bars(end="2026-07-08 14:55"), pos=-1)
hit = agg._detect_big_candle(spiked)
assert hit is not None and hit[0] == spiked.index[-1], f"last-row burst missed: {hit}"
@test("BC detect: bar older than the lookback window ignored; short df safe")
def _():
agg = _bc_agg(k=3.0)
# M5 window = 288 bars (+42 ewm warm-up tail). A burst at -350 is
# outside even the warm-up slice of a 400-bar df.
old_spike = _inject_big_bar(make_xau_bars(n=400, end="2026-07-08 14:55"), pos=-350)
assert agg._detect_big_candle(old_spike) is None, \
"bar outside the lookback window must not block"
# Short history: no raise, graceful no-trip on quiet bars.
assert agg._detect_big_candle(make_xau_bars(n=60, end="2026-07-08 14:55")) is None
@test("BC gate 6h: actionable A-grade → warning + D + setup killed")
def _():
dfs = _bc_actionable_dfs()
control = _bc_agg(k=0.0).analyze_from_dataframes(XAUUSD, dfs, use_news=False)
assert control.signal_quality == "A" and control.trade_setup is not None, \
f"fixture drifted: expected actionable A, got {control.signal_quality}"
assert not any("BIG CANDLE" in w for w in control.warnings), \
"disabled gate must stay silent"
gated = _bc_agg(k=3.0).analyze_from_dataframes(XAUUSD, dfs, use_news=False)
assert any(w.startswith("BIG CANDLE FILTER") for w in gated.warnings), \
f"gate did not fire: {gated.warnings}"
assert gated.signal_quality == "D", f"expected D, got {gated.signal_quality}"
assert gated.trade_setup is None, "setup must be killed by the gate"
@test("BC gate 6h: session-blocked F stays F (warning only, no F→D)")
def _():
end = "2026-07-08 16:55" # blocked hour → gate 6b sets F
dfs = {
Timeframe.M5: _inject_big_bar(make_xau_bars(end=end)),
Timeframe.M15: make_xau_bars(end="2026-07-08 16:45", freq="15min"),
Timeframe.H1: make_xau_bars(end="2026-07-08 16:00", freq="60min"),
}
report = _bc_agg(k=3.0).analyze_from_dataframes(XAUUSD, dfs, use_news=False)
assert any("SESSION FILTER" in w for w in report.warnings)
assert any(w.startswith("BIG CANDLE FILTER") for w in report.warnings), \
"detection must run regardless of grade (PB/CP suppression channel)"
assert report.signal_quality == "F", \
f"blocked bar must stay F, got {report.signal_quality}"
@test("BC gate 6h: XAU_PB and XAU_CP entry paths suppressed")
def _():
# The D-downgrade alone would NOT stop them: PB accepts D (check is
# != 'F') and CP even F — the warning is the suppression channel.
dfs = _bc_actionable_dfs()
def run(k):
agg = _bc_agg(k=k)
agg.config.xau_strategy.cp_enabled = True
calls = []
agg._try_m5_pullback = lambda *a, **kw: calls.append("pb")
agg._try_chart_pattern = lambda *a, **kw: calls.append("cp")
# Keep the setup slot empty so steps 9/10 are actually reached.
agg._generate_trade_setup = lambda *a, **kw: None
agg._generate_xau_atr_trade_setup = lambda *a, **kw: None
agg.analyze_from_dataframes(XAUUSD, dfs, use_news=False)
return calls
assert run(k=0.0) == ["pb", "cp"], "control must reach both entry paths"
assert run(k=3.0) == [], "tripped gate must suppress both entry paths"
@test("BC gate: disabled by default in every factory TF config")
def _():
from pair_signals.xauusd import make_config as xau_make_config
for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4):
cfg = xau_make_config(tf)
assert cfg.xau_strategy.big_candle_atr_multiple == 0.0, \
f"{tf}: gate must ship default-OFF (validate-first rollout)"
assert cfg.xau_strategy.big_candle_lookback_hours == 24.0
@test("BC backtester: --big-candle / --big-candle-hours parse, default None")
def _():
from gpu_backtest import _parse_args
args = _parse_args(["--pair", "XAUUSD",
"--big-candle", "3.0", "--big-candle-hours", "12"])
assert args.big_candle == 3.0 and args.big_candle_hours == 12.0
args2 = _parse_args(["--pair", "XAUUSD"])
assert args2.big_candle is None and args2.big_candle_hours is None
# ══════════════════════════════════════════════════════════════════════
# MODULE 18: --set research override + diagnose_filters tag freshness
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 18: --set override + diagnose tags ═══")
@test("--set: nested float/bool/JSON-list overrides apply with typing")
def _():
from gpu_backtest import _apply_config_overrides
cfg = PlatformConfig()
_apply_config_overrides(cfg, [
"xau_strategy.m5_min_abs_score=25",
"xau_strategy.m5_require_mtf=false",
'xau_strategy.sell_blocked_hours_utc=[15,17]',
"signals.min_tp_cost_multiple=20.5",
])
assert cfg.xau_strategy.m5_min_abs_score == 25
assert cfg.xau_strategy.m5_require_mtf is False
assert cfg.xau_strategy.sell_blocked_hours_utc == [15, 17]
assert cfg.signals.min_tp_cost_multiple == 20.5
@test("--set: unknown path / section leaf / missing '=' all fail loudly")
def _():
# A typo'd path MUST NOT become a silent no-op run: dataclass
# instances happily accept setattr of any name.
from gpu_backtest import _apply_config_overrides
cfg = PlatformConfig()
for bad in ("xau_strategy.m5_min_abs_scroe=25", # typo leaf
"xau_stragety.m5_min_abs_score=25", # typo section
"xau_strategy=5", # section, not leaf
"no_equals_sign"):
try:
_apply_config_overrides(cfg, [bad])
except SystemExit:
pass
else:
raise AssertionError(f"'{bad}' must raise SystemExit")
@test("--set: bare-string fallback for non-JSON values")
def _():
from gpu_backtest import _apply_config_overrides
cfg = PlatformConfig()
_apply_config_overrides(cfg, ["technical.rsi_mode=classic"])
assert cfg.technical.rsi_mode == "classic"
@test("--set: CLI flag parses as repeatable list, default empty")
def _():
from gpu_backtest import _parse_args
args = _parse_args(["--pair", "XAUUSD",
"--set", "xau_strategy.m5_min_abs_score=25",
"--set", "signals.tp_floor_rr=1.0"])
assert args.set == ["xau_strategy.m5_min_abs_score=25",
"signals.tp_floor_rr=1.0"]
assert _parse_args(["--pair", "XAUUSD"]).set == []
@test("diagnose_filters: every filter tag has a live emitter (staleness guard)")
def _():
import inspect
import diagnose_filters
import signal_aggregator as sa
src = inspect.getsource(sa)
tag_src = inspect.getsource(diagnose_filters.classify_bar)
# Extract the tag strings from the ordered list in classify_bar.
import re
tags = re.findall(r'\("([^"]+)",\s+"[A-Z0-9_]+"\)', tag_src)
assert len(tags) >= 25, f"tag extraction broke: found {len(tags)}"
for tag in tags:
assert tag in src, f"filter tag '{tag}' has no emitter in signal_aggregator.py"
@test("diagnose_filters: one tag per warning, specific prefix wins")
def _():
import diagnose_filters as d
from signal_aggregator import SignalReport
class _R:
pass
r = _R()
r.warnings = [
"M5 FILTER: SELL quality A capped — high-conviction M5 shorts enter at extremes",
"SELL FILTER: 15:00 UTC entry blocked for shorts",
"TREND EXHAUSTION — price extended >3.5 ATR past swing high",
]
r.final_signal = SignalStrength.SELL
r.signal_quality = "D"
r.trade_setup = None
outcome, hits = d.classify_bar(r, True)
assert outcome == "LOW_QUALITY"
assert hits == ["M5_SELL_QUALITY", "SELL_HOUR", "TREND_EXHAUSTION"], hits
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════