diff --git a/news_engine.py b/news_engine.py index 1b7c4f0..c7c20f4 100755 --- a/news_engine.py +++ b/news_engine.py @@ -673,7 +673,8 @@ class NewsEngine: @staticmethod def _get_affected_pairs(currency: str) -> List[str]: _MAP = { - "USD": ["XAU/USD"], + "USD": ["XAU/USD", "EUR/USD"], "XAU": ["XAU/USD"], + "EUR": ["EUR/USD"], } return _MAP.get(currency, []) diff --git a/test_all.py b/test_all.py index 887991c..123ddf9 100644 --- a/test_all.py +++ b/test_all.py @@ -1893,6 +1893,348 @@ def _(): 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("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" + + # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════