Compare commits

..

5 Commits

Author SHA1 Message Date
harry 85b7567677 Merge pull request 'Remove EUR/USD support — XAU/USD-only codebase' (#1) from xau-only-chart-patterns into main
Reviewed-on: http://192.168.55.11:3000/harry/ForexBot2/pulls/1
2026-07-16 20:09:21 +00:00
Harwinder Sekhon de6424ec3f Enable BUY-only chart patterns on M5 — bearish patterns dropped (OOS negative)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kby7M9JwpYi8NQT2yoxAi
2026-07-16 16:08:13 -04:00
Harwinder Sekhon ca37a7ab5a Docs: CLAUDE.md — document XAU_CP archetype (pipeline step 11, generator D, cp_* knobs)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kby7M9JwpYi8NQT2yoxAi
2026-07-16 14:31:45 -04:00
Harwinder Sekhon a70fc91f96 Add chart-pattern entry archetype (XAU_CP) — disabled by default
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kby7M9JwpYi8NQT2yoxAi
2026-07-16 14:23:17 -04:00
Harwinder Sekhon 68a62b0a53 Remove EUR/USD support — XAU/USD-only codebase
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kby7M9JwpYi8NQT2yoxAi
2026-07-16 14:06:27 -04:00
15 changed files with 900 additions and 922 deletions
+38 -41
View File
@@ -1,6 +1,6 @@
# ForexBot2 — Codebase Reference # 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. 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.
@@ -15,28 +15,28 @@ 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.** | | `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 | | `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) | | `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` | | `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 | | `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 | | `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/__init__.py` | 52 | `PAIR_PRODUCERS` registry + `produce_signal()` |
| `pair_signals/xauusd.py` | 71 | XAU config factory + per-timeframe overrides (docstring = validation record) | | `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` | | `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/gpu_backtest.py` | ~2231 | Bar-replay backtester (CuPy→NumPy fallback) using the real `SignalAggregator` |
| `backtest/diagnose_filters.py` | 609 | Filter-diagnosis tooling | | `backtest/diagnose_filters.py` | 609 | Filter-diagnosis tooling |
| `backtest/research/` | — | Calendar research infra (`ff_calendar_scraper.py`, `calendar_store.py`) + result CSVs | | `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) | | `backtest/cache/` | — | Parquet OHLCV cache (raw bars) |
| `test_ws_dual.py` | 108 | Standalone WebSocket dual-connection probe | | `test_ws_dual.py` | 108 | Standalone WebSocket dual-connection probe |
| `logs/telegram.log` | — | Bot log (midnight rotation, 30 backups) | | `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. - **XAU/USD**: the tuned, validated product — and the ONLY supported pair. 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). - **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 +48,7 @@ Twelve Data REST (+ WebSocket ticks in the bot)
→ engine.TechnicalEngine.analyze() per timeframe: 9 scored indicators + ATR context → regime → composite score → 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) → signal_aggregator.analyze_pair() live entry point (weekend/day filters, fetch, staleness)
└─ analyze_from_dataframes() replayable core: MTF agreement → news → bonuses/penalties → └─ 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 trade setup (3 generators) → reasons
→ SignalReport consumed by telegram_bot.py, main.py, backtest/gpu_backtest.py → SignalReport consumed by telegram_bot.py, main.py, backtest/gpu_backtest.py
``` ```
@@ -59,17 +59,17 @@ Signal currency everywhere is the `SignalStrength` enum: `STRONG_BUY=2, BUY=1, N
## config.py — every knob ## config.py — every knob
### PAIR_INFO (market constants; keys are the canonical symbols `"XAU/USD"`, `"EUR/USD"`) ### PAIR_INFO (market constants; key is the canonical symbol `"XAU/USD"`)
| Field | XAU/USD | EUR/USD | Meaning | | Field | XAU/USD | Meaning |
|---|---|---|---| |---|---|---|
| `pip_value` | 0.10 | 0.0001 | price units per pip | | `pip_value` | 0.10 | price units per pip |
| `price_precision` | 2 | 5 | decimals for SL/TP rounding & display | | `price_precision` | 2 | decimals for SL/TP rounding & display |
| `default_spread` | 0.30 | 0.0001 | round-trip cost charged by the backtester when `--spread` not given | | `default_spread` | 0.30 | round-trip cost charged by the backtester when `--spread` not given |
### TechnicalConfig (indicator parameters) ### 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 | | Field | Default | Meaning |
|---|---|---| |---|---|---|
| `mtf_agreement_threshold` | 2 | timeframes (of primary + confirmations) that must agree | | `mtf_agreement_threshold` | 2 | timeframes (of primary + confirmations) that must agree |
@@ -84,7 +84,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` | | `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 ### 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`) ### XauStrategyConfig (all XAU gates; `enabled=True`)
| Field | Default | Notes | | Field | Default | Notes |
@@ -100,12 +100,10 @@ Tracked currencies `["USD","XAU","EUR"]`; pre-event buffer 30 min, post-event 15
| `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) | | `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 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 | | M5 pullback (XAU_PB) | | `m5_pullback_enabled=True`; min score 15 (lower than gate 1 BY DESIGN — mid-pullback scores weaker); ADX ≥ 20; RSI zones BUY (35,62) / SELL (38,65); retrace depth 0.8–6.0 ATR from the 40-bar swing extreme; band touch within 3 bars. Validated +38.3R/254 trades on 12mo |
| Chart patterns (XAU_CP) | `cp_enabled=False` | `cp_*` fields: pattern list, lookback 72, swing window 3, recency 20, double tolerance 0.5 ATR, height 1.5–8 ATR, H&S head ≥0.5 ATR / shoulder tol 1.0 ATR, flag pole ≥3 ATR in ≤12 bars, flag 3–12 bars retracing 10–50%. Textbook defaults, NOT tuned. **Default OFF** — M5 factory enables BUY-only (`inv_hs`, `double_bottom`, `bull_flag`); M15/H1/H4 remain disabled (all failed OOS validation 2026-07-16). Bearish patterns (`hs_top`, `double_top`, `bear_flag`) dropped: SELL CP lost −3.21R on OOS, −4.52R on M15 5yr |
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. 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) ### 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). `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 +153,7 @@ Structure S/R (not scored): swing highs/lows (window 3) over `swing_sr_lookback`
### Composite score ### 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. 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 | | Indicator | trending_up/down/trending | ranging | transitioning |
|---|---|---|---| |---|---|---|---|
@@ -174,7 +172,7 @@ For each scored indicator: `effective_weight = base_weight × regime_multiplier`
## signal_aggregator.py — SignalAggregator ## signal_aggregator.py — SignalAggregator
Two entry points: 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). - **`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) ### Pipeline inside `analyze_from_dataframes` (numbered as in the source)
@@ -185,7 +183,7 @@ Two entry points:
5. Spread estimate = median(High−Low) of primary df. 5. Spread estimate = median(High−Low) of primary df.
6. **`_compute_final_signal`** (bonuses → event cap → penalties → thresholds → grade): 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). - 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). - **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. - **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. - Confidence clamped [0, 95]. **Final signal**: score ≥35 & conf ≥55 STRONG_BUY; ≥25 & ≥50 BUY; mirrored sells; else NEUTRAL.
@@ -199,25 +197,27 @@ Two entry points:
- **6g XAU SELL volatility gate**: sell-direction & vol regime not in {high, extreme} → D. - **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. - **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. - **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. - **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). 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. 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) ### Trade setup generators (exact geometry)
**A. `_generate_xau_atr_trade_setup`** (XAU, grade A+/A, ATR>0): TP quality scale A+ 2.0 / A 1.5 (A with exhaustion≥2 → 1.0). TP1 = entry ± `tp_atr_multiplier`(1.0)×scale×ATR; TP2 = 1.5×that; SL = entry ∓ 2.0×ATR. Gates: RR ≥ 0.5; TP1 distance ≥ $9 (spread gate). Prices rounded to 2 dp; pip 0.10 hardcoded — **this generator is XAU-only by construction**. **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: **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). - 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 (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. - 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. - 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`. **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).
--- ---
@@ -245,20 +245,18 @@ Two entry points:
## pair_signals/ — per-pair factories (ALWAYS use these, never bare PlatformConfig for a pair) ## 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). **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): **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: `signals.tp_floor_rr = 1.0` (1R minimum targets: +116.6R vs +105.4R, better in train/val and 2024 OOS; **hurts M5** — per-TF only) and `xau_strategy.blocked_regimes = ["trending"]` (mixed regime block: +65.7R vs +59.0R). A transitioning-SELL block was tested and REJECTED (bull-market artifact — failed 2024 OOS).
- M15: `xau_strategy.sell_blocked_hours_utc = [15, 17]` (SELL-only hour block, validated 2026-07-16 on the 5-yr replay: +176.7R PF 1.31 vs +144.9R PF 1.22, better in all 6 years, both 12-mo halves, and 2024-06→2025-03 OOS; M5 cross-TF pocket confirms; BUYs at those hours are +34.3R so `blocked_hours_utc` would be wrong). - M15: `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**. - 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 ## telegram_bot.py — the bot
@@ -278,7 +276,7 @@ Run: `python telegram_bot.py` (env `TELEGRAM_TOKEN`, `TELEGRAM_CHAT_ID`, `TELEGR
## main.py — CLI ## 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 +286,7 @@ Replays history bar-by-bar through the **real `SignalAggregator.analyze_from_dat
**Realism model (all validated fixes — do not regress):** **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. - 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. - 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. - 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. - **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 +295,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. **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`, `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:** **CLI:**
```bash ```bash
python backtest/gpu_backtest.py --pair XAUUSD --days 365 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 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 # 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 # --warmup-bars 250 --cooldown-bars 5 --no-cache --export-csv out.csv --detail
``` ```
@@ -317,7 +314,7 @@ Data: paginated TD fetches (5000-bar chunks) cached as parquet in `backtest/cach
## test_all.py — QA suite ## 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 +345,9 @@ Dependencies (`requirements.txt`): numpy, pandas, requests, twelvedata, python-t
## Rules for making changes (READ BEFORE EDITING SIGNAL LOGIC) ## 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. 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. 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. 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). 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).
+1 -1
View File
@@ -554,7 +554,7 @@ def main(argv: Optional[List[str]] = None) -> int:
description="Diagnose which filters reject the most bars in the backtest pipeline", description="Diagnose which filters reject the most bars in the backtest pipeline",
formatter_class=argparse.ArgumentDefaultsHelpFormatter, 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("--days", type=int, default=365, help="Days of history")
p.add_argument("--start", help="ISO date YYYY-MM-DD (overrides --days)") 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)") p.add_argument("--end", help="ISO date YYYY-MM-DD (defaults to today)")
+19 -13
View File
@@ -49,7 +49,6 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from config import ( # noqa: E402 from config import ( # noqa: E402
EURUSD,
PAIR_INFO, PAIR_INFO,
PlatformConfig, PlatformConfig,
SignalStrength, SignalStrength,
@@ -98,9 +97,6 @@ PAIR_ALIASES = {
"XAUUSD": XAUUSD, "XAUUSD": XAUUSD,
"XAU/USD": XAUUSD, "XAU/USD": XAUUSD,
"GOLD": XAUUSD, "GOLD": XAUUSD,
"EURUSD": EURUSD,
"EUR/USD": EURUSD,
"EUR": EURUSD,
} }
# Bar duration in seconds — used to compute "fully-closed" cutoffs so # Bar duration in seconds — used to compute "fully-closed" cutoffs so
@@ -1068,7 +1064,7 @@ class Trade:
final_confidence: float final_confidence: float
market_regime: str market_regime: str
volatility_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). # Per-trade trailing overrides (0 = use the run-level params).
trail_arm_r: float = 0.0 trail_arm_r: float = 0.0
trail_dist_r: float = 0.0 trail_dist_r: float = 0.0
@@ -1422,7 +1418,7 @@ class BacktestConfig:
quality_filter: List[str] = field( quality_filter: List[str] = field(
default_factory=lambda: ["A+", "A", "B", "C"] 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. # Empty = take all. Use to A/B test whether GENERIC trades add or destroy edge.
strategies_only: List[str] = field(default_factory=list) strategies_only: List[str] = field(default_factory=list)
# Round-trip transaction cost in price units, charged on every trade # Round-trip transaction cost in price units, charged on every trade
@@ -1545,12 +1541,10 @@ def _replay_span(span: Tuple[int, int]):
first_bear = report.bearish_reasons[0] if report.bearish_reasons else "" first_bear = report.bearish_reasons[0] if report.bearish_reasons else ""
if "XAU PULLBACK" in first_bull or "XAU PULLBACK" in first_bear: if "XAU PULLBACK" in first_bull or "XAU PULLBACK" in first_bear:
tag = "XAU_PB" 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: elif "XAU STRATEGY" in first_bull:
tag = "XAU" 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: if bt_cfg.strategies_only and tag not in bt_cfg.strategies_only:
continue continue
@@ -2132,7 +2126,7 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
description="GPU-accelerated backtester for ForexBot2", description="GPU-accelerated backtester for ForexBot2",
formatter_class=argparse.ArgumentDefaultsHelpFormatter, 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("--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("--start", help="ISO date YYYY-MM-DD (overrides --days)")
p.add_argument("--end", help="ISO date YYYY-MM-DD (defaults to today)") p.add_argument("--end", help="ISO date YYYY-MM-DD (defaults to today)")
@@ -2149,11 +2143,17 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
help="Override TechnicalConfig.swing_sr_lookback (0 = use config default)") 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("--quality", default="A+,A,B,C", help="Comma-separated allowed grades")
p.add_argument("--strategies", default="", 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,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("--no-cache", action="store_true", help="Skip parquet cache (force API fetch)")
p.add_argument("--spread", type=float, default=None, p.add_argument("--spread", type=float, default=None,
help="Round-trip cost in price units per trade (default: pair's " 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("--export-csv", help="Optional path to dump per-trade CSV (incl. indicator breakdown)")
p.add_argument("--detail", action="store_true", p.add_argument("--detail", action="store_true",
help="Print full per-trade reasoning (indicators, signals, agreement, why entered)") help="Print full per-trade reasoning (indicators, signals, agreement, why entered)")
@@ -2216,6 +2216,12 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
if args.swing_sr_lookback > 0: if args.swing_sr_lookback > 0:
platform_cfg.technical.swing_sr_lookback = args.swing_sr_lookback 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) confirm_tfs = list(platform_cfg.confirmation_timeframes)
end = ( end = (
+2 -2
View File
@@ -6,7 +6,7 @@ alignment (see scraper docstring).
Key API: Key API:
cal = load_calendar() # full DataFrame, `utc` column 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) mask = event_window_mask(bar_index, ev.utc, before, after)
""" """
from pathlib import Path from pathlib import Path
@@ -47,7 +47,7 @@ def load_calendar(path: Path = PARQUET) -> pd.DataFrame:
def precise_events( def precise_events(
cal: pd.DataFrame, cal: pd.DataFrame,
currencies: Iterable[str] = ("USD", "EUR"), currencies: Iterable[str] = ("USD",),
impacts: Iterable[str] = ("high",), impacts: Iterable[str] = ("high",),
require_values: bool = False, require_values: bool = False,
) -> pd.DataFrame: ) -> pd.DataFrame:
+287
View File
@@ -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
+28 -52
View File
@@ -37,7 +37,6 @@ class MarketSession(Enum):
# ── Supported pairs ────────────────────────────────────────────── # ── Supported pairs ──────────────────────────────────────────────
XAUUSD = "XAU/USD" XAUUSD = "XAU/USD"
EURUSD = "EUR/USD"
# price_precision: decimals for SL/TP rounding and display. # price_precision: decimals for SL/TP rounding and display.
# default_spread: round-trip cost per trade in price units, charged by # default_spread: round-trip cost per trade in price units, charged by
@@ -45,8 +44,6 @@ EURUSD = "EUR/USD"
PAIR_INFO = { PAIR_INFO = {
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False, XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False,
"price_precision": 2, "default_spread": 0.30}, "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 @dataclass
class NewsConfig: class NewsConfig:
"""Economic calendar & sentiment.""" """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 pre_event_buffer_minutes: int = 30
post_event_buffer_minutes: int = 15 post_event_buffer_minutes: int = 15
# Whether event risk gates the SIGNAL (danger: confidence hard-capped # Whether event risk gates the SIGNAL (danger: confidence hard-capped
@@ -277,53 +274,33 @@ class XauStrategyConfig:
# Price must have touched EMA21/KC-mid within this many bars. # Price must have touched EMA21/KC-mid within this many bars.
m5_pullback_touch_bars: int = 3 m5_pullback_touch_bars: int = 3
# ── Chart-pattern entry (XAU_CP) ──────────────────────────────
@dataclass # Third entry archetype: completion of a classical chart pattern
class EurUsdStrategyConfig: # (see chart_patterns.py for the pattern set and design contract).
"""EUR/USD strategy config — separate from XAU for independent tuning. # Defaults are textbook geometry, NOT tuned values. Disabled by
# default — per-TF adoption happens in pair_signals/xauusd.py only
Values ported from feature/eurusd-support are a pre-realism baseline # after the pattern book passes the standard validation rule
(tuned before spread charging and next-open fills) and are re-tuned # (positive R both halves of ≥12mo, PF ≥ 1.3, $0.30 spread,
against the realistic backtester. Cost knobs (round_trip_cost, # trailing exits, next-open fills).
min_tp_cost_multiple, min_sl_pips) are EUR-local so nothing cp_enabled: bool = False
gold-calibrated from SignalConfig leaks into the EUR path. cp_patterns: List[str] = field(default_factory=lambda: [
""" "inv_hs", "hs_top", "double_bottom", "double_top",
enabled: bool = True "bull_flag", "bear_flag",
blocked_hours_utc: List[int] = field(default_factory=lambda: [22, 23, 0, 1, 2, 3]) ])
weekend_filter: bool = True cp_lookback: int = 72 # bars scanned for pattern structure
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4]) cp_swing_window: int = 3 # bars each side to confirm a swing
min_atr_threshold: float = 0.0003 cp_recency_bars: int = 20 # last extreme must be this fresh
sl_atr_multiplier: float = 1.5 # swing-buffer ATR multiple for SL placement cp_min_sep_bars: int = 8 # min bars between double extremes
tp_atr_multiplier: float = 1.2 cp_tolerance_atr: float = 0.5 # double top/bottom level match tol
cp_min_height_atr: float = 1.5 # pattern height floor (noise gate)
sl_atr_floor: float = 1.2 cp_max_height_atr: float = 8.0 # pattern height ceiling
min_rr_ratio: float = 1.0 cp_hs_head_min_atr: float = 0.5 # head must exceed shoulders by this
mature_move_threshold: float = 35.0 cp_hs_shoulder_tol_atr: float = 1.0 # shoulder level match tol
tp_quality_scale: Dict[str, float] = field(default_factory=lambda: { cp_flag_pole_atr: float = 3.0 # min pole size
"A+": 1.0, "A": 1.0, "B": 1.0, "C": 1.0, cp_flag_pole_bars: int = 12 # pole measured over ≤ this many bars
}) cp_flag_min_bars: int = 3 # flag consolidation length bounds
blocked_regimes: List[str] = field(default_factory=lambda: ["ranging", "transitioning"]) cp_flag_max_bars: int = 12
min_quality_grades: List[str] = field(default_factory=lambda: ["A+", "A", "B"]) cp_flag_retrace: tuple = (0.1, 0.5) # flag depth as fraction of pole
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 @dataclass
@@ -349,7 +326,6 @@ class PlatformConfig:
news: NewsConfig = field(default_factory=NewsConfig) news: NewsConfig = field(default_factory=NewsConfig)
backtest: FilterConfig = field(default_factory=FilterConfig) backtest: FilterConfig = field(default_factory=FilterConfig)
xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig) xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig)
eur_strategy: EurUsdStrategyConfig = field(default_factory=EurUsdStrategyConfig)
symbol: str = XAUUSD symbol: str = XAUUSD
display_name: str = "XAU/USD" display_name: str = "XAU/USD"
+3 -4
View File
@@ -21,7 +21,7 @@ if hasattr(sys.stdout, "reconfigure"):
if hasattr(sys.stderr, "reconfigure"): if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8") 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 pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport from signal_aggregator import SignalAggregator, SignalReport
@@ -99,7 +99,7 @@ def _compact_reason(reason: str) -> str:
def _fmt_price(price: float) -> 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}" return f"{price:.2f}" if price >= 10 else f"{price:.5f}"
@@ -257,7 +257,7 @@ Examples:
""", """,
) )
parser.add_argument("--pair", "-p", default="XAUUSD", 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", parser.add_argument("--timeframe", "-tf", default="H1",
choices=["M5", "M15", "M30", "H1", "H4", "D1"], choices=["M5", "M15", "M30", "H1", "H4", "D1"],
help="Primary analysis timeframe (default: H1)") help="Primary analysis timeframe (default: H1)")
@@ -274,7 +274,6 @@ Examples:
def _resolve_pair(s: str) -> str: def _resolve_pair(s: str) -> str:
aliases = { aliases = {
"XAUUSD": XAUUSD, "XAU/USD": XAUUSD, "GOLD": XAUUSD, "XAUUSD": XAUUSD, "XAU/USD": XAUUSD, "GOLD": XAUUSD,
"EURUSD": EURUSD, "EUR/USD": EURUSD, "EUR": EURUSD,
} }
key = s.upper().strip() key = s.upper().strip()
if key in aliases: if key in aliases:
+1 -2
View File
@@ -673,8 +673,7 @@ class NewsEngine:
@staticmethod @staticmethod
def _get_affected_pairs(currency: str) -> List[str]: def _get_affected_pairs(currency: str) -> List[str]:
_MAP = { _MAP = {
"USD": ["XAU/USD", "EUR/USD"], "USD": ["XAU/USD"],
"XAU": ["XAU/USD"], "XAU": ["XAU/USD"],
"EUR": ["EUR/USD"],
} }
return _MAP.get(currency, []) return _MAP.get(currency, [])
+1 -3
View File
@@ -45,8 +45,6 @@ def produce_signal(
from pair_signals import xauusd as _xau_mod from pair_signals import xauusd as _xau_mod
from pair_signals import eurusd as _eur_mod from config import XAUUSD
from config import XAUUSD, EURUSD
register_pair(XAUUSD, _xau_mod.make_config) register_pair(XAUUSD, _xau_mod.make_config)
register_pair(EURUSD, _eur_mod.make_config)
-158
View File
@@ -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
+12
View File
@@ -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 shorts against gold's macro uptrend (15:00 = post-US-data reversal
window, 17:00 = London close unwind). 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 -> 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- 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 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 = (
config.xau_strategy.blocked_hours_utc + [9] 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: if primary_tf == Timeframe.M15:
# 1R minimum target on M15 only — see module docstring. # 1R minimum target on M15 only — see module docstring.
config.signals.tp_floor_rr = 1.0 config.signals.tp_floor_rr = 1.0
+153 -160
View File
@@ -13,6 +13,7 @@ from typing import Dict, List, Optional
import pandas as pd import pandas as pd
from chart_patterns import detect_chart_pattern
from config import PlatformConfig, SignalStrength, Timeframe from config import PlatformConfig, SignalStrength, Timeframe
from data_provider import ForexDataProvider from data_provider import ForexDataProvider
from engine import TechnicalEngine, TechnicalAnalysis from engine import TechnicalEngine, TechnicalAnalysis
@@ -98,6 +99,8 @@ class SignalAggregator:
# Diagnostic funnel for the M5 pullback route (shared across # Diagnostic funnel for the M5 pullback route (shared across
# instances; read by backtest tooling, harmless in live use). # instances; read by backtest tooling, harmless in live use).
pb_funnel: Dict[str, int] = {} pb_funnel: Dict[str, int] = {}
# Same, for the chart-pattern route (XAU_CP).
cp_funnel: Dict[str, int] = {}
def __init__(self, config: PlatformConfig): def __init__(self, config: PlatformConfig):
self.config = config self.config = config
@@ -157,39 +160,6 @@ class SignalAggregator:
report.action_recommendation = f"NO TRADE — Only trading {allowed}" report.action_recommendation = f"NO TRADE — Only trading {allowed}"
return report 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( timeframes = [self.config.primary_timeframe] + list(
self.config.confirmation_timeframes self.config.confirmation_timeframes
) )
@@ -596,6 +566,138 @@ class SignalAggregator:
target = report.bullish_reasons if is_buy else report.bearish_reasons target = report.bullish_reasons if is_buy else report.bearish_reasons
target.insert(0, marker) 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 ───────────────────────── # ── Public: bar-replayable entry point ─────────────────────────
def analyze_from_dataframes( def analyze_from_dataframes(
@@ -827,93 +929,6 @@ class SignalAggregator:
report.signal_quality = "D" report.signal_quality = "D"
report.trade_setup = None 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) ── # ── 6i. M5 noise gate (gold-tuned thresholds — XAU only) ──
if (self.config.primary_timeframe == Timeframe.M5 if (self.config.primary_timeframe == Timeframe.M5
and report.display_name == "XAU/USD"): and report.display_name == "XAU/USD"):
@@ -934,17 +949,6 @@ class SignalAggregator:
# ── 8. Reasons ───────────────────────────────────────────── # ── 8. Reasons ─────────────────────────────────────────────
self._collect_reasons(report) 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) ────────────────────────── # ── 9. M5 pullback entry (XAU_PB) ──────────────────────────
# Second archetype: when no breakout setup fired, look for a # Second archetype: when no breakout setup fired, look for a
# trend-continuation pullback (retrace to EMA21/KC-mid). Runs # trend-continuation pullback (retrace to EMA21/KC-mid). Runs
@@ -956,6 +960,24 @@ class SignalAggregator:
and report.signal_quality != "F"): and report.signal_quality != "F"):
self._try_m5_pullback(report, primary_df, pip_value) 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 return report
# ── Internals ────────────────────────────────────────────────── # ── Internals ──────────────────────────────────────────────────
@@ -1304,26 +1326,6 @@ class SignalAggregator:
is_buy = report.final_score >= 0 is_buy = report.final_score >= 0
levels = report.primary_analysis.key_levels levels = report.primary_analysis.key_levels
_is_eur = report.display_name == "EUR/USD" and self.config.eur_strategy.enabled
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_sl_price = self.config.signals.min_sl_pips * pip_value
min_rr = self.config.signals.min_rr_ratio min_rr = self.config.signals.min_rr_ratio
tp_floor = max(min_rr, self.config.signals.tp_floor_rr) tp_floor = max(min_rr, self.config.signals.tp_floor_rr)
@@ -1419,15 +1421,6 @@ class SignalAggregator:
# Spread gate: a target too close to entry hands most of the win # Spread gate: a target too close to entry hands most of the win
# to transaction costs (validated on M5: sub-$10 targets lose). # 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 min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
if tp1_distance < min_tp: if tp1_distance < min_tp:
report.warnings.append( report.warnings.append(
+1 -1
View File
@@ -138,7 +138,7 @@ def _build_scan_buttons() -> List[dict]:
buttons = [] buttons = []
for symbol in SCAN_PAIRS: for symbol in SCAN_PAIRS:
slug = symbol.replace("/", "").lower() 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 "" prefix = f"{base} " if multi else ""
for tf, label in _TF_BUTTON_LABELS: for tf, label in _TF_BUTTON_LABELS:
buttons.append({ buttons.append({
+335 -466
View File
@@ -930,9 +930,9 @@ def _():
def _(): def _():
ne = NewsEngine(NewsConfig()) ne = NewsEngine(NewsConfig())
context = FundamentalContext() 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) 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() assert "bullish" in desc.lower()
@@ -940,7 +940,7 @@ def _():
def _(): def _():
ne = NewsEngine(NewsConfig()) ne = NewsEngine(NewsConfig())
context = FundamentalContext() 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) score, desc = ne.get_pair_sentiment("XAU/USD", context)
assert score < 0 assert score < 0
@@ -949,7 +949,7 @@ def _():
def _(): def _():
ne = NewsEngine(NewsConfig()) ne = NewsEngine(NewsConfig())
context = FundamentalContext() 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) score, desc = ne.get_pair_sentiment("XAU/USD", context)
assert score == 0.0 assert score == 0.0
assert "neutral" in desc.lower() assert "neutral" in desc.lower()
@@ -1236,10 +1236,8 @@ def _():
conf_caut_off = recompute("caution", False) conf_caut_off = recompute("caution", False)
assert conf_caut_off == baseline assert conf_caut_off == baseline
assert any("Upcoming event" in w for w in report.warnings) 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 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") @test("SignalAggregator._generate_trade_setup BUY direction")
@@ -1472,17 +1470,6 @@ def _():
assert config.primary_timeframe is not None 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 # MODULE 7: telegram_bot.py
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
@@ -1821,12 +1808,12 @@ def _():
print("\n═══ Module 9: pair_signals registry ═══") 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 _(): def _():
import pair_signals import pair_signals
pairs = pair_signals.get_supported_pairs() pairs = pair_signals.get_supported_pairs()
assert XAUUSD in pairs, f"XAU missing from registry: {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") @test("xauusd.make_config equals inline PlatformConfig construction")
@@ -1854,27 +1841,17 @@ def _():
inline_cfg.xau_strategy.blocked_regimes = ["trending"] inline_cfg.xau_strategy.blocked_regimes = ["trending"]
inline_cfg.xau_strategy.sell_blocked_hours_utc = [15, 17] inline_cfg.xau_strategy.sell_blocked_hours_utc = [15, 17]
if tf == Timeframe.M5: if tf == Timeframe.M5:
# Documented per-TF override (see xauusd.py docstring): # Documented per-TF overrides (see xauusd.py docstring):
# hour 9 UTC blocked on M5 only. # 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 = (
inline_cfg.xau_strategy.blocked_hours_utc + [9] 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), \ assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \
f"XAU factory config differs from inline construction for {tf}" 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") @test("produce_signal raises for unregistered pair")
def _(): def _():
from pair_signals import produce_signal from pair_signals import produce_signal
@@ -1943,7 +1920,7 @@ def _():
df = make_ohlcv(n=100, seed=42) df = make_ohlcv(n=100, seed=42)
df.iloc[-11, df.columns.get_loc("Close")] = 0.0 df.iloc[-11, df.columns.get_loc("Close")] = 0.0
engine = TechnicalEngine(PlatformConfig().technical) 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: if "roc" in result.indicators:
assert np.isfinite(result.indicators["roc"].value), "ROC should not be inf" assert np.isfinite(result.indicators["roc"].value), "ROC should not be inf"
@@ -1959,440 +1936,11 @@ def _():
"Close": [100.0]*n, "Volume": [1000.0]*n, "Close": [100.0]*n, "Volume": [1000.0]*n,
}, index=dates) }, index=dates)
engine = TechnicalEngine(PlatformConfig().technical) 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: if "adx" in result.indicators:
assert np.isfinite(result.indicators["adx"].value), "ADX should be finite on flat data" 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 # MODULE 11: 2026-07-16 audit-fix regressions
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
@@ -2514,7 +2062,7 @@ def _():
spike_ts, after_ts = df.index[k], df.index[k + 1] 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 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 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 spike_ts not in cleaned.index, "spike bar must be removed"
assert after_ts in cleaned.index, \ assert after_ts in cleaned.index, \
"snap-back bar after the spike must be kept (two-bar hole regression)" "snap-back bar after the spike must be kept (two-bar hole regression)"
@@ -2624,6 +2172,327 @@ def _():
assert t2.entry_time == idx[2] and t2.entry_price == 110.0 and t2.sl == 102.0 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 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.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]
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 # SUMMARY
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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. Uses the twelvedata SDK which handles the WS protocol correctly.
Runs connection 1, then adds connection 2 after 10s, monitors for 30s total. Runs connection 1, then adds connection 2 after 10s, monitors for 30s total.
@@ -11,7 +11,7 @@ import time
from twelvedata import TDClient from twelvedata import TDClient
API_KEY = "f563b22ffdfd4924955decbe1a8c8701" API_KEY = "f563b22ffdfd4924955decbe1a8c8701"
SYMBOL = "EUR/USD" SYMBOL = "XAU/USD"
DURATION = 30 DURATION = 30
results = { results = {