#!/usr/bin/env python3 """ ForexBot2 — Comprehensive QA Test Suite ========================================= Tests every function in the codebase without requiring live API connections. """ import sys import os import time import traceback from datetime import datetime, timedelta, timezone from unittest.mock import patch, MagicMock from io import StringIO import numpy as np import pandas as pd sys.path.insert(0, os.path.dirname(__file__)) PASS = 0 FAIL = 0 ERRORS = [] def test(name): def decorator(func): global PASS, FAIL try: func() PASS += 1 print(f" ✓ {name}") except Exception as e: FAIL += 1 tb = traceback.format_exc() ERRORS.append((name, str(e), tb)) print(f" ✗ {name}: {e}") return func return decorator def make_ohlcv(n=300, start_price=1.1000, trend=0.0001, seed=42): """Generate realistic OHLCV data for testing.""" np.random.seed(seed) dates = pd.date_range( end=pd.Timestamp.now(tz="UTC").floor("h"), periods=n, freq="h" ) close = start_price + np.cumsum(np.random.randn(n) * 0.001 + trend) high = close + np.abs(np.random.randn(n) * 0.0005) low = close - np.abs(np.random.randn(n) * 0.0005) open_ = close + np.random.randn(n) * 0.0003 volume = np.random.randint(100, 10000, n).astype(float) df = pd.DataFrame({ "Open": open_, "High": high, "Low": low, "Close": close, "Volume": volume, }, index=dates) df.index.name = "Datetime" return df def make_xau_ohlcv(n=300, start_price=2400.0, seed=42): """Generate XAU/USD OHLCV data.""" np.random.seed(seed) dates = pd.date_range( end=pd.Timestamp.now(tz="UTC").floor("h"), periods=n, freq="h" ) close = start_price + np.cumsum(np.random.randn(n) * 2.0 + 0.05) high = close + np.abs(np.random.randn(n) * 1.0) low = close - np.abs(np.random.randn(n) * 1.0) open_ = close + np.random.randn(n) * 0.5 volume = np.random.randint(100, 10000, n).astype(float) df = pd.DataFrame({ "Open": open_, "High": high, "Low": low, "Close": close, "Volume": volume, }, index=dates) df.index.name = "Datetime" return df # ══════════════════════════════════════════════════════════════════════ # MODULE 1: config.py # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 1: config.py ═══") from config import ( Timeframe, SignalStrength, MarketSession, PlatformConfig, TechnicalConfig, SignalConfig, NewsConfig, FilterConfig, XauStrategyConfig, XAUUSD, PAIR_INFO, ) @test("Timeframe enum values") def _(): assert Timeframe.M1.value == "1min" assert Timeframe.M5.value == "5min" assert Timeframe.M15.value == "15min" assert Timeframe.M30.value == "30min" assert Timeframe.H1.value == "1h" assert Timeframe.H4.value == "4h" assert Timeframe.D1.value == "1d" assert Timeframe.W1.value == "1wk" @test("SignalStrength enum values and ordering") def _(): assert SignalStrength.STRONG_BUY.value == 2 assert SignalStrength.BUY.value == 1 assert SignalStrength.NEUTRAL.value == 0 assert SignalStrength.SELL.value == -1 assert SignalStrength.STRONG_SELL.value == -2 assert SignalStrength.STRONG_BUY.value > SignalStrength.BUY.value assert SignalStrength.SELL.value > SignalStrength.STRONG_SELL.value @test("MarketSession enum values") def _(): assert MarketSession.SYDNEY.value == "sydney" assert MarketSession.TOKYO.value == "tokyo" assert MarketSession.LONDON.value == "london" assert MarketSession.NEW_YORK.value == "new_york" assert MarketSession.OVERLAP_LONDON_NY.value == "london_ny_overlap" @test("PAIR_INFO completeness and correctness") def _(): assert XAUUSD in PAIR_INFO for pair, info in PAIR_INFO.items(): assert "display" in info, f"Missing 'display' for {pair}" assert "pip_value" in info, f"Missing 'pip_value' for {pair}" assert "is_jpy_pair" in info, f"Missing 'is_jpy_pair' for {pair}" assert info["pip_value"] > 0, f"pip_value must be > 0 for {pair}" assert PAIR_INFO[XAUUSD]["is_jpy_pair"] is False assert PAIR_INFO[XAUUSD]["pip_value"] == 0.10 @test("TechnicalConfig defaults") def _(): tc = TechnicalConfig() assert tc.ema_fast == 9 assert tc.ema_medium == 21 assert tc.ema_slow == 50 assert tc.ema_trend == 200 assert tc.rsi_period == 14 assert tc.rsi_overbought == 70.0 assert tc.rsi_oversold == 30.0 assert tc.macd_fast == 12 assert tc.macd_slow == 26 assert tc.macd_signal == 9 assert tc.bb_period == 20 assert tc.bb_std == 2.0 assert tc.atr_period == 14 assert tc.adx_period == 14 assert tc.ema_mode == "4ema" assert tc.rsi_mode == "regime_aware" assert tc.macd_as_pillar is True assert tc.disabled_indicators == [] assert tc.roc_period == 10 assert tc.kc_period == 20 assert tc.adx_slope_lookback == 5 @test("SignalConfig defaults") def _(): sc = SignalConfig() assert sc.min_confidence == 55.0 assert sc.min_rr_ratio == 0.5 assert sc.tp_tightening_factor == 1.0 assert sc.atr_sl_multiplier == 1.5 assert sc.default_rr_ratio == 2.0 assert sc.cooldown_bars == 5 assert "ema_trend" in sc.weights assert "rsi" in sc.weights @test("PlatformConfig defaults") def _(): pc = PlatformConfig() assert pc.symbol == XAUUSD assert pc.primary_timeframe == Timeframe.H1 assert Timeframe.M15 in pc.confirmation_timeframes assert Timeframe.H4 in pc.confirmation_timeframes assert pc.data_lookback_days == 60 assert pc.refresh_interval == 300 assert XAUUSD in pc.scan_pairs @test("XauStrategyConfig defaults") def _(): xc = XauStrategyConfig() assert xc.enabled is True assert xc.rsi_entry_threshold == 45.0 assert xc.min_ema200_tfs == 2 assert xc.allow_generic_trades is True assert xc.tp_atr_multiplier == 1.0 assert xc.sl_atr_multiplier == 2.0 assert isinstance(xc.blocked_hours_utc, list) @test("FilterConfig defaults") def _(): fc = FilterConfig() assert fc.session_filter is True assert fc.regime_filter is False @test("PlatformConfig dataclass mutable default independence") def _(): c1 = PlatformConfig() c2 = PlatformConfig() c1.scan_pairs.append("TEST") assert "TEST" not in c2.scan_pairs, "Mutable defaults should be independent" # ══════════════════════════════════════════════════════════════════════ # MODULE 2: data_provider.py # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 2: data_provider.py ═══") from data_provider import ForexDataProvider, DataIntegrityError @test("ForexDataProvider._to_td_symbol conversion") def _(): assert ForexDataProvider._to_td_symbol("XAU/USD") == "XAU/USD" @test("ForexDataProvider TD_INTERVALS complete mapping") def _(): dp = ForexDataProvider(PlatformConfig()) for tf in Timeframe: assert tf in dp.TD_INTERVALS, f"Missing TD_INTERVALS mapping for {tf}" @test("ForexDataProvider BARS_PER_DAY mapping") def _(): dp = ForexDataProvider(PlatformConfig()) for tf in Timeframe: assert tf in dp.BARS_PER_DAY, f"Missing BARS_PER_DAY for {tf}" assert dp.BARS_PER_DAY[Timeframe.H1] == 24 assert dp.BARS_PER_DAY[Timeframe.M15] == 96 assert dp.BARS_PER_DAY[Timeframe.D1] == 1 @test("ForexDataProvider CANDLE_SECONDS mapping") def _(): dp = ForexDataProvider(PlatformConfig()) for tf in Timeframe: assert tf in dp.CANDLE_SECONDS, f"Missing CANDLE_SECONDS for {tf}" assert dp.CANDLE_SECONDS[Timeframe.M1] == 60 assert dp.CANDLE_SECONDS[Timeframe.H1] == 3600 assert dp.CANDLE_SECONDS[Timeframe.D1] == 86400 @test("ForexDataProvider._validate_and_clean basic valid data") def _(): dp = ForexDataProvider(PlatformConfig()) df = make_ohlcv(300) result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1) assert len(result) > 0 assert result.index.is_monotonic_increasing assert not result.index.has_duplicates @test("ForexDataProvider._validate_and_clean empty df raises error") def _(): dp = ForexDataProvider(PlatformConfig()) try: dp._validate_and_clean(pd.DataFrame(), "XAU/USD", Timeframe.H1) assert False, "Should have raised DataIntegrityError" except DataIntegrityError: pass @test("ForexDataProvider._validate_and_clean None df raises error") def _(): dp = ForexDataProvider(PlatformConfig()) try: dp._validate_and_clean(None, "XAU/USD", Timeframe.H1) assert False, "Should have raised DataIntegrityError" except DataIntegrityError: pass @test("ForexDataProvider._validate_and_clean fixes OHLC inconsistencies") def _(): dp = ForexDataProvider(PlatformConfig()) df = make_ohlcv(300) df.iloc[10, df.columns.get_loc("High")] = df.iloc[10]["Low"] - 1 result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1) assert (result["High"] >= result["Low"]).all() @test("ForexDataProvider._validate_and_clean drops future timestamps") def _(): dp = ForexDataProvider(PlatformConfig()) df = make_ohlcv(300) future = pd.Timestamp.now(tz="UTC") + pd.Timedelta(hours=10) df.loc[future] = df.iloc[-1] result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1) assert (result.index <= pd.Timestamp.utcnow() + pd.Timedelta(minutes=5)).all() @test("ForexDataProvider._validate_and_clean removes duplicates") def _(): dp = ForexDataProvider(PlatformConfig()) df = make_ohlcv(300) dup = pd.concat([df, df.iloc[-5:]]) assert dup.index.has_duplicates, "Setup: should have duplicates" result = dp._validate_and_clean(dup, "XAU/USD", Timeframe.H1) assert not result.index.has_duplicates, "Should remove duplicate indices" @test("ForexDataProvider._validate_and_clean forward fills NaN (limit 3)") def _(): dp = ForexDataProvider(PlatformConfig()) df = make_ohlcv(300) df.iloc[100, df.columns.get_loc("Close")] = np.nan df.iloc[101, df.columns.get_loc("Close")] = np.nan result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1) assert not result["Close"].isna().any() @test("ForexDataProvider._is_data_fresh weekend override") def _(): dp = ForexDataProvider(PlatformConfig()) # Create data that ends on a Friday, call _is_data_fresh on a Saturday friday = pd.Timestamp("2025-05-09 16:00:00", tz="UTC") old_dates = pd.date_range(end=friday, periods=300, freq="h") df = make_ohlcv(300) df.index = old_dates # Simulate calling on Saturday by patching pd.Timestamp.now saturday = pd.Timestamp("2025-05-10 12:00:00", tz="UTC") original_now = pd.Timestamp.now with patch.object(pd.Timestamp, "now", return_value=saturday): result = dp._is_data_fresh(df, "XAU/USD", Timeframe.H1) assert result is True, "Weekend data should always be considered fresh" @test("ForexDataProvider._is_data_fresh returns False for empty df") def _(): dp = ForexDataProvider(PlatformConfig()) assert dp._is_data_fresh(pd.DataFrame(), "XAU/USD", Timeframe.H1) is False assert dp._is_data_fresh(None, "XAU/USD", Timeframe.H1) is False @test("ForexDataProvider.clear_cache clears all entries") def _(): dp = ForexDataProvider(PlatformConfig()) dp._cache["test_key"] = make_ohlcv(10) dp._cache_timestamps["test_key"] = time.time() count = dp.clear_cache() assert count == 1 assert len(dp._cache) == 0 assert len(dp._cache_timestamps) == 0 @test("ForexDataProvider.get_pair_info returns correct info") def _(): dp = ForexDataProvider(PlatformConfig()) info = dp.get_pair_info(XAUUSD) assert info["symbol"] == XAUUSD assert info["display"] == "XAU/USD" assert info["pip_value"] == 0.10 info_unknown = dp.get_pair_info("UNKNOWN") assert info_unknown["display"] == "XAU/USD" @test("ForexDataProvider._throttle rate limiting logic") def _(): dp = ForexDataProvider(PlatformConfig()) dp._request_times.clear() for _ in range(10): dp._throttle(credits=1) assert len(dp._request_times) == 10 @test("ForexDataProvider caching - in-memory cache returns same data") def _(): dp = ForexDataProvider(PlatformConfig()) df = make_ohlcv(300) dp._cache["XAU/USD_1h"] = df dp._cache_timestamps["XAU/USD_1h"] = time.time() cached = dp._cache.get("XAU/USD_1h") assert cached is df # ══════════════════════════════════════════════════════════════════════ # MODULE 3: engine.py # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 3: engine.py ═══") from engine import TechnicalEngine, TechnicalAnalysis, IndicatorSignal @test("TechnicalEngine.analyze returns TechnicalAnalysis with insufficient data") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(10) result = engine.analyze(df, "XAU/USD", "1h") assert isinstance(result, TechnicalAnalysis) assert result.confidence == 0.0 @test("TechnicalEngine.analyze returns TechnicalAnalysis with None df") def _(): engine = TechnicalEngine(TechnicalConfig()) result = engine.analyze(None, "XAU/USD", "1h") assert isinstance(result, TechnicalAnalysis) assert result.confidence == 0.0 @test("TechnicalEngine.analyze returns valid result with good data") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert isinstance(result, TechnicalAnalysis) assert result.symbol == "XAU/USD" assert result.timeframe == "1h" assert result.composite_signal in list(SignalStrength) assert -100 <= result.composite_score <= 100 assert 0 <= result.confidence <= 100 @test("TechnicalEngine EMA-4 mode computes all 4 EMAs") def _(): config = TechnicalConfig(ema_mode="4ema") engine = TechnicalEngine(config) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert "ema_trend" in result.indicators ema_ind = result.indicators["ema_trend"] assert isinstance(ema_ind, IndicatorSignal) assert ema_ind.name == "EMA Trend" assert ema_ind.signal in list(SignalStrength) assert "ema_fast" in result.key_levels assert "ema_medium" in result.key_levels assert "ema_slow" in result.key_levels @test("TechnicalEngine EMA-2 mode computes EMA50/200") def _(): config = TechnicalConfig(ema_mode="2ema") engine = TechnicalEngine(config) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert "ema_trend" in result.indicators assert "ema_trend" in result.key_levels or "ema_slow" in result.key_levels @test("TechnicalEngine RSI computation") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert "rsi" in result.indicators rsi = result.indicators["rsi"] assert 0 <= rsi.value <= 100, f"RSI value {rsi.value} out of range" assert rsi.signal in list(SignalStrength) assert rsi.weight == 1.5 @test("TechnicalEngine RSI with NaN handling") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) df["Close"] = df["Close"].iloc[0] # flat price = 0 delta result = engine.analyze(df, "XAU/USD", "1h") rsi = result.indicators.get("rsi") if rsi: assert not pd.isna(rsi.value), "RSI should handle NaN gracefully" @test("TechnicalEngine Bollinger Bands computation") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert "bollinger" in result.indicators bb = result.indicators["bollinger"] assert isinstance(bb.value, float) assert "bb_upper" in result.key_levels assert "bb_lower" in result.key_levels assert "bb_mid" in result.key_levels assert result.key_levels["bb_upper"] > result.key_levels["bb_lower"] @test("TechnicalEngine ADX computation") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert "adx" in result.indicators adx = result.indicators["adx"] assert adx.value >= 0, f"ADX value {adx.value} should be >= 0" assert "+DI" in adx.description assert "-DI" in adx.description @test("TechnicalEngine ATR computation and volatility regime") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert "atr" in result.indicators atr = result.indicators["atr"] assert atr.value > 0, "ATR should be positive" assert atr.weight == 0.0, "ATR weight should be 0 (not directional)" assert result.volatility_regime in ("low", "normal", "high", "extreme") assert "atr" in result.key_levels assert "atr_pct" in result.key_levels @test("TechnicalEngine MACD as pillar (enabled by default)") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert "macd" in result.indicators macd = result.indicators["macd"] assert macd.weight == 1.5 @test("TechnicalEngine MACD not as pillar when disabled") def _(): config = TechnicalConfig(macd_as_pillar=False) engine = TechnicalEngine(config) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert "macd" not in result.indicators assert hasattr(result, "_macd_histogram") @test("TechnicalEngine disabled indicators are skipped") def _(): config = TechnicalConfig(disabled_indicators=["rsi", "bollinger", "divergence"]) engine = TechnicalEngine(config) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert "rsi" not in result.indicators assert "bollinger" not in result.indicators @test("TechnicalEngine candlestick pattern detection") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) # Create a hammer pattern: long lower wick, small body, no upper wick idx = -1 close_val = df["Close"].iloc[idx] df.iloc[idx, df.columns.get_loc("Open")] = close_val - 0.0001 df.iloc[idx, df.columns.get_loc("Close")] = close_val df.iloc[idx, df.columns.get_loc("High")] = close_val + 0.00005 df.iloc[idx, df.columns.get_loc("Low")] = close_val - 0.001 result = engine.analyze(df, "XAU/USD", "1h") # Pattern may or may not be detected depending on threshold, just ensure no crash @test("TechnicalEngine swing point detection") def _(): engine = TechnicalEngine(TechnicalConfig()) series = pd.Series([10, 8, 6, 4, 6, 8, 10, 8, 6, 4, 6, 8, 10]) lows = engine._find_swing_points(series, mode="low", window=2) highs = engine._find_swing_points(series, mode="high", window=2) assert len(lows) > 0, "Should find swing lows in V-pattern data" assert any(v == 4 for _, v in lows), "Should find value 4 as swing low" assert len(highs) > 0, "Should find swing highs" @test("TechnicalEngine swing points with NaN values") def _(): engine = TechnicalEngine(TechnicalConfig()) series = pd.Series([10, 8, np.nan, 4, 6, 8, 10, 8, 6, 4, 6, 8, 10]) lows = engine._find_swing_points(series, mode="low", window=2) # Should not crash @test("TechnicalEngine market regime classification") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300, trend=0.001) # strong uptrend result = engine.analyze(df, "XAU/USD", "1h") assert result.market_regime in ("trending_up", "trending_down", "trending", "ranging") @test("TechnicalEngine composite score within bounds") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert -100 <= result.composite_score <= 100 assert result.composite_signal in list(SignalStrength) @test("TechnicalEngine regime-aware RSI reinterpretation") def _(): config = TechnicalConfig(rsi_mode="regime_aware") engine = TechnicalEngine(config) df = make_ohlcv(300, trend=0.001) result = engine.analyze(df, "XAU/USD", "1h") rsi = result.indicators.get("rsi") assert rsi is not None @test("TechnicalEngine structure S/R levels detection") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") swing_keys = [k for k in result.key_levels if k.startswith("swing_")] assert len(swing_keys) >= 0 # may or may not have swings @test("TechnicalEngine divergence detection (enabled by default)") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") # divergence may or may not be detected depending on data, just ensure no crash @test("TechnicalEngine divergence detection (disabled)") def _(): config = TechnicalConfig(disabled_indicators=["divergence"]) engine = TechnicalEngine(config) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert "divergence" not in result.indicators @test("TechnicalEngine EMA-2 golden cross detection") def _(): config = TechnicalConfig(ema_mode="2ema") engine = TechnicalEngine(config) df = make_ohlcv(300, trend=0.002) result = engine.analyze(df, "XAU/USD", "1h") assert "ema_trend" in result.indicators @test("TechnicalEngine with very short data") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(31) result = engine.analyze(df, "XAU/USD", "1h") assert isinstance(result, TechnicalAnalysis) @test("TechnicalEngine ROC computation") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300, trend=0.002) result = engine.analyze(df, "XAU/USD", "1h") assert "roc" in result.indicators roc = result.indicators["roc"] assert roc.weight == 1.5 assert roc.signal in list(SignalStrength) @test("TechnicalEngine Keltner Channels computation") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert "keltner" in result.indicators kc = result.indicators["keltner"] assert kc.weight == 1.5 assert "kc_upper" in result.key_levels assert "kc_lower" in result.key_levels assert "kc_mid" in result.key_levels assert result.key_levels["kc_upper"] > result.key_levels["kc_lower"] @test("TechnicalEngine ADX slope computation") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300, trend=0.002) result = engine.analyze(df, "XAU/USD", "1h") assert "adx" in result.indicators assert hasattr(result, "_adx_slope") @test("TechnicalEngine transitioning regime detection") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert result.market_regime in ( "trending_up", "trending_down", "trending", "ranging", "transitioning" ) @test("TechnicalEngine gold-specific composite weights") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_xau_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert -100 <= result.composite_score <= 100 assert result.composite_signal in list(SignalStrength) @test("TechnicalEngine EMA ribbon compression detection") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) result = engine.analyze(df, "XAU/USD", "1h") assert "ema_trend" in result.indicators # ══════════════════════════════════════════════════════════════════════ # MODULE 4: news_engine.py # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 4: news_engine.py ═══") from news_engine import NewsEngine, FundamentalContext, EconomicEvent, NewsSentiment @test("NewsEngine._parse_pair with slash") def _(): base, quote = NewsEngine._parse_pair("XAU/USD") assert base == "XAU" assert quote == "USD" @test("NewsEngine._parse_pair with =X suffix") def _(): base, quote = NewsEngine._parse_pair("EURUSD=X") assert base == "EUR" assert quote == "USD" @test("NewsEngine._parse_pair for XAU pair") def _(): base, quote = NewsEngine._parse_pair("XAU/USD") assert base == "XAU" assert quote == "USD" @test("NewsEngine._analyze_headline_sentiment bullish") def _(): ne = NewsEngine(NewsConfig()) score = ne._analyze_headline_sentiment("Fed hawkish rate hike boosts dollar rally") assert score > 0, f"Expected positive sentiment, got {score}" @test("NewsEngine._analyze_headline_sentiment bearish") def _(): ne = NewsEngine(NewsConfig()) score = ne._analyze_headline_sentiment("Recession fears drive dovish rate cut expectations") assert score < 0, f"Expected negative sentiment, got {score}" @test("NewsEngine._analyze_headline_sentiment neutral") def _(): ne = NewsEngine(NewsConfig()) score = ne._analyze_headline_sentiment("Markets remain quiet ahead of data release") assert score == 0.0 @test("NewsEngine._extract_currencies USD") def _(): ne = NewsEngine(NewsConfig()) currencies = ne._extract_currencies("Federal Reserve raises interest rates, dollar soars") assert "USD" in currencies @test("NewsEngine._extract_currencies EUR") def _(): ne = NewsEngine(NewsConfig()) currencies = ne._extract_currencies("ECB meeting pushes euro higher") assert "EUR" in currencies @test("NewsEngine._extract_currencies GBP") def _(): ne = NewsEngine(NewsConfig()) currencies = ne._extract_currencies("Bank of England holds rates, pound steady") assert "GBP" in currencies @test("NewsEngine._extract_currencies XAU") def _(): ne = NewsEngine(NewsConfig()) currencies = ne._extract_currencies("Gold prices surge as safe haven demand rises") assert "XAU" in currencies @test("NewsEngine._extract_currencies multiple") def _(): ne = NewsEngine(NewsConfig()) currencies = ne._extract_currencies("Gold drops as Fed hawkish, dollar surges") assert "USD" in currencies assert "XAU" in currencies @test("NewsEngine._extract_keywords") def _(): ne = NewsEngine(NewsConfig()) kws = ne._extract_keywords("Hawkish Fed rate hike surprises with positive surprise") assert "rate hike" in kws assert "hawkish" in kws assert "positive surprise" in kws @test("NewsEngine._parse_rss_date RFC822 format") def _(): dt = NewsEngine._parse_rss_date("Mon, 12 May 2025 14:30:00 +0000") assert dt is not None assert dt.year == 2025 assert dt.month == 5 assert dt.day == 12 assert dt.tzinfo is not None @test("NewsEngine._parse_rss_date ISO format") def _(): dt = NewsEngine._parse_rss_date("2025-05-12T14:30:00+00:00") assert dt is not None assert dt.year == 2025 @test("NewsEngine._parse_rss_date naive format assumes UTC") def _(): dt = NewsEngine._parse_rss_date("2025-05-12 14:30:00") assert dt is not None assert dt.tzinfo == timezone.utc @test("NewsEngine._parse_rss_date None input") def _(): assert NewsEngine._parse_rss_date(None) is None @test("NewsEngine._parse_rss_date garbage input") def _(): assert NewsEngine._parse_rss_date("not-a-date") is None @test("NewsEngine._get_affected_pairs") def _(): pairs = NewsEngine._get_affected_pairs("USD") assert "XAU/USD" in pairs pairs_xau = NewsEngine._get_affected_pairs("XAU") assert "XAU/USD" in pairs_xau pairs_unknown = NewsEngine._get_affected_pairs("ARS") assert pairs_unknown == [] @test("EconomicEvent properties") def _(): future_time = datetime.now(timezone.utc) + timedelta(minutes=30) event = EconomicEvent( time=future_time, currency="USD", impact="high", event="NFP", ) assert event.is_upcoming is True assert 25 < event.minutes_until < 35 assert event.is_high_impact is True past_time = datetime.now(timezone.utc) - timedelta(minutes=10) past_event = EconomicEvent( time=past_time, currency="EUR", impact="low", event="CPI", ) assert past_event.is_upcoming is False assert past_event.is_high_impact is False @test("NewsEngine.get_event_risk_level danger zone") def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() context.events_upcoming_1h = [ EconomicEvent( time=datetime.now(timezone.utc) + timedelta(minutes=15), currency="USD", impact="high", event="NFP", ) ] risk, desc = ne.get_event_risk_level("XAU/USD", context) assert risk == "danger" assert "NFP" in desc @test("NewsEngine.get_event_risk_level caution zone") def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() context.events_upcoming_1h = [ EconomicEvent( time=datetime.now(timezone.utc) + timedelta(minutes=45), currency="USD", impact="high", event="GDP", ) ] risk, desc = ne.get_event_risk_level("XAU/USD", context) assert risk == "caution" @test("NewsEngine.get_event_risk_level clear when no events") def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() risk, desc = ne.get_event_risk_level("XAU/USD", context) assert risk == "clear" @test("NewsEngine.get_event_risk_level ignores unrelated currency events") def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() context.events_upcoming_1h = [ EconomicEvent( time=datetime.now(timezone.utc) + timedelta(minutes=15), currency="AUD", impact="high", event="RBA Rate", ) ] risk, _ = ne.get_event_risk_level("XAU/USD", context) assert risk == "clear" @test("NewsEngine.get_pair_sentiment with positive base sentiment") def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() context.news_sentiment = {"EUR": 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 "bullish" in desc.lower() @test("NewsEngine.get_pair_sentiment with negative pair sentiment") def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() context.news_sentiment = {"EUR": -0.5, "USD": 0.3} score, desc = ne.get_pair_sentiment("XAU/USD", context) assert score < 0 @test("NewsEngine.get_pair_sentiment neutral") def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() context.news_sentiment = {"EUR": 0.0, "USD": 0.0} score, desc = ne.get_pair_sentiment("XAU/USD", context) assert score == 0.0 assert "neutral" in desc.lower() @test("NewsEngine._extract_currencies no USD false positive from 'versus'") def _(): ne = NewsEngine(NewsConfig()) currencies = ne._extract_currencies( "Canada building permits for May -1.7% versus 2.4% estimate" ) assert "USD" not in currencies, f"'versus' must not tag USD, got {currencies}" assert "CAD" in currencies @test("NewsEngine._extract_currencies no AUD false positive from 'audit'") def _(): ne = NewsEngine(NewsConfig()) currencies = ne._extract_currencies("Treasury audit finds accounting errors") assert "AUD" not in currencies @test("EconomicEvent.surprise from actual vs forecast") def _(): now = datetime.now(timezone.utc) beat = EconomicEvent(time=now, currency="USD", impact="high", event="NFP", forecast="900K", actual="1.1M") assert beat.surprise == "positive" miss = EconomicEvent(time=now, currency="USD", impact="high", event="CPI y/y", forecast="0.3%", actual="0.2%") assert miss.surprise == "negative" inline = EconomicEvent(time=now, currency="USD", impact="high", event="CPI y/y", forecast="0.3%", actual="0.3%") assert inline.surprise == "inline" pending = EconomicEvent(time=now, currency="USD", impact="high", event="CPI y/y", forecast="0.3%") assert pending.surprise is None negative = EconomicEvent(time=now, currency="USD", impact="high", event="Permits m/m", forecast="2.4%", actual="-1.7%") assert negative.surprise == "negative" @test("NewsEngine.get_event_risk_level surprise widens post-event window") def _(): ne = NewsEngine(NewsConfig()) released_at = datetime.now(timezone.utc) - timedelta(minutes=30) # Surprise print 30 min ago: outside the 15-min inline buffer but # inside the 45-min surprise buffer → still caution. context = FundamentalContext() context.events_today = [EconomicEvent( time=released_at, currency="USD", impact="high", event="CPI m/m", forecast="0.3%", actual="0.5%", )] risk, desc = ne.get_event_risk_level("XAU/USD", context) assert risk == "caution", f"Expected caution after surprise, got {risk}" assert "surprise" in desc # Same timing but inline print → clear. context.events_today = [EconomicEvent( time=released_at, currency="USD", impact="high", event="CPI m/m", forecast="0.3%", actual="0.3%", )] risk, _ = ne.get_event_risk_level("XAU/USD", context) assert risk == "clear", f"Inline release 30min ago should be clear, got {risk}" @test("NewsEngine gold-inverted sentiment for policy keywords") def _(): ne = NewsEngine(NewsConfig()) headline = "Fed signals rate hike as hawkish stance hardens" # Currency-bullish for the dollar... assert ne._analyze_headline_sentiment(headline) > 0 assert ne._analyze_headline_sentiment(headline, "USD") > 0 # ...but bearish for gold. assert ne._analyze_headline_sentiment(headline, "XAU") < 0 dovish = "Fed turns dovish, rate cut expected" assert ne._analyze_headline_sentiment(dovish) < 0 assert ne._analyze_headline_sentiment(dovish, "XAU") > 0 # Non-policy keywords keep their direction for gold. data = "Gold surges to record high" assert ne._analyze_headline_sentiment(data, "XAU") > 0 @test("NewsEngine calendar cache avoids refetch within TTL") def _(): NewsEngine._calendar_cache = (0.0, []) try: ne = NewsEngine(NewsConfig()) calls = {"n": 0} def fake_fetch(): calls["n"] += 1 return [EconomicEvent( time=datetime.now(timezone.utc), currency="USD", impact="low", event="Test Event", )] ne._try_fetch_calendar_api = fake_fetch ne._try_fetch_myfxbook_rss = lambda: [] first = ne._fetch_economic_calendar(["USD", "XAU"]) second = ne._fetch_economic_calendar(["USD", "XAU"]) assert calls["n"] == 1, f"Expected 1 fetch, got {calls['n']}" assert len(first) == 1 and len(second) == 1 # A second engine instance (new scan) must also hit the cache. ne2 = NewsEngine(NewsConfig()) ne2._try_fetch_calendar_api = fake_fetch ne2._try_fetch_myfxbook_rss = lambda: [] third = ne2._fetch_economic_calendar(["USD", "XAU"]) assert calls["n"] == 1, "New engine instance should reuse class-level cache" assert len(third) == 1 finally: NewsEngine._calendar_cache = (0.0, []) @test("FundamentalContext defaults") def _(): ctx = FundamentalContext() assert ctx.events_today == [] assert ctx.events_upcoming_1h == [] assert ctx.high_impact_warning is False assert ctx.calendar_available is False assert ctx.news_sentiment == {} assert ctx.news_headlines == [] # ══════════════════════════════════════════════════════════════════════ # MODULE 5: signal_aggregator.py # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 5: signal_aggregator.py ═══") from signal_aggregator import SignalAggregator, SignalReport, TradeSetup @test("SignalReport defaults") def _(): r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") assert r.final_signal == SignalStrength.NEUTRAL assert r.final_score == 0.0 assert r.primary_signal == SignalStrength.NEUTRAL assert r.trade_setup is None assert r.mtf_agreement is False assert r.signal_quality == "" assert r.bullish_reasons == [] assert r.bearish_reasons == [] assert r.warnings == [] @test("TradeSetup dataclass creation") def _(): ts = TradeSetup( pair="XAU/USD", display_name="XAU/USD", direction="BUY", entry_price=1.1000, stop_loss=1.0950, take_profit_1=1.1100, take_profit_2=1.1200, risk_reward=2.0, risk_pips=50, reward_pips=100, pip_value=0.0001, ) assert ts.direction == "BUY" assert ts.risk_reward == 2.0 assert ts.entry_price > ts.stop_loss @test("SignalAggregator._check_mtf_agreement with 2 bullish") def _(): config = PlatformConfig() agg = SignalAggregator(config) report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report.mtf_signals = { "15min": SignalStrength.BUY, "1h": SignalStrength.STRONG_BUY, "4h": SignalStrength.NEUTRAL, } assert agg._check_mtf_agreement(report) is True @test("SignalAggregator._check_mtf_agreement with mixed signals") def _(): config = PlatformConfig() agg = SignalAggregator(config) report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report.mtf_signals = { "15min": SignalStrength.BUY, "1h": SignalStrength.SELL, "4h": SignalStrength.NEUTRAL, } assert agg._check_mtf_agreement(report) is False @test("SignalAggregator._check_mtf_agreement with single signal") def _(): config = PlatformConfig() agg = SignalAggregator(config) report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report.mtf_signals = {"1h": SignalStrength.BUY} assert agg._check_mtf_agreement(report) is False @test("SignalAggregator._latest_adx computes valid value") def _(): df = make_ohlcv(300) adx = SignalAggregator._latest_adx(df) assert adx is not None assert adx >= 0 @test("SignalAggregator._latest_adx with insufficient data") def _(): df = make_ohlcv(10) adx = SignalAggregator._latest_adx(df) assert adx is None @test("SignalAggregator.analyze_from_dataframes produces valid report") def _(): config = PlatformConfig() config.primary_timeframe = Timeframe.H1 config.confirmation_timeframes = [Timeframe.M15, Timeframe.H4] agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, seed=42), Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) assert isinstance(report, SignalReport) assert report.symbol == "XAU/USD" assert report.display_name == "XAU/USD" assert report.final_signal in list(SignalStrength) assert report.signal_quality in ("A+", "A", "B", "C", "D", "F") assert report.current_price > 0 @test("SignalAggregator._compute_final_signal quality grades") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, seed=42), Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) assert report.signal_quality in ("A+", "A", "B", "C", "D", "F") assert report.action_recommendation != "" @test("Event-risk signal gating obeys news.event_risk_caps_signals") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, seed=42), Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) def recompute(risk, caps): config.news.event_risk_caps_signals = caps report.event_risk = risk report.event_description = "USD Test Event in 10min" report.warnings = [] agg._compute_final_signal(report) return report.final_confidence baseline = recompute("clear", True) # default XAU behavior (caps off): danger warns but does not gut conf_off = recompute("danger", False) assert conf_off == baseline, "flag off must not change confidence" assert any("EVENT RISK" in w for w in report.warnings), "warning must persist" # caps on: danger hard-caps confidence at 30 conf_on = recompute("danger", True) assert conf_on <= 30, f"danger with caps on must cap conf at 30, got {conf_on}" # caution with caps off is also warning-only 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 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") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, trend=0.003, seed=42), Timeframe.M15: make_ohlcv(300, trend=0.003, seed=43), Timeframe.H4: make_ohlcv(300, trend=0.003, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) if report.trade_setup: ts = report.trade_setup if ts.direction == "BUY": assert ts.stop_loss < ts.entry_price, "BUY SL should be below entry" assert ts.take_profit_1 > ts.entry_price, "BUY TP should be above entry" assert ts.risk_reward >= config.signals.min_rr_ratio @test("SignalAggregator._collect_reasons populates reasons") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, seed=42), Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) total_reasons = len(report.bullish_reasons) + len(report.bearish_reasons) assert total_reasons > 0, "Should have at least one reason" @test("SignalAggregator session filter blocks outside hours") def _(): config = PlatformConfig() config.backtest.session_filter = True config.xau_strategy.blocked_hours_utc = list(range(24)) config.xau_strategy.enabled = False agg = SignalAggregator(config) dfs = { Timeframe.H1: make_xau_ohlcv(300, seed=42), Timeframe.M15: make_xau_ohlcv(300, seed=43), Timeframe.H4: make_xau_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) assert report.signal_quality == "F" @test("SignalAggregator with missing primary timeframe") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.M15: make_ohlcv(300, seed=43), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) assert report.signal_quality == "F" assert any("unavailable" in w.lower() for w in report.warnings) @test("SignalAggregator XAU strategy check (conditions not met)") def _(): config = PlatformConfig() config.xau_strategy.enabled = True agg = SignalAggregator(config) dfs = { Timeframe.H1: make_xau_ohlcv(300, seed=42), Timeframe.M15: make_xau_ohlcv(300, seed=43), Timeframe.H4: make_xau_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) assert isinstance(report, SignalReport) @test("SignalAggregator event risk affects confidence") def _(): config = PlatformConfig() agg = SignalAggregator(config) context = FundamentalContext() context.events_upcoming_1h = [ EconomicEvent( time=datetime.now(timezone.utc) + timedelta(minutes=10), currency="USD", impact="high", event="NFP", ) ] dfs = { Timeframe.H1: make_ohlcv(300, seed=42), Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, context=context, use_news=True) assert any("EVENT RISK" in w or "event" in w.lower() for w in report.warnings) # ══════════════════════════════════════════════════════════════════════ # MODULE 6: main.py # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 6: main.py ═══") from main import ( signal_color, quality_color, _compact_reason, report_to_dict, build_config, Colors, ) @test("signal_color maps all SignalStrength values") def _(): for sig in SignalStrength: color = signal_color(sig) assert isinstance(color, str) @test("quality_color maps all quality grades") def _(): for q in ("A+", "A", "B", "C", "D", "F"): color = quality_color(q) assert isinstance(color, str) @test("_compact_reason strips STRONG prefix") def _(): result = _compact_reason("STRONG: EMA Trend: Full bullish alignment") assert not result.startswith("STRONG: ") @test("_compact_reason strips indicator prefix") def _(): result = _compact_reason("RSI: RSI 45.3 — Neutral") assert not result.startswith("RSI: ") @test("_compact_reason strips detail after pipe") def _(): result = _compact_reason("EMA alignment: 3/4 | Golden cross (fast/medium)") assert "|" not in result @test("_compact_reason handles plain text") def _(): result = _compact_reason("Simple reason text") assert result == "Simple reason text" @test("report_to_dict basic conversion") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, seed=42), Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) d = report_to_dict(report) assert "symbol" in d assert "signal" in d assert "score" in d assert "confidence" in d assert "quality" in d assert "action" in d assert "market_regime" in d assert "volatility_regime" in d assert "mtf_agreement" in d assert "mtf_signals" in d assert d["symbol"] == "XAU/USD" assert d["display_name"] == "XAU/USD" @test("report_to_dict includes trade_setup when present") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, trend=0.003, seed=42), Timeframe.M15: make_ohlcv(300, trend=0.003, seed=43), Timeframe.H4: make_ohlcv(300, trend=0.003, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) d = report_to_dict(report) if report.trade_setup: assert "trade_setup" in d ts = d["trade_setup"] assert "direction" in ts assert "entry" in ts assert "stop_loss" in ts assert "take_profit_1" in ts assert "risk_reward" in ts @test("report_to_dict includes indicators") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, seed=42), Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) d = report_to_dict(report) assert "indicators" in d assert "key_levels" in d for name, ind_dict in d["indicators"].items(): assert "signal" in ind_dict assert "confidence" in ind_dict assert "description" in ind_dict @test("build_config sets correct timeframe and confirmations") def _(): class FakeArgs: pair = "XAUUSD" timeframe = "H4" config = build_config(FakeArgs()) assert config.primary_timeframe == Timeframe.H4 assert Timeframe.H1 in config.confirmation_timeframes assert Timeframe.D1 in config.confirmation_timeframes @test("build_config all valid timeframes") def _(): for tf_str in ["M5", "M15", "M30", "H1", "H4", "D1"]: class FakeArgs: pair = "XAUUSD" timeframe = tf_str config = build_config(FakeArgs()) 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 # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 7: telegram_bot.py ═══") from telegram_bot import ( _escape_md, _signal_label, _mtf_line, format_signal_message, _format_scan_summary, _seconds_until_boundary, _check_level_breach, scan_keyboard, SCAN_BUTTONS, SCAN_LOOKUP, _update_level_cache, _last_prices, _level_cache, _level_cache_ts, _WS_TO_PAIR, ) @test("_escape_md escapes markdown characters") def _(): assert _escape_md("hello *world*") == "hello \\*world\\*" assert _escape_md("test_value") == "test\\_value" assert _escape_md("`code`") == "\\`code\\`" assert _escape_md("[link]") == "\\[link\\]" @test("_signal_label formatting") def _(): assert _signal_label(SignalStrength.STRONG_BUY) == "STRONG BUY" assert _signal_label(SignalStrength.STRONG_SELL) == "STRONG SELL" assert _signal_label(SignalStrength.NEUTRAL) == "NEUTRAL" assert _signal_label(SignalStrength.BUY) == "BUY" assert _signal_label(SignalStrength.SELL) == "SELL" @test("_mtf_line formatting with signals") def _(): report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report.mtf_signals = { "15min": SignalStrength.BUY, "1h": SignalStrength.NEUTRAL, } report.mtf_scores = {"15min": 25.0, "1h": 5.0} line = _mtf_line(report) assert "15min" in line assert "1h" in line assert "BUY" in line @test("_mtf_line empty when no signals") def _(): report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") assert _mtf_line(report) == "" @test("format_signal_message produces valid text") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, seed=42), Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) msg = format_signal_message(report) assert isinstance(msg, str) assert len(msg) > 0 assert "XAU/USD" in msg assert "Signal Analysis" in msg assert "Price" in msg assert "Indicators" in msg @test("_format_scan_summary produces valid text") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, seed=42), Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } reports = [agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)] summary = _format_scan_summary(reports, Timeframe.H1) assert "Auto-Scan" in summary assert "XAU/USD" in summary @test("_seconds_until_boundary returns positive value") def _(): result = _seconds_until_boundary(15, buffer=10) assert result > 0 assert result <= 15 * 60 + 10 + 1 @test("_seconds_until_boundary for M5 interval") def _(): result = _seconds_until_boundary(5, buffer=10) assert result > 0 assert result <= 5 * 60 + 10 + 1 @test("_seconds_until_boundary for H1 interval") def _(): result = _seconds_until_boundary(60, buffer=10) assert result > 0 assert result <= 60 * 60 + 10 + 1 @test("_seconds_until_boundary for H4 interval") def _(): result = _seconds_until_boundary(240, buffer=10) assert result > 0 assert result <= 240 * 60 + 10 + 1 @test("_check_level_breach detects upward cross") def _(): _level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000} _level_cache_ts["TEST_PAIR"] = time.time() _last_prices["TEST_PAIR"] = 1.0999 result = _check_level_breach("TEST_PAIR", 1.1001) assert result == "swing_high_1" # cleanup del _level_cache["TEST_PAIR"] del _level_cache_ts["TEST_PAIR"] del _last_prices["TEST_PAIR"] @test("_check_level_breach detects downward cross") def _(): _level_cache["TEST_PAIR"] = {"swing_low_1": 1.0900} _level_cache_ts["TEST_PAIR"] = time.time() _last_prices["TEST_PAIR"] = 1.0901 result = _check_level_breach("TEST_PAIR", 1.0899) assert result == "swing_low_1" del _level_cache["TEST_PAIR"] del _level_cache_ts["TEST_PAIR"] del _last_prices["TEST_PAIR"] @test("_check_level_breach no breach when price stays same side") def _(): _level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000} _level_cache_ts["TEST_PAIR"] = time.time() _last_prices["TEST_PAIR"] = 1.0990 result = _check_level_breach("TEST_PAIR", 1.0995) assert result is None del _level_cache["TEST_PAIR"] del _level_cache_ts["TEST_PAIR"] del _last_prices["TEST_PAIR"] @test("_check_level_breach returns None for expired cache") def _(): _level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000} _level_cache_ts["TEST_PAIR"] = time.time() - 7200 # 2h old _last_prices["TEST_PAIR"] = 1.0999 result = _check_level_breach("TEST_PAIR", 1.1001) assert result is None del _level_cache["TEST_PAIR"] del _level_cache_ts["TEST_PAIR"] del _last_prices["TEST_PAIR"] @test("_check_level_breach returns None when no previous price") def _(): _level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000} _level_cache_ts["TEST_PAIR"] = time.time() if "TEST_PAIR" in _last_prices: del _last_prices["TEST_PAIR"] result = _check_level_breach("TEST_PAIR", 1.1001) assert result is None del _level_cache["TEST_PAIR"] del _level_cache_ts["TEST_PAIR"] @test("SCAN_BUTTONS and SCAN_LOOKUP consistency") def _(): from telegram_bot import SCAN_PAIRS # 4 TF buttons + one "Scan All" per scanned pair assert len(SCAN_BUTTONS) == 5 * len(SCAN_PAIRS) for btn in SCAN_BUTTONS: assert btn["cb"] in SCAN_LOOKUP assert "symbol" in btn assert btn["symbol"] in SCAN_PAIRS assert "tf" in btn assert "label" in btn @test("scan_keyboard returns InlineKeyboardMarkup") def _(): kb = scan_keyboard() assert kb is not None @test("_update_level_cache populates cache from reports") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, seed=42), Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) _level_cache.clear() _level_cache_ts.clear() _update_level_cache([report]) if report.primary_analysis and report.primary_analysis.key_levels: assert "XAU/USD" in _level_cache or len(report.primary_analysis.key_levels) == 0 _level_cache.clear() _level_cache_ts.clear() @test("_WS_TO_PAIR mapping covers every scanned pair") def _(): from telegram_bot import SCAN_PAIRS for p in SCAN_PAIRS: assert _WS_TO_PAIR[PAIR_INFO[p]["display"]] == p assert _WS_TO_PAIR["XAU/USD"] == XAUUSD # ══════════════════════════════════════════════════════════════════════ # MODULE 8: Integration / Cross-module tests # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 8: Integration Tests ═══") @test("Full pipeline: config → engine → aggregator → report → dict") def _(): config = PlatformConfig() engine = TechnicalEngine(config.technical) df = make_ohlcv(300, seed=99) analysis = engine.analyze(df, "XAU/USD", "1h") assert isinstance(analysis, TechnicalAnalysis) agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, seed=99), Timeframe.M15: make_ohlcv(300, seed=100), Timeframe.H4: make_ohlcv(300, seed=101), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) d = report_to_dict(report) assert isinstance(d, dict) assert d["symbol"] == "XAU/USD" @test("Full pipeline: XAU/USD analysis") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.H1: make_xau_ohlcv(300, seed=42), Timeframe.M15: make_xau_ohlcv(300, seed=43), Timeframe.H4: make_xau_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) assert report.display_name == "XAU/USD" assert report.current_price > 1000 # XAU should be in the thousands @test("Full pipeline: format Telegram message from report") def _(): config = PlatformConfig() agg = SignalAggregator(config) dfs = { Timeframe.H1: make_ohlcv(300, seed=42), Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) msg = format_signal_message(report) assert isinstance(msg, str) assert len(msg) > 100 @test("XAU/USD analysis with different seeds") def _(): config = PlatformConfig() for seed_base in [10, 20, 30]: agg = SignalAggregator(config) dfs = { Timeframe.H1: make_xau_ohlcv(300, seed=seed_base), Timeframe.M15: make_xau_ohlcv(300, seed=seed_base+1), Timeframe.H4: make_xau_ohlcv(300, seed=seed_base+2), } report = agg.analyze_from_dataframes(XAUUSD, dfs, use_news=False) assert report.symbol == XAUUSD assert report.current_price > 0 assert report.signal_quality in ("A+", "A", "B", "C", "D", "F") @test("Edge case: all EMAs at same price (flat market)") def _(): engine = TechnicalEngine(TechnicalConfig()) dates = pd.date_range(end=pd.Timestamp.now(tz="UTC").floor("h"), periods=300, freq="h") df = pd.DataFrame({ "Open": 1.1, "High": 1.1001, "Low": 1.0999, "Close": 1.1, "Volume": 1000.0, }, index=dates) df.index.name = "Datetime" result = engine.analyze(df, "XAU/USD", "1h") assert result.composite_signal == SignalStrength.NEUTRAL @test("Edge case: extreme volatility data") def _(): engine = TechnicalEngine(TechnicalConfig()) np.random.seed(77) dates = pd.date_range(end=pd.Timestamp.now(tz="UTC").floor("h"), periods=300, freq="h") close = 1.1 + np.cumsum(np.random.randn(300) * 0.01) high = close + np.abs(np.random.randn(300) * 0.005) low = close - np.abs(np.random.randn(300) * 0.005) df = pd.DataFrame({ "Open": close + np.random.randn(300) * 0.003, "High": high, "Low": low, "Close": close, "Volume": np.random.randint(100, 50000, 300).astype(float), }, index=dates) df.index.name = "Datetime" result = engine.analyze(df, "XAU/USD", "1h") assert isinstance(result, TechnicalAnalysis) assert result.volatility_regime in ("low", "normal", "high", "extreme") @test("Determinism: same data produces same results") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300, seed=42) r1 = engine.analyze(df.copy(), "XAU/USD", "1h") r2 = engine.analyze(df.copy(), "XAU/USD", "1h") assert r1.composite_score == r2.composite_score assert r1.composite_signal == r2.composite_signal assert r1.market_regime == r2.market_regime print("\n═══ Module 9: pair_signals registry ═══") @test("pair_signals registry includes XAU/USD and EUR/USD") 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}" @test("xauusd.make_config equals inline PlatformConfig construction") def _(): import dataclasses from pair_signals import PAIR_PRODUCERS for tf, ctfs in [ (Timeframe.M5, [Timeframe.M15, Timeframe.H1]), (Timeframe.M15, [Timeframe.M5, Timeframe.H1]), (Timeframe.H1, [Timeframe.M15, Timeframe.H4]), ]: factory_cfg = PAIR_PRODUCERS[XAUUSD](tf, ctfs) inline_cfg = PlatformConfig( symbol=XAUUSD, display_name="XAU/USD", pip_value=0.10, primary_timeframe=tf, confirmation_timeframes=ctfs, ) if tf == Timeframe.M15: # Documented per-TF overrides (see xauusd.py docstring): # 1R minimum targets + mixed-"trending"-regime block, # both validated for M15 only. inline_cfg.signals.tp_floor_rr = 1.0 inline_cfg.xau_strategy.blocked_regimes = ["trending"] if tf == Timeframe.M5: # Documented per-TF override (see xauusd.py docstring): # hour 9 UTC blocked on M5 only. inline_cfg.xau_strategy.blocked_hours_utc = ( inline_cfg.xau_strategy.blocked_hours_utc + [9] ) assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \ 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 try: produce_signal("GBP/JPY", Timeframe.H1, None) assert False, "Expected ValueError for unregistered pair" except ValueError as e: assert "GBP/JPY" in str(e) print("\n═══ Module 9b: Engine Bug Fix Regressions ═══") # ── 9a. Swing point NaN fix ────────────────────────────────────────── @test("Swing points: all-NaN neighbors produces no false positives") def _(): series = pd.Series([np.nan, np.nan, np.nan, 5.0, np.nan, np.nan, np.nan]) lows = TechnicalEngine._find_swing_points(series, mode="low", window=3) highs = TechnicalEngine._find_swing_points(series, mode="high", window=3) assert lows == [], f"Expected no lows with all-NaN neighbors, got {lows}" assert highs == [], f"Expected no highs with all-NaN neighbors, got {highs}" @test("Swing points: partial NaN neighbors still detect valid swings") def _(): series = pd.Series([10.0, 8.0, np.nan, 4.0, np.nan, 8.0, 10.0]) lows = TechnicalEngine._find_swing_points(series, mode="low", window=2) low_vals = [v for _, v in lows] assert 4.0 in low_vals, f"Expected 4.0 as swing low, got {low_vals}" @test("Swing points: empty series returns empty list") def _(): series = pd.Series([], dtype=float) assert TechnicalEngine._find_swing_points(series, mode="low") == [] assert TechnicalEngine._find_swing_points(series, mode="high") == [] @test("Swing points: monotonic increasing has no interior lows") def _(): series = pd.Series([float(i) for i in range(20)]) lows = TechnicalEngine._find_swing_points(series, mode="low", window=2) assert len(lows) == 0, f"Monotonic increase should have no interior lows, got {lows}" @test("Swing points: V-shape detects low at bottom") def _(): series = pd.Series([10.0, 8.0, 6.0, 4.0, 6.0, 8.0, 10.0]) lows = TechnicalEngine._find_swing_points(series, mode="low", window=2) low_vals = [v for _, v in lows] assert 4.0 in low_vals, f"V-shape should detect 4.0 as low, got {low_vals}" @test("Swing points: inverted V-shape detects high at top") def _(): series = pd.Series([4.0, 6.0, 8.0, 10.0, 8.0, 6.0, 4.0]) highs = TechnicalEngine._find_swing_points(series, mode="high", window=2) high_vals = [v for _, v in highs] assert 10.0 in high_vals, f"Inverted V should detect 10.0 as high, got {high_vals}" # ── 9b. ROC div-by-zero fix ────────────────────────────────────────── @test("ROC: zero close at lookback position does not produce inf") 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) if "roc" in result.indicators: assert np.isfinite(result.indicators["roc"].value), "ROC should not be inf" # ── 9c. ADX epsilon fix ────────────────────────────────────────────── @test("ADX: flat data produces finite ADX value") def _(): n = 100 dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h") df = pd.DataFrame({ "Open": [100.0]*n, "High": [100.01]*n, "Low": [99.99]*n, "Close": [100.0]*n, "Volume": [1000.0]*n, }, index=dates) engine = TechnicalEngine(PlatformConfig().technical) result = engine.analyze(df, "EUR/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("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 # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 11: 2026-07-16 audit fixes ═══") _BT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backtest") if _BT_DIR not in sys.path: sys.path.insert(0, _BT_DIR) def make_xau_bars(n=400, end="2026-07-08 16:55", freq="5min", drift=-0.8, seed=7): """Seeded XAU-priced bars ending at a FIXED timestamp (deterministic).""" rng = np.random.default_rng(seed) idx = pd.date_range(end=pd.Timestamp(end, tz="UTC"), periods=n, freq=freq) close = 3400 + np.cumsum(drift + rng.normal(0, 0.5, n)) open_ = np.roll(close, 1) open_[0] = close[0] high = np.maximum(open_, close) + rng.uniform(0.5, 2.0, n) low = np.minimum(open_, close) - rng.uniform(0.5, 2.0, n) df = pd.DataFrame({ "Open": open_, "High": high, "Low": low, "Close": close, "Volume": rng.uniform(1e3, 5e3, n), }, index=idx) df.index.name = "Datetime" return df @test("Gate 6e: session-blocked sell-direction report stays F (no F→D upgrade)") def _(): # Bar LABELED in a blocked hour (16:xx UTC, Wednesday) with a bearish # score: gate 6b sets F; before the fix, 6e re-graded it D, which # re-opened the M5 pullback path (entry check: quality != "F"). from pair_signals.xauusd import make_config as xau_make_config primary = make_xau_bars(end="2026-07-08 16:55", freq="5min") m15 = make_xau_bars(end="2026-07-08 16:45", freq="15min") h1 = make_xau_bars(end="2026-07-08 16:00", freq="60min") agg = SignalAggregator(xau_make_config(Timeframe.M5, [Timeframe.M15, Timeframe.H1])) report = agg.analyze_from_dataframes( XAUUSD, {Timeframe.M5: primary, Timeframe.M15: m15, Timeframe.H1: h1}, use_news=False, ) assert any("SESSION FILTER" in w for w in report.warnings), \ f"session filter did not fire: {report.warnings}" assert report.primary_score < 0, "test setup must produce a sell-direction score" assert report.signal_quality == "F", \ f"blocked bar must stay F, got {report.signal_quality}" assert report.trade_setup is None assert not any("SELL FILTER: quality" in w for w in report.warnings), \ "6e must not fire on non-actionable grades" @test("GPU engine parity: composite + ema_trend identical to live (incl. ribbon)") def _(): from gpu_backtest import precompute_indicators, GPUTechnicalEngine from engine import TechnicalEngine cfg = PlatformConfig() # Two-phase series: flat chop (EMA ribbon compresses) then a strong # trend (ribbon expands) — guarantees the sweep crosses both ribbon # states, the exact logic the GPU engine was missing. rng = np.random.default_rng(11) n = 650 drift = np.where(np.arange(n) < 450, 0.0, 1.2) idx = pd.date_range(end=pd.Timestamp("2026-06-30 12:00", tz="UTC"), periods=n, freq="60min") close = 3400 + np.cumsum(drift + rng.normal(0, 0.5, n)) open_ = np.roll(close, 1) open_[0] = close[0] high = np.maximum(open_, close) + rng.uniform(0.5, 2.0, n) low = np.minimum(open_, close) - rng.uniform(0.5, 2.0, n) df = pd.DataFrame({"Open": open_, "High": high, "Low": low, "Close": close, "Volume": rng.uniform(1e3, 5e3, n)}, index=idx) df.index.name = "Datetime" cache = precompute_indicators(df, cfg.technical) gpu = GPUTechnicalEngine(cfg.technical, {"1h": cache}) live = TechnicalEngine(cfg.technical) seen_compressed = seen_expanding = False for i in range(250, n + 1, 3): s = df.iloc[:i] r_live = live.analyze(s, "XAU/USD", "1h") r_gpu = gpu.analyze(s, "XAU/USD", "1h") assert r_live.composite_signal == r_gpu.composite_signal, f"signal flip @ {i}" assert abs(r_live.composite_score - r_gpu.composite_score) < 1e-9, f"score @ {i}" assert abs(r_live.confidence - r_gpu.confidence) < 1e-9, f"confidence @ {i}" el = r_live.indicators["ema_trend"] eg = r_gpu.indicators["ema_trend"] assert el.confidence == eg.confidence, f"ema_trend conf @ {i}" assert el.description == eg.description, f"ema_trend desc @ {i}" bl = r_live.indicators["bollinger"] bg = r_gpu.indicators["bollinger"] assert bl.signal == bg.signal and bl.description == bg.description, f"bollinger @ {i}" if "Ribbon COMPRESSED" in el.description: seen_compressed = True if "Ribbon EXPANDING" in el.description: seen_expanding = True assert seen_compressed, "sweep never saw a compressed ribbon — no teeth" assert seen_expanding, "sweep never saw a ribbon expansion (+10 conf path) — no teeth" @test("disabled_indicators=['macd'] removes the pillar but keeps the histogram") def _(): from engine import TechnicalEngine from config import TechnicalConfig df = make_xau_ohlcv(n=300, seed=5) result = TechnicalEngine(TechnicalConfig(disabled_indicators=["macd"])).analyze( df, "XAU/USD", "1h") assert "macd" not in result.indicators, "disabled MACD still scored" assert getattr(result, "_macd_histogram", None) is not None, \ "histogram must survive for divergence" baseline = TechnicalEngine(TechnicalConfig()).analyze(df, "XAU/USD", "1h") assert "macd" in baseline.indicators @test("Outlier filter drops the spike bar but keeps the snap-back bar") def _(): provider = ForexDataProvider(PlatformConfig()) df = make_ohlcv(n=300, seed=3) k = 150 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) 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)" @test("Cache: pre-boundary snapshot refetched; boundary-complete snapshot served") def _(): def bars_ending(last): idx = pd.date_range(end=last, periods=300, freq="15min", tz="UTC") v = np.linspace(3000.0, 3100.0, 300) return pd.DataFrame({"Open": v, "High": v + 1, "Low": v - 1, "Close": v, "Volume": 1000.0}, index=idx) for _attempt in range(2): provider = ForexDataProvider(PlatformConfig()) started = pd.Timestamp.now(tz="UTC").floor("15min") last_closed = started - pd.Timedelta(minutes=15) stale = bars_ending(last_closed - pd.Timedelta(minutes=15)) fresh = bars_ending(last_closed) key = "XAU/USD_15min" with patch("data_provider._is_forex_weekend", return_value=False): # (a) snapshot cached mid-candle 5 min ago, missing the # just-closed bar: TTL says fresh, boundary check must refetch. provider._cache[key] = stale provider._cache_timestamps[key] = time.time() - 300 with patch.object(provider, "_fetch_twelve_data", return_value=fresh.copy()) as mock_fetch: served = provider.fetch_ohlcv("XAU/USD", Timeframe.M15) # (b) cache now boundary-complete: served without any fetch. with patch.object(provider, "_fetch_twelve_data", side_effect=AssertionError("must not refetch")): served2 = provider.fetch_ohlcv("XAU/USD", Timeframe.M15) if pd.Timestamp.now(tz="UTC").floor("15min") != started: continue # boundary crossed mid-test — retry once assert mock_fetch.called, "stale pre-boundary snapshot was served from cache" assert served.index[-1] == last_closed assert served2.index[-1] == last_closed break @test("Alert dedup: drift dedups, materially-moved setup re-alerts, no early commit") def _(): import telegram_bot as tb from signal_aggregator import SignalReport, TradeSetup def rpt(entry, sl): r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t") r.final_signal = SignalStrength.BUY r.signal_quality = "B" r.trade_setup = TradeSetup( pair="XAU/USD", display_name="XAU/USD", direction="BUY", entry_price=entry, stop_loss=sl, take_profit_1=entry + 12, take_profit_2=entry + 20, risk_reward=1.5, risk_pips=80.0, reward_pips=120.0, pip_value=0.10, ) return r key = "TEST_ALERT_FP" tb._last_alert_fp.pop(key, None) tb._last_alert_ts.pop(key, None) r1 = rpt(3400.0, 3392.0) # risk $8 → $4 buckets assert tb._should_alert(key, r1) assert tb._should_alert(key, r1), "_should_alert must not commit state itself" tb._commit_alert(key, r1) assert not tb._should_alert(key, r1), "identical signal must dedup" assert not tb._should_alert(key, rpt(3400.5, 3392.5)), \ "sub-bucket drift must dedup" assert tb._should_alert(key, rpt(3412.0, 3404.0)), \ "setup moved 1.5x risk — must re-alert" tb._last_alert_fp.pop(key, None) tb._last_alert_ts.pop(key, None) @test("Backtester --confirm-tfs defaults to None (factory's live map)") def _(): from gpu_backtest import _parse_args args = _parse_args(["--pair", "XAUUSD"]) assert args.confirm_tfs is None @test("Backtest entries re-anchor to the first RAW bar after the signal bar") def _(): from gpu_backtest import Trade, _reanchor_entries_to_raw idx = pd.date_range("2026-01-05 10:00", periods=6, freq="5min", tz="UTC") opens = np.array([100.0, 101.0, 110.0, 102.0, 103.0, 104.0]) sim = pd.DataFrame({"Open": opens, "High": opens + 1, "Low": opens - 1, "Close": opens}, index=idx) def mk_trade(entry_time, entry_price): return Trade(pair="XAU/USD", entry_time=entry_time, entry_price=entry_price, sl=entry_price - 8, tp1=entry_price + 12, tp2=entry_price + 20, direction="BUY", pip_value=0.10, risk_pips=80.0, reward_pips=120.0, rr_planned=1.5, quality="B", final_score=30.0, final_confidence=60.0, market_regime="trending_up") # Cleaned frame lacked the spike at idx[2], so the replay entered at idx[3]. t = mk_trade(idx[3], 102.0) _reanchor_entries_to_raw([t], [idx[1]], sim) assert t.entry_time == idx[2] and t.entry_price == 110.0 assert t.sl == 102.0 and t.tp1 == 122.0 and t.tp2 == 130.0, \ "SL/TP distances must be preserved relative to the corrected entry" # Entry already the first raw bar after the signal → untouched. t2 = mk_trade(idx[2], 110.0) _reanchor_entries_to_raw([t2], [idx[1]], sim) assert t2.entry_time == idx[2] and t2.entry_price == 110.0 and t2.sl == 102.0 # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ print(f"\n{'═' * 60}") print(f" RESULTS: {PASS} passed, {FAIL} failed, {PASS + FAIL} total") print(f"{'═' * 60}") if ERRORS: print("\n FAILURES:") for name, err, tb in ERRORS: print(f"\n ✗ {name}") print(f" Error: {err}") for line in tb.strip().split("\n")[-3:]: print(f" {line}") print() sys.exit(1 if FAIL > 0 else 0)