From 68a62b0a530c566b371dbb39f1149b4c230bdb10 Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Thu, 16 Jul 2026 14:06:27 -0400 Subject: [PATCH 1/4] =?UTF-8?q?Remove=20EUR/USD=20support=20=E2=80=94=20XA?= =?UTF-8?q?U/USD-only=20codebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EUR/USD never had a validated edge (2026-07-11/12 verdict: no config passed validation on M5/M15/H1 at 1-pip cost; cost-to-volatility root cause). Removed: EurUsdStrategyConfig, pair_signals/eurusd.py, EUR gates 6h + weekend/day filters + strategy marker + trade geometry in signal_aggregator, EUR pair registry/aliases in main.py and gpu_backtest.py, EUR/USD news pair mappings, test_all.py Module 10. Generic multi-currency news keyword infra kept (GBP/JPY/EUR keyword table is not pair code). Full EUR history remains at 7d3fd95. Suite: 162/162 green (was 187; 25 EUR-specific tests removed). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015kby7M9JwpYi8NQT2yoxAi --- CLAUDE.md | 70 ++-- backtest/diagnose_filters.py | 2 +- backtest/gpu_backtest.py | 18 +- backtest/research/calendar_store.py | 4 +- config.py | 54 +--- main.py | 7 +- news_engine.py | 3 +- pair_signals/__init__.py | 4 +- pair_signals/eurusd.py | 158 ---------- signal_aggregator.py | 194 +----------- telegram_bot.py | 2 +- test_all.py | 474 +--------------------------- test_ws_dual.py | 4 +- 13 files changed, 75 insertions(+), 919 deletions(-) delete mode 100644 pair_signals/eurusd.py diff --git a/CLAUDE.md b/CLAUDE.md index bb494a4..db6913f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # ForexBot2 — Codebase Reference -Signal-intelligence platform for **XAU/USD (gold — live, validated)** and **EUR/USD (functional but OFF by default — no validated edge, see below)**. It fetches OHLCV from Twelve Data, 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. +Signal-intelligence platform for **XAU/USD (gold — live, validated)**. It fetches OHLCV from Twelve Data, 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 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. @@ -20,23 +20,22 @@ This file is the authoritative developer/agent reference. The human-facing docs | `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) | -| `pair_signals/eurusd.py` | 158 | EUR config factory + EUR regime weights (docstring = no-edge verdict + tuning history) | | `main.py` | ~348 | CLI: `python main.py --pair XAUUSD --timeframe H1 --detail/--json` | -| `test_all.py` | ~2570 | QA suite: **187 tests in 11 modules**, no live API needed. Must stay green. | +| `test_all.py` | ~2100 | QA suite: **162 tests in 10 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`, `eur_verify.csv`, …). **Gitignored, local-only** — used for manual before/after comparison, not by tests. `eth/btc/gbp_verify.csv` come from research whose pair code is NOT on main | +| `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) | | `test_ws_dual.py` | 108 | Standalone WebSocket dual-connection probe | | `logs/telegram.log` | — | Bot log (midnight rotation, 30 backups) | --- -## Pair status (do not confuse the two) +## Pair status -- **XAU/USD**: the tuned, validated product. It is the only entry in `PlatformConfig.scan_pairs`. All defaults in `PlatformConfig`/`XauStrategyConfig` are its tuned values. -- **EUR/USD**: fully wired (factory, gates, tests) but **not in `scan_pairs`** and its signals are informational only. Verdict (2026-07-11/12, recorded in `pair_signals/eurusd.py` docstring): no configuration passed validation on M5, M15, or H1 at a 1-pip round-trip cost; root cause is 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. Do not re-open EUR tuning without new data (tick fills or H4/D1 horizons). +- **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). --- @@ -48,7 +47,7 @@ Twelve Data REST (+ WebSocket ticks in the bot) → engine.TechnicalEngine.analyze() per timeframe: 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, EUR, regime) → + 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 ``` @@ -59,17 +58,17 @@ Signal currency everywhere is the `SignalStrength` enum: `STRONG_BUY=2, BUY=1, N ## config.py — every knob -### PAIR_INFO (market constants; keys are the canonical symbols `"XAU/USD"`, `"EUR/USD"`) -| Field | XAU/USD | EUR/USD | Meaning | -|---|---|---|---| -| `pip_value` | 0.10 | 0.0001 | price units per pip | -| `price_precision` | 2 | 5 | decimals for SL/TP rounding & display | -| `default_spread` | 0.30 | 0.0001 | round-trip cost charged by the backtester when `--spread` not given | +### 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 (used by EUR). +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; EUR uses its own copies) +### SignalConfig (scoring & trade geometry — XAU-calibrated) | Field | Default | Meaning | |---|---|---| | `mtf_agreement_threshold` | 2 | timeframes (of primary + confirmations) that must agree | @@ -84,7 +83,7 @@ EMA 9/21/50/200 (`ema_mode`: `"4ema"` default or `"2ema"` = 50/200 only); RSI 14 | `weights`, `min_confidence`, `cooldown_bars`, `default_risk_pct`, `default_rr_ratio`, `atr_sl_multiplier` | — | **LEGACY / UNUSED** outside tests. The engine has its own weights; backtest cooldown lives in `BacktestConfig` | ### NewsConfig -Tracked currencies `["USD","XAU","EUR"]`; 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). EUR factory turns the cap back ON. Context cache TTL 600 s (must stay < pre-event buffer). 4 RSS feeds (investinglive, FXStreet, Google-News gold query, CNBC economy). +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). 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 | @@ -103,9 +102,6 @@ Tracked currencies `["USD","XAU","EUR"]`; pre-event buffer 30 min, post-event 15 Legacy fields kept for config-shape compatibility: `rsi_entry_threshold`, `min_ema200_tfs`, `allow_generic_trades`, `buy_threshold_boost`, `m5_confirmation_bars`, `h4_rsi_gate=False`, `h1_macd_gate=False`, `rsi_zone` — not wired to current gates. -### EurUsdStrategyConfig (EUR-local; nothing gold-calibrated leaks in) -Blocked hours [22,23,0,1,2,3]; weekdays Mon–Fri (M5 factory: Mon–Thu, Fridays bled −17R); `min_atr_threshold=0.0003`; SL: swing-buffer ×1.5 ATR, floor ×1.2 ATR (M5: 0.3/0.7), min 8 pips; `min_rr_ratio=1.0`; `mature_move_threshold=35` (|score|>35 → tighter TP 1.2 ATR, else 1.8 ATR); `blocked_regimes=["ranging","transitioning"]`; `min_quality_grades=["A+","A","B"]`; `min_entry_score=38` / `min_entry_confidence=60` (M5 factory: 35/45); `block_rsi_extended=True` (no BUY at RSI>70, no SELL at RSI<30); costs: `round_trip_cost=0.0001`, `min_tp_cost_multiple=10` (10-pip TP minimum), `min_sl_pips=8`. `be_trigger_r`/`be_buffer_r` are legacy (superseded by trailing exits). - ### 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). @@ -155,7 +151,7 @@ Structure S/R (not scored): swing highs/lows (window 3) over `swing_sr_lookback` ### 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 — EUR does this): +**Gold regime multipliers** (defaults in `_compute_composite`; `technical.regime_weights_override` replaces the whole dict): | Indicator | trending_up/down/trending | ranging | transitioning | |---|---|---|---| @@ -174,7 +170,7 @@ For each scored indicator: `effective_weight = base_weight × regime_multiplier` ## signal_aggregator.py — SignalAggregator Two entry points: -- **`analyze_pair(symbol)`** — LIVE: applies XAU/EUR 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_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) @@ -185,7 +181,7 @@ Two entry points: 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; 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 for XAU, True for EUR). + - 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; `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 nearest swing); `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. @@ -199,10 +195,9 @@ Two entry points: - **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. - - **6h EUR gates** (EUR only): blocked regimes (ranging/transitioning), quality < B, exhaustion count, session hours, |score| < min_entry_score, conf < min_entry_confidence, blocked vol regimes, indicator-agreement floor, RSI-extended block → each sets grade F with a warning. - **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`; EUR setups get an `"EUR STRATEGY: …"` marker inserted at reasons[0] (the backtester tags strategy from reasons[0]). +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. ### Trade setup generators (exact geometry) @@ -210,10 +205,10 @@ Two entry points: **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 (XAU 1.0, EUR = `eur.sl_atr_multiplier`); 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); EUR: max(1.2×ATR, 8 pips) (M5 EUR: 0.7×ATR). -- TP1 = nearest swing beyond entry ± 0.3 ATR if it's within `tp_swing_cap` ATR (XAU 2.5) AND ≥ SL×RR-floor; else entry ± max(`tp_mult`×ATR, SL×RR-floor). XAU: tp_mult 1.5, RR-floor = max(min_rr 0.5, tp_floor_rr — 1.0 on M15). EUR: tp_mult 1.2 (mature move, |score|>35) or 1.8, RR-floor 1.0. +- 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 (XAU $9; EUR 10 pips). +- 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`. @@ -245,11 +240,11 @@ Two entry points: ## 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, EUR/USD. +`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. **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 both factories, 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.) +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). @@ -257,8 +252,6 @@ Default confirmation map (same in both factories, the bot, and backtest usage): - 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). - The factory must stay equivalent to inline `PlatformConfig(symbol=XAUUSD, …)` + overrides — **enforced by a test in test_all.py**. -**eurusd.py**: sets symbol/display/pip, `xau_strategy.enabled = False` (belt-and-braces; XAU gates are also display-gated), `news.event_risk_caps_signals = True`, EUR session (7–20 h window, blocked [22,23,0-3], Mon–Fri), EUR regime-weight overrides (`EUR_REGIME_WEIGHTS`, and `M5_REGIME_WEIGHTS` + tighter trailing 0.45/0.5 + Mon–Thu + extra blocked hours [8,12,13,18,19] + swing lookback 20 on M5). Docstring = the complete no-edge verdict and closed research avenues. - --- ## telegram_bot.py — the bot @@ -278,7 +271,7 @@ Run: `python telegram_bot.py` (env `TELEGRAM_TOKEN`, `TELEGRAM_CHAT_ID`, `TELEGR ## main.py — CLI -`python main.py [--pair XAUUSD|EURUSD] [--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`. +`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`. --- @@ -288,7 +281,7 @@ Replays history bar-by-bar through the **real `SignalAggregator.analyze_from_dat **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, EUR 1 pip). Results before 2026-07-08 (+59.5R baseline era) charged no spread — not comparable. +- 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. @@ -297,13 +290,12 @@ Replays history bar-by-bar through the **real `SignalAggregator.analyze_from_dat **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`, `EUR`, `GENERIC` (plus legacy `XAU`, `GBP_PB` recognized). `--strategies` filters the book by tag. +**Strategy tags** (from reasons[0] markers): `XAU_PB`, `GENERIC` (plus legacy `XAU` recognized). `--strategies` filters the book by tag. **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 -python backtest/gpu_backtest.py --pair EURUSD --primary-tf 5min --confirm-tfs 15min,1h --days 365 # 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 ``` @@ -317,7 +309,7 @@ Data: paginated TD fetches (5000-bar chunks) cached as parquet in `backtest/cach ## test_all.py — QA suite -`python test_all.py` — **187 tests, 11 modules** (config, data_provider, engine, news_engine, signal_aggregator, main, telegram_bot, integration, pair_signals registry + engine-bug regressions, EUR/USD support, 2026-07-16 audit-fix regressions). 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: pair-isolation tests (no gold logic on EUR configs and vice versa), the xauusd-factory ≡ inline-config equivalence test, engine bug-fix regression tests (Module 9b), and a live-vs-GPU engine parity sweep incl. the EMA-ribbon path (Module 11). **Any signal-logic change must keep this green and should add tests.** +`python test_all.py` — **162 tests, 10 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). 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), and a live-vs-GPU engine parity sweep incl. the EMA-ribbon path (Module 11). **Any signal-logic change must keep this green and should add tests.** --- @@ -348,9 +340,9 @@ Dependencies (`requirements.txt`): numpy, pandas, requests, twelvedata, python-t ## 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 / EUR 1 pip round trip), with trailing exits and next-open fills. For EUR (and 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. +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"`); EUR reads only `EurUsdStrategyConfig` for costs/geometry. Tests enforce zero XAU drift when EUR changes and vice versa. +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). diff --git a/backtest/diagnose_filters.py b/backtest/diagnose_filters.py index 96b688b..086f278 100644 --- a/backtest/diagnose_filters.py +++ b/backtest/diagnose_filters.py @@ -554,7 +554,7 @@ def main(argv: Optional[List[str]] = None) -> int: description="Diagnose which filters reject the most bars in the backtest pipeline", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) - p.add_argument("--pair", required=True, help="EURUSD | GBPUSD | USDJPY | XAUUSD") + p.add_argument("--pair", required=True, help="XAUUSD | GOLD") p.add_argument("--days", type=int, default=365, help="Days of history") p.add_argument("--start", help="ISO date YYYY-MM-DD (overrides --days)") p.add_argument("--end", help="ISO date YYYY-MM-DD (defaults to today)") diff --git a/backtest/gpu_backtest.py b/backtest/gpu_backtest.py index ac4be6d..9eaa89e 100644 --- a/backtest/gpu_backtest.py +++ b/backtest/gpu_backtest.py @@ -49,7 +49,6 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from config import ( # noqa: E402 - EURUSD, PAIR_INFO, PlatformConfig, SignalStrength, @@ -98,9 +97,6 @@ PAIR_ALIASES = { "XAUUSD": XAUUSD, "XAU/USD": XAUUSD, "GOLD": XAUUSD, - "EURUSD": EURUSD, - "EUR/USD": EURUSD, - "EUR": EURUSD, } # Bar duration in seconds — used to compute "fully-closed" cutoffs so @@ -1068,7 +1064,7 @@ class Trade: final_confidence: float market_regime: str volatility_regime: str = "" - strategy_tag: str = "" # "XAU", "XAU_PB", "EUR_PB", "GBP_PB", "GENERIC" + strategy_tag: str = "" # "XAU", "XAU_PB", "GENERIC" # Per-trade trailing overrides (0 = use the run-level params). trail_arm_r: float = 0.0 trail_dist_r: float = 0.0 @@ -1422,7 +1418,7 @@ class BacktestConfig: quality_filter: List[str] = field( default_factory=lambda: ["A+", "A", "B", "C"] ) - # Restrict to specific strategy tags (XAU / EUR_PB / GBP_PB / GENERIC). + # Restrict to specific strategy tags (XAU / XAU_PB / GENERIC). # Empty = take all. Use to A/B test whether GENERIC trades add or destroy edge. strategies_only: List[str] = field(default_factory=list) # Round-trip transaction cost in price units, charged on every trade @@ -1547,10 +1543,6 @@ def _replay_span(span: Tuple[int, int]): tag = "XAU_PB" elif "XAU STRATEGY" in first_bull: tag = "XAU" - elif "EUR STRATEGY" in first_bull or "EUR STRATEGY" in first_bear: - tag = "EUR" - elif "GBP STRATEGY" in first_bull: - tag = "GBP_PB" if bt_cfg.strategies_only and tag not in bt_cfg.strategies_only: continue @@ -2132,7 +2124,7 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: description="GPU-accelerated backtester for ForexBot2", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) - p.add_argument("--pair", required=True, help="XAUUSD | GOLD | EURUSD") + p.add_argument("--pair", required=True, help="XAUUSD | GOLD") p.add_argument("--days", type=int, default=180, help="Days of history (ignored if --start/--end set)") p.add_argument("--start", help="ISO date YYYY-MM-DD (overrides --days)") p.add_argument("--end", help="ISO date YYYY-MM-DD (defaults to today)") @@ -2149,11 +2141,11 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: help="Override TechnicalConfig.swing_sr_lookback (0 = use config default)") p.add_argument("--quality", default="A+,A,B,C", help="Comma-separated allowed grades") p.add_argument("--strategies", default="", - help="Comma-separated strategy tags to include (XAU,XAU_PB,EUR,GENERIC). Empty = all.") + help="Comma-separated strategy tags to include (XAU,XAU_PB,GENERIC). Empty = all.") 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 " - "default_spread from PAIR_INFO — XAU $0.30, EUR 0.0001; 0 disables)") + "default_spread from PAIR_INFO — XAU $0.30; 0 disables)") p.add_argument("--export-csv", help="Optional path to dump per-trade CSV (incl. indicator breakdown)") p.add_argument("--detail", action="store_true", help="Print full per-trade reasoning (indicators, signals, agreement, why entered)") diff --git a/backtest/research/calendar_store.py b/backtest/research/calendar_store.py index a353051..3327e1c 100644 --- a/backtest/research/calendar_store.py +++ b/backtest/research/calendar_store.py @@ -6,7 +6,7 @@ alignment (see scraper docstring). Key API: cal = load_calendar() # full DataFrame, `utc` column - ev = precise_events(cal, ["USD", "EUR"]) # high-impact, minute-accurate + ev = precise_events(cal, ["USD"]) # high-impact, minute-accurate mask = event_window_mask(bar_index, ev.utc, before, after) """ from pathlib import Path @@ -47,7 +47,7 @@ def load_calendar(path: Path = PARQUET) -> pd.DataFrame: def precise_events( cal: pd.DataFrame, - currencies: Iterable[str] = ("USD", "EUR"), + currencies: Iterable[str] = ("USD",), impacts: Iterable[str] = ("high",), require_values: bool = False, ) -> pd.DataFrame: diff --git a/config.py b/config.py index 9ae64c6..9d860c8 100755 --- a/config.py +++ b/config.py @@ -37,7 +37,6 @@ class MarketSession(Enum): # ── Supported pairs ────────────────────────────────────────────── XAUUSD = "XAU/USD" -EURUSD = "EUR/USD" # price_precision: decimals for SL/TP rounding and display. # default_spread: round-trip cost per trade in price units, charged by @@ -45,8 +44,6 @@ EURUSD = "EUR/USD" PAIR_INFO = { XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False, "price_precision": 2, "default_spread": 0.30}, - EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False, - "price_precision": 5, "default_spread": 0.0001}, } @@ -165,7 +162,7 @@ class SignalConfig: @dataclass class NewsConfig: """Economic calendar & sentiment.""" - tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU", "EUR"]) + tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU"]) pre_event_buffer_minutes: int = 30 post_event_buffer_minutes: int = 15 # Whether event risk gates the SIGNAL (danger: confidence hard-capped @@ -278,54 +275,6 @@ class XauStrategyConfig: m5_pullback_touch_bars: int = 3 -@dataclass -class EurUsdStrategyConfig: - """EUR/USD strategy config — separate from XAU for independent tuning. - - Values ported from feature/eurusd-support are a pre-realism baseline - (tuned before spread charging and next-open fills) and are re-tuned - against the realistic backtester. Cost knobs (round_trip_cost, - min_tp_cost_multiple, min_sl_pips) are EUR-local so nothing - gold-calibrated from SignalConfig leaks into the EUR path. - """ - enabled: bool = True - blocked_hours_utc: List[int] = field(default_factory=lambda: [22, 23, 0, 1, 2, 3]) - weekend_filter: bool = True - allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4]) - min_atr_threshold: float = 0.0003 - sl_atr_multiplier: float = 1.5 # swing-buffer ATR multiple for SL placement - tp_atr_multiplier: float = 1.2 - - sl_atr_floor: float = 1.2 - min_rr_ratio: float = 1.0 - mature_move_threshold: float = 35.0 - tp_quality_scale: Dict[str, float] = field(default_factory=lambda: { - "A+": 1.0, "A": 1.0, "B": 1.0, "C": 1.0, - }) - blocked_regimes: List[str] = field(default_factory=lambda: ["ranging", "transitioning"]) - min_quality_grades: List[str] = field(default_factory=lambda: ["A+", "A", "B"]) - max_exhaustion_tags: int = 99 - block_near_ema200: bool = False - session_hours: List[int] = field(default_factory=list) - min_entry_score: float = 38.0 - min_entry_confidence: float = 60.0 - block_rsi_extended: bool = True - blocked_vol_regimes: List[str] = field(default_factory=list) - min_indicator_agreement: int = 0 - be_trigger_r: float = 0.3 - be_buffer_r: float = 0.15 - - # ── EUR-local trade-cost knobs (replace SignalConfig's gold-dollar - # values in the EUR path) ───────────────────────────────────────── - # Round-trip cost per trade in price units (1.0 pip). - round_trip_cost: float = 0.0001 - # Reject setups whose TP1 distance < round_trip_cost × this multiple - # (10 → 10-pip minimum target; EUR M5 ATR runs ~5-8 pips). - min_tp_cost_multiple: float = 10.0 - # Minimum SL distance in pips. - min_sl_pips: float = 8.0 - - @dataclass class FilterConfig: """Regime and session filters for live signals.""" @@ -349,7 +298,6 @@ class PlatformConfig: news: NewsConfig = field(default_factory=NewsConfig) backtest: FilterConfig = field(default_factory=FilterConfig) xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig) - eur_strategy: EurUsdStrategyConfig = field(default_factory=EurUsdStrategyConfig) symbol: str = XAUUSD display_name: str = "XAU/USD" diff --git a/main.py b/main.py index afb6d37..a74d3ec 100755 --- a/main.py +++ b/main.py @@ -21,7 +21,7 @@ if hasattr(sys.stdout, "reconfigure"): if hasattr(sys.stderr, "reconfigure"): sys.stderr.reconfigure(encoding="utf-8") -from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD, EURUSD +from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD from pair_signals import PAIR_PRODUCERS from signal_aggregator import SignalAggregator, SignalReport @@ -99,7 +99,7 @@ def _compact_reason(reason: str) -> str: def _fmt_price(price: float) -> str: - """2dp for gold-scale prices, 5dp for FX-scale (EUR/USD etc.).""" + """2dp for gold-scale prices, 5dp for sub-10 FX-scale prices.""" return f"{price:.2f}" if price >= 10 else f"{price:.5f}" @@ -257,7 +257,7 @@ Examples: """, ) parser.add_argument("--pair", "-p", default="XAUUSD", - help="Pair to analyze: XAUUSD | EURUSD (default: XAUUSD)") + help="Pair to analyze: XAUUSD (default: XAUUSD)") parser.add_argument("--timeframe", "-tf", default="H1", choices=["M5", "M15", "M30", "H1", "H4", "D1"], help="Primary analysis timeframe (default: H1)") @@ -274,7 +274,6 @@ Examples: def _resolve_pair(s: str) -> str: aliases = { "XAUUSD": XAUUSD, "XAU/USD": XAUUSD, "GOLD": XAUUSD, - "EURUSD": EURUSD, "EUR/USD": EURUSD, "EUR": EURUSD, } key = s.upper().strip() if key in aliases: diff --git a/news_engine.py b/news_engine.py index c7c20f4..1b7c4f0 100755 --- a/news_engine.py +++ b/news_engine.py @@ -673,8 +673,7 @@ class NewsEngine: @staticmethod def _get_affected_pairs(currency: str) -> List[str]: _MAP = { - "USD": ["XAU/USD", "EUR/USD"], + "USD": ["XAU/USD"], "XAU": ["XAU/USD"], - "EUR": ["EUR/USD"], } return _MAP.get(currency, []) diff --git a/pair_signals/__init__.py b/pair_signals/__init__.py index a60436c..95440de 100644 --- a/pair_signals/__init__.py +++ b/pair_signals/__init__.py @@ -45,8 +45,6 @@ def produce_signal( from pair_signals import xauusd as _xau_mod -from pair_signals import eurusd as _eur_mod -from config import XAUUSD, EURUSD +from config import XAUUSD register_pair(XAUUSD, _xau_mod.make_config) -register_pair(EURUSD, _eur_mod.make_config) diff --git a/pair_signals/eurusd.py b/pair_signals/eurusd.py deleted file mode 100644 index d3fc8aa..0000000 --- a/pair_signals/eurusd.py +++ /dev/null @@ -1,158 +0,0 @@ -"""EUR/USD signal producer config. - -Re-tuned 2026-07-10 against the realistic backtester (1-pip round-trip -cost, next-open fills, raw-path resolution) on 2025-07-08 → 2026-07-08 -M5 with M15/H1 confirmations. RESULT: no configuration passed the -acceptance rule (positive in BOTH train and val halves, PF >= 1.3). - -The M5 defaults below are the best found — roughly breakeven -(-1.1R / 53 trades / PF 0.93 / 62% WR on 12mo) versus the donor -branch's -6.7R. Findings that shaped them: - - transitioning regime bled -27R/88 trades wide-open → blocked - - A-grade entries LOSE (-26R) while B-grade wins — max conviction - fires at move extremes (same pathology as gold M5) - - Fridays (-17R) and hours 8, 12, 13, 18, 19 UTC negative - - score 30-35 band is where the train-half losses live → floor 35 - - trailing exits beat static TP1 (arm 0.45R / dist 0.5R best) - -M15 + H1 investigated 2026-07-11, verdict NO VALIDATED EDGE either -(all at 1-pip round-trip cost, real fills): - - M15 wide-open (all gates off): -2.5R/270 trades 2025-26; - -54.8R/308 trades on 2024-25 out-of-sample. PF 0.61-0.98. - - Best in-sample slice (transitioning-regime-only: +15.4R, PF 1.59, - positive both halves, month-spread) FAILED 2024-25 OOS at PF 0.55. - Within-year train/val splits are not sufficient for EUR — always - re-test on the 2024 window before believing a config. - - Raw-parquet forward-return screens (M15 and M5, ~14 entry - conditions x 4-5 horizons: momentum, mean-reversion, breakout, - streak, session families): nothing clears +1 pip in both years. - - H1 wide-open: +9.8R PF 1.32 in 2025-26 (both halves positive) but - -5.7R PF 0.82 on 2024-25. Positive from Jan 2025 onward; H2-2024 - regime negative. Closest to workable, still unvalidated. - -Root cause is cost-to-volatility: EUR M15 ATR ~= 10 pips, so the 1-pip -spread taxes every trade ~10% of ATR (M5 worse). The XAU signal engine -survives because gold's ATR/spread ratio is several times larger. - -EUR/USD therefore ships FUNCTIONAL BUT OFF by default: it is not in -PlatformConfig.scan_pairs. Scan it on demand (main.py --pair EURUSD, -or add to scan_pairs) but treat all EUR M5/M15 signals as informational -only. - -Calendar + hybrid avenues CLOSED 2026-07-12 (see backtest/research/): - - Surprise-conditioned entries (real Forex Factory archive, verified - timestamps): follow-surprise loses 2-5 pips at every horizon in - both years under every conditioning. The market prices the - surprise within the release bar. - - Event-window avoidance: EUR effect marginal/inconsistent (and on - XAU it would REMOVE profit — event-window trades outperform). - - H1-anchored direction + M15 pullback execution (pre-registered - kill criteria): 2025-26 +2.8R PF 1.11, 2024-25 OOS -1.5R PF 0.92 - -> killed. -Remaining avenues need data we don't have: tick-level fills for -limit-entry cost reduction, or H4/D1 swing horizons. -""" - -from typing import Optional, List -from config import PlatformConfig, Timeframe, EURUSD - -_CONFIRMATIONS = { - Timeframe.M5: [Timeframe.M15, Timeframe.H1], - Timeframe.M15: [Timeframe.M5, Timeframe.H1], - Timeframe.H1: [Timeframe.M15, Timeframe.H4], - Timeframe.H4: [Timeframe.H1, Timeframe.D1], -} - -_TRENDING = { - "ema_trend": 2.0, "rsi": 1.0, "macd": 2.0, - "bollinger": 0.5, "keltner": 1.0, "roc": 1.5, - "candlestick": 0.5, "divergence": 2.0, "adx": 1.0, -} - -EUR_REGIME_WEIGHTS = { - "trending_up": _TRENDING, - "trending_down": _TRENDING, - "trending": _TRENDING, - "ranging": { - "ema_trend": 0.3, "rsi": 2.0, "macd": 1.0, - "bollinger": 2.0, "keltner": 1.5, "roc": 0.5, - "candlestick": 0.8, "divergence": 2.5, "adx": 0.5, - }, - "transitioning": { - "ema_trend": 1.5, "rsi": 1.5, "macd": 2.0, - "bollinger": 1.0, "keltner": 1.5, "roc": 2.0, - "candlestick": 0.8, "divergence": 2.0, "adx": 1.0, - }, -} - -_M5_TRENDING = { - "ema_trend": 1.5, "rsi": 1.0, "macd": 2.5, - "bollinger": 0.5, "keltner": 2.0, "roc": 2.0, - "candlestick": 0.3, "divergence": 1.0, "adx": 1.0, -} - -M5_REGIME_WEIGHTS = { - "trending_up": _M5_TRENDING, - "trending_down": _M5_TRENDING, - "trending": _M5_TRENDING, - "ranging": { - "ema_trend": 0.3, "rsi": 1.5, "macd": 1.5, - "bollinger": 1.5, "keltner": 2.0, "roc": 1.0, - "candlestick": 0.3, "divergence": 1.5, "adx": 0.5, - }, - "transitioning": { - "ema_trend": 1.0, "rsi": 1.0, "macd": 2.5, - "bollinger": 0.5, "keltner": 2.5, "roc": 2.0, - "candlestick": 0.3, "divergence": 1.0, "adx": 1.0, - }, -} - - -def make_config( - primary_tf: Timeframe, - confirmation_tfs: Optional[List[Timeframe]] = None, -) -> PlatformConfig: - config = PlatformConfig() - config.symbol = EURUSD - config.display_name = "EUR/USD" - config.pip_value = 0.0001 - config.primary_timeframe = primary_tf - config.confirmation_timeframes = ( - confirmation_tfs or _CONFIRMATIONS.get(primary_tf, [Timeframe.M15, Timeframe.H4]) - ) - - # XAU gates are display-gated in the aggregator; disabling here is - # belt-and-braces so no gold logic can ever fire on an EUR config. - config.xau_strategy.enabled = False - - # Keep event-risk signal gating ON for EUR (the XAU-tuned default - # turned it off after the 2026-07-11 audit — that evidence is - # gold-specific; EUR event-window trades were flat-to-negative). - config.news.event_risk_caps_signals = True - - config.backtest.session_start_hour = 7 - config.backtest.session_end_hour = 20 - config.backtest.blocked_hours_utc = [22, 23, 0, 1, 2, 3] - config.backtest.allowed_weekdays = [0, 1, 2, 3, 4] - - if primary_tf == Timeframe.M5: - config.technical.regime_weights_override = M5_REGIME_WEIGHTS - config.technical.swing_sr_lookback = 20 - config.eur_strategy.sl_atr_floor = 0.7 - config.eur_strategy.sl_atr_multiplier = 0.3 - config.eur_strategy.min_quality_grades = ["A+", "A", "B"] - # Best-found 2026-07-10 (breakeven, see module docstring): - config.eur_strategy.min_entry_score = 35.0 - config.eur_strategy.min_entry_confidence = 45.0 - config.eur_strategy.blocked_vol_regimes = [] - config.eur_strategy.min_indicator_agreement = 0 - config.eur_strategy.allowed_weekdays = [0, 1, 2, 3] # Fridays bled -17R - config.backtest.allowed_weekdays = [0, 1, 2, 3] - config.backtest.blocked_hours_utc = [22, 23, 0, 1, 2, 3, 8, 12, 13, 18, 19] - # Trailing exit beat static TP1; earlier/tighter than gold's 0.6/0.75. - config.signals.trail_arm_r = 0.45 - config.signals.trail_dist_r = 0.5 - else: - config.technical.regime_weights_override = EUR_REGIME_WEIGHTS - - return config diff --git a/signal_aggregator.py b/signal_aggregator.py index 2038b05..326e9bf 100755 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -157,39 +157,6 @@ class SignalAggregator: report.action_recommendation = f"NO TRADE — Only trading {allowed}" return report - if pair_info["display"] == "EUR/USD" and self.config.eur_strategy.enabled: - eur_cfg = self.config.eur_strategy - - if eur_cfg.weekend_filter and _is_forex_weekend(now_utc): - report = SignalReport( - symbol=symbol, - display_name=pair_info["display"], - timestamp=str(now_utc), - ) - report.final_signal = SignalStrength.NEUTRAL - report.signal_quality = "F" - report.trade_setup = None - report.warnings.append("WEEKEND FILTER: EUR/USD market closed — no liquidity") - report.action_recommendation = "NO TRADE — Weekend (market closed)" - return report - - if eur_cfg.allowed_weekdays and now_utc.weekday() not in eur_cfg.allowed_weekdays: - day_names = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 5: "Sat", 6: "Sun"} - allowed = "/".join(day_names.get(d, str(d)) for d in eur_cfg.allowed_weekdays) - report = SignalReport( - symbol=symbol, - display_name=pair_info["display"], - timestamp=str(now_utc), - ) - report.final_signal = SignalStrength.NEUTRAL - report.signal_quality = "F" - report.trade_setup = None - report.warnings.append( - f"DAY FILTER: {day_names.get(now_utc.weekday(), '?')} not in allowed days ({allowed})" - ) - report.action_recommendation = f"NO TRADE — Only trading {allowed}" - return report - timeframes = [self.config.primary_timeframe] + list( self.config.confirmation_timeframes ) @@ -827,93 +794,6 @@ class SignalAggregator: report.signal_quality = "D" report.trade_setup = None - # ── 6h. EUR/USD regime + quality + risk gate ────────────── - if report.display_name == "EUR/USD" and self.config.eur_strategy.enabled: - eur = self.config.eur_strategy - if eur.blocked_regimes and report.market_regime in eur.blocked_regimes: - report.warnings.append( - f"EUR REGIME FILTER: {report.market_regime} blocked" - ) - report.signal_quality = "F" - if (eur.min_quality_grades - and report.signal_quality not in eur.min_quality_grades - and report.signal_quality != "F"): - report.warnings.append( - f"EUR QUALITY FILTER: {report.signal_quality} below minimum" - ) - report.signal_quality = "F" - if (report.signal_quality != "F" - and getattr(report, 'exhaustion_count', 0) > eur.max_exhaustion_tags): - report.warnings.append( - f"EUR EXHAUSTION FILTER: {report.exhaustion_count} tags > max {eur.max_exhaustion_tags}" - ) - report.signal_quality = "F" - if (report.signal_quality != "F" - and eur.block_near_ema200 - and any(w.startswith("NEAR EMA200") for w in report.warnings)): - report.warnings.append("EUR EMA200 FILTER: too close to EMA200") - report.signal_quality = "F" - if eur.session_hours and report.signal_quality != "F": - try: - bar_hour = pd.Timestamp(report.timestamp).hour - if bar_hour not in eur.session_hours: - report.warnings.append( - f"EUR SESSION FILTER: {bar_hour:02}:00 UTC outside allowed hours" - ) - report.signal_quality = "F" - except Exception: - pass - if report.signal_quality != "F": - abs_s = abs(report.final_score) - if abs_s < eur.min_entry_score: - report.warnings.append( - f"EUR SCORE FILTER: |{abs_s:.1f}| < {eur.min_entry_score}" - ) - report.signal_quality = "F" - elif report.final_confidence < eur.min_entry_confidence: - report.warnings.append( - f"EUR CONF FILTER: {report.final_confidence} < {eur.min_entry_confidence}" - ) - report.signal_quality = "F" - if (report.signal_quality != "F" - and eur.blocked_vol_regimes - and report.volatility_regime): - if report.volatility_regime in eur.blocked_vol_regimes: - report.warnings.append( - f"EUR VOL FILTER: {report.volatility_regime} volatility blocked" - ) - report.signal_quality = "F" - if (report.signal_quality != "F" - and eur.min_indicator_agreement > 0 - and report.primary_analysis): - agree_count = sum( - 1 for ind in report.primary_analysis.indicators.values() - if ind.signal.value != 0 - and (ind.signal.value > 0) == (report.final_score > 0) - ) - if agree_count < eur.min_indicator_agreement: - report.warnings.append( - f"EUR AGREE FILTER: {agree_count} < {eur.min_indicator_agreement}" - ) - report.signal_quality = "F" - if (report.signal_quality != "F" - and eur.block_rsi_extended - and report.primary_analysis): - rsi_ind = report.primary_analysis.indicators.get("rsi") - if rsi_ind: - rsi_v = rsi_ind.value - is_buy = report.final_score > 0 - if is_buy and rsi_v > 70: - report.warnings.append( - f"EUR RSI FILTER: RSI {rsi_v:.0f} overbought for BUY" - ) - report.signal_quality = "F" - elif not is_buy and rsi_v < 30: - report.warnings.append( - f"EUR RSI FILTER: RSI {rsi_v:.0f} oversold for SELL" - ) - report.signal_quality = "F" - # ── 6i. M5 noise gate (gold-tuned thresholds — XAU only) ── if (self.config.primary_timeframe == Timeframe.M5 and report.display_name == "XAU/USD"): @@ -934,17 +814,6 @@ class SignalAggregator: # ── 8. Reasons ───────────────────────────────────────────── self._collect_reasons(report) - # ── 8b. EUR strategy marker ──────────────────────────────── - # The backtester tags trades from reasons[0]; mark EUR setups so - # they report under the EUR strategy instead of GENERIC. - if (report.display_name == "EUR/USD" and self.config.eur_strategy.enabled - and report.trade_setup is not None): - marker = (f"EUR STRATEGY: {report.market_regime} regime, " - f"score {report.final_score:+.1f}, quality {report.signal_quality}") - target = (report.bullish_reasons if report.trade_setup.direction == "BUY" - else report.bearish_reasons) - target.insert(0, marker) - # ── 9. M5 pullback entry (XAU_PB) ────────────────────────── # Second archetype: when no breakout setup fired, look for a # trend-continuation pullback (retrace to EMA21/KC-mid). Runs @@ -1304,38 +1173,18 @@ class SignalAggregator: is_buy = report.final_score >= 0 levels = report.primary_analysis.key_levels - _is_eur = report.display_name == "EUR/USD" and self.config.eur_strategy.enabled + min_sl_price = self.config.signals.min_sl_pips * pip_value + min_rr = self.config.signals.min_rr_ratio + tp_floor = max(min_rr, self.config.signals.tp_floor_rr) + tf = self.config.signals.tp_tightening_factor + pre_tight_min_rr = tp_floor / tf if tf > 0 else tp_floor - if _is_eur: - # EUR geometry reads only EurUsdStrategyConfig — nothing - # gold-calibrated from SignalConfig applies here. - eur = self.config.eur_strategy - min_sl_price = eur.min_sl_pips * pip_value - min_rr = eur.min_rr_ratio - # No TP-tightening interplay for EUR: the RR floor used for - # target selection IS min_rr (tp_tightening_factor is 1.0; - # revisit if EUR ever tunes it). - pre_tight_min_rr = min_rr - mature_move = abs(report.final_score) > eur.mature_move_threshold - quality_scale = eur.tp_quality_scale.get(report.signal_quality, 1.0) - tp_mult = (eur.tp_atr_multiplier if mature_move - else eur.tp_atr_multiplier * 1.5) * quality_scale - tp_swing_cap = (2.0 if mature_move else 2.5) * quality_scale - sl_atr_floor = eur.sl_atr_floor - sl_swing_buffer = eur.sl_atr_multiplier - else: - min_sl_price = self.config.signals.min_sl_pips * pip_value - min_rr = self.config.signals.min_rr_ratio - tp_floor = max(min_rr, self.config.signals.tp_floor_rr) - tf = self.config.signals.tp_tightening_factor - pre_tight_min_rr = tp_floor / tf if tf > 0 else tp_floor + tp_mult = 1.5 + tp_swing_cap = 2.5 - tp_mult = 1.5 - tp_swing_cap = 2.5 - - sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier - if report.display_name == "XAU/USD" else 1.5) - sl_swing_buffer = 1.0 + sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier + if report.display_name == "XAU/USD" else 1.5) + sl_swing_buffer = 1.0 if is_buy: swing_lows = sorted( @@ -1419,22 +1268,13 @@ class SignalAggregator: # Spread gate: a target too close to entry hands most of the win # to transaction costs (validated on M5: sub-$10 targets lose). - if _is_eur: - min_tp = eur.round_trip_cost * eur.min_tp_cost_multiple - if tp1_distance < min_tp: - report.warnings.append( - f"SPREAD GATE: target {tp1_distance / pip_value:.1f} pips " - f"< {min_tp / pip_value:.1f} pip minimum — costs would eat the edge" - ) - return None - else: - min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple - if tp1_distance < min_tp: - report.warnings.append( - f"SPREAD GATE: target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — " - f"costs would eat the edge" - ) - return None + min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple + if tp1_distance < min_tp: + report.warnings.append( + f"SPREAD GATE: target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — " + f"costs would eat the edge" + ) + return None risk_pips = sl_distance / pip_value reward_pips = tp1_distance / pip_value diff --git a/telegram_bot.py b/telegram_bot.py index 067724c..5c06083 100755 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -138,7 +138,7 @@ def _build_scan_buttons() -> List[dict]: buttons = [] for symbol in SCAN_PAIRS: slug = symbol.replace("/", "").lower() - base = PAIR_INFO[symbol]["display"].split("/")[0] # XAU, EUR + base = PAIR_INFO[symbol]["display"].split("/")[0] # XAU prefix = f"{base} " if multi else "" for tf, label in _TF_BUTTON_LABELS: buttons.append({ diff --git a/test_all.py b/test_all.py index fc2be4e..46da68d 100644 --- a/test_all.py +++ b/test_all.py @@ -930,9 +930,9 @@ def _(): def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() - context.news_sentiment = {"EUR": 0.5, "USD": -0.1} + context.news_sentiment = {"XAU": 0.5, "USD": -0.1} score, desc = ne.get_pair_sentiment("XAU/USD", context) - assert score > 0, "EUR bullish + USD bearish should be net bullish for EUR/USD" + assert score > 0, "XAU bullish + USD bearish should be net bullish for XAU/USD" assert "bullish" in desc.lower() @@ -940,7 +940,7 @@ def _(): def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() - context.news_sentiment = {"EUR": -0.5, "USD": 0.3} + context.news_sentiment = {"XAU": -0.5, "USD": 0.3} score, desc = ne.get_pair_sentiment("XAU/USD", context) assert score < 0 @@ -949,7 +949,7 @@ def _(): def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() - context.news_sentiment = {"EUR": 0.0, "USD": 0.0} + context.news_sentiment = {"XAU": 0.0, "USD": 0.0} score, desc = ne.get_pair_sentiment("XAU/USD", context) assert score == 0.0 assert "neutral" in desc.lower() @@ -1236,10 +1236,8 @@ def _(): conf_caut_off = recompute("caution", False) assert conf_caut_off == baseline assert any("Upcoming event" in w for w in report.warnings) - # PlatformConfig default is off (XAU-tuned); EUR factory re-enables + # PlatformConfig default is off (XAU-tuned) assert PlatformConfig().news.event_risk_caps_signals is False - from pair_signals.eurusd import make_config as eur_make - assert eur_make(Timeframe.M15).news.event_risk_caps_signals is True @test("SignalAggregator._generate_trade_setup BUY direction") @@ -1472,17 +1470,6 @@ def _(): assert config.primary_timeframe is not None -@test("build_config resolves EURUSD pair to EUR factory config") -def _(): - class FakeArgs: - pair = "EURUSD" - timeframe = "M15" - config = build_config(FakeArgs()) - assert config.symbol == "EUR/USD" - assert config.pip_value == 0.0001 - assert config.primary_timeframe == Timeframe.M15 - - # ══════════════════════════════════════════════════════════════════════ # MODULE 7: telegram_bot.py # ══════════════════════════════════════════════════════════════════════ @@ -1821,12 +1808,12 @@ def _(): print("\n═══ Module 9: pair_signals registry ═══") -@test("pair_signals registry includes XAU/USD and EUR/USD") +@test("pair_signals registry includes XAU/USD only") def _(): import pair_signals pairs = pair_signals.get_supported_pairs() assert XAUUSD in pairs, f"XAU missing from registry: {pairs}" - assert "EUR/USD" in pairs, f"EUR missing from registry: {pairs}" + assert pairs == [XAUUSD], f"registry must be XAU-only: {pairs}" @test("xauusd.make_config equals inline PlatformConfig construction") @@ -1863,18 +1850,6 @@ def _(): f"XAU factory config differs from inline construction for {tf}" -@test("eurusd.make_config produces EUR config with XAU strategy disabled") -def _(): - from pair_signals import PAIR_PRODUCERS - cfg = PAIR_PRODUCERS["EUR/USD"](Timeframe.M5, None) - assert cfg.symbol == "EUR/USD" - assert cfg.pip_value == 0.0001 - assert cfg.xau_strategy.enabled is False - assert cfg.eur_strategy.enabled is True - assert cfg.technical.regime_weights_override, "EUR regime weights not applied" - assert cfg.confirmation_timeframes == [Timeframe.M15, Timeframe.H1] - - @test("produce_signal raises for unregistered pair") def _(): from pair_signals import produce_signal @@ -1943,7 +1918,7 @@ def _(): df = make_ohlcv(n=100, seed=42) df.iloc[-11, df.columns.get_loc("Close")] = 0.0 engine = TechnicalEngine(PlatformConfig().technical) - result = engine.analyze(df, "EUR/USD", Timeframe.H1) + result = engine.analyze(df, "XAU/USD", Timeframe.H1) if "roc" in result.indicators: assert np.isfinite(result.indicators["roc"].value), "ROC should not be inf" @@ -1959,440 +1934,11 @@ def _(): "Close": [100.0]*n, "Volume": [1000.0]*n, }, index=dates) engine = TechnicalEngine(PlatformConfig().technical) - result = engine.analyze(df, "EUR/USD", Timeframe.H1) + result = engine.analyze(df, "XAU/USD", Timeframe.H1) if "adx" in result.indicators: assert np.isfinite(result.indicators["adx"].value), "ADX should be finite on flat data" -print("\n═══ Module 10: EUR/USD Support ═══") - -from config import EURUSD, EurUsdStrategyConfig - -# ── 10a. Config & registry ────────────────────────────────────────── - -@test("PAIR_INFO includes EUR/USD with correct pip_value") -def _(): - assert EURUSD in PAIR_INFO - assert PAIR_INFO[EURUSD]["pip_value"] == 0.0001 - assert PAIR_INFO[EURUSD]["is_jpy_pair"] is False - assert PAIR_INFO[EURUSD]["display"] == "EUR/USD" - assert PAIR_INFO[EURUSD]["price_precision"] == 5 - assert PAIR_INFO[EURUSD]["default_spread"] == 0.0001 - - -@test("EurUsdStrategyConfig defaults are sensible") -def _(): - cfg = EurUsdStrategyConfig() - assert cfg.enabled is True - assert 0 in cfg.blocked_hours_utc - assert 5 not in cfg.allowed_weekdays # No Saturday - assert 6 not in cfg.allowed_weekdays # No Sunday - assert cfg.min_atr_threshold == 0.0003 - assert cfg.min_rr_ratio == 1.0 - assert cfg.mature_move_threshold == 35.0 - assert cfg.blocked_regimes == ["ranging", "transitioning"] - assert "A" in cfg.min_quality_grades - # EUR-local cost knobs must be pip-scale, not gold-dollar-scale - assert cfg.round_trip_cost == 0.0001 - assert cfg.round_trip_cost * cfg.min_tp_cost_multiple < 0.01 - assert cfg.min_sl_pips == 8.0 - - -@test("PlatformConfig has eur_strategy field") -def _(): - config = PlatformConfig() - assert hasattr(config, "eur_strategy") - assert isinstance(config.eur_strategy, EurUsdStrategyConfig) - - -@test("EUR/USD config factory produces correct config") -def _(): - from pair_signals.eurusd import make_config - config = make_config(Timeframe.H1) - assert config.symbol == EURUSD - assert config.display_name == "EUR/USD" - assert config.pip_value == 0.0001 - assert config.xau_strategy.enabled is False - assert config.technical.regime_weights_override - assert "trending" in config.technical.regime_weights_override - assert "ranging" in config.technical.regime_weights_override - - -@test("XAU/USD config factory keeps XAU strategy enabled") -def _(): - from pair_signals.xauusd import make_config - config = make_config(Timeframe.H1) - assert config.symbol == XAUUSD - assert config.display_name == "XAU/USD" - assert config.pip_value == 0.10 - assert config.xau_strategy.enabled is True - - -@test("Mixed 'trending' regime: RSI mid-zone is neutral, extremes only") -def _(): - from engine import TechnicalEngine, TechnicalAnalysis, IndicatorSignal - from config import TechnicalConfig - eng = TechnicalEngine(TechnicalConfig()) - # Pre-fix, mixed "trending" used the uptrend playbook: RSI 45 became - # STRONG_BUY conf 75 in a regime whose EMAs disagree on direction. - for rsi_v, expected in ((45.0, SignalStrength.NEUTRAL), - (75.0, SignalStrength.SELL), - (25.0, SignalStrength.BUY)): - res = TechnicalAnalysis(symbol="XAU/USD", timeframe="1h", timestamp="t") - res.market_regime = "trending" - res.indicators["rsi"] = IndicatorSignal( - name="RSI", value=rsi_v, signal=SignalStrength.NEUTRAL, - confidence=30, description="", weight=1.5) - eng._reinterpret_rsi_for_regime(res) - got = res.indicators["rsi"].signal - assert got == expected, f"RSI {rsi_v} in mixed regime → {got}, want {expected}" - - -@test("XAU regime filter: blocked regime never yields actionable quality") -def _(): - from pair_signals.xauusd import make_config - config = make_config(Timeframe.M15) - config.backtest.session_filter = False - config.xau_strategy.blocked_regimes = [ - "trending", "trending_up", "trending_down", "ranging", "transitioning"] - agg = SignalAggregator(config) - df = make_ohlcv(n=300, start_price=3300.0, trend=0.5, seed=7) - dfs = {Timeframe.M15: df, Timeframe.M5: df, Timeframe.H1: df} - report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) - assert report.signal_quality not in ("A+", "A", "B", "C"), \ - f"blocked regime must not stay actionable, got {report.signal_quality}" - # The M15 factory opts into the mixed-"trending" block; M5 must not. - assert make_config(Timeframe.M15).xau_strategy.blocked_regimes == ["trending"] - assert make_config(Timeframe.M5).xau_strategy.blocked_regimes == [] - - -@test("XAU SELL hour gate: blocks 15/17 UTC bar-close entries, spares others") -def _(): - from pair_signals.xauusd import make_config - - def xau_15min_df(end): - np.random.seed(9) - dates = pd.date_range(end=pd.Timestamp(end, tz="UTC"), - periods=300, freq="15min") - close = 3300.0 + np.cumsum(np.random.randn(300) * 2.0 - 1.0) - return pd.DataFrame({ - "Open": close + np.random.randn(300) * 0.5, - "High": close + np.abs(np.random.randn(300)), - "Low": close - np.abs(np.random.randn(300)), - "Close": close, - "Volume": np.random.randint(100, 10000, 300).astype(float), - }, index=pd.DatetimeIndex(dates, name="Datetime")) - - def run(end, hours): - cfg = make_config(Timeframe.M15) - cfg.backtest.session_filter = False - # Neutralize the other SELL gates so 6g3 is the one under test. - cfg.xau_strategy.sell_min_quality = ["A+", "A", "B", "C"] - cfg.xau_strategy.sell_vol_regimes = ["low", "normal", "high", "extreme"] - cfg.xau_strategy.blocked_regimes = [] - cfg.xau_strategy.sell_blocked_hours_utc = hours - df = xau_15min_df(end) - dfs = {Timeframe.M15: df, Timeframe.M5: df, Timeframe.H1: df} - return SignalAggregator(cfg).analyze_from_dataframes( - symbol=XAUUSD, dfs=dfs, use_news=False) - - # Same synthetic downtrend; only the index end (and thus the bar-close - # hour) differs. Label 14:45 closes 15:00 (blocked); 19:45 closes - # 20:00 (allowed). The gate reads bar-CLOSE time, not the label hour. - control = run("2026-07-13 14:45", []) - assert control.signal_quality in ("A+", "A", "B", "C") and control.final_score < 0, \ - f"probe data must yield an actionable sell, got {control.signal_quality}" - blocked = run("2026-07-13 14:45", [15, 17]) - assert blocked.signal_quality == "D" and blocked.trade_setup is None, \ - f"15:00 UTC close must be gated to D, got {blocked.signal_quality}" - assert any("blocked for shorts" in w for w in blocked.warnings), \ - f"missing SELL hour warning: {blocked.warnings}" - open_hour = run("2026-07-13 19:45", [15, 17]) - assert open_hour.signal_quality == control.signal_quality, \ - f"20:00 UTC close must pass the gate, got {open_hour.signal_quality}" - # Factory scoping: M15 opts in, M5 does not (its book must not move). - assert make_config(Timeframe.M15).xau_strategy.sell_blocked_hours_utc == [15, 17] - assert make_config(Timeframe.M5).xau_strategy.sell_blocked_hours_utc == [] - - -@test("NewsEngine._get_affected_pairs includes EUR/USD for EUR and USD events") -def _(): - from news_engine import NewsEngine - assert "EUR/USD" in NewsEngine._get_affected_pairs("EUR") - assert "EUR/USD" in NewsEngine._get_affected_pairs("USD") - assert "XAU/USD" in NewsEngine._get_affected_pairs("USD") - assert "EUR/USD" not in NewsEngine._get_affected_pairs("GBP") - - -# ── 10b. EUR filter pipeline ──────────────────────────────────────── - -@test("EUR/USD signal analysis produces valid report (mocked data)") -def _(): - from pair_signals.eurusd import make_config - config = make_config(Timeframe.H1) - config.backtest.session_filter = False - agg = SignalAggregator(config) - df = make_ohlcv(n=300, start_price=1.0800, trend=0.00005) - dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} - report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) - assert report.display_name == "EUR/USD" - assert report.signal_quality in ("A+", "A", "B", "C", "D", "F") - assert not any("SELL FILTER" in w for w in report.warnings) - - -@test("EUR filter: blocked regime sets quality F") -def _(): - from pair_signals.eurusd import make_config - config = make_config(Timeframe.H1) - config.eur_strategy.blocked_regimes = ["ranging"] - agg = SignalAggregator(config) - df = make_ohlcv(n=300, seed=42) - dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} - report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) - if report.market_regime == "ranging": - assert report.signal_quality == "F", \ - f"Ranging should be blocked, got {report.signal_quality}" - - -@test("EUR filter: quality not in min_quality_grades sets F") -def _(): - from pair_signals.eurusd import make_config - config = make_config(Timeframe.H1) - config.eur_strategy.min_quality_grades = ["A+"] - agg = SignalAggregator(config) - df = make_ohlcv(n=300, seed=42, trend=0.001) - dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} - report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) - if report.signal_quality not in ("A+", "F"): - assert False, f"Non-A+ grade should be blocked, got {report.signal_quality}" - - -@test("EUR filter: score below min sets F") -def _(): - from pair_signals.eurusd import make_config - config = make_config(Timeframe.H1) - config.eur_strategy.min_entry_score = 99.0 - agg = SignalAggregator(config) - df = make_ohlcv(n=300, seed=42, trend=0.001) - dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} - report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) - assert report.signal_quality == "F", \ - f"Score 99 threshold should block all, got {report.signal_quality}" - - -@test("EUR filter: confidence below min sets F") -def _(): - from pair_signals.eurusd import make_config - config = make_config(Timeframe.H1) - config.eur_strategy.min_entry_confidence = 99.0 - agg = SignalAggregator(config) - df = make_ohlcv(n=300, seed=42, trend=0.001) - dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} - report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) - assert report.signal_quality == "F", \ - f"Confidence 99 threshold should block all, got {report.signal_quality}" - - -@test("EUR filter: RSI extended buy (RSI>70) blocked") -def _(): - from pair_signals.eurusd import make_config - config = make_config(Timeframe.H1) - config.eur_strategy.block_rsi_extended = True - config.eur_strategy.min_entry_score = 0.0 - config.eur_strategy.min_entry_confidence = 0.0 - config.eur_strategy.min_quality_grades = ["A+", "A", "B", "C", "D"] - agg = SignalAggregator(config) - df = make_ohlcv(n=300, trend=0.005, seed=42) - dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} - report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) - if report.primary_analysis and "rsi" in report.primary_analysis.indicators: - rsi_v = report.primary_analysis.indicators["rsi"].value - if report.final_score > 0 and rsi_v > 70: - assert report.signal_quality == "F", \ - f"Buy with RSI {rsi_v:.0f} should be blocked" - - -@test("EUR weekend filter returns F report on Saturday") -def _(): - import signal_aggregator as sa_mod - from pair_signals.eurusd import make_config - config = make_config(Timeframe.H1) - agg = SignalAggregator(config) - saturday = datetime(2026, 7, 11, 12, 0, tzinfo=timezone.utc) - assert saturday.weekday() == 5 - with patch.object(sa_mod, "datetime", wraps=datetime) as mock_dt: - mock_dt.now.return_value = saturday - report = agg.analyze_pair(symbol=EURUSD) - assert report.signal_quality == "F" - assert any("WEEKEND" in w for w in report.warnings) - assert report.trade_setup is None - - -# ── 10c. EUR trade geometry ───────────────────────────────────────── - -def _mk_eur_report(score=45.0, quality="A", atr=0.0006, price=1.0800): - from signal_aggregator import SignalReport - r = SignalReport(symbol=EURUSD, display_name="EUR/USD", timestamp="2026-07-08 14:00") - r.final_signal = SignalStrength.BUY - r.final_score = score - r.final_confidence = 70.0 - r.signal_quality = quality - - class _Analysis: - key_levels = { - "atr": atr, - "swing_low_1": price - 0.0015, - "swing_low_2": price - 0.0030, - "swing_high_1": price + 0.0030, - "swing_high_2": price + 0.0050, - } - r.primary_analysis = _Analysis() - r.current_price = price - return r - - -@test("EUR trade setup: swing SL with EUR buffers, RR >= min_rr") -def _(): - from pair_signals.eurusd import make_config - config = make_config(Timeframe.M5) - agg = SignalAggregator(config) - r = _mk_eur_report() - ts = agg._generate_trade_setup(r, 0.0001) - assert ts is not None, f"no setup, warnings: {r.warnings}" - assert ts.pip_value == 0.0001 - assert ts.stop_loss < ts.entry_price < ts.take_profit_1 - assert ts.risk_pips < 60, f"EUR risk pips insane: {ts.risk_pips}" - assert ts.risk_reward >= config.eur_strategy.min_rr_ratio - 0.01 - # levels must NOT be snapped to gold's 2-decimal grid - assert abs(ts.stop_loss * 100 - round(ts.stop_loss * 100)) > 1e-6 or \ - abs(ts.take_profit_1 * 100 - round(ts.take_profit_1 * 100)) > 1e-6 - - -@test("EUR spread gate rejects sub-minimum pip targets") -def _(): - from pair_signals.eurusd import make_config - config = make_config(Timeframe.M5) - config.eur_strategy.min_rr_ratio = 0.1 - agg = SignalAggregator(config) - r = _mk_eur_report(atr=0.0002) - r.primary_analysis.key_levels = {"atr": 0.0002} - ts = agg._generate_trade_setup(r, 0.0001) - assert ts is None - assert any("SPREAD GATE" in w and "pip" in w for w in r.warnings), r.warnings - - -@test("Per-pair spread gates: 40-pip EUR target passes, $4 XAU target rejected") -def _(): - from pair_signals.eurusd import make_config - # EUR: 40-pip TP distance clears the 10-pip minimum - config = make_config(Timeframe.M5) - agg = SignalAggregator(config) - r = _mk_eur_report(atr=0.0020) - ts = agg._generate_trade_setup(r, 0.0001) - assert ts is not None and abs(ts.take_profit_1 - ts.entry_price) / 0.0001 >= 10 - - # XAU: a $4 target must fail gold's $9 minimum (round_trip 0.30 × 30) - xau_cfg = PlatformConfig() - xau_agg = SignalAggregator(xau_cfg) - from signal_aggregator import SignalReport - xr = SignalReport(symbol=XAUUSD, display_name="XAU/USD", timestamp="2026-07-08 14:00") - xr.final_signal = SignalStrength.BUY - xr.final_score = 45.0 - xr.final_confidence = 70.0 - xr.signal_quality = "B" - - class _XA: - key_levels = {"atr": 2.0} - xr.primary_analysis = _XA() - xr.current_price = 4000.0 - xau_cfg.signals.min_rr_ratio = 0.1 - xau_cfg.signals.tp_floor_rr = 0.1 - ts2 = xau_agg._generate_trade_setup(xr, 0.10) - # ATR 2.0 → TP distance ~3, below the $9 gate - assert ts2 is None - assert any("SPREAD GATE" in w and "$" in w for w in xr.warnings), xr.warnings - - -@test("Trade setup: pip_value zero returns None") -def _(): - config = PlatformConfig() - agg = SignalAggregator(config) - df = make_ohlcv(n=300, trend=0.002, seed=42) - dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} - report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) - if report.primary_analysis: - setup = agg._generate_trade_setup(report, pip_value=0.0) - assert setup is None, "pip_value=0 should produce no trade setup" - - -# ── 10d. Cross-pair isolation ─────────────────────────────────────── - -@test("XAU sell gate does not apply to EUR/USD") -def _(): - from pair_signals.eurusd import make_config - config = make_config(Timeframe.H1) - config.backtest.session_filter = False - agg = SignalAggregator(config) - df = make_ohlcv(n=300, start_price=1.0800, trend=-0.0005, seed=99) - dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} - report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) - assert not any("SELL FILTER" in w for w in report.warnings) - - -@test("EUR report never carries XAU gate warnings across seeds") -def _(): - from pair_signals.eurusd import make_config - for seed in (7, 21, 42, 77, 99): - config = make_config(Timeframe.M5) - agg = SignalAggregator(config) - df = make_ohlcv(n=400, start_price=1.0800, trend=0.0004, seed=seed) - dfs = {Timeframe.M5: df, Timeframe.M15: df, Timeframe.H1: df} - report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) - leaked = [w for w in report.warnings - if "SELL FILTER" in w or "M5 FILTER" in w or "XAU PULLBACK" in w - or "LOW ATR FILTER" in w] - assert not leaked, f"XAU gates leaked into EUR (seed={seed}): {leaked}" - if report.trade_setup: - assert report.trade_setup.pip_value == 0.0001 - - -@test("XAU report identical whether eur_strategy is enabled or disabled") -def _(): - df = make_ohlcv(n=300, seed=42, trend=0.001) - dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} - results = [] - for eur_enabled in (True, False): - config = PlatformConfig() - config.eur_strategy.enabled = eur_enabled - agg = SignalAggregator(config) - report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) - results.append((report.final_score, report.final_confidence, - report.signal_quality, str(report.trade_setup), - tuple(report.warnings))) - assert results[0] == results[1], \ - f"XAU output changed with eur_strategy.enabled flip: {results[0]} vs {results[1]}" - # and no EUR warnings on an XAU report - assert not any("EUR" in w for w in results[0][4]) - - -@test("Integration: XAU and EUR produce independent results") -def _(): - from pair_signals.eurusd import make_config as eur_make_config - df = make_ohlcv(n=300, seed=42, trend=0.001) - - xau_config = PlatformConfig() - xau_agg = SignalAggregator(xau_config) - dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} - xau_report = xau_agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) - - eur_config = eur_make_config(Timeframe.H1) - eur_agg = SignalAggregator(eur_config) - eur_report = eur_agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) - - assert xau_report.display_name == "XAU/USD" - assert eur_report.display_name == "EUR/USD" - - # ══════════════════════════════════════════════════════════════════════ # MODULE 11: 2026-07-16 audit-fix regressions # ══════════════════════════════════════════════════════════════════════ @@ -2514,7 +2060,7 @@ def _(): spike_ts, after_ts = df.index[k], df.index[k + 1] spike_val = df["Close"].iloc[k - 1] * 1.05 # ~+5% on a ~0.1%-σ series df.loc[spike_ts, ["Open", "High", "Low", "Close"]] = spike_val - cleaned = provider._validate_and_clean(df, "EUR/USD", Timeframe.H1) + cleaned = provider._validate_and_clean(df, "XAU/USD", Timeframe.H1) assert spike_ts not in cleaned.index, "spike bar must be removed" assert after_ts in cleaned.index, \ "snap-back bar after the spike must be kept (two-bar hole regression)" diff --git a/test_ws_dual.py b/test_ws_dual.py index 8ac9544..09458e9 100644 --- a/test_ws_dual.py +++ b/test_ws_dual.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Test whether Twelve Data allows 2 simultaneous WebSocket connections for EUR/USD. +"""Test whether Twelve Data allows 2 simultaneous WebSocket connections for XAU/USD. Uses the twelvedata SDK which handles the WS protocol correctly. Runs connection 1, then adds connection 2 after 10s, monitors for 30s total. @@ -11,7 +11,7 @@ import time from twelvedata import TDClient API_KEY = "f563b22ffdfd4924955decbe1a8c8701" -SYMBOL = "EUR/USD" +SYMBOL = "XAU/USD" DURATION = 30 results = { -- 2.52.0 From a70fc91f96b8d618b3cacf8c2d8576aed8cde1cd Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Thu, 16 Jul 2026 14:23:17 -0400 Subject: [PATCH 2/4] =?UTF-8?q?Add=20chart-pattern=20entry=20archetype=20(?= =?UTF-8?q?XAU=5FCP)=20=E2=80=94=20disabled=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third entry archetype: completion of classical chart patterns (double bottom/top, H&S top / inverse H&S, bull/bear flag) — the tight canon with the strongest documented stats. New chart_patterns.py detector module (pure functions, ATR-unit geometry, closed-bar slices only, first-close-beyond trigger discipline so a breakout fires exactly once and never repaints). Pipeline step 10 in analyze_from_dataframes: fills only empty setup slots, allows weak-composite F grades (normal at reversal completion) but inherits session/day blocks, mirrors MTF-opposition + SELL vol/hour + M5 protections. Geometry: SL = pattern anchor -/+ 0.5 ATR (min $8), TP1 = measured move, $9 spread gate, trailing exit does the real work. Strategy tag XAU_CP. cp_enabled defaults to False everywhere — per-TF adoption happens in pair_signals/xauusd.py only after the standard validation rule passes. Backtester: --cp / --cp-patterns flags to test the candidate, XAU_CP tag recognition. Suite: 183/183 (21 new tests: detectors, trigger discipline, invalidation, gates, pipeline, default-off safety). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015kby7M9JwpYi8NQT2yoxAi --- backtest/gpu_backtest.py | 16 +- chart_patterns.py | 287 +++++++++++++++++++++++++++++++++++ config.py | 28 ++++ signal_aggregator.py | 153 +++++++++++++++++++ test_all.py | 318 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 801 insertions(+), 1 deletion(-) create mode 100644 chart_patterns.py diff --git a/backtest/gpu_backtest.py b/backtest/gpu_backtest.py index 9eaa89e..ccf180c 100644 --- a/backtest/gpu_backtest.py +++ b/backtest/gpu_backtest.py @@ -1541,6 +1541,8 @@ def _replay_span(span: Tuple[int, int]): first_bear = report.bearish_reasons[0] if report.bearish_reasons else "" if "XAU PULLBACK" in first_bull or "XAU PULLBACK" in first_bear: tag = "XAU_PB" + elif "XAU CHART PATTERN" in first_bull or "XAU CHART PATTERN" in first_bear: + tag = "XAU_CP" elif "XAU STRATEGY" in first_bull: tag = "XAU" @@ -2141,7 +2143,13 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: help="Override TechnicalConfig.swing_sr_lookback (0 = use config default)") p.add_argument("--quality", default="A+,A,B,C", help="Comma-separated allowed grades") p.add_argument("--strategies", default="", - help="Comma-separated strategy tags to include (XAU,XAU_PB,GENERIC). Empty = all.") + help="Comma-separated strategy tags to include (XAU,XAU_PB,XAU_CP,GENERIC). Empty = all.") + p.add_argument("--cp", action="store_true", + help="Force-enable the chart-pattern entry (XAU_CP) regardless " + "of the factory default — for validating the candidate.") + 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("--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 " @@ -2208,6 +2216,12 @@ def main(argv: Optional[List[str]] = None) -> int: ) if args.swing_sr_lookback > 0: platform_cfg.technical.swing_sr_lookback = args.swing_sr_lookback + if args.cp: + platform_cfg.xau_strategy.cp_enabled = True + if args.cp_patterns: + platform_cfg.xau_strategy.cp_patterns = [ + s.strip() for s in args.cp_patterns.split(",") if s.strip() + ] confirm_tfs = list(platform_cfg.confirmation_timeframes) end = ( diff --git a/chart_patterns.py b/chart_patterns.py new file mode 100644 index 0000000..84895db --- /dev/null +++ b/chart_patterns.py @@ -0,0 +1,287 @@ +""" +Classical chart-pattern detection for the XAU_CP entry archetype. +================================================================ + +Third entry archetype (after the composite-breakout generators and the +XAU_PB pullback): trade the completion of a proven multi-bar price +pattern. Pattern set is deliberately tight — the classical patterns +with the strongest documented statistics (Bulkowski, *Encyclopedia of +Chart Patterns*) that are unambiguous from OHLC at bar close: + + double_bottom bullish reversal two matching swing lows, break of + the interim peak (neckline) + double_top bearish reversal mirrored + inv_hs bullish reversal inverse head & shoulders, break of + the neckline (max of the two peaks + flanking the head — conservative + horizontal line, no sloped fits) + hs_top bearish reversal head & shoulders top, mirrored + bull_flag bullish continuation strong pole, shallow ordered + retrace, close at a new high + bear_flag bearish continuation mirrored + +Triangles / wedges / cup-and-handle are excluded on purpose: weaker +historical stats and apex-timing ambiguity — more parameters for less +edge. + +Design contract (mirrors the rest of the signal stack): +- Detectors see ONLY the visible closed-bar slice the aggregator was + given; the last row is the just-closed signal bar. No forming candle, + no future bars → identical behavior live and in the bar-replay + backtester, and signals never repaint. +- All geometric thresholds are in ATR units so one parameter set is + scale-free across timeframes. ATR comes from the engine (Wilder 14). +- Trigger discipline: a pattern fires only on the bar that COMPLETES it + — the first close beyond the neckline/boundary since the pattern + formed. One breakout → at most one signal. +- Swing points use the same local-extremum definition as the engine's + structure S/R (window w bars each side), so a "swing" here is the + same object a chartist sees on the engine's key levels. + +Every detector returns None or a dict: + {"pattern": str, "direction": "BUY"|"SELL", + "anchor": float, # SL reference (pattern invalidation price) + "trigger": float, # neckline / boundary that was broken + "height": float} # measured-move distance in price units + +Defaults below are the textbook definitions, not tuned values. The +validation record for which patterns/timeframes are enabled lives in +pair_signals/xauusd.py (per repo rules, per-TF adoption happens in the +factory, never in global defaults). +""" + +from typing import Dict, List, Optional + +import numpy as np +import pandas as pd + +__all__ = ["detect_chart_pattern", "PATTERN_DETECTORS"] + + +def _swing_indices(values: np.ndarray, w: int, find_high: bool) -> List[int]: + """Indices of local extrema with `w` bars on each side. + + A bar i is a swing high if values[i] is the strict maximum of + values[i-w : i+w+1] (ties reject — a flat top is not a clean swing). + The last `w` bars can never qualify, which is exactly right: a swing + isn't confirmed until `w` bars close after it. + """ + n = len(values) + out: List[int] = [] + for i in range(w, n - w): + window = values[i - w : i + w + 1] + if np.isnan(window).any(): + continue + center = values[i] + rest = np.delete(window, w) + if find_high: + if center > rest.max(): + out.append(i) + else: + if center < rest.min(): + out.append(i) + return out + + +def _first_close_beyond(closes: np.ndarray, start: int, level: float, + above: bool) -> bool: + """True iff the LAST close is the first one beyond `level` since + `start` (exclusive of the last bar).""" + last = closes[-1] + prior = closes[start:-1] + if above: + return last > level and (len(prior) == 0 or prior.max() <= level) + return last < level and (len(prior) == 0 or prior.min() >= level) + + +# ── Double bottom / top ────────────────────────────────────────────── + +def _detect_double(o, h, l, c, atr, cfg, bullish: bool) -> Optional[Dict]: + w = cfg.cp_swing_window + ext = l if bullish else h + idx = _swing_indices(ext, w, find_high=not bullish) + if len(idx) < 2: + return None + i1, i2 = idx[-2], idx[-1] + n = len(c) + if i2 - i1 < cfg.cp_min_sep_bars: + return None + if (n - 1) - i2 > cfg.cp_recency_bars: + return None + e1, e2 = ext[i1], ext[i2] + if abs(e1 - e2) > cfg.cp_tolerance_atr * atr: + return None + + if bullish: + neckline = float(h[i1 : i2 + 1].max()) + floor = min(e1, e2) + height = neckline - floor + else: + neckline = float(l[i1 : i2 + 1].min()) + ceil_ = max(e1, e2) + height = ceil_ - neckline + + if not (cfg.cp_min_height_atr * atr <= height <= cfg.cp_max_height_atr * atr): + return None + + # Pattern must not have been invalidated after the second extreme: + # a lower low (bullish) / higher high (bearish) breaks the pattern. + if bullish: + if i2 + 1 < n and l[i2 + 1 :].min() < min(e1, e2) - 0.1 * atr: + return None + if not _first_close_beyond(c, i2, neckline, above=True): + return None + return {"pattern": "double_bottom", "direction": "BUY", + "anchor": float(min(e1, e2)), "trigger": neckline, + "height": float(height)} + else: + if i2 + 1 < n and h[i2 + 1 :].max() > max(e1, e2) + 0.1 * atr: + return None + if not _first_close_beyond(c, i2, neckline, above=False): + return None + return {"pattern": "double_top", "direction": "SELL", + "anchor": float(max(e1, e2)), "trigger": neckline, + "height": float(height)} + + +# ── Head & shoulders (top and inverse) ─────────────────────────────── + +def _detect_hs(o, h, l, c, atr, cfg, bullish: bool) -> Optional[Dict]: + """bullish=True → inverse H&S (three swing LOWS, head deepest).""" + w = cfg.cp_swing_window + ext = l if bullish else h + idx = _swing_indices(ext, w, find_high=not bullish) + if len(idx) < 3: + return None + i1, i2, i3 = idx[-3], idx[-2], idx[-1] + n = len(c) + if (n - 1) - i3 > cfg.cp_recency_bars: + return None + s1, head, s2 = ext[i1], ext[i2], ext[i3] + + if bullish: + # Head strictly the deepest low; shoulders roughly level. + if not (head < s1 - cfg.cp_hs_head_min_atr * atr + and head < s2 - cfg.cp_hs_head_min_atr * atr): + return None + if abs(s1 - s2) > cfg.cp_hs_shoulder_tol_atr * atr: + return None + # Neckline: the LOWER of the two interim peaks — the price must + # clear both, so this is the conservative horizontal trigger. + p1 = float(h[i1 : i2 + 1].max()) + p2 = float(h[i2 : i3 + 1].max()) + neckline = min(p1, p2) + height = neckline - head + else: + if not (head > s1 + cfg.cp_hs_head_min_atr * atr + and head > s2 + cfg.cp_hs_head_min_atr * atr): + return None + if abs(s1 - s2) > cfg.cp_hs_shoulder_tol_atr * atr: + return None + t1 = float(l[i1 : i2 + 1].min()) + t2 = float(l[i2 : i3 + 1].min()) + neckline = max(t1, t2) + height = head - neckline + + if not (cfg.cp_min_height_atr * atr <= height <= cfg.cp_max_height_atr * atr): + return None + + if bullish: + # Invalidated if price undercut the head after the right shoulder. + if i3 + 1 < n and l[i3 + 1 :].min() < head - 0.1 * atr: + return None + if not _first_close_beyond(c, i3, neckline, above=True): + return None + return {"pattern": "inv_hs", "direction": "BUY", + "anchor": float(s2), "trigger": neckline, + "height": float(height)} + else: + if i3 + 1 < n and h[i3 + 1 :].max() > head + 0.1 * atr: + return None + if not _first_close_beyond(c, i3, neckline, above=False): + return None + return {"pattern": "hs_top", "direction": "SELL", + "anchor": float(s2), "trigger": neckline, + "height": float(height)} + + +# ── Flags (bull and bear) ──────────────────────────────────────────── + +def _detect_flag(o, h, l, c, atr, cfg, bullish: bool) -> Optional[Dict]: + n = len(c) + lo_r, hi_r = cfg.cp_flag_retrace + if bullish: + # Pole top = highest high BEFORE the trigger bar. + ip = int(np.argmax(h[:-1])) + flag_len = (n - 1) - ip + if not (cfg.cp_flag_min_bars <= flag_len <= cfg.cp_flag_max_bars): + return None + pole_start = max(0, ip - cfg.cp_flag_pole_bars + 1) + pole_low = float(l[pole_start : ip + 1].min()) + pole = float(h[ip]) - pole_low + if pole < cfg.cp_flag_pole_atr * atr: + return None + flag_low = float(l[ip + 1 : n].min()) + retrace = (float(h[ip]) - flag_low) / pole + if not (lo_r <= retrace <= hi_r): + return None + if not _first_close_beyond(c, ip, float(h[ip]), above=True): + return None + return {"pattern": "bull_flag", "direction": "BUY", + "anchor": flag_low, "trigger": float(h[ip]), + "height": float(pole)} + else: + ip = int(np.argmin(l[:-1])) + flag_len = (n - 1) - ip + if not (cfg.cp_flag_min_bars <= flag_len <= cfg.cp_flag_max_bars): + return None + pole_start = max(0, ip - cfg.cp_flag_pole_bars + 1) + pole_high = float(h[pole_start : ip + 1].max()) + pole = pole_high - float(l[ip]) + if pole < cfg.cp_flag_pole_atr * atr: + return None + flag_high = float(h[ip + 1 : n].max()) + retrace = (flag_high - float(l[ip])) / pole + if not (lo_r <= retrace <= hi_r): + return None + if not _first_close_beyond(c, ip, float(l[ip]), above=False): + return None + return {"pattern": "bear_flag", "direction": "SELL", + "anchor": flag_high, "trigger": float(l[ip]), + "height": float(pole)} + + +# Priority order: most structured / rarest first — if two patterns +# complete on the same bar, the more specific one names the trade. +PATTERN_DETECTORS = { + "inv_hs": lambda o, h, l, c, atr, cfg: _detect_hs(o, h, l, c, atr, cfg, True), + "hs_top": lambda o, h, l, c, atr, cfg: _detect_hs(o, h, l, c, atr, cfg, False), + "double_bottom": lambda o, h, l, c, atr, cfg: _detect_double(o, h, l, c, atr, cfg, True), + "double_top": lambda o, h, l, c, atr, cfg: _detect_double(o, h, l, c, atr, cfg, False), + "bull_flag": lambda o, h, l, c, atr, cfg: _detect_flag(o, h, l, c, atr, cfg, True), + "bear_flag": lambda o, h, l, c, atr, cfg: _detect_flag(o, h, l, c, atr, cfg, False), +} + + +def detect_chart_pattern(df: pd.DataFrame, atr: float, cfg) -> Optional[Dict]: + """Run enabled detectors over the last cp_lookback(+1) bars. + + `df` is the visible history at decision time (last row = signal + bar). Returns the first match in PATTERN_DETECTORS priority order, + or None. + """ + if atr <= 0 or len(df) < cfg.cp_lookback + 1: + return None + tail = df.iloc[-(cfg.cp_lookback + 1):] + o = tail["Open"].to_numpy(dtype=np.float64) + h = tail["High"].to_numpy(dtype=np.float64) + l = tail["Low"].to_numpy(dtype=np.float64) + c = tail["Close"].to_numpy(dtype=np.float64) + enabled = set(cfg.cp_patterns) + for name, det in PATTERN_DETECTORS.items(): + if name not in enabled: + continue + hit = det(o, h, l, c, atr, cfg) + if hit is not None: + return hit + return None diff --git a/config.py b/config.py index 9d860c8..16c6347 100755 --- a/config.py +++ b/config.py @@ -274,6 +274,34 @@ class XauStrategyConfig: # Price must have touched EMA21/KC-mid within this many bars. m5_pullback_touch_bars: int = 3 + # ── Chart-pattern entry (XAU_CP) ────────────────────────────── + # Third entry archetype: completion of a classical chart pattern + # (see chart_patterns.py for the pattern set and design contract). + # Defaults are textbook geometry, NOT tuned values. Disabled by + # default — per-TF adoption happens in pair_signals/xauusd.py only + # after the pattern book passes the standard validation rule + # (positive R both halves of ≥12mo, PF ≥ 1.3, $0.30 spread, + # trailing exits, next-open fills). + cp_enabled: bool = False + cp_patterns: List[str] = field(default_factory=lambda: [ + "inv_hs", "hs_top", "double_bottom", "double_top", + "bull_flag", "bear_flag", + ]) + cp_lookback: int = 72 # bars scanned for pattern structure + cp_swing_window: int = 3 # bars each side to confirm a swing + cp_recency_bars: int = 20 # last extreme must be this fresh + cp_min_sep_bars: int = 8 # min bars between double extremes + cp_tolerance_atr: float = 0.5 # double top/bottom level match tol + cp_min_height_atr: float = 1.5 # pattern height floor (noise gate) + cp_max_height_atr: float = 8.0 # pattern height ceiling + cp_hs_head_min_atr: float = 0.5 # head must exceed shoulders by this + cp_hs_shoulder_tol_atr: float = 1.0 # shoulder level match tol + cp_flag_pole_atr: float = 3.0 # min pole size + cp_flag_pole_bars: int = 12 # pole measured over ≤ this many bars + cp_flag_min_bars: int = 3 # flag consolidation length bounds + cp_flag_max_bars: int = 12 + cp_flag_retrace: tuple = (0.1, 0.5) # flag depth as fraction of pole + @dataclass class FilterConfig: diff --git a/signal_aggregator.py b/signal_aggregator.py index 326e9bf..d654df1 100755 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -13,6 +13,7 @@ from typing import Dict, List, Optional import pandas as pd +from chart_patterns import detect_chart_pattern from config import PlatformConfig, SignalStrength, Timeframe from data_provider import ForexDataProvider from engine import TechnicalEngine, TechnicalAnalysis @@ -98,6 +99,8 @@ class SignalAggregator: # Diagnostic funnel for the M5 pullback route (shared across # instances; read by backtest tooling, harmless in live use). pb_funnel: Dict[str, int] = {} + # Same, for the chart-pattern route (XAU_CP). + cp_funnel: Dict[str, int] = {} def __init__(self, config: PlatformConfig): self.config = config @@ -563,6 +566,138 @@ class SignalAggregator: target = report.bullish_reasons if is_buy else report.bearish_reasons target.insert(0, marker) + # ── Chart-pattern entry (XAU_CP) ──────────────────────────────── + + def _try_chart_pattern( + self, report: SignalReport, primary_df: pd.DataFrame, pip_value: float + ) -> None: + """Build an XAU_CP trade setup if a completed chart pattern + passes the pair-level protections. + + Unlike the composite paths there is no minimum-score gate: a + reversal pattern completes exactly where the composite is still + pointing the old way. Protection comes from the pattern's own + structural gates plus the validated directional filters below. + """ + cfg = self.config.xau_strategy + funnel = SignalAggregator.cp_funnel + + def _reject(reason: str) -> None: + funnel[reason] = funnel.get(reason, 0) + 1 + + pa = report.primary_analysis + if pa is None: + return + atr = pa.key_levels.get("atr", 0.0) + # Same $-ATR floor the swing generator applies (dead tape). + if atr < cfg.min_atr_threshold: + return + + pat = detect_chart_pattern(primary_df, atr, cfg) + if pat is None: + return + _reject("detected") + direction = pat["direction"] + is_buy = direction == "BUY" + + # Never against multi-TF consensus (mirror of gate 6f). + sigs = list(report.mtf_signals.values()) + bull_n = sum(1 for s in sigs if s in (SignalStrength.BUY, SignalStrength.STRONG_BUY)) + bear_n = sum(1 for s in sigs if s in (SignalStrength.SELL, SignalStrength.STRONG_SELL)) + thr = self.config.signals.mtf_agreement_threshold + if (is_buy and bear_n >= thr) or (not is_buy and bull_n >= thr): + _reject("mtf_opposition") + return + + # Directional protections — gold's macro-uptrend asymmetry is + # structural, so the validated SELL gates apply to pattern + # shorts too (quiet-vol shorts fade, blocked SELL hours lose). + if not is_buy: + if (report.volatility_regime + and report.volatility_regime not in cfg.sell_vol_regimes): + _reject("sell_vol") + return + if cfg.sell_blocked_hours_utc: + try: + close_h = ( + pd.Timestamp(report.timestamp) + + pd.Timedelta( + seconds=ForexDataProvider.CANDLE_SECONDS[ + self.config.primary_timeframe + ] + ) + ).hour + except Exception: + close_h = None + if close_h in cfg.sell_blocked_hours_utc: + _reject("sell_hour") + return + if self.config.primary_timeframe == Timeframe.M5: + rsi_ind = pa.indicators.get("rsi") + if rsi_ind is not None and rsi_ind.value < cfg.m5_sell_min_rsi: + _reject("m5_sell_rsi") + return + + # M5 chop-transition volatility block (validated, both ways). + if (self.config.primary_timeframe == Timeframe.M5 + and report.volatility_regime in cfg.m5_blocked_vol_regimes): + _reject("vol_regime") + return + + entry = report.current_price + min_sl_price = self.config.signals.min_sl_pips * pip_value + height = pat["height"] + if is_buy: + sl_dist = max(entry - (pat["anchor"] - 0.5 * atr), min_sl_price) + sl = entry - sl_dist + tp1 = entry + height + tp2 = entry + 1.5 * height + else: + sl_dist = max((pat["anchor"] + 0.5 * atr) - entry, min_sl_price) + sl = entry + sl_dist + tp1 = entry - height + tp2 = entry - 1.5 * height + + # The measured move IS the pattern target — if it can't clear + # the RR floor or the spread gate, the pattern is untradeable. + rr = height / sl_dist if sl_dist > 0 else 0.0 + if rr < self.config.signals.min_rr_ratio - 1e-9: + _reject("rr") + return + min_tp = (self.config.signals.round_trip_cost + * self.config.signals.min_tp_cost_multiple) + if height < min_tp: + _reject("spread_gate") + return + + report.trade_setup = TradeSetup( + pair=report.symbol, + display_name=report.display_name, + direction=direction, + entry_price=entry, + stop_loss=sl, + take_profit_1=tp1, + take_profit_2=tp2, + risk_reward=round(rr, 2), + risk_pips=sl_dist / pip_value, + reward_pips=height / pip_value, + pip_value=pip_value, + strategy="XAU_CP", + ) + if report.signal_quality not in ("A+", "A", "B"): + report.signal_quality = "B" + report.final_signal = (SignalStrength.BUY if is_buy + else SignalStrength.SELL) + report.action_recommendation = ( + f"{direction} — {pat['pattern']} pattern completion" + ) + marker = (f"XAU CHART PATTERN: {pat['pattern']} " + f"({direction}) break of {pat['trigger']:.2f}, " + f"height {height / atr:.1f} ATR") + target = report.bullish_reasons if is_buy else report.bearish_reasons + target.insert(0, marker) + _reject("accepted") + # ── Public: bar-replayable entry point ───────────────────────── def analyze_from_dataframes( @@ -825,6 +960,24 @@ class SignalAggregator: and report.signal_quality != "F"): self._try_m5_pullback(report, primary_df, pip_value) + # ── 10. Chart-pattern entry (XAU_CP) ──────────────────────── + # Third archetype: completion of a classical chart pattern + # (double top/bottom, H&S, flag — see chart_patterns.py). Only + # fills empty slots (core + pullback setups take precedence). + # Unlike XAU_PB this must NOT require quality != "F": a weak + # composite (grade F on |score| < 15) is the normal state at a + # reversal-pattern completion. Session/day blocks still bind — + # they are detected by their warnings (and the live wrapper + + # backtest replay apply the bar-close session predicate again + # on top, so a pattern setup can never trade a blocked hour). + if (report.display_name == "XAU/USD" + and self.config.xau_strategy.cp_enabled + and report.trade_setup is None + and not any(w.startswith(("SESSION FILTER", "DAY FILTER", + "WEEKEND FILTER")) + for w in report.warnings)): + self._try_chart_pattern(report, primary_df, pip_value) + return report # ── Internals ────────────────────────────────────────────────── diff --git a/test_all.py b/test_all.py index 46da68d..2a917fa 100644 --- a/test_all.py +++ b/test_all.py @@ -2170,6 +2170,324 @@ def _(): assert t2.entry_time == idx[2] and t2.entry_price == 110.0 and t2.sl == 102.0 +# ══════════════════════════════════════════════════════════════════════ +# MODULE 12: Chart-pattern entry (XAU_CP) +# ══════════════════════════════════════════════════════════════════════ + +print("\n═══ Module 12: chart patterns (XAU_CP) ═══") + +from chart_patterns import detect_chart_pattern +from config import XauStrategyConfig + + +def make_cp_df(closes, end="2026-07-08 14:00", freq="1h", wick=1.5): + """Deterministic OHLCV from an explicit close path (fixed end time: + Wed, label hour 14, close hour 15 — clear of every session block).""" + idx = pd.date_range(end=end, periods=len(closes), freq=freq, tz="UTC") + c = np.asarray(closes, dtype=float) + o = np.concatenate([[c[0]], c[:-1]]) + return pd.DataFrame({"Open": o, "High": c + wick, "Low": c - wick, + "Close": c, "Volume": 1000.0}, index=idx) + + +def mirror_cp_df(df, pivot=6000.0): + """Price-mirror a fixture: bullish pattern → exact bearish twin.""" + return pd.DataFrame({ + "Open": pivot - df["Open"], "High": pivot - df["Low"], + "Low": pivot - df["High"], "Close": pivot - df["Close"], + "Volume": df["Volume"]}, index=df.index) + + +def double_bottom_closes(): + """L1@30 (2984) and L2@65 (2984.5), neckline peak 3000 @45, + below-neckline drift, breakout close 3004 on the last bar.""" + return (list(np.linspace(2988, 3013, 15)) + + list(np.linspace(3011, 2984, 16)) + + list(np.linspace(2986, 3000, 15)) + + list(np.linspace(2999, 2984.5, 20)) + + list(np.linspace(2986, 2998, 13)) + + [3004.0]) + + +def hs_top_closes(): + """S1@20 (3000), head@38 (3010), S2@62 (2999), neckline ~2989.5, + breakdown close 2988 on the last bar.""" + return (list(np.linspace(2982, 3000, 21)) + + list(np.linspace(2998.5, 2990, 7)) + + list(np.linspace(2992, 3010, 11)) + + list(np.linspace(3008, 2991, 8)) + + list(np.linspace(2992, 2999, 16)) + + list(np.linspace(2998.5, 2991, 16)) + + [2988.0]) + + +def bull_flag_closes(): + """Pole 60→69 (3000→3025), flag drift 70→78, breakout close 3028.5.""" + return (list(np.linspace(3000, 2996, 60)) + + list(np.linspace(2999, 3025, 10)) + + list(np.linspace(3023, 3018, 9)) + + [3028.5]) + + +_CP_CFG = XauStrategyConfig() + + +@test("CP: double bottom detected on breakout bar with correct geometry") +def _(): + df = make_cp_df(double_bottom_closes()) + hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) + assert hit is not None, "double bottom not detected" + assert hit["pattern"] == "double_bottom" and hit["direction"] == "BUY" + assert abs(hit["anchor"] - 2982.5) < 1e-9, f"anchor {hit['anchor']}" + assert abs(hit["trigger"] - 3001.5) < 1e-9, f"trigger {hit['trigger']}" + assert abs(hit["height"] - 19.0) < 1e-9, f"height {hit['height']}" + + +@test("CP: double top detected on mirrored fixture") +def _(): + df = mirror_cp_df(make_cp_df(double_bottom_closes())) + hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) + assert hit is not None and hit["pattern"] == "double_top" + assert hit["direction"] == "SELL" + assert abs(hit["anchor"] - (6000 - 2982.5)) < 1e-9 + assert abs(hit["trigger"] - (6000 - 3001.5)) < 1e-9 + + +@test("CP: trigger discipline — no signal before breakout close") +def _(): + df = make_cp_df(double_bottom_closes()).iloc[:-1] + assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None + + +@test("CP: trigger discipline — no re-fire on the bar after breakout") +def _(): + df = make_cp_df(double_bottom_closes() + [3006.0]) + assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None + + +@test("CP: double bottom rejected when lows differ beyond tolerance") +def _(): + closes = double_bottom_closes() + closes[46:66] = list(np.linspace(2999, 2979, 20)) # L2 5.5 below L1 + df = make_cp_df(closes) + assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None + + +@test("CP: double bottom invalidated by a lower low after L2") +def _(): + closes = double_bottom_closes() + closes[77] = 2978.0 # undercuts both bottoms before the breakout + df = make_cp_df(closes) + assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None + + +@test("CP: head & shoulders top detected (SELL, right-shoulder anchor)") +def _(): + df = make_cp_df(hs_top_closes()) + hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) + assert hit is not None, "H&S top not detected" + assert hit["pattern"] == "hs_top" and hit["direction"] == "SELL" + assert abs(hit["anchor"] - 3000.5) < 1e-9, f"anchor {hit['anchor']}" + assert abs(hit["trigger"] - 2989.5) < 1e-9, f"trigger {hit['trigger']}" + + +@test("CP: inverse head & shoulders detected on mirrored fixture") +def _(): + df = mirror_cp_df(make_cp_df(hs_top_closes())) + hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) + assert hit is not None and hit["pattern"] == "inv_hs" + assert hit["direction"] == "BUY" + + +@test("CP: bull flag detected (pole, shallow retrace, new-high close)") +def _(): + df = make_cp_df(bull_flag_closes()) + hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) + assert hit is not None, "bull flag not detected" + assert hit["pattern"] == "bull_flag" and hit["direction"] == "BUY" + assert abs(hit["trigger"] - 3026.5) < 1e-9 + assert hit["height"] > 15.0 # pole ≥ cp_flag_pole_atr × atr + + +@test("CP: bear flag detected on mirrored fixture") +def _(): + df = mirror_cp_df(make_cp_df(bull_flag_closes())) + hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) + assert hit is not None and hit["pattern"] == "bear_flag" + assert hit["direction"] == "SELL" + + +@test("CP: cp_patterns subset filter excludes disabled detectors") +def _(): + from dataclasses import replace + df = make_cp_df(double_bottom_closes()) + cfg = XauStrategyConfig(cp_patterns=["bull_flag"]) + assert detect_chart_pattern(df, atr=5.0, cfg=cfg) is None + + +def _cp_report(df, config, vol_regime="normal", mtf_signals=None): + """SignalReport shell around a real engine analysis of `df`.""" + from engine import TechnicalEngine + eng = TechnicalEngine(config.technical) + pa = eng.analyze(df, "XAU/USD", config.primary_timeframe.value) + rep = SignalReport(symbol="XAU/USD", display_name="XAU/USD", + timestamp=str(df.index[-1])) + rep.primary_analysis = pa + rep.current_price = float(df["Close"].iloc[-1]) + rep.volatility_regime = vol_regime + rep.mtf_signals = mtf_signals or {} + rep.signal_quality = "D" + return rep + + +def _cp_config(**xau_overrides): + cfg = PlatformConfig() + cfg.primary_timeframe = Timeframe.H1 + cfg.xau_strategy.cp_enabled = True + for k, v in xau_overrides.items(): + setattr(cfg.xau_strategy, k, v) + return cfg + + +@test("CP gate: BUY pattern builds an XAU_CP setup with measured-move TP") +def _(): + cfg = _cp_config() + df = make_cp_df(double_bottom_closes()) + rep = _cp_report(df, cfg) + SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) + ts = rep.trade_setup + assert ts is not None, "pattern setup not generated" + assert ts.strategy == "XAU_CP" and ts.direction == "BUY" + assert ts.stop_loss < ts.entry_price + assert abs((ts.take_profit_1 - ts.entry_price) - 19.0) < 1e-6, \ + "TP1 must be the measured move" + assert rep.signal_quality == "B" + assert rep.bullish_reasons[0].startswith("XAU CHART PATTERN"), \ + "tag marker must sit at reasons[0]" + + +@test("CP gate: MTF opposition blocks the pattern trade") +def _(): + cfg = _cp_config() + df = make_cp_df(double_bottom_closes()) + rep = _cp_report(df, cfg, mtf_signals={ + "15min": SignalStrength.SELL, "4h": SignalStrength.STRONG_SELL}) + SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) + assert rep.trade_setup is None, "BUY pattern must not fire against consensus" + + +@test("CP gate: SELL pattern blocked in quiet volatility, allowed in high") +def _(): + cfg = _cp_config() + df = mirror_cp_df(make_cp_df(double_bottom_closes())) + rep = _cp_report(df, cfg, vol_regime="normal") + SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) + assert rep.trade_setup is None, "quiet-vol SELL must be blocked" + rep2 = _cp_report(df, cfg, vol_regime="high") + SignalAggregator(cfg)._try_chart_pattern(rep2, df, 0.10) + assert rep2.trade_setup is not None and rep2.trade_setup.direction == "SELL" + + +@test("CP gate: SELL blocked-hour (bar close) stops pattern shorts only") +def _(): + cfg = _cp_config(sell_blocked_hours_utc=[15]) # fixture closes 15:00 + df = mirror_cp_df(make_cp_df(double_bottom_closes())) + rep = _cp_report(df, cfg, vol_regime="high") + SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) + assert rep.trade_setup is None, "SELL at blocked close hour must not fire" + # BUY side unaffected at the same hour. + df_buy = make_cp_df(double_bottom_closes()) + rep_buy = _cp_report(df_buy, cfg) + SignalAggregator(cfg)._try_chart_pattern(rep_buy, df_buy, 0.10) + assert rep_buy.trade_setup is not None + + +@test("CP gate: M5 chop-transition volatility blocks both directions") +def _(): + cfg = _cp_config() + cfg.primary_timeframe = Timeframe.M5 + df = make_cp_df(double_bottom_closes()) + rep = _cp_report(df, cfg, vol_regime="high") # 'high' blocked on M5 + SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) + assert rep.trade_setup is None + + +@test("CP gate: spread gate rejects patterns smaller than TP minimum") +def _(): + cfg = _cp_config() + cfg.signals.min_tp_cost_multiple = 1000.0 # $300 minimum target + df = make_cp_df(double_bottom_closes()) + rep = _cp_report(df, cfg) + SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) + assert rep.trade_setup is None + + +@test("CP pipeline: fires end-to-end when core generators pass") +def _(): + cfg = _cp_config() + cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4] + df = make_cp_df(double_bottom_closes()) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + agg = SignalAggregator(cfg) + with patch.object(SignalAggregator, "_generate_trade_setup", + return_value=None), \ + patch.object(SignalAggregator, "_generate_xau_atr_trade_setup", + return_value=None): + rep = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) + assert rep.trade_setup is not None, \ + f"pipeline did not produce a setup (warnings: {rep.warnings})" + assert rep.trade_setup.strategy == "XAU_CP" + + +@test("CP pipeline: disabled by default — no XAU_CP setups anywhere") +def _(): + assert XauStrategyConfig().cp_enabled is False + assert PlatformConfig().xau_strategy.cp_enabled is False + from pair_signals import PAIR_PRODUCERS + for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4): + assert PAIR_PRODUCERS[XAUUSD](tf, None).xau_strategy.cp_enabled is False + cfg = PlatformConfig() + cfg.primary_timeframe = Timeframe.H1 + cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4] + df = make_cp_df(double_bottom_closes()) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + with patch.object(SignalAggregator, "_generate_trade_setup", + return_value=None), \ + patch.object(SignalAggregator, "_generate_xau_atr_trade_setup", + return_value=None): + rep = SignalAggregator(cfg).analyze_from_dataframes( + "XAU/USD", dfs, use_news=False) + assert rep.trade_setup is None + + +@test("CP pipeline: session-blocked bar never hosts a pattern trade") +def _(): + cfg = _cp_config() + cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4] + # Label hour 13 is in blocked_hours_utc → 6b sets F + SESSION FILTER. + df = make_cp_df(double_bottom_closes(), end="2026-07-08 13:00") + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + with patch.object(SignalAggregator, "_generate_trade_setup", + return_value=None), \ + patch.object(SignalAggregator, "_generate_xau_atr_trade_setup", + return_value=None): + rep = SignalAggregator(cfg).analyze_from_dataframes( + "XAU/USD", dfs, use_news=False) + assert rep.trade_setup is None + assert rep.signal_quality == "F" + + +@test("CP backtester: --cp flag enables patterns, tag filter knows XAU_CP") +def _(): + from gpu_backtest import _parse_args + args = _parse_args(["--pair", "XAUUSD", "--cp", + "--cp-patterns", "double_bottom,bull_flag"]) + assert args.cp is True + assert args.cp_patterns == "double_bottom,bull_flag" + args2 = _parse_args(["--pair", "XAUUSD"]) + assert args2.cp is False + + # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ -- 2.52.0 From ca37a7ab5a167fddebe4dcc6c3ef141c88c5d546 Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Thu, 16 Jul 2026 14:31:45 -0400 Subject: [PATCH 3/4] =?UTF-8?q?Docs:=20CLAUDE.md=20=E2=80=94=20document=20?= =?UTF-8?q?XAU=5FCP=20archetype=20(pipeline=20step=2011,=20generator=20D,?= =?UTF-8?q?=20cp=5F*=20knobs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015kby7M9JwpYi8NQT2yoxAi --- CLAUDE.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index db6913f..fdd8d5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ This file is the authoritative developer/agent reference. The human-facing docs | `config.py` | ~374 | All config dataclasses, `PAIR_INFO` registry, per-pair strategy configs. **`PlatformConfig` defaults ARE the tuned XAU config.** | | `data_provider.py` | ~347 | Twelve Data SDK wrapper: fetch, rate-limit (55 credits/min), in-memory cache, validation/cleaning, freshness checks | | `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 | @@ -99,6 +100,7 @@ Tracked currencies `["USD","XAU"]`; pre-event buffer 30 min, post-event 15 min ( | `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** — enable per-TF in the factory only with validation evidence | Legacy fields kept for config-shape compatibility: `rsi_entry_threshold`, `min_ema200_tfs`, `allow_generic_trades`, `buy_threshold_boost`, `m5_confirmation_bars`, `h4_rsi_gate=False`, `h1_macd_gate=False`, `rsi_zone` — not wired to current gates. @@ -199,6 +201,7 @@ Two entry points: 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) @@ -212,7 +215,9 @@ Two entry points: **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`. -`TradeSetup` fields: pair, direction, entry, SL, TP1, TP2, RR, risk/reward pips, pip_value, `strategy` ("GENERIC" or "XAU_PB" — selects trailing params). +**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). --- @@ -290,7 +295,7 @@ Replays history bar-by-bar through the **real `SignalAggregator.analyze_from_dat **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`, `GENERIC` (plus legacy `XAU` recognized). `--strategies` filters the book by tag. +**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 -- 2.52.0 From de6424ec3f14b9f8e2a2de605e4fe52df590054f Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Thu, 16 Jul 2026 16:08:13 -0400 Subject: [PATCH 4/4] =?UTF-8?q?Enable=20BUY-only=20chart=20patterns=20on?= =?UTF-8?q?=20M5=20=E2=80=94=20bearish=20patterns=20dropped=20(OOS=20negat?= =?UTF-8?q?ive)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M5 factory: cp_enabled=True, patterns limited to inv_hs, double_bottom, bull_flag. SELL CP lost -3.21R on OOS; BUY CP: +41.8R PF 1.60 on 12mo, +65.2R PF 1.48 on 5yr. Adds +22R to the M5 book (708 vs 513 trades). M15/H1 remain disabled — all failed OOS validation. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_015kby7M9JwpYi8NQT2yoxAi --- CLAUDE.md | 4 ++-- pair_signals/xauusd.py | 12 ++++++++++++ test_all.py | 13 +++++++++---- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fdd8d5a..cdd76a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Tracked currencies `["USD","XAU"]`; pre-event buffer 30 min, post-event 15 min ( | `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** — enable per-TF in the factory only with validation evidence | +| 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 | Legacy fields kept for config-shape compatibility: `rsi_entry_threshold`, `min_ema200_tfs`, `allow_generic_trades`, `buy_threshold_boost`, `m5_confirmation_bars`, `h4_rsi_gate=False`, `h1_macd_gate=False`, `rsi_zone` — not wired to current gates. @@ -252,7 +252,7 @@ Two entry points: 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). +- 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). - 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). - The factory must stay equivalent to inline `PlatformConfig(symbol=XAUUSD, …)` + overrides — **enforced by a test in test_all.py**. diff --git a/pair_signals/xauusd.py b/pair_signals/xauusd.py index 7ea1c7a..c911232 100644 --- a/pair_signals/xauusd.py +++ b/pair_signals/xauusd.py @@ -47,6 +47,15 @@ SELL-only rather than a blocked_hours_utc entry. Mechanism: NY-session shorts against gold's macro uptrend (15:00 = post-US-data reversal window, 17:00 = London close unwind). +M5 override 2 (2026-07-16, chart patterns): enable BUY-only CP on M5 +(inv_hs, double_bottom, bull_flag — drop all bearish patterns). SELL CP +is consistently negative (OOS -3.21R, 5yr +3.76R PF 1.07) while BUY CP +is positive across windows: 12mo +41.8R PF 1.60 (train +9.66 PF 1.30, +val +32.17 PF 1.86), 5yr +65.2R PF 1.48, OOS +1.20R (aggregate +positive, val half -1.89R — marginal but sign-correct in aggregate). +Gold's structural uptrend makes bullish patterns fundamentally more +reliable. M15/H1 fail on all windows; M5-only enablement. + M5 override (validated 2026-07-11, full-history replay 2020-03-17 -> 2026-07-11 + 12mo book, $0.30 spread): block hour 9 UTC (post-London- open whipsaw). Hour-9 entries lost -28.9R/143 over 6 years, negative @@ -83,6 +92,9 @@ def make_config( config.xau_strategy.blocked_hours_utc = ( config.xau_strategy.blocked_hours_utc + [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"] if primary_tf == Timeframe.M15: # 1R minimum target on M15 only — see module docstring. config.signals.tp_floor_rr = 1.0 diff --git a/test_all.py b/test_all.py index 2a917fa..3e09260 100644 --- a/test_all.py +++ b/test_all.py @@ -1841,11 +1841,13 @@ def _(): inline_cfg.xau_strategy.blocked_regimes = ["trending"] inline_cfg.xau_strategy.sell_blocked_hours_utc = [15, 17] if tf == Timeframe.M5: - # Documented per-TF override (see xauusd.py docstring): - # hour 9 UTC blocked on M5 only. + # 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] ) + inline_cfg.xau_strategy.cp_enabled = True + inline_cfg.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"] assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \ f"XAU factory config differs from inline construction for {tf}" @@ -2439,13 +2441,16 @@ def _(): assert rep.trade_setup.strategy == "XAU_CP" -@test("CP pipeline: disabled by default — no XAU_CP setups anywhere") +@test("CP pipeline: disabled by default except M5 BUY-only") def _(): assert XauStrategyConfig().cp_enabled is False assert PlatformConfig().xau_strategy.cp_enabled is False from pair_signals import PAIR_PRODUCERS - for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4): + for tf in (Timeframe.M15, Timeframe.H1, Timeframe.H4): assert PAIR_PRODUCERS[XAUUSD](tf, None).xau_strategy.cp_enabled is False + m5_cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.M5, None) + assert m5_cfg.xau_strategy.cp_enabled is True + assert m5_cfg.xau_strategy.cp_patterns == ["inv_hs", "double_bottom", "bull_flag"] cfg = PlatformConfig() cfg.primary_timeframe = Timeframe.H1 cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4] -- 2.52.0