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).

Claude-Session: https://claude.ai/code/session_015kby7M9JwpYi8NQT2yoxAi
This commit is contained in:
Harwinder Sekhon
2026-07-16 14:06:27 -04:00
parent 665ffdda48
commit 14448c10e8
13 changed files with 75 additions and 919 deletions
+31 -39
View File
@@ -1,6 +1,6 @@
# ForexBot2 — Codebase Reference
Signal-intelligence platform for **XAU/USD (gold — live, validated)** and **EUR/USD (functional but OFF by default — no validated edge, see below)**. It fetches OHLCV from Twelve Data, scores 9 technical indicators per timeframe with regime-adaptive weights, checks multi-timeframe agreement and news/calendar risk, grades the signal (A+…F), and generates a full trade setup (entry, SL, TP1/TP2, trailing plan). Delivery: Telegram bot (auto-scanner + subscriber channel) and a CLI. A realism-focused backtester replays history through the *exact same* signal code.
Signal-intelligence platform for **XAU/USD (gold — live, validated)**. It fetches OHLCV from Twelve Data, scores 9 technical indicators per timeframe with regime-adaptive weights, checks multi-timeframe agreement and news/calendar risk, grades the signal (A+…F), and generates a full trade setup (entry, SL, TP1/TP2, trailing plan). Delivery: Telegram bot (auto-scanner + subscriber channel) and a CLI. A realism-focused backtester replays history through the *exact same* signal code.
This file is the authoritative developer/agent reference. The human-facing docs live in `docs/` (`user_guide.html` + `beginner_guide.html`, and their PDF builds) — keep them in sync when signal behavior changes. `backtest/instructions_for_backtest.txt` lists what every backtest report must record.
@@ -20,23 +20,22 @@ This file is the authoritative developer/agent reference. The human-facing docs
| `telegram_bot.py` | ~1259 | Async Telegram bot: candle-boundary auto-scanner, WebSocket level-breach monitor, owner lock, subscriber channel |
| `pair_signals/__init__.py` | 52 | `PAIR_PRODUCERS` registry + `produce_signal()` |
| `pair_signals/xauusd.py` | 71 | XAU config factory + per-timeframe overrides (docstring = validation record) |
| `pair_signals/eurusd.py` | 158 | EUR config factory + EUR regime weights (docstring = no-edge verdict + tuning history) |
| `main.py` | ~348 | CLI: `python main.py --pair XAUUSD --timeframe H1 --detail/--json` |
| `test_all.py` | ~2570 | QA suite: **187 tests in 11 modules**, no live API needed. Must stay green. |
| `test_all.py` | ~2100 | QA suite: **162 tests in 10 modules**, no live API needed. Must stay green. |
| `backtest/gpu_backtest.py` | ~2231 | Bar-replay backtester (CuPy→NumPy fallback) using the real `SignalAggregator` |
| `backtest/diagnose_filters.py` | 609 | Filter-diagnosis tooling |
| `backtest/research/` | — | Calendar research infra (`ff_calendar_scraper.py`, `calendar_store.py`) + result CSVs |
| `backtest/*.csv` | — | Verification trade books from past tuning campaigns (`xau_*.csv`, `eur_verify.csv`, …). **Gitignored, local-only** — used for manual before/after comparison, not by tests. `eth/btc/gbp_verify.csv` come from research whose pair code is NOT on main |
| `backtest/*.csv` | — | Verification trade books from past tuning campaigns (`xau_*.csv`, …). **Gitignored, local-only** — used for manual before/after comparison, not by tests |
| `backtest/cache/` | — | Parquet OHLCV cache (raw bars) |
| `test_ws_dual.py` | 108 | Standalone WebSocket dual-connection probe |
| `logs/telegram.log` | — | Bot log (midnight rotation, 30 backups) |
---
## Pair status (do not confuse the two)
## Pair status
- **XAU/USD**: the tuned, validated product. It is the only entry in `PlatformConfig.scan_pairs`. All defaults in `PlatformConfig`/`XauStrategyConfig` are its tuned values.
- **EUR/USD**: fully wired (factory, gates, tests) but **not in `scan_pairs`** and its signals are informational only. Verdict (2026-07-11/12, recorded in `pair_signals/eurusd.py` docstring): no configuration passed validation on M5, M15, or H1 at a 1-pip round-trip cost; root cause is cost-to-volatility (EUR M15 ATR ≈ 10 pips → 1-pip spread taxes ~10% of ATR; gold's ATR/spread ratio is several times larger). Calendar-surprise and H1-hybrid avenues were also tested and closed. Do not re-open EUR tuning without new data (tick fills or H4/D1 horizons).
- **XAU/USD**: the tuned, validated product — and the ONLY supported pair. All defaults in `PlatformConfig`/`XauStrategyConfig` are its tuned values.
- **EUR/USD**: REMOVED from the codebase (2026-07-16, branch `xau-only-chart-patterns`). It never had a validated edge: the 2026-07-11/12 verdict found no configuration passing validation on M5, M15, or H1 at a 1-pip round-trip cost; root cause was cost-to-volatility (EUR M15 ATR ≈ 10 pips → 1-pip spread taxes ~10% of ATR; gold's ATR/spread ratio is several times larger). Calendar-surprise and H1-hybrid avenues were also tested and closed. The full EUR implementation and verdict history live in git history (last commit with EUR support: `7d3fd95`). Do not re-add EUR without new data (tick fills or H4/D1 horizons).
---
@@ -48,7 +47,7 @@ Twelve Data REST (+ WebSocket ticks in the bot)
→ engine.TechnicalEngine.analyze() per timeframe: 9 scored indicators + ATR context → regime → composite score
→ signal_aggregator.analyze_pair() live entry point (weekend/day filters, fetch, staleness)
└─ analyze_from_dataframes() replayable core: MTF agreement → news → bonuses/penalties →
final signal + quality grade → gates (session, SELL, M5, EUR, regime) →
final signal + quality grade → gates (session, SELL, M5, regime) →
trade setup (3 generators) → reasons
→ SignalReport consumed by telegram_bot.py, main.py, backtest/gpu_backtest.py
```
@@ -59,17 +58,17 @@ Signal currency everywhere is the `SignalStrength` enum: `STRONG_BUY=2, BUY=1, N
## config.py — every knob
### PAIR_INFO (market constants; keys are the canonical symbols `"XAU/USD"`, `"EUR/USD"`)
| Field | XAU/USD | EUR/USD | Meaning |
|---|---|---|---|
| `pip_value` | 0.10 | 0.0001 | price units per pip |
| `price_precision` | 2 | 5 | decimals for SL/TP rounding & display |
| `default_spread` | 0.30 | 0.0001 | round-trip cost charged by the backtester when `--spread` not given |
### PAIR_INFO (market constants; key is the canonical symbol `"XAU/USD"`)
| Field | XAU/USD | Meaning |
|---|---|---|
| `pip_value` | 0.10 | price units per pip |
| `price_precision` | 2 | decimals for SL/TP rounding & display |
| `default_spread` | 0.30 | round-trip cost charged by the backtester when `--spread` not given |
### TechnicalConfig (indicator parameters)
EMA 9/21/50/200 (`ema_mode`: `"4ema"` default or `"2ema"` = 50/200 only); RSI 14 (OB 70 / OS 30 / neutral zone 40–60; `rsi_mode`: `"regime_aware"` default or `"classic"`); MACD 12/26/9 (`macd_as_pillar=True` — MACD is a scored indicator, not just divergence input); BB 20/2.0σ; ATR 14; ADX 14 (strong ≥25, very strong ≥50); divergence lookback 40; swing-S/R lookback 50; ROC period 10, threshold 1.0%; Keltner: EMA 20 mid ± 1.5 × ATR(10); ADX slope lookback 5, "emerging" threshold 3.0; `disabled_indicators` list (removes the pillar from scoring; for MACD the histogram is still computed — divergence needs it); `regime_weights_override` dict (used by EUR).
EMA 9/21/50/200 (`ema_mode`: `"4ema"` default or `"2ema"` = 50/200 only); RSI 14 (OB 70 / OS 30 / neutral zone 40–60; `rsi_mode`: `"regime_aware"` default or `"classic"`); MACD 12/26/9 (`macd_as_pillar=True` — MACD is a scored indicator, not just divergence input); BB 20/2.0σ; ATR 14; ADX 14 (strong ≥25, very strong ≥50); divergence lookback 40; swing-S/R lookback 50; ROC period 10, threshold 1.0%; Keltner: EMA 20 mid ± 1.5 × ATR(10); ADX slope lookback 5, "emerging" threshold 3.0; `disabled_indicators` list (removes the pillar from scoring; for MACD the histogram is still computed — divergence needs it); `regime_weights_override` dict (replaces the gold regime-multiplier table for experiments).
### SignalConfig (scoring & trade geometry — XAU-calibrated; EUR uses its own copies)
### SignalConfig (scoring & trade geometry — XAU-calibrated)
| Field | Default | Meaning |
|---|---|---|
| `mtf_agreement_threshold` | 2 | timeframes (of primary + confirmations) that must agree |
@@ -84,7 +83,7 @@ EMA 9/21/50/200 (`ema_mode`: `"4ema"` default or `"2ema"` = 50/200 only); RSI 14
| `weights`, `min_confidence`, `cooldown_bars`, `default_risk_pct`, `default_rr_ratio`, `atr_sl_multiplier` | — | **LEGACY / UNUSED** outside tests. The engine has its own weights; backtest cooldown lives in `BacktestConfig` |
### NewsConfig
Tracked currencies `["USD","XAU","EUR"]`; pre-event buffer 30 min, post-event 15 min (45 min after a *surprise* print); `event_risk_caps_signals=False` — **XAU default: event risk warns but does NOT cap confidence** (2026-07-11 audit: the caps suppressed trades worth +4.6..+7.2R/yr; XAU trades near USD releases outperform). EUR factory turns the cap back ON. Context cache TTL 600 s (must stay < pre-event buffer). 4 RSS feeds (investinglive, FXStreet, Google-News gold query, CNBC economy).
Tracked currencies `["USD","XAU"]`; pre-event buffer 30 min, post-event 15 min (45 min after a *surprise* print); `event_risk_caps_signals=False` — **XAU default: event risk warns but does NOT cap confidence** (2026-07-11 audit: the caps suppressed trades worth +4.6..+7.2R/yr; XAU trades near USD releases outperform). Context cache TTL 600 s (must stay < pre-event buffer). 4 RSS feeds (investinglive, FXStreet, Google-News gold query, CNBC economy).
### XauStrategyConfig (all XAU gates; `enabled=True`)
| Field | Default | Notes |
@@ -103,9 +102,6 @@ Tracked currencies `["USD","XAU","EUR"]`; pre-event buffer 30 min, post-event 15
Legacy fields kept for config-shape compatibility: `rsi_entry_threshold`, `min_ema200_tfs`, `allow_generic_trades`, `buy_threshold_boost`, `m5_confirmation_bars`, `h4_rsi_gate=False`, `h1_macd_gate=False`, `rsi_zone` — not wired to current gates.
### EurUsdStrategyConfig (EUR-local; nothing gold-calibrated leaks in)
Blocked hours [22,23,0,1,2,3]; weekdays Mon–Fri (M5 factory: Mon–Thu, Fridays bled −17R); `min_atr_threshold=0.0003`; SL: swing-buffer ×1.5 ATR, floor ×1.2 ATR (M5: 0.3/0.7), min 8 pips; `min_rr_ratio=1.0`; `mature_move_threshold=35` (|score|>35 → tighter TP 1.2 ATR, else 1.8 ATR); `blocked_regimes=["ranging","transitioning"]`; `min_quality_grades=["A+","A","B"]`; `min_entry_score=38` / `min_entry_confidence=60` (M5 factory: 35/45); `block_rsi_extended=True` (no BUY at RSI>70, no SELL at RSI<30); costs: `round_trip_cost=0.0001`, `min_tp_cost_multiple=10` (10-pip TP minimum), `min_sl_pips=8`. `be_trigger_r`/`be_buffer_r` are legacy (superseded by trailing exits).
### FilterConfig (`config.backtest` — despite the name it applies LIVE too)
`session_filter=True`, window 03:00–23:00 UTC, `blocked_hours_utc` mirrors XAU's list (non-XAU pairs read this copy), `allowed_weekdays` Mon–Fri+Sun. `regime_filter=False` (optional H4-ADX≥25 gate, off).
@@ -155,7 +151,7 @@ Structure S/R (not scored): swing highs/lows (window 3) over `swing_sr_lookback`
### Composite score
For each scored indicator: `effective_weight = base_weight × regime_multiplier`; contribution = `signal.value × effective_weight × confidence/100`. Composite = `(Σ contributions / Σ effective_weights) × 50`, clamped to ±100. Confidence = weight-averaged indicator confidence. Composite signal: ≥40 STRONG_BUY, ≥15 BUY, ≤−40 STRONG_SELL, ≤−15 SELL, else NEUTRAL.
**Gold regime multipliers** (defaults in `_compute_composite`; `technical.regime_weights_override` replaces the whole dict — EUR does this):
**Gold regime multipliers** (defaults in `_compute_composite`; `technical.regime_weights_override` replaces the whole dict):
| Indicator | trending_up/down/trending | ranging | transitioning |
|---|---|---|---|
@@ -174,7 +170,7 @@ For each scored indicator: `effective_weight = base_weight × regime_multiplier`
## signal_aggregator.py — SignalAggregator
Two entry points:
- **`analyze_pair(symbol)`** — LIVE: applies XAU/EUR weekend + allowed-weekday filters on wall clock, fetches all TFs (primary + confirmations), flags stale data (primary stale → signal suppressed to NEUTRAL/F), delegates to `analyze_from_dataframes`, then applies the live session check on the **bar-CLOSE time** (bar timestamp + candle duration): outside 03:00–23:00 or in blocked hours → NO TRADE. This is the exact predicate the backtester replays, so live == backtest.
- **`analyze_pair(symbol)`** — LIVE: applies XAU weekend + allowed-weekday filters on wall clock, fetches all TFs (primary + confirmations), flags stale data (primary stale → signal suppressed to NEUTRAL/F), delegates to `analyze_from_dataframes`, then applies the live session check on the **bar-CLOSE time** (bar timestamp + candle duration): outside 03:00–23:00 or in blocked hours → NO TRADE. This is the exact predicate the backtester replays, so live == backtest.
- **`analyze_from_dataframes(symbol, dfs, context, use_news)`** — replayable core; treats each df as the visible history at decision time (caller must slice; no weekend filter here — the backtester applies it).
### Pipeline inside `analyze_from_dataframes` (numbered as in the source)
@@ -185,7 +181,7 @@ Two entry points:
5. Spread estimate = median(High−Low) of primary df.
6. **`_compute_final_signal`** (bonuses → event cap → penalties → thresholds → grade):
- Bonuses: MTF agreement → +10 conf and `score = 0.6×primary + 0.4×mean(all TF scores)`; news sentiment aligned (|sent|>0.2) → +5; in a *directional* trend, RSI in pullback zone → +5, BB position favorable → +5 (mixed `trending` regime gets NO directional bonus).
- Event risk `danger`: warning always; confidence hard-capped at 30 ONLY if `news.event_risk_caps_signals` (False for XAU, True for EUR).
- Event risk `danger`: warning always; confidence hard-capped at 30 ONLY if `news.event_risk_caps_signals` (False by default — XAU-tuned).
- **Penalty system (diminishing, replaces old additive stacking):** collect, sort by size desc, apply with decay ×1.0, ×0.5, ×0.25…, total capped at 30. Penalties: `mtf_disagreement` 15; `news_conflict` 10; `event_caution` 15 (config-gated); `counter_trend` 15; `extreme_vol` 10; `ema200_near` 5 (price within 0.5% of EMA200 — env `FB2_EMA_LO` overrides); `ema200_far` 5 (>2.5%); `exhaustion` 8 (price >3.5 ATR beyond nearest swing); `no_pullback` 8 (>2.5 ATR from EMA21 in trend) / `weak_pullback` 5 (>2.0); `rsi_extended` 5 (RSI>65 with BUY / <35 with SELL in any strong-ADX regime, both directions in mixed `trending`); `bb_overextended` 8 (BB pos >0.85 BUY / <0.15 SELL).
- **Exhaustion hard block**: ≥2 exhaustion-tagged penalties ({exhaustion, no_pullback, rsi_extended, bb_overextended} → `report.exhaustion_count`) AND momentum fading (ROC sign against score, or MACD signal against score) → confidence = 0.
- Confidence clamped [0, 95]. **Final signal**: score ≥35 & conf ≥55 STRONG_BUY; ≥25 & ≥50 BUY; mirrored sells; else NEUTRAL.
@@ -199,10 +195,9 @@ Two entry points:
- **6g XAU SELL volatility gate**: sell-direction & vol regime not in {high, extreme} → D.
- **6g2 XAU regime gates**: regime in `blocked_regimes` (M15: mixed `trending`) → D; sell-direction & regime in `sell_blocked_regimes` → D.
- **6g3 XAU SELL hour gate**: sell-direction & bar-CLOSE hour (label + candle duration — the entry hour under next-open fills) in `sell_blocked_hours_utc` → D. Per-TF opt-in; M15 factory blocks 15/17 UTC.
- **6h EUR gates** (EUR only): blocked regimes (ranging/transitioning), quality < B, exhaustion count, session hours, |score| < min_entry_score, conf < min_entry_confidence, blocked vol regimes, indicator-agreement floor, RSI-extended block → each sets grade F with a warning.
- **6i XAU M5 noise gates** (`_apply_m5_filters`, primary TF = M5 only): (1) |score| ≥ 30; (2) MTF agreement required; (3) ≥1 confirmation TF with score ≥ ±10 in trade direction (NEUTRAL confirms don't count); (4) SELL grade must be B/C (A-grade M5 shorts lose −0.017R/trade); (5) vol regime `high` blocked; (6) SELL blocked when RSI < 25. Each failure → D, setup killed.
8. **Trade setup**: XAU + grade A+/A → `_generate_xau_atr_trade_setup` first; any pair + grade A+/A/B/C and no setup yet → `_generate_trade_setup` (swing-based).
9. Reasons: every non-neutral indicator description → `bullish_reasons`/`bearish_reasons`; EUR setups get an `"EUR STRATEGY: …"` marker inserted at reasons[0] (the backtester tags strategy from reasons[0]).
9. Reasons: every non-neutral indicator description → `bullish_reasons`/`bearish_reasons` (the backtester tags strategy from reasons[0] markers).
10. **XAU_PB pullback** (M5 XAU only, `m5_pullback_enabled`, no setup yet, grade ≠ F): `_try_m5_pullback` — see below.
### Trade setup generators (exact geometry)
@@ -210,10 +205,10 @@ Two entry points:
**A. `_generate_xau_atr_trade_setup`** (XAU, grade A+/A, ATR>0): TP quality scale A+ 2.0 / A 1.5 (A with exhaustion≥2 → 1.0). TP1 = entry ± `tp_atr_multiplier`(1.0)×scale×ATR; TP2 = 1.5×that; SL = entry ∓ 2.0×ATR. Gates: RR ≥ 0.5; TP1 distance ≥ $9 (spread gate). Prices rounded to 2 dp; pip 0.10 hardcoded — **this generator is XAU-only by construction**.
**B. `_generate_trade_setup`** (swing-based, any pair, grade A+..C): XAU requires ATR ≥ $1 (LOW ATR FILTER). Per-side:
- SL anchor = nearest swing low below price (BUY) / swing high above (SELL) ∓/± `sl_swing_buffer`×ATR (XAU 1.0, EUR = `eur.sl_atr_multiplier`); fallback (no swing) = (1+buffer)×ATR. SL distance floored at max(ATR × sl_atr_floor, min_sl_pips×pip) — XAU: max(2.0×ATR, $8); EUR: max(1.2×ATR, 8 pips) (M5 EUR: 0.7×ATR).
- TP1 = nearest swing beyond entry ± 0.3 ATR if it's within `tp_swing_cap` ATR (XAU 2.5) AND ≥ SL×RR-floor; else entry ± max(`tp_mult`×ATR, SL×RR-floor). XAU: tp_mult 1.5, RR-floor = max(min_rr 0.5, tp_floor_rr — 1.0 on M15). EUR: tp_mult 1.2 (mature move, |score|>35) or 1.8, RR-floor 1.0.
- SL anchor = nearest swing low below price (BUY) / swing high above (SELL) ∓/± `sl_swing_buffer`×ATR (1.0); fallback (no swing) = (1+buffer)×ATR. SL distance floored at max(ATR × sl_atr_floor, min_sl_pips×pip) — XAU: max(2.0×ATR, $8).
- TP1 = nearest swing beyond entry ± 0.3 ATR if it's within `tp_swing_cap` ATR (2.5) AND ≥ SL×RR-floor; else entry ± max(`tp_mult`×ATR, SL×RR-floor). tp_mult 1.5, RR-floor = max(min_rr 0.5, tp_floor_rr — 1.0 on M15).
- TP2 = second swing if within 5 ATR, else entry ± max((tp_mult+1.5)×ATR, SL×floor×1.5); forced beyond TP1 by ≥1 ATR.
- Gates: RR ≥ min_rr; spread gate TP1 distance ≥ cost×multiple (XAU $9; EUR 10 pips).
- Gates: RR ≥ min_rr; spread gate TP1 distance ≥ cost×multiple ($9 on XAU).
**C. `_try_m5_pullback` (XAU_PB)**: detection = EMA alignment ≥3 of 4 checks (9>21, 21>50, 50>200, close>200 — mirrored for SELL), ADX ≥ 20, price touched max(EMA21, KC-mid) within last 3 bars, resumption bar closed back in trend direction beyond EMA9, retrace depth 0.8–6.0 ATR from the 40-bar extreme, RSI in pullback zone (BUY 35–62 / SELL 38–65). Then gates: directional score ≥ 15; ≥1 confirmation TF ≥ ±10; not against MTF consensus; vol regime gates (same as breakout path). Geometry: SL = pullback 8-bar extreme ∓ 0.5 ATR (min $8); TP1 = 1.5×risk, TP2 = 2.5×risk; spread gate $9. Sets `strategy="XAU_PB"`, grade min B, marker `"XAU PULLBACK STRATEGY: …"` at reasons[0]. Rejection funnel counters in class attr `SignalAggregator.pb_funnel`.
@@ -245,11 +240,11 @@ Two entry points:
## pair_signals/ — per-pair factories (ALWAYS use these, never bare PlatformConfig for a pair)
`PAIR_PRODUCERS: Dict[symbol, factory]`; factory signature `make_config(primary_tf, confirmation_tfs=None) -> PlatformConfig`. `produce_signal(symbol, tf, data_provider)` builds config → aggregator → `analyze_pair`. Registered: XAU/USD, EUR/USD.
`PAIR_PRODUCERS: Dict[symbol, factory]`; factory signature `make_config(primary_tf, confirmation_tfs=None) -> PlatformConfig`. `produce_signal(symbol, tf, data_provider)` builds config → aggregator → `analyze_pair`. Registered: XAU/USD.
**A bare `PlatformConfig()` silently runs any pair with XAU defaults — always go through the factory** (the bot, CLI, and backtester all do).
Default confirmation map (same in both factories, the bot, and backtest usage): M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1]. (Exception: `main.py`'s CLI map uses M5→[M1,M15] — a CLI-only quirk.)
Default confirmation map (same in the factory, the bot, and backtest usage): M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1]. (Exception: `main.py`'s CLI map uses M5→[M1,M15] — a CLI-only quirk.)
**xauusd.py**: `PlatformConfig()` defaults + per-TF overrides (each validated; full evidence in the module docstring):
- M5: append hour 9 UTC to blocked hours (6-yr replay: +207.1R vs +178.3R; M15-and-up keep hour 9 — it's positive there).
@@ -257,8 +252,6 @@ Default confirmation map (same in both factories, the bot, and backtest usage):
- M15: `xau_strategy.sell_blocked_hours_utc = [15, 17]` (SELL-only hour block, validated 2026-07-16 on the 5-yr replay: +176.7R PF 1.31 vs +144.9R PF 1.22, better in all 6 years, both 12-mo halves, and 2024-06→2025-03 OOS; M5 cross-TF pocket confirms; BUYs at those hours are +34.3R so `blocked_hours_utc` would be wrong).
- The factory must stay equivalent to inline `PlatformConfig(symbol=XAUUSD, …)` + overrides — **enforced by a test in test_all.py**.
**eurusd.py**: sets symbol/display/pip, `xau_strategy.enabled = False` (belt-and-braces; XAU gates are also display-gated), `news.event_risk_caps_signals = True`, EUR session (7–20 h window, blocked [22,23,0-3], Mon–Fri), EUR regime-weight overrides (`EUR_REGIME_WEIGHTS`, and `M5_REGIME_WEIGHTS` + tighter trailing 0.45/0.5 + Mon–Thu + extra blocked hours [8,12,13,18,19] + swing lookback 20 on M5). Docstring = the complete no-edge verdict and closed research avenues.
---
## telegram_bot.py — the bot
@@ -278,7 +271,7 @@ Run: `python telegram_bot.py` (env `TELEGRAM_TOKEN`, `TELEGRAM_CHAT_ID`, `TELEGR
## main.py — CLI
`python main.py [--pair XAUUSD|EURUSD] [--timeframe M5|M15|M30|H1|H4|D1] [--detail] [--json] [--verbose]`. Uses the pair factory, renders a compact terminal report (decision, setup, context, top-3 reasons per side, warnings) or full JSON via `report_to_dict`.
`python main.py [--pair XAUUSD] [--timeframe M5|M15|M30|H1|H4|D1] [--detail] [--json] [--verbose]`. Uses the pair factory, renders a compact terminal report (decision, setup, context, top-3 reasons per side, warnings) or full JSON via `report_to_dict`.
---
@@ -288,7 +281,7 @@ Replays history bar-by-bar through the **real `SignalAggregator.analyze_from_dat
**Realism model (all validated fixes — do not regress):**
- Entry at the **next bar's open** after the signal bar; SL/TP distances preserved relative to actual entry. Entries are **re-anchored to the raw price path** (`_reanchor_entries_to_raw`): if cleaning dropped a >10σ spike bar right after the signal bar, the trade enters at the spike bar's open, not the bar after it — skipping it was a favorable bias.
- Round-trip `--spread` subtracted from every trade's PnL before computing R (default = pair's `default_spread`: XAU $0.30, EUR 1 pip). Results before 2026-07-08 (+59.5R baseline era) charged no spread — not comparable.
- Round-trip `--spread` subtracted from every trade's PnL before computing R (default = pair's `default_spread`: XAU $0.30). Results before 2026-07-08 (+59.5R baseline era) charged no spread — not comparable.
- Trade resolution runs against the **RAW price path** (`fetch_history(clean=False)`): the parquet cache stores raw bars; cleaning (which drops >10σ news spikes that hit stops in reality) is applied only to the signal-side data. Caches written before this fix contain cleaned data — refetch to restore spike bars.
- Same-bar SL+TP tie → SL first (conservative). Gap through SL/TP → filled at the bar open.
- **Trailing exits** (default on, from `signals.trail_arm_r/trail_dist_r`; XAU_PB trades carry per-trade 0.8/0.75 overrides): stop ratchets at bar close only, so a new stop can only fill from the next bar (conservative). Exit reasons: SL / TRAIL / TP1 (static mode) / TIMEOUT / NO_DATA.
@@ -297,13 +290,12 @@ Replays history bar-by-bar through the **real `SignalAggregator.analyze_from_dat
**Performance:** `precompute_indicators` builds every indicator series once per TF (CuPy on GPU or NumPy — check the log line `Backend: CuPy GPU` vs `NumPy CPU fallback`; CuPy needs the `nvidia-*-cu12` pip wheels or it silently falls back). `GPUTechnicalEngine` subclasses the live engine and overrides only the `_compute_*` methods with cache lookups — outputs are byte-identical to the live engine (regression-tested). Bar replay: pure per-bar evaluation fans out over forked worker processes (`--workers`, 0 = auto: cores−2 capped 12; <5000 bars forces 1); the entry **cooldown (5 bars) is the only sequential rule**, applied in an ordered pass afterwards — parallel and sequential books are identical. ~300 bars/s/core, ~1700 bars/s at 10 workers.
**Strategy tags** (from reasons[0] markers): `XAU_PB`, `EUR`, `GENERIC` (plus legacy `XAU`, `GBP_PB` recognized). `--strategies` filters the book by tag.
**Strategy tags** (from reasons[0] markers): `XAU_PB`, `GENERIC` (plus legacy `XAU` recognized). `--strategies` filters the book by tag.
**CLI:**
```bash
python backtest/gpu_backtest.py --pair XAUUSD --days 365
python backtest/gpu_backtest.py --pair XAUUSD --start 2025-01-01 --end 2025-06-01 --primary-tf 15min --confirm-tfs 1h,4h
python backtest/gpu_backtest.py --pair EURUSD --primary-tf 5min --confirm-tfs 15min,1h --days 365
# key flags: --spread --quality A+,A,B,C --strategies --workers --max-hold-bars 300
# --warmup-bars 250 --cooldown-bars 5 --no-cache --export-csv out.csv --detail
```
@@ -317,7 +309,7 @@ Data: paginated TD fetches (5000-bar chunks) cached as parquet in `backtest/cach
## test_all.py — QA suite
`python test_all.py` — **187 tests, 11 modules** (config, data_provider, engine, news_engine, signal_aggregator, main, telegram_bot, integration, pair_signals registry + engine-bug regressions, EUR/USD support, 2026-07-16 audit-fix regressions). No network needed (mocked). Uses a custom `@test("name")` decorator that runs immediately (NOT pytest — don't grep for `def test_`). Exit code non-zero on failure. Includes: pair-isolation tests (no gold logic on EUR configs and vice versa), the xauusd-factory ≡ inline-config equivalence test, engine bug-fix regression tests (Module 9b), and a live-vs-GPU engine parity sweep incl. the EMA-ribbon path (Module 11). **Any signal-logic change must keep this green and should add tests.**
`python test_all.py` — **162 tests, 10 modules** (config, data_provider, engine, news_engine, signal_aggregator, main, telegram_bot, integration, pair_signals registry + engine-bug regressions, 2026-07-16 audit-fix regressions). No network needed (mocked). Uses a custom `@test("name")` decorator that runs immediately (NOT pytest — don't grep for `def test_`). Exit code non-zero on failure. Includes: the xauusd-factory ≡ inline-config equivalence test, engine bug-fix regression tests (Module 9b), and a live-vs-GPU engine parity sweep incl. the EMA-ribbon path (Module 11). **Any signal-logic change must keep this green and should add tests.**
---
@@ -348,9 +340,9 @@ Dependencies (`requirements.txt`): numpy, pandas, requests, twelvedata, python-t
## Rules for making changes (READ BEFORE EDITING SIGNAL LOGIC)
1. **Never change a tuned value without re-validation.** The acceptance rule used throughout: positive total R in BOTH the train and validation halves of a ≥12-month window, PF ≥ 1.3, at realistic cost (XAU $0.30 / EUR 1 pip round trip), with trailing exits and next-open fills. For EUR (and any marginal edge) additionally re-test on the 2024-06→2025-03 out-of-sample window — within-year splits alone have approved configs that failed OOS.
1. **Never change a tuned value without re-validation.** The acceptance rule used throughout: positive total R in BOTH the train and validation halves of a ≥12-month window, PF ≥ 1.3, at realistic cost (XAU $0.30 round trip), with trailing exits and next-open fills. For any marginal edge additionally re-test on the 2024-06→2025-03 out-of-sample window — within-year splits alone have approved configs that failed OOS.
2. **Per-timeframe overrides live in the pair factory** (`pair_signals/*.py`), never in global defaults — the same knob has opposite effects on different TFs (proven twice: `tp_floor_rr`, hour-9 block). Document the validation evidence in the factory docstring.
3. **Pair isolation is a hard invariant.** XAU gates in the aggregator are display-gated (`report.display_name == "XAU/USD"`); EUR reads only `EurUsdStrategyConfig` for costs/geometry. Tests enforce zero XAU drift when EUR changes and vice versa.
3. **Pair isolation is a hard invariant.** XAU gates in the aggregator are display-gated (`report.display_name == "XAU/USD"`) so a future pair port cannot inherit gold-calibrated logic by accident.
4. **Live == backtest parity**: session/weekend/hour filters must evaluate bar-CLOSE time in both paths; `xauusd.make_config` must stay equivalent to the inline config the backtester/bot would build (test-enforced). If you add a live gate, add it to the replay path (or `analyze_from_dataframes`) in the same commit.
5. **ROC stays weighted** (see engine table) unless you retune composite thresholds and re-validate — removing it lost 13R.
6. **Backtester realism knobs are results-critical**: raw-path resolution, spread charging, next-open entry, SL-first ties, close-only trail ratchet. Any "optimization" that touches these invalidates comparability with the pinned books (`backtest/*.csv`, memory notes).
+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",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--pair", required=True, help="EURUSD | GBPUSD | USDJPY | XAUUSD")
p.add_argument("--pair", required=True, help="XAUUSD | GOLD")
p.add_argument("--days", type=int, default=365, help="Days of history")
p.add_argument("--start", help="ISO date YYYY-MM-DD (overrides --days)")
p.add_argument("--end", help="ISO date YYYY-MM-DD (defaults to today)")
+5 -13
View File
@@ -49,7 +49,6 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from config import ( # noqa: E402
EURUSD,
PAIR_INFO,
PlatformConfig,
SignalStrength,
@@ -98,9 +97,6 @@ PAIR_ALIASES = {
"XAUUSD": XAUUSD,
"XAU/USD": XAUUSD,
"GOLD": XAUUSD,
"EURUSD": EURUSD,
"EUR/USD": EURUSD,
"EUR": EURUSD,
}
# Bar duration in seconds — used to compute "fully-closed" cutoffs so
@@ -1068,7 +1064,7 @@ class Trade:
final_confidence: float
market_regime: str
volatility_regime: str = ""
strategy_tag: str = "" # "XAU", "XAU_PB", "EUR_PB", "GBP_PB", "GENERIC"
strategy_tag: str = "" # "XAU", "XAU_PB", "GENERIC"
# Per-trade trailing overrides (0 = use the run-level params).
trail_arm_r: float = 0.0
trail_dist_r: float = 0.0
@@ -1422,7 +1418,7 @@ class BacktestConfig:
quality_filter: List[str] = field(
default_factory=lambda: ["A+", "A", "B", "C"]
)
# Restrict to specific strategy tags (XAU / EUR_PB / GBP_PB / GENERIC).
# Restrict to specific strategy tags (XAU / XAU_PB / GENERIC).
# Empty = take all. Use to A/B test whether GENERIC trades add or destroy edge.
strategies_only: List[str] = field(default_factory=list)
# Round-trip transaction cost in price units, charged on every trade
@@ -1547,10 +1543,6 @@ def _replay_span(span: Tuple[int, int]):
tag = "XAU_PB"
elif "XAU STRATEGY" in first_bull:
tag = "XAU"
elif "EUR STRATEGY" in first_bull or "EUR STRATEGY" in first_bear:
tag = "EUR"
elif "GBP STRATEGY" in first_bull:
tag = "GBP_PB"
if bt_cfg.strategies_only and tag not in bt_cfg.strategies_only:
continue
@@ -2132,7 +2124,7 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
description="GPU-accelerated backtester for ForexBot2",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--pair", required=True, help="XAUUSD | GOLD | EURUSD")
p.add_argument("--pair", required=True, help="XAUUSD | GOLD")
p.add_argument("--days", type=int, default=180, help="Days of history (ignored if --start/--end set)")
p.add_argument("--start", help="ISO date YYYY-MM-DD (overrides --days)")
p.add_argument("--end", help="ISO date YYYY-MM-DD (defaults to today)")
@@ -2149,11 +2141,11 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
help="Override TechnicalConfig.swing_sr_lookback (0 = use config default)")
p.add_argument("--quality", default="A+,A,B,C", help="Comma-separated allowed grades")
p.add_argument("--strategies", default="",
help="Comma-separated strategy tags to include (XAU,XAU_PB,EUR,GENERIC). Empty = all.")
help="Comma-separated strategy tags to include (XAU,XAU_PB,GENERIC). Empty = all.")
p.add_argument("--no-cache", action="store_true", help="Skip parquet cache (force API fetch)")
p.add_argument("--spread", type=float, default=None,
help="Round-trip cost in price units per trade (default: pair's "
"default_spread from PAIR_INFO — XAU $0.30, EUR 0.0001; 0 disables)")
"default_spread from PAIR_INFO — XAU $0.30; 0 disables)")
p.add_argument("--export-csv", help="Optional path to dump per-trade CSV (incl. indicator breakdown)")
p.add_argument("--detail", action="store_true",
help="Print full per-trade reasoning (indicators, signals, agreement, why entered)")
+2 -2
View File
@@ -6,7 +6,7 @@ alignment (see scraper docstring).
Key API:
cal = load_calendar() # full DataFrame, `utc` column
ev = precise_events(cal, ["USD", "EUR"]) # high-impact, minute-accurate
ev = precise_events(cal, ["USD"]) # high-impact, minute-accurate
mask = event_window_mask(bar_index, ev.utc, before, after)
"""
from pathlib import Path
@@ -47,7 +47,7 @@ def load_calendar(path: Path = PARQUET) -> pd.DataFrame:
def precise_events(
cal: pd.DataFrame,
currencies: Iterable[str] = ("USD", "EUR"),
currencies: Iterable[str] = ("USD",),
impacts: Iterable[str] = ("high",),
require_values: bool = False,
) -> pd.DataFrame:
+1 -53
View File
@@ -37,7 +37,6 @@ class MarketSession(Enum):
# ── Supported pairs ──────────────────────────────────────────────
XAUUSD = "XAU/USD"
EURUSD = "EUR/USD"
# price_precision: decimals for SL/TP rounding and display.
# default_spread: round-trip cost per trade in price units, charged by
@@ -45,8 +44,6 @@ EURUSD = "EUR/USD"
PAIR_INFO = {
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False,
"price_precision": 2, "default_spread": 0.30},
EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False,
"price_precision": 5, "default_spread": 0.0001},
}
@@ -165,7 +162,7 @@ class SignalConfig:
@dataclass
class NewsConfig:
"""Economic calendar & sentiment."""
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU", "EUR"])
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU"])
pre_event_buffer_minutes: int = 30
post_event_buffer_minutes: int = 15
# Whether event risk gates the SIGNAL (danger: confidence hard-capped
@@ -278,54 +275,6 @@ class XauStrategyConfig:
m5_pullback_touch_bars: int = 3
@dataclass
class EurUsdStrategyConfig:
"""EUR/USD strategy config — separate from XAU for independent tuning.
Values ported from feature/eurusd-support are a pre-realism baseline
(tuned before spread charging and next-open fills) and are re-tuned
against the realistic backtester. Cost knobs (round_trip_cost,
min_tp_cost_multiple, min_sl_pips) are EUR-local so nothing
gold-calibrated from SignalConfig leaks into the EUR path.
"""
enabled: bool = True
blocked_hours_utc: List[int] = field(default_factory=lambda: [22, 23, 0, 1, 2, 3])
weekend_filter: bool = True
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4])
min_atr_threshold: float = 0.0003
sl_atr_multiplier: float = 1.5 # swing-buffer ATR multiple for SL placement
tp_atr_multiplier: float = 1.2
sl_atr_floor: float = 1.2
min_rr_ratio: float = 1.0
mature_move_threshold: float = 35.0
tp_quality_scale: Dict[str, float] = field(default_factory=lambda: {
"A+": 1.0, "A": 1.0, "B": 1.0, "C": 1.0,
})
blocked_regimes: List[str] = field(default_factory=lambda: ["ranging", "transitioning"])
min_quality_grades: List[str] = field(default_factory=lambda: ["A+", "A", "B"])
max_exhaustion_tags: int = 99
block_near_ema200: bool = False
session_hours: List[int] = field(default_factory=list)
min_entry_score: float = 38.0
min_entry_confidence: float = 60.0
block_rsi_extended: bool = True
blocked_vol_regimes: List[str] = field(default_factory=list)
min_indicator_agreement: int = 0
be_trigger_r: float = 0.3
be_buffer_r: float = 0.15
# ── EUR-local trade-cost knobs (replace SignalConfig's gold-dollar
# values in the EUR path) ─────────────────────────────────────────
# Round-trip cost per trade in price units (1.0 pip).
round_trip_cost: float = 0.0001
# Reject setups whose TP1 distance < round_trip_cost × this multiple
# (10 → 10-pip minimum target; EUR M5 ATR runs ~5-8 pips).
min_tp_cost_multiple: float = 10.0
# Minimum SL distance in pips.
min_sl_pips: float = 8.0
@dataclass
class FilterConfig:
"""Regime and session filters for live signals."""
@@ -349,7 +298,6 @@ class PlatformConfig:
news: NewsConfig = field(default_factory=NewsConfig)
backtest: FilterConfig = field(default_factory=FilterConfig)
xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig)
eur_strategy: EurUsdStrategyConfig = field(default_factory=EurUsdStrategyConfig)
symbol: str = XAUUSD
display_name: str = "XAU/USD"
+3 -4
View File
@@ -21,7 +21,7 @@ if hasattr(sys.stdout, "reconfigure"):
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD, EURUSD
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
@@ -99,7 +99,7 @@ def _compact_reason(reason: str) -> str:
def _fmt_price(price: float) -> str:
"""2dp for gold-scale prices, 5dp for FX-scale (EUR/USD etc.)."""
"""2dp for gold-scale prices, 5dp for sub-10 FX-scale prices."""
return f"{price:.2f}" if price >= 10 else f"{price:.5f}"
@@ -257,7 +257,7 @@ Examples:
""",
)
parser.add_argument("--pair", "-p", default="XAUUSD",
help="Pair to analyze: XAUUSD | EURUSD (default: XAUUSD)")
help="Pair to analyze: XAUUSD (default: XAUUSD)")
parser.add_argument("--timeframe", "-tf", default="H1",
choices=["M5", "M15", "M30", "H1", "H4", "D1"],
help="Primary analysis timeframe (default: H1)")
@@ -274,7 +274,6 @@ Examples:
def _resolve_pair(s: str) -> str:
aliases = {
"XAUUSD": XAUUSD, "XAU/USD": XAUUSD, "GOLD": XAUUSD,
"EURUSD": EURUSD, "EUR/USD": EURUSD, "EUR": EURUSD,
}
key = s.upper().strip()
if key in aliases:
+1 -2
View File
@@ -673,8 +673,7 @@ class NewsEngine:
@staticmethod
def _get_affected_pairs(currency: str) -> List[str]:
_MAP = {
"USD": ["XAU/USD", "EUR/USD"],
"USD": ["XAU/USD"],
"XAU": ["XAU/USD"],
"EUR": ["EUR/USD"],
}
return _MAP.get(currency, [])
+1 -3
View File
@@ -45,8 +45,6 @@ def produce_signal(
from pair_signals import xauusd as _xau_mod
from pair_signals import eurusd as _eur_mod
from config import XAUUSD, EURUSD
from config import XAUUSD
register_pair(XAUUSD, _xau_mod.make_config)
register_pair(EURUSD, _eur_mod.make_config)
-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
+17 -177
View File
@@ -157,39 +157,6 @@ class SignalAggregator:
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
return report
if pair_info["display"] == "EUR/USD" and self.config.eur_strategy.enabled:
eur_cfg = self.config.eur_strategy
if eur_cfg.weekend_filter and _is_forex_weekend(now_utc):
report = SignalReport(
symbol=symbol,
display_name=pair_info["display"],
timestamp=str(now_utc),
)
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.trade_setup = None
report.warnings.append("WEEKEND FILTER: EUR/USD market closed — no liquidity")
report.action_recommendation = "NO TRADE — Weekend (market closed)"
return report
if eur_cfg.allowed_weekdays and now_utc.weekday() not in eur_cfg.allowed_weekdays:
day_names = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 5: "Sat", 6: "Sun"}
allowed = "/".join(day_names.get(d, str(d)) for d in eur_cfg.allowed_weekdays)
report = SignalReport(
symbol=symbol,
display_name=pair_info["display"],
timestamp=str(now_utc),
)
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.trade_setup = None
report.warnings.append(
f"DAY FILTER: {day_names.get(now_utc.weekday(), '?')} not in allowed days ({allowed})"
)
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
return report
timeframes = [self.config.primary_timeframe] + list(
self.config.confirmation_timeframes
)
@@ -827,93 +794,6 @@ class SignalAggregator:
report.signal_quality = "D"
report.trade_setup = None
# ── 6h. EUR/USD regime + quality + risk gate ──────────────
if report.display_name == "EUR/USD" and self.config.eur_strategy.enabled:
eur = self.config.eur_strategy
if eur.blocked_regimes and report.market_regime in eur.blocked_regimes:
report.warnings.append(
f"EUR REGIME FILTER: {report.market_regime} blocked"
)
report.signal_quality = "F"
if (eur.min_quality_grades
and report.signal_quality not in eur.min_quality_grades
and report.signal_quality != "F"):
report.warnings.append(
f"EUR QUALITY FILTER: {report.signal_quality} below minimum"
)
report.signal_quality = "F"
if (report.signal_quality != "F"
and getattr(report, 'exhaustion_count', 0) > eur.max_exhaustion_tags):
report.warnings.append(
f"EUR EXHAUSTION FILTER: {report.exhaustion_count} tags > max {eur.max_exhaustion_tags}"
)
report.signal_quality = "F"
if (report.signal_quality != "F"
and eur.block_near_ema200
and any(w.startswith("NEAR EMA200") for w in report.warnings)):
report.warnings.append("EUR EMA200 FILTER: too close to EMA200")
report.signal_quality = "F"
if eur.session_hours and report.signal_quality != "F":
try:
bar_hour = pd.Timestamp(report.timestamp).hour
if bar_hour not in eur.session_hours:
report.warnings.append(
f"EUR SESSION FILTER: {bar_hour:02}:00 UTC outside allowed hours"
)
report.signal_quality = "F"
except Exception:
pass
if report.signal_quality != "F":
abs_s = abs(report.final_score)
if abs_s < eur.min_entry_score:
report.warnings.append(
f"EUR SCORE FILTER: |{abs_s:.1f}| < {eur.min_entry_score}"
)
report.signal_quality = "F"
elif report.final_confidence < eur.min_entry_confidence:
report.warnings.append(
f"EUR CONF FILTER: {report.final_confidence} < {eur.min_entry_confidence}"
)
report.signal_quality = "F"
if (report.signal_quality != "F"
and eur.blocked_vol_regimes
and report.volatility_regime):
if report.volatility_regime in eur.blocked_vol_regimes:
report.warnings.append(
f"EUR VOL FILTER: {report.volatility_regime} volatility blocked"
)
report.signal_quality = "F"
if (report.signal_quality != "F"
and eur.min_indicator_agreement > 0
and report.primary_analysis):
agree_count = sum(
1 for ind in report.primary_analysis.indicators.values()
if ind.signal.value != 0
and (ind.signal.value > 0) == (report.final_score > 0)
)
if agree_count < eur.min_indicator_agreement:
report.warnings.append(
f"EUR AGREE FILTER: {agree_count} < {eur.min_indicator_agreement}"
)
report.signal_quality = "F"
if (report.signal_quality != "F"
and eur.block_rsi_extended
and report.primary_analysis):
rsi_ind = report.primary_analysis.indicators.get("rsi")
if rsi_ind:
rsi_v = rsi_ind.value
is_buy = report.final_score > 0
if is_buy and rsi_v > 70:
report.warnings.append(
f"EUR RSI FILTER: RSI {rsi_v:.0f} overbought for BUY"
)
report.signal_quality = "F"
elif not is_buy and rsi_v < 30:
report.warnings.append(
f"EUR RSI FILTER: RSI {rsi_v:.0f} oversold for SELL"
)
report.signal_quality = "F"
# ── 6i. M5 noise gate (gold-tuned thresholds — XAU only) ──
if (self.config.primary_timeframe == Timeframe.M5
and report.display_name == "XAU/USD"):
@@ -934,17 +814,6 @@ class SignalAggregator:
# ── 8. Reasons ─────────────────────────────────────────────
self._collect_reasons(report)
# ── 8b. EUR strategy marker ────────────────────────────────
# The backtester tags trades from reasons[0]; mark EUR setups so
# they report under the EUR strategy instead of GENERIC.
if (report.display_name == "EUR/USD" and self.config.eur_strategy.enabled
and report.trade_setup is not None):
marker = (f"EUR STRATEGY: {report.market_regime} regime, "
f"score {report.final_score:+.1f}, quality {report.signal_quality}")
target = (report.bullish_reasons if report.trade_setup.direction == "BUY"
else report.bearish_reasons)
target.insert(0, marker)
# ── 9. M5 pullback entry (XAU_PB) ──────────────────────────
# Second archetype: when no breakout setup fired, look for a
# trend-continuation pullback (retrace to EMA21/KC-mid). Runs
@@ -1304,38 +1173,18 @@ class SignalAggregator:
is_buy = report.final_score >= 0
levels = report.primary_analysis.key_levels
_is_eur = report.display_name == "EUR/USD" and self.config.eur_strategy.enabled
min_sl_price = self.config.signals.min_sl_pips * pip_value
min_rr = self.config.signals.min_rr_ratio
tp_floor = max(min_rr, self.config.signals.tp_floor_rr)
tf = self.config.signals.tp_tightening_factor
pre_tight_min_rr = tp_floor / tf if tf > 0 else tp_floor
if _is_eur:
# EUR geometry reads only EurUsdStrategyConfig — nothing
# gold-calibrated from SignalConfig applies here.
eur = self.config.eur_strategy
min_sl_price = eur.min_sl_pips * pip_value
min_rr = eur.min_rr_ratio
# No TP-tightening interplay for EUR: the RR floor used for
# target selection IS min_rr (tp_tightening_factor is 1.0;
# revisit if EUR ever tunes it).
pre_tight_min_rr = min_rr
mature_move = abs(report.final_score) > eur.mature_move_threshold
quality_scale = eur.tp_quality_scale.get(report.signal_quality, 1.0)
tp_mult = (eur.tp_atr_multiplier if mature_move
else eur.tp_atr_multiplier * 1.5) * quality_scale
tp_swing_cap = (2.0 if mature_move else 2.5) * quality_scale
sl_atr_floor = eur.sl_atr_floor
sl_swing_buffer = eur.sl_atr_multiplier
else:
min_sl_price = self.config.signals.min_sl_pips * pip_value
min_rr = self.config.signals.min_rr_ratio
tp_floor = max(min_rr, self.config.signals.tp_floor_rr)
tf = self.config.signals.tp_tightening_factor
pre_tight_min_rr = tp_floor / tf if tf > 0 else tp_floor
tp_mult = 1.5
tp_swing_cap = 2.5
tp_mult = 1.5
tp_swing_cap = 2.5
sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier
if report.display_name == "XAU/USD" else 1.5)
sl_swing_buffer = 1.0
sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier
if report.display_name == "XAU/USD" else 1.5)
sl_swing_buffer = 1.0
if is_buy:
swing_lows = sorted(
@@ -1419,22 +1268,13 @@ class SignalAggregator:
# Spread gate: a target too close to entry hands most of the win
# to transaction costs (validated on M5: sub-$10 targets lose).
if _is_eur:
min_tp = eur.round_trip_cost * eur.min_tp_cost_multiple
if tp1_distance < min_tp:
report.warnings.append(
f"SPREAD GATE: target {tp1_distance / pip_value:.1f} pips "
f"< {min_tp / pip_value:.1f} pip minimum — costs would eat the edge"
)
return None
else:
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
if tp1_distance < min_tp:
report.warnings.append(
f"SPREAD GATE: target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — "
f"costs would eat the edge"
)
return None
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
if tp1_distance < min_tp:
report.warnings.append(
f"SPREAD GATE: target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — "
f"costs would eat the edge"
)
return None
risk_pips = sl_distance / pip_value
reward_pips = tp1_distance / pip_value
+1 -1
View File
@@ -138,7 +138,7 @@ def _build_scan_buttons() -> List[dict]:
buttons = []
for symbol in SCAN_PAIRS:
slug = symbol.replace("/", "").lower()
base = PAIR_INFO[symbol]["display"].split("/")[0] # XAU, EUR
base = PAIR_INFO[symbol]["display"].split("/")[0] # XAU
prefix = f"{base} " if multi else ""
for tf, label in _TF_BUTTON_LABELS:
buttons.append({
+10 -464
View File
@@ -930,9 +930,9 @@ def _():
def _():
ne = NewsEngine(NewsConfig())
context = FundamentalContext()
context.news_sentiment = {"EUR": 0.5, "USD": -0.1}
context.news_sentiment = {"XAU": 0.5, "USD": -0.1}
score, desc = ne.get_pair_sentiment("XAU/USD", context)
assert score > 0, "EUR bullish + USD bearish should be net bullish for EUR/USD"
assert score > 0, "XAU bullish + USD bearish should be net bullish for XAU/USD"
assert "bullish" in desc.lower()
@@ -940,7 +940,7 @@ def _():
def _():
ne = NewsEngine(NewsConfig())
context = FundamentalContext()
context.news_sentiment = {"EUR": -0.5, "USD": 0.3}
context.news_sentiment = {"XAU": -0.5, "USD": 0.3}
score, desc = ne.get_pair_sentiment("XAU/USD", context)
assert score < 0
@@ -949,7 +949,7 @@ def _():
def _():
ne = NewsEngine(NewsConfig())
context = FundamentalContext()
context.news_sentiment = {"EUR": 0.0, "USD": 0.0}
context.news_sentiment = {"XAU": 0.0, "USD": 0.0}
score, desc = ne.get_pair_sentiment("XAU/USD", context)
assert score == 0.0
assert "neutral" in desc.lower()
@@ -1236,10 +1236,8 @@ def _():
conf_caut_off = recompute("caution", False)
assert conf_caut_off == baseline
assert any("Upcoming event" in w for w in report.warnings)
# PlatformConfig default is off (XAU-tuned); EUR factory re-enables
# PlatformConfig default is off (XAU-tuned)
assert PlatformConfig().news.event_risk_caps_signals is False
from pair_signals.eurusd import make_config as eur_make
assert eur_make(Timeframe.M15).news.event_risk_caps_signals is True
@test("SignalAggregator._generate_trade_setup BUY direction")
@@ -1472,17 +1470,6 @@ def _():
assert config.primary_timeframe is not None
@test("build_config resolves EURUSD pair to EUR factory config")
def _():
class FakeArgs:
pair = "EURUSD"
timeframe = "M15"
config = build_config(FakeArgs())
assert config.symbol == "EUR/USD"
assert config.pip_value == 0.0001
assert config.primary_timeframe == Timeframe.M15
# ══════════════════════════════════════════════════════════════════════
# MODULE 7: telegram_bot.py
# ══════════════════════════════════════════════════════════════════════
@@ -1821,12 +1808,12 @@ def _():
print("\n═══ Module 9: pair_signals registry ═══")
@test("pair_signals registry includes XAU/USD and EUR/USD")
@test("pair_signals registry includes XAU/USD only")
def _():
import pair_signals
pairs = pair_signals.get_supported_pairs()
assert XAUUSD in pairs, f"XAU missing from registry: {pairs}"
assert "EUR/USD" in pairs, f"EUR missing from registry: {pairs}"
assert pairs == [XAUUSD], f"registry must be XAU-only: {pairs}"
@test("xauusd.make_config equals inline PlatformConfig construction")
@@ -1863,18 +1850,6 @@ def _():
f"XAU factory config differs from inline construction for {tf}"
@test("eurusd.make_config produces EUR config with XAU strategy disabled")
def _():
from pair_signals import PAIR_PRODUCERS
cfg = PAIR_PRODUCERS["EUR/USD"](Timeframe.M5, None)
assert cfg.symbol == "EUR/USD"
assert cfg.pip_value == 0.0001
assert cfg.xau_strategy.enabled is False
assert cfg.eur_strategy.enabled is True
assert cfg.technical.regime_weights_override, "EUR regime weights not applied"
assert cfg.confirmation_timeframes == [Timeframe.M15, Timeframe.H1]
@test("produce_signal raises for unregistered pair")
def _():
from pair_signals import produce_signal
@@ -1943,7 +1918,7 @@ def _():
df = make_ohlcv(n=100, seed=42)
df.iloc[-11, df.columns.get_loc("Close")] = 0.0
engine = TechnicalEngine(PlatformConfig().technical)
result = engine.analyze(df, "EUR/USD", Timeframe.H1)
result = engine.analyze(df, "XAU/USD", Timeframe.H1)
if "roc" in result.indicators:
assert np.isfinite(result.indicators["roc"].value), "ROC should not be inf"
@@ -1959,440 +1934,11 @@ def _():
"Close": [100.0]*n, "Volume": [1000.0]*n,
}, index=dates)
engine = TechnicalEngine(PlatformConfig().technical)
result = engine.analyze(df, "EUR/USD", Timeframe.H1)
result = engine.analyze(df, "XAU/USD", Timeframe.H1)
if "adx" in result.indicators:
assert np.isfinite(result.indicators["adx"].value), "ADX should be finite on flat data"
print("\n═══ Module 10: EUR/USD Support ═══")
from config import EURUSD, EurUsdStrategyConfig
# ── 10a. Config & registry ──────────────────────────────────────────
@test("PAIR_INFO includes EUR/USD with correct pip_value")
def _():
assert EURUSD in PAIR_INFO
assert PAIR_INFO[EURUSD]["pip_value"] == 0.0001
assert PAIR_INFO[EURUSD]["is_jpy_pair"] is False
assert PAIR_INFO[EURUSD]["display"] == "EUR/USD"
assert PAIR_INFO[EURUSD]["price_precision"] == 5
assert PAIR_INFO[EURUSD]["default_spread"] == 0.0001
@test("EurUsdStrategyConfig defaults are sensible")
def _():
cfg = EurUsdStrategyConfig()
assert cfg.enabled is True
assert 0 in cfg.blocked_hours_utc
assert 5 not in cfg.allowed_weekdays # No Saturday
assert 6 not in cfg.allowed_weekdays # No Sunday
assert cfg.min_atr_threshold == 0.0003
assert cfg.min_rr_ratio == 1.0
assert cfg.mature_move_threshold == 35.0
assert cfg.blocked_regimes == ["ranging", "transitioning"]
assert "A" in cfg.min_quality_grades
# EUR-local cost knobs must be pip-scale, not gold-dollar-scale
assert cfg.round_trip_cost == 0.0001
assert cfg.round_trip_cost * cfg.min_tp_cost_multiple < 0.01
assert cfg.min_sl_pips == 8.0
@test("PlatformConfig has eur_strategy field")
def _():
config = PlatformConfig()
assert hasattr(config, "eur_strategy")
assert isinstance(config.eur_strategy, EurUsdStrategyConfig)
@test("EUR/USD config factory produces correct config")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
assert config.symbol == EURUSD
assert config.display_name == "EUR/USD"
assert config.pip_value == 0.0001
assert config.xau_strategy.enabled is False
assert config.technical.regime_weights_override
assert "trending" in config.technical.regime_weights_override
assert "ranging" in config.technical.regime_weights_override
@test("XAU/USD config factory keeps XAU strategy enabled")
def _():
from pair_signals.xauusd import make_config
config = make_config(Timeframe.H1)
assert config.symbol == XAUUSD
assert config.display_name == "XAU/USD"
assert config.pip_value == 0.10
assert config.xau_strategy.enabled is True
@test("Mixed 'trending' regime: RSI mid-zone is neutral, extremes only")
def _():
from engine import TechnicalEngine, TechnicalAnalysis, IndicatorSignal
from config import TechnicalConfig
eng = TechnicalEngine(TechnicalConfig())
# Pre-fix, mixed "trending" used the uptrend playbook: RSI 45 became
# STRONG_BUY conf 75 in a regime whose EMAs disagree on direction.
for rsi_v, expected in ((45.0, SignalStrength.NEUTRAL),
(75.0, SignalStrength.SELL),
(25.0, SignalStrength.BUY)):
res = TechnicalAnalysis(symbol="XAU/USD", timeframe="1h", timestamp="t")
res.market_regime = "trending"
res.indicators["rsi"] = IndicatorSignal(
name="RSI", value=rsi_v, signal=SignalStrength.NEUTRAL,
confidence=30, description="", weight=1.5)
eng._reinterpret_rsi_for_regime(res)
got = res.indicators["rsi"].signal
assert got == expected, f"RSI {rsi_v} in mixed regime → {got}, want {expected}"
@test("XAU regime filter: blocked regime never yields actionable quality")
def _():
from pair_signals.xauusd import make_config
config = make_config(Timeframe.M15)
config.backtest.session_filter = False
config.xau_strategy.blocked_regimes = [
"trending", "trending_up", "trending_down", "ranging", "transitioning"]
agg = SignalAggregator(config)
df = make_ohlcv(n=300, start_price=3300.0, trend=0.5, seed=7)
dfs = {Timeframe.M15: df, Timeframe.M5: df, Timeframe.H1: df}
report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False)
assert report.signal_quality not in ("A+", "A", "B", "C"), \
f"blocked regime must not stay actionable, got {report.signal_quality}"
# The M15 factory opts into the mixed-"trending" block; M5 must not.
assert make_config(Timeframe.M15).xau_strategy.blocked_regimes == ["trending"]
assert make_config(Timeframe.M5).xau_strategy.blocked_regimes == []
@test("XAU SELL hour gate: blocks 15/17 UTC bar-close entries, spares others")
def _():
from pair_signals.xauusd import make_config
def xau_15min_df(end):
np.random.seed(9)
dates = pd.date_range(end=pd.Timestamp(end, tz="UTC"),
periods=300, freq="15min")
close = 3300.0 + np.cumsum(np.random.randn(300) * 2.0 - 1.0)
return pd.DataFrame({
"Open": close + np.random.randn(300) * 0.5,
"High": close + np.abs(np.random.randn(300)),
"Low": close - np.abs(np.random.randn(300)),
"Close": close,
"Volume": np.random.randint(100, 10000, 300).astype(float),
}, index=pd.DatetimeIndex(dates, name="Datetime"))
def run(end, hours):
cfg = make_config(Timeframe.M15)
cfg.backtest.session_filter = False
# Neutralize the other SELL gates so 6g3 is the one under test.
cfg.xau_strategy.sell_min_quality = ["A+", "A", "B", "C"]
cfg.xau_strategy.sell_vol_regimes = ["low", "normal", "high", "extreme"]
cfg.xau_strategy.blocked_regimes = []
cfg.xau_strategy.sell_blocked_hours_utc = hours
df = xau_15min_df(end)
dfs = {Timeframe.M15: df, Timeframe.M5: df, Timeframe.H1: df}
return SignalAggregator(cfg).analyze_from_dataframes(
symbol=XAUUSD, dfs=dfs, use_news=False)
# Same synthetic downtrend; only the index end (and thus the bar-close
# hour) differs. Label 14:45 closes 15:00 (blocked); 19:45 closes
# 20:00 (allowed). The gate reads bar-CLOSE time, not the label hour.
control = run("2026-07-13 14:45", [])
assert control.signal_quality in ("A+", "A", "B", "C") and control.final_score < 0, \
f"probe data must yield an actionable sell, got {control.signal_quality}"
blocked = run("2026-07-13 14:45", [15, 17])
assert blocked.signal_quality == "D" and blocked.trade_setup is None, \
f"15:00 UTC close must be gated to D, got {blocked.signal_quality}"
assert any("blocked for shorts" in w for w in blocked.warnings), \
f"missing SELL hour warning: {blocked.warnings}"
open_hour = run("2026-07-13 19:45", [15, 17])
assert open_hour.signal_quality == control.signal_quality, \
f"20:00 UTC close must pass the gate, got {open_hour.signal_quality}"
# Factory scoping: M15 opts in, M5 does not (its book must not move).
assert make_config(Timeframe.M15).xau_strategy.sell_blocked_hours_utc == [15, 17]
assert make_config(Timeframe.M5).xau_strategy.sell_blocked_hours_utc == []
@test("NewsEngine._get_affected_pairs includes EUR/USD for EUR and USD events")
def _():
from news_engine import NewsEngine
assert "EUR/USD" in NewsEngine._get_affected_pairs("EUR")
assert "EUR/USD" in NewsEngine._get_affected_pairs("USD")
assert "XAU/USD" in NewsEngine._get_affected_pairs("USD")
assert "EUR/USD" not in NewsEngine._get_affected_pairs("GBP")
# ── 10b. EUR filter pipeline ────────────────────────────────────────
@test("EUR/USD signal analysis produces valid report (mocked data)")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.backtest.session_filter = False
agg = SignalAggregator(config)
df = make_ohlcv(n=300, start_price=1.0800, trend=0.00005)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
assert report.display_name == "EUR/USD"
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
assert not any("SELL FILTER" in w for w in report.warnings)
@test("EUR filter: blocked regime sets quality F")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.eur_strategy.blocked_regimes = ["ranging"]
agg = SignalAggregator(config)
df = make_ohlcv(n=300, seed=42)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
if report.market_regime == "ranging":
assert report.signal_quality == "F", \
f"Ranging should be blocked, got {report.signal_quality}"
@test("EUR filter: quality not in min_quality_grades sets F")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.eur_strategy.min_quality_grades = ["A+"]
agg = SignalAggregator(config)
df = make_ohlcv(n=300, seed=42, trend=0.001)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
if report.signal_quality not in ("A+", "F"):
assert False, f"Non-A+ grade should be blocked, got {report.signal_quality}"
@test("EUR filter: score below min sets F")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.eur_strategy.min_entry_score = 99.0
agg = SignalAggregator(config)
df = make_ohlcv(n=300, seed=42, trend=0.001)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
assert report.signal_quality == "F", \
f"Score 99 threshold should block all, got {report.signal_quality}"
@test("EUR filter: confidence below min sets F")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.eur_strategy.min_entry_confidence = 99.0
agg = SignalAggregator(config)
df = make_ohlcv(n=300, seed=42, trend=0.001)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
assert report.signal_quality == "F", \
f"Confidence 99 threshold should block all, got {report.signal_quality}"
@test("EUR filter: RSI extended buy (RSI>70) blocked")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.eur_strategy.block_rsi_extended = True
config.eur_strategy.min_entry_score = 0.0
config.eur_strategy.min_entry_confidence = 0.0
config.eur_strategy.min_quality_grades = ["A+", "A", "B", "C", "D"]
agg = SignalAggregator(config)
df = make_ohlcv(n=300, trend=0.005, seed=42)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
if report.primary_analysis and "rsi" in report.primary_analysis.indicators:
rsi_v = report.primary_analysis.indicators["rsi"].value
if report.final_score > 0 and rsi_v > 70:
assert report.signal_quality == "F", \
f"Buy with RSI {rsi_v:.0f} should be blocked"
@test("EUR weekend filter returns F report on Saturday")
def _():
import signal_aggregator as sa_mod
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
agg = SignalAggregator(config)
saturday = datetime(2026, 7, 11, 12, 0, tzinfo=timezone.utc)
assert saturday.weekday() == 5
with patch.object(sa_mod, "datetime", wraps=datetime) as mock_dt:
mock_dt.now.return_value = saturday
report = agg.analyze_pair(symbol=EURUSD)
assert report.signal_quality == "F"
assert any("WEEKEND" in w for w in report.warnings)
assert report.trade_setup is None
# ── 10c. EUR trade geometry ─────────────────────────────────────────
def _mk_eur_report(score=45.0, quality="A", atr=0.0006, price=1.0800):
from signal_aggregator import SignalReport
r = SignalReport(symbol=EURUSD, display_name="EUR/USD", timestamp="2026-07-08 14:00")
r.final_signal = SignalStrength.BUY
r.final_score = score
r.final_confidence = 70.0
r.signal_quality = quality
class _Analysis:
key_levels = {
"atr": atr,
"swing_low_1": price - 0.0015,
"swing_low_2": price - 0.0030,
"swing_high_1": price + 0.0030,
"swing_high_2": price + 0.0050,
}
r.primary_analysis = _Analysis()
r.current_price = price
return r
@test("EUR trade setup: swing SL with EUR buffers, RR >= min_rr")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.M5)
agg = SignalAggregator(config)
r = _mk_eur_report()
ts = agg._generate_trade_setup(r, 0.0001)
assert ts is not None, f"no setup, warnings: {r.warnings}"
assert ts.pip_value == 0.0001
assert ts.stop_loss < ts.entry_price < ts.take_profit_1
assert ts.risk_pips < 60, f"EUR risk pips insane: {ts.risk_pips}"
assert ts.risk_reward >= config.eur_strategy.min_rr_ratio - 0.01
# levels must NOT be snapped to gold's 2-decimal grid
assert abs(ts.stop_loss * 100 - round(ts.stop_loss * 100)) > 1e-6 or \
abs(ts.take_profit_1 * 100 - round(ts.take_profit_1 * 100)) > 1e-6
@test("EUR spread gate rejects sub-minimum pip targets")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.M5)
config.eur_strategy.min_rr_ratio = 0.1
agg = SignalAggregator(config)
r = _mk_eur_report(atr=0.0002)
r.primary_analysis.key_levels = {"atr": 0.0002}
ts = agg._generate_trade_setup(r, 0.0001)
assert ts is None
assert any("SPREAD GATE" in w and "pip" in w for w in r.warnings), r.warnings
@test("Per-pair spread gates: 40-pip EUR target passes, $4 XAU target rejected")
def _():
from pair_signals.eurusd import make_config
# EUR: 40-pip TP distance clears the 10-pip minimum
config = make_config(Timeframe.M5)
agg = SignalAggregator(config)
r = _mk_eur_report(atr=0.0020)
ts = agg._generate_trade_setup(r, 0.0001)
assert ts is not None and abs(ts.take_profit_1 - ts.entry_price) / 0.0001 >= 10
# XAU: a $4 target must fail gold's $9 minimum (round_trip 0.30 × 30)
xau_cfg = PlatformConfig()
xau_agg = SignalAggregator(xau_cfg)
from signal_aggregator import SignalReport
xr = SignalReport(symbol=XAUUSD, display_name="XAU/USD", timestamp="2026-07-08 14:00")
xr.final_signal = SignalStrength.BUY
xr.final_score = 45.0
xr.final_confidence = 70.0
xr.signal_quality = "B"
class _XA:
key_levels = {"atr": 2.0}
xr.primary_analysis = _XA()
xr.current_price = 4000.0
xau_cfg.signals.min_rr_ratio = 0.1
xau_cfg.signals.tp_floor_rr = 0.1
ts2 = xau_agg._generate_trade_setup(xr, 0.10)
# ATR 2.0 → TP distance ~3, below the $9 gate
assert ts2 is None
assert any("SPREAD GATE" in w and "$" in w for w in xr.warnings), xr.warnings
@test("Trade setup: pip_value zero returns None")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
df = make_ohlcv(n=300, trend=0.002, seed=42)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False)
if report.primary_analysis:
setup = agg._generate_trade_setup(report, pip_value=0.0)
assert setup is None, "pip_value=0 should produce no trade setup"
# ── 10d. Cross-pair isolation ───────────────────────────────────────
@test("XAU sell gate does not apply to EUR/USD")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.backtest.session_filter = False
agg = SignalAggregator(config)
df = make_ohlcv(n=300, start_price=1.0800, trend=-0.0005, seed=99)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
assert not any("SELL FILTER" in w for w in report.warnings)
@test("EUR report never carries XAU gate warnings across seeds")
def _():
from pair_signals.eurusd import make_config
for seed in (7, 21, 42, 77, 99):
config = make_config(Timeframe.M5)
agg = SignalAggregator(config)
df = make_ohlcv(n=400, start_price=1.0800, trend=0.0004, seed=seed)
dfs = {Timeframe.M5: df, Timeframe.M15: df, Timeframe.H1: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
leaked = [w for w in report.warnings
if "SELL FILTER" in w or "M5 FILTER" in w or "XAU PULLBACK" in w
or "LOW ATR FILTER" in w]
assert not leaked, f"XAU gates leaked into EUR (seed={seed}): {leaked}"
if report.trade_setup:
assert report.trade_setup.pip_value == 0.0001
@test("XAU report identical whether eur_strategy is enabled or disabled")
def _():
df = make_ohlcv(n=300, seed=42, trend=0.001)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
results = []
for eur_enabled in (True, False):
config = PlatformConfig()
config.eur_strategy.enabled = eur_enabled
agg = SignalAggregator(config)
report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False)
results.append((report.final_score, report.final_confidence,
report.signal_quality, str(report.trade_setup),
tuple(report.warnings)))
assert results[0] == results[1], \
f"XAU output changed with eur_strategy.enabled flip: {results[0]} vs {results[1]}"
# and no EUR warnings on an XAU report
assert not any("EUR" in w for w in results[0][4])
@test("Integration: XAU and EUR produce independent results")
def _():
from pair_signals.eurusd import make_config as eur_make_config
df = make_ohlcv(n=300, seed=42, trend=0.001)
xau_config = PlatformConfig()
xau_agg = SignalAggregator(xau_config)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
xau_report = xau_agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False)
eur_config = eur_make_config(Timeframe.H1)
eur_agg = SignalAggregator(eur_config)
eur_report = eur_agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
assert xau_report.display_name == "XAU/USD"
assert eur_report.display_name == "EUR/USD"
# ══════════════════════════════════════════════════════════════════════
# MODULE 11: 2026-07-16 audit-fix regressions
# ══════════════════════════════════════════════════════════════════════
@@ -2514,7 +2060,7 @@ def _():
spike_ts, after_ts = df.index[k], df.index[k + 1]
spike_val = df["Close"].iloc[k - 1] * 1.05 # ~+5% on a ~0.1%-σ series
df.loc[spike_ts, ["Open", "High", "Low", "Close"]] = spike_val
cleaned = provider._validate_and_clean(df, "EUR/USD", Timeframe.H1)
cleaned = provider._validate_and_clean(df, "XAU/USD", Timeframe.H1)
assert spike_ts not in cleaned.index, "spike bar must be removed"
assert after_ts in cleaned.index, \
"snap-back bar after the spike must be kept (two-bar hole regression)"
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Test whether Twelve Data allows 2 simultaneous WebSocket connections for EUR/USD.
"""Test whether Twelve Data allows 2 simultaneous WebSocket connections for XAU/USD.
Uses the twelvedata SDK which handles the WS protocol correctly.
Runs connection 1, then adds connection 2 after 10s, monitors for 30s total.
@@ -11,7 +11,7 @@ import time
from twelvedata import TDClient
API_KEY = "f563b22ffdfd4924955decbe1a8c8701"
SYMBOL = "EUR/USD"
SYMBOL = "XAU/USD"
DURATION = 30
results = {