#!/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 threading import tempfile import traceback from datetime import datetime, timedelta, timezone from unittest.mock import patch import numpy as np import pandas as pd sys.path.insert(0, os.path.dirname(__file__)) # ── Hermetic app-config: the suite must not depend on the developer's # local config.json or TELEGRAM_*/TWELVE_DATA_API_KEY env vars. The # dummy TD key mirrors the pre-config.json behavior where a key was # always present (TDClient built, no network at construction). import json import app_config for _k in ("TELEGRAM_TOKEN", "TELEGRAM_CHAT_ID", "TELEGRAM_CHANNEL_ID", "TWELVE_DATA_API_KEY", "DEEPSEEK_API_KEY"): os.environ.pop(_k, None) # The suite must never write the developer's (or the live box's) real # databases either: the RSS fixture tests run the real archive hook and # were found writing six fixture headlines into logs/newsdata.db # (2026-09-17, seen_count 6 = six suite runs). Every writer resolves its # path through app_config, so the hermetic config points both at a # suite-private temp dir; tests that need a specific file still set # SIGNAL_LOG_PATH / NEWS_ARCHIVE_PATH themselves (env wins). _TEST_DB_DIR = tempfile.TemporaryDirectory(prefix="fb2-tests-") _TEST_APP = app_config.AppConfig( provider_settings={"twelve_data": {"api_key": "test-key"}}, signal_log_settings={"path": os.path.join(_TEST_DB_DIR.name, "signals.db")}, news_archive_settings={"path": os.path.join(_TEST_DB_DIR.name, "newsdata.db")}, ) app_config.set_app_config(_TEST_APP) 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, 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("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 info["pip_value"] > 0, f"pip_value must be > 0 for {pair}" 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.mtf_agreement_threshold == 2 assert sc.min_rr_ratio == 0.5 assert sc.tp_tightening_factor == 1.0 @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.tp_atr_multiplier == 1.0 assert xc.sl_atr_multiplier == 2.0 assert isinstance(xc.blocked_hours_utc, list) assert xc.sell_blocked_hours_utc == [], \ "sell hour gate must be off by default (per-TF factories opt in)" @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: providers/twelve_data.py # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 2: providers/twelve_data.py ═══") from providers import TwelveDataProvider, DataIntegrityError @test("TwelveDataProvider._to_td_symbol conversion") def _(): assert TwelveDataProvider._to_td_symbol("XAU/USD") == "XAU/USD" @test("TwelveDataProvider TD_INTERVALS complete mapping") def _(): dp = TwelveDataProvider(PlatformConfig()) for tf in Timeframe: assert tf in dp.TD_INTERVALS, f"Missing TD_INTERVALS mapping for {tf}" @test("TwelveDataProvider BARS_PER_DAY mapping") def _(): dp = TwelveDataProvider(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("TwelveDataProvider CANDLE_SECONDS mapping") def _(): dp = TwelveDataProvider(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("TwelveDataProvider._validate_and_clean basic valid data") def _(): dp = TwelveDataProvider(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("TwelveDataProvider._validate_and_clean empty df raises error") def _(): dp = TwelveDataProvider(PlatformConfig()) try: dp._validate_and_clean(pd.DataFrame(), "XAU/USD", Timeframe.H1) assert False, "Should have raised DataIntegrityError" except DataIntegrityError: pass @test("TwelveDataProvider._validate_and_clean None df raises error") def _(): dp = TwelveDataProvider(PlatformConfig()) try: dp._validate_and_clean(None, "XAU/USD", Timeframe.H1) assert False, "Should have raised DataIntegrityError" except DataIntegrityError: pass @test("TwelveDataProvider._validate_and_clean fixes OHLC inconsistencies") def _(): dp = TwelveDataProvider(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("TwelveDataProvider._validate_and_clean drops future timestamps") def _(): dp = TwelveDataProvider(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.now(tz="UTC") + pd.Timedelta(minutes=5)).all() @test("TwelveDataProvider._validate_and_clean removes duplicates") def _(): dp = TwelveDataProvider(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("TwelveDataProvider._validate_and_clean forward fills NaN (limit 3)") def _(): dp = TwelveDataProvider(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("TwelveDataProvider._is_data_fresh weekend override") def _(): dp = TwelveDataProvider(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("TwelveDataProvider._is_data_fresh returns False for empty df") def _(): dp = TwelveDataProvider(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("TwelveDataProvider.clear_cache clears all entries") def _(): dp = TwelveDataProvider(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("TwelveDataProvider.get_pair_info returns correct info") def _(): dp = TwelveDataProvider(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("TwelveDataProvider._throttle rate limiting logic") def _(): dp = TwelveDataProvider(PlatformConfig()) dp._request_times.clear() for _ in range(10): dp._throttle(credits=1) assert len(dp._request_times) == 10 @test("TwelveDataProvider caching - in-memory cache returns same data") def _(): dp = TwelveDataProvider(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("EMA-4 partial alignment squashes to NEUTRAL with ema_partial_neutral") def _(): import numpy as np # Long decline then a medium rally: price>EMA9>EMA21>EMA50 but # EMA50 alignment 2/4 = partial BUY under legacy scoring. closes = np.concatenate([np.linspace(3000, 2400, 400), np.linspace(2400, 2560, 55)]) idx = pd.date_range("2026-01-01", periods=len(closes), freq="5min", tz="UTC") df = pd.DataFrame({"Open": closes, "High": closes + 1.0, "Low": closes - 1.0, "Close": closes, "Volume": 1000.0}, index=idx) assert TechnicalConfig().ema_partial_neutral is False # legacy default base = TechnicalEngine(TechnicalConfig()).analyze( df, "XAU/USD", "5min").indicators["ema_trend"] assert base.signal == SignalStrength.BUY and 0.2 < base.value <= 0.6, \ f"fixture must yield a partial-alignment BUY, got {base.signal} {base.value}" ind = TechnicalEngine(TechnicalConfig(ema_partial_neutral=True)).analyze( df, "XAU/USD", "5min").indicators["ema_trend"] assert ind.signal == SignalStrength.NEUTRAL and ind.confidence == 40 # Full alignment is untouched by the knob. up = np.linspace(2000, 3000, 380) df2 = pd.DataFrame({"Open": up, "High": up + 1.0, "Low": up - 1.0, "Close": up, "Volume": 1000.0}, index=pd.date_range("2026-01-01", periods=380, freq="5min", tz="UTC")) strong = TechnicalEngine(TechnicalConfig(ema_partial_neutral=True)).analyze( df2, "XAU/USD", "5min").indicators["ema_trend"] assert strong.signal == SignalStrength.STRONG_BUY @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 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 named zones carry their real offset") def _(): # EST is UTC−5: 12:00 EST == 17:00 UTC. The old "%Z" path accepted the # abbreviation but stamped UTC — a silent 5-hour shift. dt = NewsEngine._parse_rss_date("Mon, 20 Jul 2026 12:00:00 EST") assert dt is not None and dt.tzinfo is not None assert dt.astimezone(timezone.utc).hour == 17 # GMT stays a no-op; without-seconds (MyFXBook) form still parses. dt2 = NewsEngine._parse_rss_date("Mon, 20 Jul 2026 12:00 GMT") assert dt2 is not None and dt2.astimezone(timezone.utc).hour == 12 @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("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 grades from the soonest event") def _(): # Far event listed FIRST: an unsorted feed must still yield danger # for the nearer event, not caution for the first match. ne = NewsEngine(NewsConfig()) context = FundamentalContext() context.events_upcoming_1h = [ EconomicEvent( time=datetime.now(timezone.utc) + timedelta(minutes=55), currency="USD", impact="high", event="GDP", ), EconomicEvent( time=datetime.now(timezone.utc) + timedelta(minutes=10), 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 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 = {"XAU": 0.5, "USD": -0.1} score, desc = ne.get_pair_sentiment("XAU/USD", context) assert score > 0, "XAU bullish + USD bearish should be net bullish for XAU/USD" assert "bullish" in desc.lower() @test("NewsEngine.get_pair_sentiment with negative pair sentiment") def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() context.news_sentiment = {"XAU": -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 = {"XAU": 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("NewsEngine.get_week_events filters, sorts, and rides the cache") def _(): NewsEngine._calendar_cache = (0.0, []) try: ne = NewsEngine(NewsConfig()) calls = {"n": 0} now = datetime.now(timezone.utc) def fake_fetch(): calls["n"] += 1 return [ EconomicEvent(time=now + timedelta(hours=5), currency="USD", impact="high", event="NFP"), EconomicEvent(time=now - timedelta(hours=2), currency="USD", impact="low", event="Past Event"), EconomicEvent(time=now + timedelta(hours=1), currency="EUR", impact="high", event="ECB Rate"), ] ne._try_fetch_calendar_api = fake_fetch ne._try_fetch_myfxbook_rss = lambda: [] events = ne.get_week_events() # defaults to tracked ["USD", "XAU"] assert [e.event for e in events] == ["Past Event", "NFP"], \ f"Expected USD-only sorted by time, got {[e.event for e in events]}" # Second call (and explicit currency widening) must not refetch. wide = ne.get_week_events(currencies=["USD", "EUR"]) assert calls["n"] == 1, f"Expected 1 fetch, got {calls['n']}" assert [e.event for e in wide] == ["Past Event", "ECB Rate", "NFP"] 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 assert ts.trail_arm_r is None and ts.trail_dist_r is None # unstamped default assert ts.setup_pattern is None and ts.round_trip_cost is None @test("trade setups carry trail params from the producing config") def _(): config = PlatformConfig() config.signals.trail_arm_r = 0.70 config.signals.trail_dist_r = 0.85 agg = SignalAggregator(config) report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report.final_signal = SignalStrength.BUY report.final_score = 40.0 report.signal_quality = "A+" report.current_price = 3300.0 ts = agg._generate_xau_atr_trade_setup(report, 10.0) assert ts is not None, "A+ setup with ATR 10 should generate" assert ts.strategy == "XAU_ATR" assert ts.round_trip_cost == config.signals.round_trip_cost assert ts.trail_arm_r == 0.70, "setup must carry the producing config's arm" assert ts.trail_dist_r == 0.85, "setup must carry the producing config's dist" @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) assert PlatformConfig().news.event_risk_caps_signals is False @test("Sentiment signal gating obeys news.sentiment_affects_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(sentiment, affects): config.news.sentiment_affects_signals = affects report.news_sentiment = sentiment report.warnings = [] agg._compute_final_signal(report) return report.final_confidence score_sign = 1 if report.primary_score >= 0 else -1 aligned, conflicting = 0.5 * score_sign, -0.5 * score_sign baseline = recompute(0.0, False) # default: neither aligned sentiment nor conflict moves confidence assert recompute(aligned, False) == baseline, "aligned sentiment must not add confidence" assert recompute(conflicting, False) == baseline, "conflicting sentiment must not penalize" assert any("CONFLICTS" in w for w in report.warnings), "conflict warning must persist" # flag on: aligned sentiment adds confidence again assert recompute(aligned, True) > baseline, "flag on must restore the alignment bonus" # PlatformConfig default is off (news is informational only) assert PlatformConfig().news.sentiment_affects_signals is False @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, ) @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: every CLI timeframe uses the pair factory's confirmation map") def _(): from pair_signals import xauusd as _xau for tf_str, tf in (("M5", Timeframe.M5), ("M15", Timeframe.M15), ("H1", Timeframe.H1), ("H4", Timeframe.H4)): class FakeArgs: pair = "XAUUSD" timeframe = tf_str config = build_config(FakeArgs()) assert config.primary_timeframe == tf # The CLI must not carry its own confirmation table: it once gave # M5 an unvalidated [M1, M15] pairing that no other surface used. assert config.confirmation_timeframes == _xau._CONFIRMATIONS[tf], tf_str # ══════════════════════════════════════════════════════════════════════ # MODULE 7: telegram_bot.py # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 7: telegram_bot.py ═══") from telegram_bot import ( _escape_md, _signal_label, _mtf_line, _indicator_summary, format_signal_message, _news_text, _seconds_until_boundary, _check_level_breach, SCAN_BUTTONS, SCAN_LOOKUP, _update_level_cache, _last_prices, _level_cache, _level_cache_ts, ) @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 "Score" in msg assert "Regime" in msg assert "Not financial advice" in msg @test("format_signal_message makes chart pattern actions Markdown-safe") def _(): report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report.action_recommendation = "BUY — double_bottom pattern completion" msg = format_signal_message(report) assert "_BUY — double bottom pattern completion_" in msg assert "double_bottom" not in msg @test("format_signal_message renders stamped per-TF trail plan") def _(): def _setup(**extra): return TradeSetup( pair="XAU/USD", display_name="XAU/USD", direction="BUY", entry_price=3300.0, stop_loss=3290.0, take_profit_1=3315.0, take_profit_2=3325.0, risk_reward=1.5, risk_pips=100, reward_pips=150, pip_value=0.10, **extra, ) report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report.final_signal = SignalStrength.BUY report.signal_quality = "A" # Stamped setup (M15-style overrides) must render the stamped values… report.trade_setup = _setup(trail_arm_r=0.70, trail_dist_r=0.85) msg = format_signal_message(report) assert "+0.7R" in msg, "must render the stamped arm, not the 0.6 default" assert "$8.50" in msg, "must render the stamped dist × risk, not the default" # …while a legacy unstamped setup falls back to platform defaults. report.trade_setup = _setup() msg = format_signal_message(report) assert "+0.6R" in msg assert "$7.50" in msg @test("format_signal_message renders 100-pip informational TP") def _(): def _setup(direction="BUY", **extra): return TradeSetup( pair="XAU/USD", display_name="XAU/USD", direction=direction, entry_price=3300.0, stop_loss=3290.0, take_profit_1=3315.0, take_profit_2=3325.0, risk_reward=1.5, risk_pips=100, reward_pips=150, pip_value=0.10, **extra, ) report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report.final_signal = SignalStrength.BUY report.signal_quality = "A" # BUY: 100 pips × $0.10 = $10 above entry; risk $10 → exactly 1.0R. report.trade_setup = _setup() msg = format_signal_message(report) assert "TP 100p" in msg assert "3310.00" in msg, "BUY 100p TP must be entry + $10.00" assert "≈1.0R" in msg # SELL mirror: $10 below entry. report.final_signal = SignalStrength.SELL report.trade_setup = _setup(direction="SELL") msg = format_signal_message(report) assert "TP 100p" in msg assert "3290.00" in msg, "SELL 100p TP must be entry - $10.00" # No setup → no informational TP line. report.trade_setup = None msg = format_signal_message(report) assert "TP 100p" not in msg @test("_indicator_summary condenses indicators to one arrow line") 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) summary = _indicator_summary(report) assert "EMA" in summary assert "RSI" in summary assert summary.count("\n") == 0, "summary must be a single line" msg = format_signal_message(report) assert summary in msg assert "[Bullish]" not in msg and "[Bearish]" not in msg and "[Neutral]" not in msg @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 # NOTE 2026-08-04 (audit H34): the level cache is keyed symbol → TF → # levels and _check_level_breach returns " " — these tests # were updated to the new shape in the same change. @test("_check_level_breach detects upward cross") def _(): _level_cache["TEST_PAIR"] = {"15min": {"swing_high_1": 1.1000}} _level_cache_ts["TEST_PAIR"] = {"15min": time.time()} _last_prices["TEST_PAIR"] = 1.0999 result = _check_level_breach("TEST_PAIR", 1.1001) assert result == "15min 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"] = {"15min": {"swing_low_1": 1.0900}} _level_cache_ts["TEST_PAIR"] = {"15min": time.time()} _last_prices["TEST_PAIR"] = 1.0901 result = _check_level_breach("TEST_PAIR", 1.0899) assert result == "15min 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"] = {"15min": {"swing_high_1": 1.1000}} _level_cache_ts["TEST_PAIR"] = {"15min": 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"] = {"15min": {"swing_high_1": 1.1000}} _level_cache_ts["TEST_PAIR"] = {"15min": 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"] = {"15min": {"swing_high_1": 1.1000}} _level_cache_ts["TEST_PAIR"] = {"15min": 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("_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("streaming symbol mapping covers every scanned pair") def _(): # iter_ticks maps the provider's WS symbols (display names) back to # canonical pair symbols — the mapping must be invertible for every # scanned pair or ticks would be silently dropped. from telegram_bot import SCAN_PAIRS ws_to_pair = {PAIR_INFO[p]["display"]: p for p in SCAN_PAIRS} for p in SCAN_PAIRS: assert ws_to_pair[PAIR_INFO[p]["display"]] == p assert ws_to_pair["XAU/USD"] == XAUUSD @test("_news_text bullish score") def _(): ctx = FundamentalContext(news_sentiment={"XAU": 0.5, "USD": -0.1}) text = _news_text(ctx, NewsEngine(PlatformConfig().news)) assert "+0.60" in text assert "Bullish" in text assert "no scored headlines" not in text @test("_news_text bearish score") def _(): ctx = FundamentalContext(news_sentiment={"XAU": -0.4, "USD": 0.1}) text = _news_text(ctx, NewsEngine(PlatformConfig().news)) assert "-0.50" in text assert "Bearish" in text @test("_news_text never renders negative zero") def _(): # Balanced feed: XAU and USD means nearly cancel → net -0.0035, # which "+.2f" would render as "-0.00" without normalization. ctx = FundamentalContext(news_sentiment={"XAU": 0.0238, "USD": 0.0273}) text = _news_text(ctx, NewsEngine(PlatformConfig().news)) assert "-0.00" not in text assert "+0.00" in text assert "Neutral" in text @test("_news_text degrades on empty context") def _(): text = _news_text(FundamentalContext(), NewsEngine(PlatformConfig().news)) assert "+0.00" in text assert "Neutral" in text assert "no scored headlines" in text # ══════════════════════════════════════════════════════════════════════ # 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 only") def _(): import pair_signals pairs = list(pair_signals.PAIR_PRODUCERS) assert XAUUSD in pairs, f"XAU missing from registry: {pairs}" assert pairs == [XAUUSD], f"registry must be XAU-only: {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", primary_timeframe=tf, confirmation_timeframes=ctfs, ) app_config.apply_pair_overrides(inline_cfg) if tf == Timeframe.M15: # Documented per-TF overrides (see xauusd.py docstring): # 1R minimum targets + mixed-"trending"-regime block + # SELL hour block (15/17 UTC), all validated for M15 only. inline_cfg.signals.tp_floor_rr = 1.0 inline_cfg.xau_strategy.blocked_regimes = ["trending"] inline_cfg.xau_strategy.sell_blocked_hours_utc = [15, 17] inline_cfg.signals.trail_arm_r = 0.70 inline_cfg.signals.trail_dist_r = 0.75 # M15 override 5 (2026-09-09): RSI x 0.5 in every regime — # LITERAL so a silent default-table change fails loudly here. inline_cfg.technical.regime_weights_override = { "trending_up": { "ema_trend": 2.0, "rsi": 0.5, "macd": 2.0, "bollinger": 0.5, "keltner": 1.5, "roc": 1.5, "candlestick": 0.8, "divergence": 1.0, "adx": 0.8, }, "trending_down": { "ema_trend": 2.0, "rsi": 0.5, "macd": 2.0, "bollinger": 0.5, "keltner": 1.5, "roc": 1.5, "candlestick": 0.8, "divergence": 1.0, "adx": 0.8, }, "trending": { "ema_trend": 2.0, "rsi": 0.5, "macd": 2.0, "bollinger": 0.5, "keltner": 1.5, "roc": 1.5, "candlestick": 0.8, "divergence": 1.0, "adx": 0.8, }, "ranging": { "ema_trend": 0.3, "rsi": 0.75, "macd": 1.5, "bollinger": 1.5, "keltner": 2.0, "roc": 0.5, "candlestick": 1.0, "divergence": 2.5, "adx": 0.5, }, "transitioning": { "ema_trend": 1.5, "rsi": 0.5, "macd": 2.5, "bollinger": 1.0, "keltner": 2.5, "roc": 2.0, "candlestick": 1.0, "divergence": 2.0, "adx": 1.0, }, } if tf == Timeframe.M5: # Documented per-TF overrides (see xauusd.py docstring): # hour 9 UTC blocked, hour 21 UNBLOCKED (2026-07-30 # relaxation campaign) + BUY-only chart patterns on M5 only. inline_cfg.xau_strategy.blocked_hours_utc = [ h for h in inline_cfg.xau_strategy.blocked_hours_utc if h != 21 ] + [9] inline_cfg.xau_strategy.cp_enabled = True inline_cfg.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"] inline_cfg.xau_strategy.m5_pullback_enabled = False inline_cfg.technical.ema_partial_neutral = True inline_cfg.signals.trail_arm_r = 0.60 inline_cfg.signals.trail_dist_r = 0.85 # Block SELL entries while regime == "transitioning" on M5 # only (2026-09-03 signal-log campaign) — see docstring. inline_cfg.xau_strategy.sell_blocked_regimes = ["transitioning"] if tf == Timeframe.H1: # Block every H1 SELL (2026-09-03 signal-log campaign) — # see docstring; H1 shorts were a structural loser. inline_cfg.xau_strategy.sell_min_quality = [] assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \ f"XAU factory config differs from inline construction for {tf}" @test("M5 factory disables XAU_PB; other TFs keep the global default") def _(): # 2026-08-04 campaign (Oanda bars, $0.65 real spread): pullback # entries are the largest wrong-direction loss source on M5 and # fail at real cost; disabled in the M5 factory branch only. from pair_signals import PAIR_PRODUCERS m5 = PAIR_PRODUCERS[XAUUSD](Timeframe.M5, [Timeframe.M15, Timeframe.H1]) assert m5.xau_strategy.m5_pullback_enabled is False assert m5.technical.ema_partial_neutral is True # 2026-08-04 override 5 m15 = PAIR_PRODUCERS[XAUUSD](Timeframe.M15, [Timeframe.M5, Timeframe.H1]) assert m15.xau_strategy.m5_pullback_enabled is True, \ "PB flag must stay at the global default off-M5 (inert there)" assert m15.technical.ema_partial_neutral is False, \ "partial-alignment squash is M5-validated only" @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, "XAU/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, "XAU/USD", Timeframe.H1) if "adx" in result.indicators: assert np.isfinite(result.indicators["adx"].value), "ADX should be finite on flat data" # ══════════════════════════════════════════════════════════════════════ # 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}" al = r_live.indicators["atr"] ag = r_gpu.indicators["atr"] assert abs(al.value - ag.value) < 1e-9, f"atr value @ {i}" assert al.description == ag.description, f"atr description @ {i}" assert r_live.volatility_regime == r_gpu.volatility_regime, f"vol regime @ {i}" assert abs(r_live.key_levels["atr"] - r_gpu.key_levels["atr"]) < 1e-9, f"key_levels atr @ {i}" assert abs(r_live.key_levels["atr_pct"] - r_gpu.key_levels["atr_pct"]) < 1e-9, f"key_levels atr_pct @ {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("Regime filter: live gate semantics + backtest calls the same method") def _(): from signal_aggregator import SignalAggregator from pair_signals import PAIR_PRODUCERS cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.M15, [Timeframe.M5, Timeframe.H1]) cfg.backtest.regime_filter = True agg = SignalAggregator(cfg) df = make_ohlcv(n=300, start_price=3400.0, trend=0.05, seed=3) flat = make_ohlcv(n=300, start_price=3400.0, trend=0.0, seed=7) adx = agg._latest_adx(flat, cfg.technical.adx_period) assert adx is not None and adx < cfg.backtest.regime_filter_min_adx, \ f"fixture must be choppy (ADX {adx}) or the test has no teeth" # Low higher-TF ADX → signal downgraded to NEUTRAL/F, setup killed. report = agg.analyze_from_dataframes(XAUUSD, {Timeframe.M15: df}, context=None, use_news=False) agg._apply_regime_filter_live(report, {Timeframe.H4: flat}) assert report.final_signal == SignalStrength.NEUTRAL assert report.signal_quality == "F" assert report.trade_setup is None assert any("REGIME FILTER" in w for w in report.warnings) # Regime TF absent from dfs → warning only, report untouched. report2 = agg.analyze_from_dataframes(XAUUSD, {Timeframe.M15: df}, context=None, use_news=False) quality_before = report2.signal_quality agg._apply_regime_filter_live(report2, {}) assert report2.signal_quality == quality_before assert any("data unavailable" in w for w in report2.warnings) # Backtest wiring: the replay applies the SAME method under the SAME # guard as live analyze_pair (function-identity parity). import gpu_backtest as _gb src = open(_gb.__file__, encoding="utf-8").read() assert "_apply_regime_filter_live(report, slice_dfs)" in src assert "backtest.regime_filter" in src # …and loads the regime-filter TF like live analyze_pair does, or the # method silently no-ops for M5/M15 primaries (confirm maps lack H4). assert "regime_filter_timeframe not in timeframes" in src, \ "replay must fetch/slice the regime-filter TF when the gate is on" @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 = TwelveDataProvider(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, "XAU/USD", Timeframe.H1) assert spike_ts not in cleaned.index, "spike bar must be removed" assert after_ts in cleaned.index, \ "snap-back bar after the spike must be kept (two-bar hole regression)" @test("Outlier filter keeps a >10σ bar whose move PERSISTS (real news bar)") def _(): # 2026-09-07 audit: the 12:30 UTC NFP bar (-1.7%, ~23σ) was deleted # from the signal-side M5/M15 data, hiding the day's most violent bar # from ATR/exhaustion math. Three years of Oanda XAU bars: 155 such # M5 bars, 7 snapped back. A level shift that holds is real price. provider = TwelveDataProvider(PlatformConfig()) df = make_ohlcv(n=300, seed=3) k = 150 jump_ts = df.index[k] shift = df["Close"].iloc[k - 1] * 0.05 # ~+5% step on a ~0.1%-σ series df.loc[df.index[k]:, ["Open", "High", "Low", "Close"]] += shift cleaned = provider._validate_and_clean(df, "XAU/USD", Timeframe.H1) assert jump_ts in cleaned.index, \ "a 10σ move that the next close confirms must be KEPT" assert len(cleaned) == len(df), "no bar may be dropped for a real move" @test("Outlier filter keeps the NEWEST >10σ bar (no successor to judge it)") def _(): # Live at 12:35 on a release day the crash bar IS the last closed # bar. Dropping it would make the scan run on pre-release data (the # 2026-09-04 12:35 scan was a byte-identical repeat of 12:30). With # no next close available the bar is treated as real; a true bad # print on the newest bar is caught on the following scan. provider = TwelveDataProvider(PlatformConfig()) df = make_ohlcv(n=300, seed=3) last_ts = df.index[-1] df.loc[last_ts, ["Open", "High", "Low", "Close"]] = df["Close"].iloc[-2] * 1.05 cleaned = provider._validate_and_clean(df, "XAU/USD", Timeframe.H1) assert last_ts in cleaned.index, "newest 10σ bar must be kept" @test("Outlier filter: partial snap-back (below the revert fraction) is a real move") def _(): provider = TwelveDataProvider(PlatformConfig()) df = make_ohlcv(n=300, seed=3) k = 150 base = df["Close"].iloc[k - 1] spike_ts = df.index[k] # +5% spike, next close gives back only half — a violent real move, # not a bad print (revert fraction 0.5 < OUTLIER_REVERT_FRACTION 0.8). df.loc[spike_ts, ["Open", "High", "Low", "Close"]] = base * 1.05 df.loc[df.index[k + 1]:, ["Open", "High", "Low", "Close"]] += base * 0.025 cleaned = provider._validate_and_clean(df, "XAU/USD", Timeframe.H1) assert spike_ts in cleaned.index, "half-retraced spike is real price" assert provider.OUTLIER_REVERT_FRACTION == 0.8 @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 = TwelveDataProvider(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("providers.base._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("Fresh fetch missing the just-closed bar retries once, then warns") 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 = TwelveDataProvider(PlatformConfig()) started = pd.Timestamp.now(tz="UTC").floor("15min") last_closed = started - pd.Timedelta(minutes=15) lagging = bars_ending(last_closed - pd.Timedelta(minutes=15)) complete = bars_ending(last_closed) with patch("providers.base._is_forex_weekend", return_value=False), \ patch.object(TwelveDataProvider, "FRESH_FETCH_RETRY_SECONDS", 0.0): # (a) lagging first response → one retry returns the closed bar. with patch.object(provider, "_fetch_twelve_data", side_effect=[lagging.copy(), complete.copy()]) as mf: served = provider.fetch_ohlcv("XAU/USD", Timeframe.M15) # (b) complete first response → no retry at all. provider._cache.clear() provider._cache_timestamps.clear() with patch.object(provider, "_fetch_twelve_data", return_value=complete.copy()) as mf2: provider.fetch_ohlcv("XAU/USD", Timeframe.M15) if pd.Timestamp.now(tz="UTC").floor("15min") != started: continue # boundary crossed mid-test — retry once assert mf.call_count == 2, "lagging response must trigger exactly one retry" assert served.index[-1] == last_closed, "retry result must be served" assert mf2.call_count == 1, "boundary-complete response must not retry" 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) tb._last_alert_setup.pop(key, None) r1 = rpt(3400.0, 3392.0) # risk $8 → re-alert threshold $4 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-threshold drift must dedup" assert not tb._should_alert(key, rpt(3401.0, 3392.6)), \ "SL-distance drift (ATR/anchor wobble) must not re-fingerprint" assert tb._should_alert(key, rpt(3412.0, 3404.0)), \ "setup moved 1.5x risk — must re-alert" assert tb._should_alert(key, rpt(3395.5, 3387.5)), \ "setup moved half risk AGAINST the entry — must re-alert too" tb._last_alert_fp.pop(key, None) tb._last_alert_ts.pop(key, None) tb._last_alert_setup.pop(key, None) tb._last_alert_quality.pop(key, None) @test("Alert dedup: grade flicker does not re-alert, an upgrade does") def _(): import telegram_bot as tb from signal_aggregator import SignalReport, TradeSetup def rpt(q): r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t") r.final_signal = SignalStrength.SELL r.signal_quality = q r.trade_setup = TradeSetup( pair="XAU/USD", display_name="XAU/USD", direction="SELL", entry_price=3400.0, stop_loss=3408.0, take_profit_1=3388.0, take_profit_2=3380.0, risk_reward=1.5, risk_pips=80.0, reward_pips=120.0, pip_value=0.10) return r key = "TEST_ALERT_GRADE" for d in (tb._last_alert_fp, tb._last_alert_ts, tb._last_alert_setup, tb._last_alert_quality): d.pop(key, None) try: assert tb._should_alert(key, rpt("B")) tb._commit_alert(key, rpt("B")) assert not tb._should_alert(key, rpt("B")), "identical → dedup" assert tb._should_alert(key, rpt("A")), "B→A upgrade must re-alert" tb._commit_alert(key, rpt("A")) assert not tb._should_alert(key, rpt("B")), \ "A→B downgrade (flicker) must NOT re-alert" assert not tb._should_alert(key, rpt("A")), "A again → dedup" assert tb._alert_fingerprint(rpt("A")) == tb._alert_fingerprint(rpt("B")), \ "grade is not part of the alert identity" finally: for d in (tb._last_alert_fp, tb._last_alert_ts, tb._last_alert_setup, tb._last_alert_quality): d.pop(key, None) @test("Alert candidates: a quality-passing signal without a trade setup is skipped and logged") def _(): import logging import telegram_bot as tb from signal_aggregator import SignalReport, TradeSetup def rpt(setup: bool, signal=SignalStrength.SELL, q="B"): r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t") r.final_signal = signal r.signal_quality = q r.warnings = ["SPREAD GATE: target $8.31 < $9.00 minimum — " "costs would eat the edge"] if setup: r.trade_setup = TradeSetup( pair="XAU/USD", display_name="XAU/USD", direction="SELL", entry_price=4388.0, stop_loss=4410.0, take_profit_1=4377.0, take_profit_2=4372.0, risk_reward=0.5, risk_pips=220.0, reward_pips=110.0, pip_value=0.10) return r records = [] handler = logging.Handler() handler.emit = records.append tb.logger.addHandler(handler) saved_threshold = tb._alert_min_quality tb._alert_min_quality = "C" try: assert tb._is_alert_candidate(rpt(True), "5min") assert not tb._is_alert_candidate(rpt(False), "5min"), \ "no trade setup → no alert (the 2026-09-08 07:50 UTC no-setup SELL)" assert not tb._is_alert_candidate(rpt(True, SignalStrength.NEUTRAL), "5min") assert not tb._is_alert_candidate(rpt(True, q="F"), "5min") finally: tb._alert_min_quality = saved_threshold tb.logger.removeHandler(handler) skips = [r.getMessage() for r in records if r.getMessage().startswith("alert skipped:")] assert len(skips) == 1, skips assert "5min SELL grade B" in skips[0] and "SPREAD GATE" in skips[0], skips[0] line = tb._alert_sent_line(rpt(True), "5min", 2) assert line.startswith("alert sent: XAU/USD 5min SELL grade B"), line assert "SELL entry 4388.00 sl 4410.00 tp1 4377.00" in line and "2 chat(s)" in line, line r = rpt(True) r._signal_log_rowid = 917 breach = tb._alert_sent_line(r, "15min", 1, kind="breach alert") assert breach.startswith("breach alert sent:") and "row #917" in breach, breach @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 @test("Max drawdown counts the decline from flat starting equity") def _(): from gpu_backtest import Trade, compute_metrics def mk(r): t = Trade(pair="XAU/USD", entry_time=pd.Timestamp("2026-01-05 10:00", tz="UTC"), entry_price=3300.0, sl=3292.0, tp1=3312.0, tp2=3320.0, 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") t.exit_reason = "SL" if r < 0 else "TP1" t.r_multiple = r return t # Opening losing streak: equity 0 → −3R with no prior peak. The old # high-water started at the first trade's cum (−1) and reported 2R. m = compute_metrics([mk(-1.0), mk(-1.0), mk(-1.0)]) assert m.max_drawdown_r == 3.0, f"expected 3.0, got {m.max_drawdown_r}" # Unchanged when a peak precedes the streak. m2 = compute_metrics([mk(2.0), mk(-1.0), mk(-1.0), mk(-1.0)]) assert m2.max_drawdown_r == 3.0, f"expected 3.0, got {m2.max_drawdown_r}" # ══════════════════════════════════════════════════════════════════════ # MODULE 12: Chart-pattern entry (XAU_CP) # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 12: chart patterns (XAU_CP) ═══") from chart_patterns import detect_chart_pattern from config import XauStrategyConfig def make_cp_df(closes, end="2026-07-08 14:00", freq="1h", wick=1.5): """Deterministic OHLCV from an explicit close path (fixed end time: Wed, label hour 14, close hour 15 — clear of every session block).""" idx = pd.date_range(end=end, periods=len(closes), freq=freq, tz="UTC") c = np.asarray(closes, dtype=float) o = np.concatenate([[c[0]], c[:-1]]) return pd.DataFrame({"Open": o, "High": c + wick, "Low": c - wick, "Close": c, "Volume": 1000.0}, index=idx) def mirror_cp_df(df, pivot=6000.0): """Price-mirror a fixture: bullish pattern → exact bearish twin.""" return pd.DataFrame({ "Open": pivot - df["Open"], "High": pivot - df["Low"], "Low": pivot - df["High"], "Close": pivot - df["Close"], "Volume": df["Volume"]}, index=df.index) def double_bottom_closes(): """L1@30 (2984) and L2@65 (2984.5), neckline peak 3000 @45, below-neckline drift, breakout close 3004 on the last bar.""" return (list(np.linspace(2988, 3013, 15)) + list(np.linspace(3011, 2984, 16)) + list(np.linspace(2986, 3000, 15)) + list(np.linspace(2999, 2984.5, 20)) + list(np.linspace(2986, 2998, 13)) + [3004.0]) def hs_top_closes(): """S1@20 (3000), head@38 (3010), S2@62 (2999), neckline ~2989.5, breakdown close 2988 on the last bar.""" return (list(np.linspace(2982, 3000, 21)) + list(np.linspace(2998.5, 2990, 7)) + list(np.linspace(2992, 3010, 11)) + list(np.linspace(3008, 2991, 8)) + list(np.linspace(2992, 2999, 16)) + list(np.linspace(2998.5, 2991, 16)) + [2988.0]) def bull_flag_closes(): """Pole 60→69 (3000→3025), flag drift 70→78, breakout close 3028.5.""" return (list(np.linspace(3000, 2996, 60)) + list(np.linspace(2999, 3025, 10)) + list(np.linspace(3023, 3018, 9)) + [3028.5]) _CP_CFG = XauStrategyConfig() @test("CP: double bottom detected on breakout bar with correct geometry") def _(): df = make_cp_df(double_bottom_closes()) hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) assert hit is not None, "double bottom not detected" assert hit["pattern"] == "double_bottom" and hit["direction"] == "BUY" assert abs(hit["anchor"] - 2982.5) < 1e-9, f"anchor {hit['anchor']}" assert abs(hit["trigger"] - 3001.5) < 1e-9, f"trigger {hit['trigger']}" assert abs(hit["height"] - 19.0) < 1e-9, f"height {hit['height']}" @test("CP: double top detected on mirrored fixture") def _(): df = mirror_cp_df(make_cp_df(double_bottom_closes())) hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) assert hit is not None and hit["pattern"] == "double_top" assert hit["direction"] == "SELL" assert abs(hit["anchor"] - (6000 - 2982.5)) < 1e-9 assert abs(hit["trigger"] - (6000 - 3001.5)) < 1e-9 @test("CP: trigger discipline — no signal before breakout close") def _(): df = make_cp_df(double_bottom_closes()).iloc[:-1] assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None @test("CP: trigger discipline — no re-fire on the bar after breakout") def _(): df = make_cp_df(double_bottom_closes() + [3006.0]) assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None @test("CP: double bottom rejected when lows differ beyond tolerance") def _(): closes = double_bottom_closes() closes[46:66] = list(np.linspace(2999, 2979, 20)) # L2 5.5 below L1 df = make_cp_df(closes) assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None @test("CP: double bottom invalidated by a lower low after L2") def _(): closes = double_bottom_closes() closes[77] = 2978.0 # undercuts both bottoms before the breakout df = make_cp_df(closes) assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None @test("CP: head & shoulders top detected (SELL, right-shoulder anchor)") def _(): df = make_cp_df(hs_top_closes()) hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) assert hit is not None, "H&S top not detected" assert hit["pattern"] == "hs_top" and hit["direction"] == "SELL" assert abs(hit["anchor"] - 3000.5) < 1e-9, f"anchor {hit['anchor']}" assert abs(hit["trigger"] - 2989.5) < 1e-9, f"trigger {hit['trigger']}" @test("CP: inverse head & shoulders detected on mirrored fixture") def _(): df = mirror_cp_df(make_cp_df(hs_top_closes())) hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) assert hit is not None and hit["pattern"] == "inv_hs" assert hit["direction"] == "BUY" @test("CP: bull flag detected (pole, shallow retrace, new-high close)") def _(): df = make_cp_df(bull_flag_closes()) hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) assert hit is not None, "bull flag not detected" assert hit["pattern"] == "bull_flag" and hit["direction"] == "BUY" assert abs(hit["trigger"] - 3026.5) < 1e-9 assert hit["height"] > 15.0 # pole ≥ cp_flag_pole_atr × atr @test("CP: bear flag detected on mirrored fixture") def _(): df = mirror_cp_df(make_cp_df(bull_flag_closes())) hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) assert hit is not None and hit["pattern"] == "bear_flag" assert hit["direction"] == "SELL" @test("CP: cp_patterns subset filter excludes disabled detectors") def _(): df = make_cp_df(double_bottom_closes()) cfg = XauStrategyConfig(cp_patterns=["bull_flag"]) assert detect_chart_pattern(df, atr=5.0, cfg=cfg) is None def _cp_report(df, config, vol_regime="normal", mtf_signals=None): """SignalReport shell around a real engine analysis of `df`.""" from engine import TechnicalEngine eng = TechnicalEngine(config.technical) pa = eng.analyze(df, "XAU/USD", config.primary_timeframe.value) rep = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp=str(df.index[-1])) rep.primary_analysis = pa rep.current_price = float(df["Close"].iloc[-1]) rep.volatility_regime = vol_regime rep.mtf_signals = mtf_signals or {} rep.signal_quality = "D" return rep def _cp_config(**xau_overrides): cfg = PlatformConfig() cfg.primary_timeframe = Timeframe.H1 cfg.xau_strategy.cp_enabled = True for k, v in xau_overrides.items(): setattr(cfg.xau_strategy, k, v) return cfg @test("CP gate: BUY pattern builds an XAU_CP setup with measured-move TP") def _(): cfg = _cp_config() df = make_cp_df(double_bottom_closes()) rep = _cp_report(df, cfg) SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) ts = rep.trade_setup assert ts is not None, "pattern setup not generated" assert ts.strategy == "XAU_CP" and ts.direction == "BUY" assert ts.setup_pattern == "double_bottom" assert ts.stop_loss < ts.entry_price assert abs((ts.take_profit_1 - ts.entry_price) - 19.0) < 1e-6, \ "TP1 must be the measured move" assert rep.signal_quality == "B" assert rep.bullish_reasons[0].startswith("XAU CHART PATTERN"), \ "tag marker must sit at reasons[0]" @test("CP gate: accepted setup removes stale vetoes and deduplicates extension context") def _(): cfg = _cp_config() df = make_cp_df(double_bottom_closes()) rep = _cp_report(df, cfg) rep.warnings = [ "SPREAD GATE: old core target failed", "EXHAUSTED TREND FILTER: C-grade blocked in exhausted trend", "M5 FILTER: no MTF agreement — M5 signals require confirmation", "FAR FROM EMA200 (4.0%) — over-extended", "TREND EXHAUSTION — price extended >3.5 ATR past swing low", "NO PULLBACK — price 3.0 ATR from EMA21", "Extended — RSI 78 in uptrend", "EVENT RISK: CPI in 20m", "NEAR EMA200 (0.3%) — chop risk", ] SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) assert rep.trade_setup is not None assert rep.warnings == [ "TREND EXHAUSTION — price extended >3.5 ATR past swing low", "EVENT RISK: CPI in 20m", "NEAR EMA200 (0.3%) — chop risk", ], rep.warnings @test("Core setup: a stale ATR spread-gate veto is cleaned when the swing generator ships a setup") def _(): from types import SimpleNamespace from signal_aggregator import SignalAggregator, SignalReport, TradeSetup agg = SignalAggregator(PlatformConfig()) price = 4400.0 def rpt(levels): r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t") r.final_signal = SignalStrength.STRONG_BUY r.final_score = 40.0 r.signal_quality = "A" r.current_price = price r.primary_analysis = SimpleNamespace(key_levels=dict(levels)) return r # ATR $2 → the A-grade ATR target is 1.5 × $2 = $3, under the $9 cost # floor (veto + warning). The swing generator's stop sits under the # $20-deep swing low, so its RR-floor target is $11 and a GENERIC # setup ships — the ATR veto is then stale (alert #915, 2026-09-08). r = rpt({"atr": 2.0, "swing_low_1": price - 20.0}) agg._attach_core_setup(r, 0.10) assert r.trade_setup is not None and r.trade_setup.strategy == "GENERIC", r.trade_setup assert sum(w.startswith("SPREAD GATE:") for w in r.warnings) == 1, \ "the generators themselves leave the veto in place" agg._drop_stale_spread_gate_vetoes(r) assert not any(w.startswith("SPREAD GATE:") for w in r.warnings), r.warnings assert sum(w.startswith("SPREAD GATE:") for w in r.cleaned_warnings) == 1, \ "the veto must stay auditable in cleaned_warnings" # No swing structure: the swing generator's fallback target is # max(1.5 × ATR, RR floor × the $8 minimum stop) = $4 — also gated — # so no setup ships and BOTH vetoes stay visible. r2 = rpt({"atr": 2.0}) agg._attach_core_setup(r2, 0.10) agg._drop_stale_spread_gate_vetoes(r2) assert r2.trade_setup is None assert sum(w.startswith("SPREAD GATE:") for w in r2.warnings) == 2, r2.warnings assert not r2.cleaned_warnings # The pass runs after the PB/CP archetypes too: a setup shipped by a # later generator (XAU_PB shape here) supersedes BOTH core vetoes, # and unrelated warnings keep their order. r3 = rpt({"atr": 2.0}) agg._attach_core_setup(r3, 0.10) r3.warnings.insert(0, "NEAR EMA200 (0.2%) — chop risk") r3.warnings.append("TREND EXHAUSTION — price extended >3.5 ATR past swing low") r3.trade_setup = TradeSetup( pair="XAU/USD", display_name="XAU/USD", direction="BUY", entry_price=price, stop_loss=price - 8.0, take_profit_1=price + 12.0, take_profit_2=price + 18.0, risk_reward=1.5, risk_pips=80.0, reward_pips=120.0, pip_value=0.10, strategy="XAU_PB") agg._drop_stale_spread_gate_vetoes(r3) assert r3.warnings == ["NEAR EMA200 (0.2%) — chop risk", "TREND EXHAUSTION — price extended >3.5 ATR past swing low"], r3.warnings assert sum(w.startswith("SPREAD GATE:") for w in r3.cleaned_warnings) == 2 @test("CP gate: MTF opposition blocks the pattern trade") def _(): cfg = _cp_config() df = make_cp_df(double_bottom_closes()) rep = _cp_report(df, cfg, mtf_signals={ "15min": SignalStrength.SELL, "4h": SignalStrength.STRONG_SELL}) SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) assert rep.trade_setup is None, "BUY pattern must not fire against consensus" @test("CP gate: SELL pattern blocked in quiet volatility, allowed in high") def _(): cfg = _cp_config() df = mirror_cp_df(make_cp_df(double_bottom_closes())) rep = _cp_report(df, cfg, vol_regime="normal") SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) assert rep.trade_setup is None, "quiet-vol SELL must be blocked" rep2 = _cp_report(df, cfg, vol_regime="high") SignalAggregator(cfg)._try_chart_pattern(rep2, df, 0.10) assert rep2.trade_setup is not None and rep2.trade_setup.direction == "SELL" @test("CP gate: SELL blocked-hour (bar close) stops pattern shorts only") def _(): cfg = _cp_config(sell_blocked_hours_utc=[15]) # fixture closes 15:00 df = mirror_cp_df(make_cp_df(double_bottom_closes())) rep = _cp_report(df, cfg, vol_regime="high") SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) assert rep.trade_setup is None, "SELL at blocked close hour must not fire" # BUY side unaffected at the same hour. df_buy = make_cp_df(double_bottom_closes()) rep_buy = _cp_report(df_buy, cfg) SignalAggregator(cfg)._try_chart_pattern(rep_buy, df_buy, 0.10) assert rep_buy.trade_setup is not None @test("CP gate: M5 chop-transition volatility blocks both directions") def _(): cfg = _cp_config() cfg.primary_timeframe = Timeframe.M5 df = make_cp_df(double_bottom_closes()) rep = _cp_report(df, cfg, vol_regime="high") # 'high' blocked on M5 SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) assert rep.trade_setup is None @test("CP gate: spread gate rejects patterns smaller than TP minimum") def _(): cfg = _cp_config() cfg.signals.min_tp_cost_multiple = 1000.0 # $300 minimum target df = make_cp_df(double_bottom_closes()) rep = _cp_report(df, cfg) rep.warnings = ["SPREAD GATE: prior warning", "NO PULLBACK — context"] before = list(rep.warnings) SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10) assert rep.trade_setup is None assert rep.warnings == before, "rejected CP must not rewrite diagnostics" @test("CP pipeline: fires end-to-end when core generators pass") def _(): cfg = _cp_config() cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4] df = make_cp_df(double_bottom_closes()) dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} agg = SignalAggregator(cfg) with patch.object(SignalAggregator, "_generate_trade_setup", return_value=None), \ patch.object(SignalAggregator, "_generate_xau_atr_trade_setup", return_value=None): rep = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) assert rep.trade_setup is not None, \ f"pipeline did not produce a setup (warnings: {rep.warnings})" assert rep.trade_setup.strategy == "XAU_CP" @test("CP pipeline: disabled by default except M5 BUY-only") def _(): assert XauStrategyConfig().cp_enabled is False assert PlatformConfig().xau_strategy.cp_enabled is False from pair_signals import PAIR_PRODUCERS for tf in (Timeframe.M15, Timeframe.H1, Timeframe.H4): assert PAIR_PRODUCERS[XAUUSD](tf, None).xau_strategy.cp_enabled is False m5_cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.M5, None) assert m5_cfg.xau_strategy.cp_enabled is True assert m5_cfg.xau_strategy.cp_patterns == ["inv_hs", "double_bottom", "bull_flag"] cfg = PlatformConfig() cfg.primary_timeframe = Timeframe.H1 cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4] df = make_cp_df(double_bottom_closes()) dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} with patch.object(SignalAggregator, "_generate_trade_setup", return_value=None), \ patch.object(SignalAggregator, "_generate_xau_atr_trade_setup", return_value=None): rep = SignalAggregator(cfg).analyze_from_dataframes( "XAU/USD", dfs, use_news=False) assert rep.trade_setup is None @test("CP pipeline: session-blocked bar never hosts a pattern trade") def _(): cfg = _cp_config() cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4] # Label hour 13 is in blocked_hours_utc → 6b sets F + SESSION FILTER. df = make_cp_df(double_bottom_closes(), end="2026-07-08 13:00") dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} with patch.object(SignalAggregator, "_generate_trade_setup", return_value=None), \ patch.object(SignalAggregator, "_generate_xau_atr_trade_setup", return_value=None): rep = SignalAggregator(cfg).analyze_from_dataframes( "XAU/USD", dfs, use_news=False) assert rep.trade_setup is None assert rep.signal_quality == "F" @test("CP backtester: --cp flag enables patterns, tag filter knows XAU_CP") def _(): from gpu_backtest import _parse_args args = _parse_args(["--pair", "XAUUSD", "--cp", "--cp-patterns", "double_bottom,bull_flag"]) assert args.cp is True assert args.cp_patterns == "double_bottom,bull_flag" args2 = _parse_args(["--pair", "XAUUSD"]) assert args2.cp is False @test("Backtester: --trail-arm/--trail-dist research knobs parse, default None") def _(): from gpu_backtest import _parse_args args = _parse_args(["--pair", "XAUUSD", "--trail-arm", "0.45", "--trail-dist", "0.75"]) assert args.trail_arm == 0.45 and args.trail_dist == 0.75 args2 = _parse_args(["--pair", "XAUUSD"]) assert args2.trail_arm is None and args2.trail_dist is None # ══════════════════════════════════════════════════════════════════════ # MODULE 13: app_config.py (config.json overlay) # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 13: app_config.py ═══") import tempfile from app_config import ( AppConfig, AppConfigError, load_app_config, set_app_config, telegram_token, telegram_chat_id, telegram_channel_id, provider_api_key, apply_pair_overrides, ) def _write_json(payload) -> str: fh = tempfile.NamedTemporaryFile( "w", suffix=".json", delete=False, encoding="utf-8") if isinstance(payload, str): fh.write(payload) else: json.dump(payload, fh) fh.close() return fh.name _FULL_CFG = { "telegram": {"token": "t0k", "chat_id": "111", "channel_id": "-100222", "alert_min_quality": "B"}, "data_providers": {"active": "twelve_data", "twelve_data": {"api_key": "json-key"}}, "scanning": {"pairs": ["XAU/USD"], "timeframes": ["M15", "H1"]}, "pairs": {"XAU/USD": {"blocked_hours_utc": [3, 4]}}, "log_level": "WARNING", } @test("app_config: valid config.json maps every field") def _(): app = load_app_config(_write_json(_FULL_CFG)) assert app.telegram_token == "t0k" assert app.telegram_chat_id == "111" assert app.telegram_channel_id == "-100222" assert app.alert_min_quality == "B" assert app.active_provider == "twelve_data" assert app.provider_settings["twelve_data"]["api_key"] == "json-key" assert app.scan_pairs == [XAUUSD] assert app.scan_timeframes == [Timeframe.M15, Timeframe.H1] assert app.pair_overrides[XAUUSD]["blocked_hours_utc"] == [3, 4] assert app.log_level == "WARNING" @test("app_config: missing file returns pure defaults") def _(): app = load_app_config("/nonexistent/config.json") assert app == AppConfig(), "absent config.json must yield pure defaults" @test("app_config: malformed JSON raises AppConfigError") def _(): path = _write_json("{not json") try: load_app_config(path) assert False, "should have raised" except AppConfigError as e: assert "not valid JSON" in str(e) @test("app_config: unknown keys raise AppConfigError") def _(): for payload in ( {"telgram": {}}, # top-level typo {"telegram": {"tokn": "x"}}, # section typo {"pairs": {"XAU/USD": {"blocked_hours": [1]}}}, # pair-block typo {"scanning": {"pair": ["XAU/USD"]}}, # scanning typo ): try: load_app_config(_write_json(payload)) assert False, f"should have raised for {payload}" except AppConfigError as e: assert "unknown key" in str(e).lower() @test("app_config: invalid values raise AppConfigError") def _(): bad_payloads = [ {"pairs": {"XAU/USD": {"blocked_hours_utc": [24]}}}, # hour range {"pairs": {"XAU/USD": {"blocked_hours_utc": "3"}}}, # not a list {"pairs": {"EUR/USD": {"blocked_hours_utc": [3]}}}, # unknown pair {"scanning": {"pairs": ["EUR/USD"]}}, # unknown pair {"scanning": {"timeframes": ["M7"]}}, # bad TF name {"scanning": {"timeframes": ["W1"]}}, # unscannable TF {"scanning": {"pairs": 5}}, # pairs not a list {"scanning": {"timeframes": 5}}, # timeframes not a list {"data_providers": {"active": "polygon", "twelve_data": {"api_key": "k"}}}, # no block {"telegram": {"alert_min_quality": "Z"}}, # bad quality {"log_level": "CHATTY"}, # bad level ] for payload in bad_payloads: try: load_app_config(_write_json(payload)) assert False, f"should have raised for {payload}" except AppConfigError: pass @test("app_config: env precedence env → config.json → default") def _(): app = load_app_config(_write_json(_FULL_CFG)) with patch.dict(os.environ, {}, clear=True): assert telegram_token(app) == "t0k" assert telegram_chat_id(app) == "111" assert telegram_channel_id(app) == "-100222" assert provider_api_key(app) == "json-key" with patch.dict(os.environ, {"TELEGRAM_TOKEN": "env-tok", "TELEGRAM_CHAT_ID": "999", "TWELVE_DATA_API_KEY": "env-key"}): assert telegram_token(app) == "env-tok" assert telegram_chat_id(app) == "999" assert provider_api_key(app) == "env-key" # channel: env set-but-empty means "disable feed" and must beat JSON with patch.dict(os.environ, {"TELEGRAM_CHANNEL_ID": ""}): assert telegram_channel_id(app) == "" @test("app_config: non-object sections raise AppConfigError") def _(): for payload in ( {"telegram": None}, {"telegram": "oops"}, {"scanning": 5}, {"pairs": ["oops"]}, {"pairs": {"XAU/USD": ["blocked_hours_utc"]}}, ): try: load_app_config(_write_json(payload)) assert False, f"should have raised for {payload}" except AppConfigError: pass @test("app_config: apply_pair_overrides stamps both blocked-hours mirrors") def _(): app = AppConfig(pair_overrides={XAUUSD: {"blocked_hours_utc": [3, 4]}}, scan_pairs=[XAUUSD], scan_timeframes=[Timeframe.M15]) cfg = apply_pair_overrides(PlatformConfig(), app) assert cfg.xau_strategy.blocked_hours_utc == [3, 4] assert cfg.backtest.blocked_hours_utc == [3, 4] assert cfg.scan_timeframes == [Timeframe.M15] # the two mirrors must be independent lists, not aliases cfg.xau_strategy.blocked_hours_utc.append(5) assert cfg.backtest.blocked_hours_utc == [3, 4] @test("app_config: M5 factory appends hour 9 on top of a JSON base") def _(): from pair_signals import PAIR_PRODUCERS app = AppConfig(pair_overrides={XAUUSD: {"blocked_hours_utc": [3, 4]}}, provider_settings={"twelve_data": {"api_key": "test-key"}}) set_app_config(app) try: cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.M5, None) finally: set_app_config(_TEST_APP) assert cfg.xau_strategy.blocked_hours_utc == [3, 4, 9], \ "validated M5 hour-9 delta must land on top of the JSON base" assert cfg.backtest.blocked_hours_utc == [3, 4], \ "mirror carries the base only (per-TF delta is XAU-strategy-side)" @test("app_config: config.json.example is valid and behavior-neutral") def _(): import dataclasses from pair_signals import PAIR_PRODUCERS example = load_app_config( os.path.join(os.path.dirname(__file__), "config.json.example")) for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4): baseline = PAIR_PRODUCERS[XAUUSD](tf, None) # under _TEST_APP set_app_config(example) try: with_example = PAIR_PRODUCERS[XAUUSD](tf, None) finally: set_app_config(_TEST_APP) assert dataclasses.asdict(baseline) == dataclasses.asdict(with_example), \ f"example config.json must be byte-identical to defaults on {tf}" @test("app_config: a legacy market_outlook block is tolerated and ignored") def _(): # The market-outlook feature was removed 2026-09-15; config.json files # on the live box still carry its block and must keep loading (the # oanda.account_id precedent), with one warning so the owner deletes it. import logging records = [] class _Grab(logging.Handler): def emit(self, record): records.append(record) log = logging.getLogger("app_config") handler = _Grab() log.addHandler(handler) path = _write_json({ "market_outlook": {"enabled": True, "telegram": True, "max_per_day": 12, "candles_bars": 20}, }) try: app = load_app_config(path) finally: log.removeHandler(handler) os.remove(path) assert not hasattr(app, "market_outlook_settings") assert any(r.levelno == logging.WARNING and "market_outlook" in r.getMessage() for r in records), \ "loading a legacy market_outlook block must log one warning" @test("providers: api key resolves env → config.json (no code default)") def _(): from providers import TwelveDataProvider app = AppConfig(provider_settings={"twelve_data": {"api_key": "json-key"}}) set_app_config(app) try: with patch.dict(os.environ, {}, clear=True): dp = TwelveDataProvider(PlatformConfig()) assert dp._api_key == "json-key" with patch.dict(os.environ, {"TWELVE_DATA_API_KEY": "env-key"}): dp = TwelveDataProvider(PlatformConfig()) assert dp._api_key == "env-key" set_app_config(AppConfig()) # no key anywhere with patch.dict(os.environ, {}, clear=True): dp = TwelveDataProvider(PlatformConfig()) assert dp._api_key is None and dp._td is None finally: set_app_config(_TEST_APP) @test("providers: make_data_provider dispatches on active provider") def _(): from providers import TwelveDataProvider, make_data_provider assert isinstance(make_data_provider(PlatformConfig()), TwelveDataProvider) set_app_config(AppConfig(active_provider="polygon", provider_settings={"polygon": {"api_key": "k"}})) try: make_data_provider(PlatformConfig()) assert False, "unregistered provider must raise" except AppConfigError as e: assert "polygon" in str(e) finally: set_app_config(_TEST_APP) @test("providers: make_data_provider explicit name overrides active") def _(): from providers import (TiingoDataProvider as _Tii, TwelveDataProvider, make_data_provider) # active is twelve_data under _TEST_APP; explicit name wins with patch.dict(os.environ, {"TIINGO_API_KEY": "k"}): assert isinstance(make_data_provider(PlatformConfig(), "tiingo"), _Tii) assert isinstance(make_data_provider(PlatformConfig()), TwelveDataProvider) try: make_data_provider(PlatformConfig(), "polygon") assert False, "unregistered explicit name must raise" except AppConfigError as e: assert "polygon" in str(e) @test("app_config: data_providers.streaming scalar selects the tick stream") def _(): app = load_app_config(_write_json({ "data_providers": {"active": "oanda", "streaming": "tiingo", "oanda": {}, "tiingo": {"api_key": "k"}}})) assert app.streaming_provider == "tiingo" assert set(app.provider_settings) == {"oanda", "tiingo"}, \ "reserved scalars must not become provider blocks" app = load_app_config(_write_json( {"data_providers": {"active": "oanda", "oanda": {}}})) assert app.streaming_provider is None @test("app_config: streaming provider must be a string with a settings block") def _(): for payload in ( {"data_providers": {"active": "oanda", "oanda": {}, "streaming": "polygon"}}, # no block {"data_providers": {"active": "oanda", "oanda": {}, "streaming": 5}}, # not a string ): try: load_app_config(_write_json(payload)) assert False, f"should have raised for {payload}" except AppConfigError: pass @test("app_config: deepseek block parses, validates, defaults empty") def _(): app = load_app_config(_write_json({"deepseek": { "api_key": "sk-x", "model": "deepseek-v4-flash", "enabled": False, "timeout_seconds": 5, "daily_request_cap": 100}})) assert app.deepseek_settings["api_key"] == "sk-x" assert app.deepseek_settings["enabled"] is False assert load_app_config(_write_json({})).deepseek_settings == {} for payload in ( {"deepseek": {"api_keyy": "typo"}}, # unknown key {"deepseek": {"enabled": "yes"}}, # not a bool {"deepseek": {"timeout_seconds": 0}}, # not positive {"deepseek": {"timeout_seconds": True}}, # bool is not a number {"deepseek": {"daily_request_cap": 2.5}}, # not an int {"deepseek": {"model": ""}}, # empty model {"deepseek": "oops"}, # not an object ): try: load_app_config(_write_json(payload)) assert False, f"should have raised for {payload}" except AppConfigError: pass @test("app_config: deepseek_api_key resolves env → config.json → empty") def _(): from app_config import deepseek_api_key app = load_app_config(_write_json({"deepseek": {"api_key": "json-ds"}})) with patch.dict(os.environ, {}, clear=True): assert deepseek_api_key(app) == "json-ds" with patch.dict(os.environ, {"DEEPSEEK_API_KEY": "env-ds"}): assert deepseek_api_key(app) == "env-ds" with patch.dict(os.environ, {}, clear=True): assert deepseek_api_key(AppConfig()) == "" @test("config.py: removed PlatformConfig fields stay gone (twelve_data_api_key, pip_value)") def _(): assert not hasattr(PlatformConfig(), "twelve_data_api_key"), \ "credentials must not live in committed source" # pip value comes from PAIR_INFO; the config field was never read (2026-09-06). assert not hasattr(PlatformConfig(), "pip_value") @test("telegram_bot: credentials, quality, pairs, loops from app_config") def _(): import importlib import telegram_bot app = AppConfig(telegram_token="json-tok", telegram_chat_id="42", telegram_channel_id="", alert_min_quality="B", scan_timeframes=[Timeframe.M15, Timeframe.H1], provider_settings={"twelve_data": {"api_key": "k"}}) set_app_config(app) try: with patch.dict(os.environ, {}, clear=True): tb = importlib.reload(telegram_bot) assert tb.TOKEN == "json-tok" assert tb.OWNER_ID == 42 assert tb.CHANNEL_ID is None, '"" channel id must disable the feed' assert tb._alert_min_quality == "B" assert [tf for tf, _ in tb._AUTOSCAN_LOOPS] == \ [Timeframe.M15, Timeframe.H1] assert tb._AUTOSCAN_LOOPS == [(Timeframe.M15, 15), (Timeframe.H1, 60)] finally: set_app_config(_TEST_APP) importlib.reload(telegram_bot) @test("telegram_bot: main() refuses to start without credentials") def _(): import telegram_bot if telegram_bot.TOKEN: # only meaningful in the hermetic no-cred env return try: telegram_bot.main() assert False, "main() must refuse to start without token/chat_id" except SystemExit as e: assert "credentials" in str(e) @test("app_config: absolute parity — default overlay leaves tuned values untouched") def _(): from pair_signals import PAIR_PRODUCERS cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.H1, None) assert cfg.xau_strategy.blocked_hours_utc == [0, 1, 2, 10, 11, 13, 16, 21] assert cfg.backtest.blocked_hours_utc == [0, 1, 2, 10, 11, 13, 16, 21] assert cfg.scan_pairs == [XAUUSD] assert cfg.scan_timeframes == [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4] # ══════════════════════════════════════════════════════════════════════ # MODULE 14: .env loader + telegram_bot resilience # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 14: .env loader + telegram_bot resilience ═══") from app_config import load_dotenv @test("app_config: load_dotenv parses lines, never overrides real env") def _(): fh = tempfile.NamedTemporaryFile( "w", suffix=".env", delete=False, encoding="utf-8") fh.write("# comment\n\nPLAIN=abc\nexport EXPORTED=def\n" 'QUOTED="with spaces"\nSINGLE=\'sq\'\nEXISTING=from-dotenv\n' "NOT A KV LINE\n" "COMMENTED=abc123 # prod key\n" 'QUOTED_HASH="keep #this"\n' "HASH_IN_VALUE=abc#123\n") fh.close() with patch.dict(os.environ, {"EXISTING": "from-shell"}, clear=True): load_dotenv(fh.name) assert os.environ["PLAIN"] == "abc" assert os.environ["EXPORTED"] == "def" assert os.environ["QUOTED"] == "with spaces" assert os.environ["SINGLE"] == "sq" assert os.environ["EXISTING"] == "from-shell", \ "a real shell export must beat .env" assert os.environ["COMMENTED"] == "abc123", \ "inline comment must be stripped, not absorbed into the value" assert os.environ["QUOTED_HASH"] == "keep #this", \ "quoted values keep their hashes" assert os.environ["HASH_IN_VALUE"] == "abc#123", \ "a hash with no preceding whitespace is part of the value" load_dotenv("/nonexistent/.env") # missing file is a silent no-op @test("telegram_bot: log rotation is daily with 30-day retention") def _(): import logging.handlers import telegram_bot as tb h = tb._file_handler assert isinstance(h, logging.handlers.TimedRotatingFileHandler) assert h.when.upper() == "MIDNIGHT", \ "one file per day (2026-09-08: was 3-hourly × 240)" assert h.interval == 86400, "plain daily — an interval=N would stretch retention" assert h.backupCount == 30, "30 daily files = 30-day retention" @test("telegram_bot: candle loop survives a failing scan cycle") def _(): import asyncio import telegram_bot as tb calls = {"n": 0} async def boom(bot, tf, confirm): calls["n"] += 1 if calls["n"] == 1: raise RuntimeError("scan blew up") tb._autoscan_chats.clear() # let the loop exit after call 2 tb._autoscan_chats.add(1) try: with patch.object(tb, "_scan_timeframe", boom), \ patch.object(tb, "_seconds_until_boundary", lambda interval, buffer=10: 0.0): asyncio.run(tb._candle_loop(None, Timeframe.M15, 15)) assert calls["n"] == 2, \ "loop must keep firing after an exception, not die on it" finally: tb._autoscan_chats.clear() @test("telegram_bot: _get_streaming_dp resolves streaming name, else shared") def _(): import telegram_bot as tb from providers import TiingoDataProvider as _Tii set_app_config(AppConfig( active_provider="twelve_data", streaming_provider="tiingo", provider_settings={"twelve_data": {"api_key": "k"}, "tiingo": {"api_key": "k"}})) tb._streaming_dp = None tb._shared_dp = None try: dp = tb._get_streaming_dp() assert isinstance(dp, _Tii) assert dp is not tb._get_shared_dp(), \ "streaming provider must be a separate instance" # unset → falls back to the shared active provider set_app_config(_TEST_APP) tb._streaming_dp = None tb._shared_dp = None assert tb._get_streaming_dp() is tb._get_shared_dp() finally: set_app_config(_TEST_APP) tb._streaming_dp = None tb._shared_dp = None @test("telegram_bot: ws monitor auto-disables on non-streaming or keyless") def _(): import asyncio import telegram_bot as tb class NoStream: name = "stub" supports_streaming = False class KeylessStream: name = "stub" supports_streaming = True _api_key = None try: for stub in (NoStream(), KeylessStream()): tb._streaming_dp = stub asyncio.run(tb._ws_monitor(None)) # must return, not connect finally: tb._streaming_dp = None # ══════════════════════════════════════════════════════════════════════ # MODULE 15: Oanda provider # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 15: Oanda provider ═══") import providers.oanda @test("providers.oanda: OandaDataProvider parses Oanda candle JSON into correct DataFrame") def _(): sample_candles = [ { "complete": True, "time": "2026-07-20T10:00:00.000000000Z", "mid": {"o": "2400.00", "h": "2405.00", "l": "2398.00", "c": "2403.00"}, "bid": {"o": "2399.85", "h": "2404.85", "l": "2397.85", "c": "2402.85"}, "ask": {"o": "2400.15", "h": "2405.15", "l": "2398.15", "c": "2403.15"}, "volume": 1234, }, { "complete": True, "time": "2026-07-20T11:00:00.000000000Z", "mid": {"o": "2403.00", "h": "2408.00", "l": "2401.00", "c": "2406.00"}, "bid": {"o": "2402.85", "h": "2407.85", "l": "2400.85", "c": "2405.85"}, "ask": {"o": "2403.15", "h": "2408.15", "l": "2401.15", "c": "2406.15"}, "volume": 5678, }, { "complete": False, "time": "2026-07-20T12:00:00.000000000Z", "mid": {"o": "2406.00", "h": "2406.50", "l": "2405.50", "c": "2406.20"}, "bid": {"o": "2405.85", "h": "2406.35", "l": "2405.35", "c": "2406.05"}, "ask": {"o": "2406.15", "h": "2406.65", "l": "2405.65", "c": "2406.35"}, "volume": 100, }, ] mock_resp = type("MockResp", (), { "status_code": 200, "json": lambda self: {"candles": sample_candles, "instrument": "XAU_USD"}, "raise_for_status": lambda self: None, })() provider2 = providers.oanda.OandaDataProvider.__new__( providers.oanda.OandaDataProvider) provider2.config = PlatformConfig() provider2._base_url = "https://test" provider2._cache = {} provider2._cache_timestamps = {} provider2._cache_outputsize = {} provider2._cache_lock = threading.Lock() provider2._inflight = {} provider2._spread_cache = {} provider2._session = type("S", (), { "get": lambda self, *a, **k: mock_resp, "headers": {}, })() df = provider2._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300) assert len(df) == 2, f"Expected 2 complete bars, got {len(df)}" assert list(df.columns) == ["Open", "High", "Low", "Close", "Volume"], \ f"Wrong columns: {list(df.columns)}" assert df.index.name == "Datetime" assert df.index.tz is not None, "Index must be tz-aware" assert df.iloc[0]["Open"] == 2400.0 assert df.iloc[1]["Close"] == 2406.0 assert df.iloc[0]["Volume"] == 1234 spread = provider2._spread_cache.get("XAU/USD") assert spread is not None, "Spread should be cached" assert abs(spread - 0.30) < 0.01, f"Expected spread ~0.30, got {spread}" def _make_oanda_provider(responses): """Bare OandaDataProvider whose session pops canned responses in order.""" provider = providers.oanda.OandaDataProvider.__new__( providers.oanda.OandaDataProvider) provider.config = PlatformConfig() provider._base_url = "https://test" provider._cache = {} provider._cache_timestamps = {} provider._cache_outputsize = {} provider._cache_lock = threading.Lock() provider._inflight = {} provider._spread_cache = {} queue = list(responses) provider._session = type("S", (), { "get": lambda self, *a, **k: queue.pop(0), "headers": {}, })() return provider def _oanda_resp(status_code, candles=None, text=""): return type("MockResp", (), { "status_code": status_code, "text": text, "json": lambda self: {"candles": candles or [], "instrument": "XAU_USD"}, })() _OANDA_OK_CANDLE = { "complete": True, "time": "2026-07-20T10:00:00.000000000Z", "mid": {"o": "2400.00", "h": "2405.00", "l": "2398.00", "c": "2403.00"}, "bid": {"o": "2399.85", "h": "2404.85", "l": "2397.85", "c": "2402.85"}, "ask": {"o": "2400.15", "h": "2405.15", "l": "2398.15", "c": "2403.15"}, "volume": 1234, } @test("providers.oanda: transient 401 is retried once and recovers") def _(): provider = _make_oanda_provider([ _oanda_resp(401, text='{"errorMessage":"Insufficient authorization"}'), _oanda_resp(200, candles=[_OANDA_OK_CANDLE]), ]) orig_sleep = providers.oanda.time.sleep slept = [] providers.oanda.time.sleep = lambda s: slept.append(s) try: df = provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300) finally: providers.oanda.time.sleep = orig_sleep assert len(df) == 1, f"Expected recovered fetch with 1 bar, got {len(df)}" assert slept == [providers.oanda.OandaDataProvider.RETRY_DELAY_S], \ f"Expected one retry sleep, got {slept}" @test("providers.oanda: persistent 401 walks the whole retry ladder then raises; 4xx never retried") def _(): orig_sleep = providers.oanda.time.sleep slept = [] providers.oanda.time.sleep = lambda s: slept.append(s) try: ladder = providers.oanda.OandaDataProvider.RETRY_DELAYS_S provider = _make_oanda_provider([ _oanda_resp(401, text='{"errorMessage":"Insufficient authorization"}') for _ in range(1 + len(ladder)) ]) try: provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300) assert False, "Persistent 401 should raise OandaDataError" except providers.oanda.OandaDataError as e: assert "401" in str(e) assert slept == list(ladder), \ f"every ladder delay must be slept before giving up, got {slept}" assert ladder == (2.0, 5.0, 10.0), \ "ladder is sized to outlast a 10-15 s top-of-hour provider blip" # Non-auth 4xx must fail immediately — only one response queued, # so a retry attempt would IndexError on the empty queue. provider = _make_oanda_provider([_oanda_resp(400, text="bad request")]) try: provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300) assert False, "HTTP 400 should raise OandaDataError" except providers.oanda.OandaDataError as e: assert "400" in str(e) finally: providers.oanda.time.sleep = orig_sleep @test("providers.oanda: a 5xx blip that clears on the third attempt recovers") def _(): # The 2026-09-07 signal-log audit: 11 boundary scans lost their # primary frame because one 2 s retry could not outlast a ~12 s # provider blip. Two failures then success must recover, having # slept exactly the first two ladder steps. orig_sleep = providers.oanda.time.sleep slept = [] providers.oanda.time.sleep = lambda s: slept.append(s) try: provider = _make_oanda_provider([ _oanda_resp(502, text="bad gateway"), _oanda_resp(503, text="unavailable"), _oanda_resp(200, candles=[_OANDA_OK_CANDLE]), ]) df = provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300) finally: providers.oanda.time.sleep = orig_sleep assert len(df) == 1, "third attempt must return the frame" assert slept == list( providers.oanda.OandaDataProvider.RETRY_DELAYS_S[:2]), slept @test("providers.oanda: retry budget stops the ladder before a delay would overrun it") def _(): # A scan fetches three frames; the whole ladder must stay bounded so # an M5 scan finishes inside its bar. With the clock jumped past the # budget after the first failure, no further attempt (and no sleep) # may happen — the queue holds only ONE response, so a second # attempt would IndexError. orig_sleep = providers.oanda.time.sleep orig_mono = providers.oanda.time.monotonic slept = [] ticks = iter([0.0, 10_000.0, 10_000.0, 10_000.0]) providers.oanda.time.sleep = lambda s: slept.append(s) providers.oanda.time.monotonic = lambda: next(ticks) try: provider = _make_oanda_provider([_oanda_resp(503, text="unavailable")]) try: provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300) assert False, "budget exhaustion must raise the last error" except providers.oanda.OandaDataError as e: assert "503" in str(e) finally: providers.oanda.time.sleep = orig_sleep providers.oanda.time.monotonic = orig_mono assert slept == [], "no sleep once the budget cannot fit the next delay" @test("providers.oanda: instrument name mapping") def _(): provider = providers.oanda.OandaDataProvider.__new__( providers.oanda.OandaDataProvider) assert provider._to_oanda_instrument("XAU/USD") == "XAU_USD" try: provider._to_oanda_instrument("EUR/USD") assert False, "unregistered symbol must raise, not be guessed" except DataIntegrityError: pass @test("providers.oanda: granularity mapping covers all scan timeframes") def _(): for tf in [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]: assert tf in providers.oanda.OandaDataProvider.OANDA_GRANULARITY, \ f"{tf} missing from OANDA_GRANULARITY" @test("providers.oanda: get_spread returns default when no cache") def _(): provider = providers.oanda.OandaDataProvider.__new__( providers.oanda.OandaDataProvider) provider._spread_cache = {} spread = provider.get_spread("XAU/USD") assert spread == 0.30, f"Expected default spread 0.30, got {spread}" @test("app_config: unknown oanda key raises AppConfigError") def _(): import tempfile bad = {"oanda": {"unknown_key": 1}} with tempfile.NamedTemporaryFile( mode="w", suffix=".json", delete=False) as f: json.dump(bad, f) tmp_path = f.name try: app_config.load_app_config(tmp_path) assert False, f"Should have raised for {bad}" except app_config.AppConfigError: pass finally: os.unlink(tmp_path) @test("app_config: oanda in _KNOWN_TOP (no unknown-key error)") def _(): assert "oanda" in app_config._KNOWN_TOP, \ "'oanda' must be in _KNOWN_TOP to avoid config.json validation errors" @test("app_config: oanda resolver helpers return defaults without config") def _(): url = app_config.oanda_base_url() assert "oanda.com" in url, f"Expected Oanda URL, got {url}" @test("providers.oanda: HTTP error surfaces status code and body") def _(): mock_resp = type("MockResp", (), { "status_code": 401, "text": '{"errorMessage":"Insufficient authorization to perform request"}', })() provider = providers.oanda.OandaDataProvider.__new__( providers.oanda.OandaDataProvider) provider._base_url = "https://test" provider._spread_cache = {} provider._session = type("S", (), { "get": lambda self, *a, **k: mock_resp, "headers": {}, })() try: provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300) assert False, "Should have raised OandaDataError" except providers.oanda.OandaDataError as e: msg = str(e) assert "401" in msg, f"Status code missing from error: {msg}" assert "Insufficient authorization" in msg, \ f"Body detail missing from error: {msg}" @test("providers.oanda: boundary-retry failure keeps original data") def _(): provider = providers.oanda.OandaDataProvider.__new__( providers.oanda.OandaDataProvider) provider.config = PlatformConfig() provider._cache = {} provider._cache_timestamps = {} provider._cache_outputsize = {} provider._cache_lock = threading.Lock() provider._inflight = {} provider._spread_cache = {} provider.FRESH_FETCH_RETRY_SECONDS = 0 # instance override — no sleep call_count = {"n": 0} idx = pd.date_range("2026-07-01", periods=250, freq="5min", tz="UTC") original_df = pd.DataFrame({ "Open": 2400.0, "High": 2401.0, "Low": 2399.0, "Close": 2400.5, "Volume": 100.0, }, index=idx) def _fetch_oanda_candles(symbol, timeframe, count): call_count["n"] += 1 if call_count["n"] == 1: return original_df raise providers.oanda.OandaDataError("boom") def _validate_and_clean(df, symbol, timeframe): return df def _has_latest_closed_bar(df, timeframe): return False # forces the retry path every call def _is_data_fresh(df, symbol, timeframe): return True # Plain functions assigned to the instance bypass the descriptor # protocol — they run unbound, so they take exactly the params # fetch_ohlcv passes (no self). provider._fetch_oanda_candles = _fetch_oanda_candles provider._validate_and_clean = _validate_and_clean provider._has_latest_closed_bar = _has_latest_closed_bar provider._is_data_fresh = _is_data_fresh result = provider.fetch_ohlcv("XAU/USD", Timeframe.M5) assert call_count["n"] == 2, \ f"Expected original fetch + one retry attempt, got {call_count['n']} calls" assert len(result) == 250, \ f"Retry failure must keep the original 250-bar df, got {len(result)}" # ══════════════════════════════════════════════════════════════════════ # MODULE 16: Provider abstraction (providers/ package) # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 16: Provider abstraction ═══") from providers import ( BaseDataProvider, PROVIDER_REGISTRY, TwelveDataProvider, make_data_provider, ) from providers.oanda import OandaDataProvider as _POanda from providers.tiingo import TiingoDataError, TiingoDataProvider @test("providers: registry holds all providers, active default builds TD") def _(): assert PROVIDER_REGISTRY["twelve_data"] is TwelveDataProvider assert PROVIDER_REGISTRY["oanda"] is _POanda assert PROVIDER_REGISTRY["tiingo"] is TiingoDataProvider dp = make_data_provider(PlatformConfig()) assert isinstance(dp, TwelveDataProvider) assert isinstance(dp, BaseDataProvider) @test("providers: single knob — data_providers.active selects the class") def _(): from app_config import AppConfig for name, cls in PROVIDER_REGISTRY.items(): fake = AppConfig() fake.active_provider = name fake.provider_settings = {name: {"api_key": ""}} with patch("providers.get_app_config", return_value=fake): dp = make_data_provider(PlatformConfig()) assert type(dp) is cls, f"active={name} built {type(dp).__name__}" # An unknown name must fail loudly, not fall back silently. from app_config import AppConfigError fake = AppConfig() fake.active_provider = "nonsense" with patch("providers.get_app_config", return_value=fake): try: make_data_provider(PlatformConfig()) assert False, "unknown provider must raise" except AppConfigError as e: assert "nonsense" in str(e) @test("providers: shared behavior is defined ONCE on the base class") def _(): # The pre-refactor bug this guards against: Oanda's provider was a # copy-paste of the TD one, so shared-logic fixes had to be made # twice. Every shared method must now BE the base implementation. for meth in ("fetch_ohlcv", "fetch_range", "_validate_and_clean", "_is_data_fresh", "_has_latest_closed_bar", "_drop_forming_candle", "clear_cache", "get_pair_info"): for cls in PROVIDER_REGISTRY.values(): assert getattr(cls, meth) is getattr(BaseDataProvider, meth), \ f"{cls.__name__} overrides shared method {meth}" assert TwelveDataProvider.CANDLE_SECONDS is BaseDataProvider.CANDLE_SECONDS assert _POanda.BARS_PER_DAY is BaseDataProvider.BARS_PER_DAY assert TiingoDataProvider.CANDLE_SECONDS is BaseDataProvider.CANDLE_SECONDS @test("providers: live fetch depth floors every TF at MIN_LIVE_BARS (H4/D1 seeding parity)") def _(): import providers.base as pbase class _Depth(pbase.BaseDataProvider): PROVIDER = "depth16" def _fetch_raw(self, symbol, timeframe, outputsize, start=None, end=None): raise RuntimeError("must not fetch") def get_pair_info(self, symbol): return PAIR_INFO[XAUUSD] p = _Depth(PlatformConfig()) # data_lookback_days = 60 assert pbase.BaseDataProvider.MIN_LIVE_BARS == 1000 depth = {tf: p._live_outputsize(tf, 60) for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4, Timeframe.D1)} # unchanged where the lookback already exceeds the floor ... assert depth[Timeframe.M5] == 5000 and depth[Timeframe.M15] == 5000, depth assert depth[Timeframe.H1] == 1460, depth # ... raised where it did not (were 380 and 300 bars) assert depth[Timeframe.H4] == 1000 and depth[Timeframe.D1] == 1000, depth assert all(v <= pbase.BaseDataProvider.MAX_BARS_PER_REQUEST for v in depth.values()) assert p._live_outputsize(Timeframe.M5, 1) == 1000, "floor applies to short lookbacks" # the seed bar's weight in the 200-bar trend EMA at the floor assert (1 - 2 / 201) ** pbase.BaseDataProvider.MIN_LIVE_BARS < 1e-4 @test("providers: fetch_range paginates, dedupes, and trims to the window") def _(): class FakeProvider(BaseDataProvider): name = "fake" def __init__(self, config): super().__init__(config) idx = pd.date_range("2026-06-01", "2026-06-30 23:00", freq="1h", tz="UTC") v = np.linspace(3000.0, 3100.0, len(idx)) self.series = pd.DataFrame( {"Open": v, "High": v + 1, "Low": v - 1, "Close": v, "Volume": 100.0}, index=idx) self.series.index.name = "Datetime" self.calls = 0 def _fetch_raw(self, symbol, timeframe, outputsize, start=None, end=None): self.calls += 1 df = self.series if start is not None: df = df[df.index >= pd.Timestamp(start)] if end is not None: df = df[df.index <= pd.Timestamp(end)] # Overlap by a bar to prove concat dedupes. return df.tail(min(len(df), 200)) # Keep the test date-stable: nothing here is "forming". def _drop_forming_candle(self, df, timeframe): return df fp = FakeProvider(PlatformConfig()) start = datetime(2026, 6, 5, tzinfo=timezone.utc) end = datetime(2026, 6, 25, tzinfo=timezone.utc) df = fp.fetch_range("XAU/USD", Timeframe.H1, start, end) assert fp.calls > 1, "a 20-day H1 window must take multiple chunks" assert df.index.is_monotonic_increasing assert not df.index.duplicated().any() assert df.index[0] >= pd.Timestamp(start) assert df.index[-1] <= pd.Timestamp(end) expected = fp.series[(fp.series.index >= pd.Timestamp(start)) & (fp.series.index <= pd.Timestamp(end))] assert len(df) == len(expected), \ f"window must be gapless: {len(df)} vs {len(expected)}" @test("providers: streaming capability flags and base refusal") def _(): assert TwelveDataProvider.supports_streaming is True assert _POanda.supports_streaming is False assert TiingoDataProvider.supports_streaming is True class Minimal(BaseDataProvider): name = "minimal" def _fetch_raw(self, symbol, timeframe, outputsize, start=None, end=None): raise NotImplementedError m = Minimal(PlatformConfig()) assert m.supports_streaming is False try: m.iter_ticks(["XAU/USD"]) assert False, "base iter_ticks must refuse" except NotImplementedError: pass @test("providers: TD WS price-message parsing") def _(): parse = TwelveDataProvider._parse_ws_price assert parse('{"event":"price","symbol":"XAU/USD","price":"3345.5"}') \ == ("XAU/USD", 3345.5) assert parse('{"event":"subscribe-status","fails":[]}') is None assert parse('{"event":"heartbeat"}') is None assert parse('not json{{') is None assert parse('{"event":"price","symbol":"XAU/USD"}') is None # no price assert parse('{"event":"price","symbol":"XAU/USD","price":"abc"}') is None @test("providers: Tiingo WS quote-message parsing") def _(): parse = TiingoDataProvider._parse_ws_message # Field order matches a live-captured frame (2026-08-03): # ["Q", ticker, ts, bidSize, bid, mid, askSize, ask] quote = ('{"service":"fx","messageType":"A","data":' '["Q","xauusd","2026-08-03T17:09:46.084000+00:00",' '1000000.0,4035.05,4035.1,1000000.0,4035.15]}') assert parse(quote) == ("xauusd", 4035.1) # mid null → (bid+ask)/2, ask read from index 7 (index 6 is askSize) mid_null = ('{"messageType":"A","service":"fx","data":' '["Q","xauusd","2026-08-03T10:00:00+00:00",' '1000000,3344.0,null,1000000,3346.0]}') assert parse(mid_null) == ("xauusd", 3345.0) # mid and bid null → unusable, skipped all_null = ('{"messageType":"A","service":"fx","data":' '["Q","xauusd","2026-08-03T10:00:00+00:00",' '1000000,null,null,1000000,3346.0]}') assert parse(all_null) is None assert parse('{"messageType":"H"}') is None # heartbeat assert parse('{"messageType":"I","response":{"code":200}}') is None assert parse('not json{{') is None assert parse('{"messageType":"A","data":["X","xauusd"]}') is None # server error (e.g. bad token) must RAISE so the caller's backoff # applies — Tiingo auths inside the socket, not at the HTTP upgrade try: parse('{"messageType":"E","response":' '{"code":401,"message":"bad token"}}') assert False, "server error message must raise" except TiingoDataError: pass @test("providers: Tiingo WS ticker mapping round-trips the scanned pairs") def _(): tp = TiingoDataProvider.__new__(TiingoDataProvider) for pair in PAIR_INFO: ticker = tp._to_tiingo_ticker(pair) assert ticker.islower() and "/" not in ticker inverse = {tp._to_tiingo_ticker(s): s for s in PAIR_INFO} assert inverse[ticker] == pair @test("providers: Oanda date-window fetch omits count and sends from/to") def _(): captured = {} def mock_get(self, url, params=None, timeout=None): captured["params"] = params return type("R", (), { "status_code": 200, "json": lambda self: {"candles": [{ "complete": True, "time": "2026-06-02T10:00:00.000000000Z", "mid": {"o": "3000", "h": "3001", "l": "2999", "c": "3000.5"}, "bid": {"c": "3000.2"}, "ask": {"c": "3000.8"}, "volume": 10, }]}, })() provider = _POanda.__new__(_POanda) provider.config = PlatformConfig() provider._base_url = "https://test" provider._spread_cache = {} provider._session = type("S", (), {"get": mock_get})() start = datetime(2026, 6, 1, tzinfo=timezone.utc) end = datetime(2026, 6, 3, tzinfo=timezone.utc) df = provider._fetch_raw("XAU/USD", Timeframe.H1, 5000, start, end) assert "count" not in captured["params"], \ "v20 rejects count combined with from/to" assert captured["params"]["from"].startswith("2026-06-01") assert captured["params"]["to"].startswith("2026-06-03") assert len(df) == 1 # Live path (no window) keeps sending count only. provider._fetch_raw("XAU/USD", Timeframe.H1, 300) assert captured["params"]["count"] == 300 assert "from" not in captured["params"] def _tiingo_provider(response): """TiingoDataProvider with a canned session response (no network).""" provider = TiingoDataProvider.__new__(TiingoDataProvider) provider.config = PlatformConfig() provider._api_key = "test-key" provider._spread_cache = {} captured = {} def mock_get(self, url, params=None, timeout=None): captured["url"] = url captured["params"] = params return response provider._session = type("S", (), {"get": mock_get})() return provider, captured def _tiingo_resp(status_code, payload): return type("R", (), { "status_code": status_code, "json": lambda self: payload, "text": json.dumps(payload) if not isinstance(payload, str) else payload, })() @test("providers: Tiingo parses FX JSON into the normalized frame") def _(): payload = [ {"date": "2026-07-20T11:00:00.000Z", "ticker": "xauusd", "open": 2403.0, "high": 2408.0, "low": 2401.0, "close": 2406.0}, # Deliberately out of order — provider must sort oldest-first. {"date": "2026-07-20T10:00:00.000Z", "ticker": "xauusd", "open": 2400.0, "high": 2405.0, "low": 2398.0, "close": 2403.0}, ] provider, captured = _tiingo_provider(_tiingo_resp(200, payload)) df = provider._fetch_raw("XAU/USD", Timeframe.H1, 300) assert list(df.columns) == ["Open", "High", "Low", "Close", "Volume"] assert df.index.name == "Datetime" assert df.index.tz is not None, "Index must be tz-aware UTC" assert df.index.is_monotonic_increasing, "must be oldest-first" assert df.iloc[0]["Open"] == 2400.0 and df.iloc[1]["Close"] == 2406.0 assert (df["Volume"] == 0.0).all(), "FX endpoint has no volume" assert captured["url"].endswith("/tiingo/fx/xauusd/prices") assert captured["params"]["resampleFreq"] == "1hour" assert "startDate" in captured["params"], \ "live fetch must derive a start window from the bar count" assert "token" not in captured["params"], \ "key must travel in the auth header, never the URL" @test("providers: Tiingo date-window fetch sends startDate AND endDate") def _(): payload = [{"date": "2026-06-02T10:00:00.000Z", "open": 1, "high": 2, "low": 0, "close": 1}] provider, captured = _tiingo_provider(_tiingo_resp(200, payload)) start = datetime(2026, 6, 1, tzinfo=timezone.utc) end = datetime(2026, 6, 3, tzinfo=timezone.utc) provider._fetch_raw("XAU/USD", Timeframe.H1, 5000, start, end) assert captured["params"]["startDate"].startswith("2026-06-01") assert captured["params"]["endDate"].startswith("2026-06-03") @test("providers: Tiingo fails LOUDLY on every bad-response shape") def _(): cases = [ (_tiingo_resp(429, "quota"), "rate limit"), (_tiingo_resp(401, "denied"), "auth failed"), (_tiingo_resp(500, "boom"), "HTTP 500"), (_tiingo_resp(200, []), "Empty candle response"), (_tiingo_resp(200, [{"open": 1}]), "missing 'date'"), (_tiingo_resp(200, [{"date": "2026-07-20T10:00:00Z", "close": "n/a"}]), "no numeric closes"), ] for resp, needle in cases: provider, _cap = _tiingo_provider(resp) try: provider._fetch_raw("XAU/USD", Timeframe.H1, 300) assert False, f"must raise for: {needle}" except TiingoDataError as e: assert needle.lower() in str(e).lower(), \ f"error must name the cause ({needle}): {e}" # Missing key and unsupported timeframe are named errors too. provider, _cap = _tiingo_provider(_tiingo_resp(200, [])) provider._api_key = None try: provider._fetch_raw("XAU/USD", Timeframe.H1, 300) assert False, "missing key must raise" except TiingoDataError as e: assert "TIINGO_API_KEY" in str(e) provider._api_key = "k" try: provider._fetch_raw("XAU/USD", Timeframe.W1, 300) assert False, "unsupported TF must raise" except TiingoDataError as e: assert "Unsupported timeframe" in str(e) # And everything above is catchable as the shared base error. assert issubclass(TiingoDataError, DataIntegrityError) @test("providers: per-provider env keys (TIINGO_API_KEY vs TWELVE_DATA_API_KEY)") def _(): from app_config import AppConfig, provider_api_key app = AppConfig() app.active_provider = "tiingo" app.provider_settings = {"tiingo": {"api_key": "json-tiingo"}, "twelve_data": {"api_key": "json-td"}} with patch.dict(os.environ, {"TIINGO_API_KEY": "env-tiingo", "TWELVE_DATA_API_KEY": "env-td"}): assert provider_api_key(app) == "env-tiingo" assert provider_api_key(app, provider="twelve_data") == "env-td" with patch.dict(os.environ, {}, clear=False): os.environ.pop("TIINGO_API_KEY", None) assert provider_api_key(app) == "json-tiingo" # The TD env var must never leak into another provider's key. with patch.dict(os.environ, {"TWELVE_DATA_API_KEY": "env-td"}): assert provider_api_key(app, provider="tiingo") == "json-tiingo" @test("providers: aggregator bar-close clock uses the base-class table") def _(): # signal_aggregator computes bar-CLOSE hours (session filter, SELL # hour gate) from BaseDataProvider.CANDLE_SECONDS — a provider swap # must never change the session clock. import signal_aggregator as sa assert sa.BaseDataProvider.CANDLE_SECONDS[Timeframe.M15] == 900 assert not hasattr(sa, "TwelveDataProvider"), \ "aggregator must not depend on a concrete provider class" @test("providers: backtest parquet cache is keyed per provider") def _(): # Bars differ between feeds (Oanda/TD divergence study 2026-07-24) — # a provider switch must never serve another feed's cached bars. # twelve_data keeps the legacy un-prefixed name so pinned-book caches # stay valid. from datetime import datetime from backtest.gpu_backtest import _cache_path s, e = datetime(2026, 1, 1), datetime(2026, 2, 1) td = _cache_path(XAUUSD, Timeframe.M15, s, e, "twelve_data") oa = _cache_path(XAUUSD, Timeframe.M15, s, e, "oanda") legacy = _cache_path(XAUUSD, Timeframe.M15, s, e) assert td.name == "XAU_USD_15min_20260101_20260201.parquet", td.name assert oa.name == "oanda_XAU_USD_15min_20260101_20260201.parquet", oa.name assert legacy == td, "default must stay the legacy twelve_data key" assert td != oa, "providers must not share cache files" # ══════════════════════════════════════════════════════════════════════ # MODULE 17: Big-candle entry gate (gate 6h) # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 17: big-candle entry gate ═══") def _inject_big_bar(df, pos=-250, mult=8.0): """Copy df and widen one bar to ~mult x the median range (a burst candle) at position `pos` — far enough back not to disturb the near-window indicators (RSI/BB/ATR 14-20 bars, swing S/R 120).""" out = df.copy() atr_est = (out["High"] - out["Low"]).median() ts = out.index[pos] out.loc[ts, "High"] = out.loc[ts, "Open"] + mult * atr_est out.loc[ts, "Low"] = out.loc[ts, "Open"] - 0.5 * atr_est out.loc[ts, "Close"] = out.loc[ts, "Open"] + mult * 0.9 * atr_est return out def _bc_agg(k=3.0, tf=Timeframe.M5, confirms=(Timeframe.M15, Timeframe.H1)): from pair_signals.xauusd import make_config as xau_make_config cfg = xau_make_config(tf, list(confirms)) cfg.xau_strategy.big_candle_atr_multiple = k return SignalAggregator(cfg) # Deterministic ACTIONABLE fixture: grade A BUY with a GENERIC setup # through the factory M5 config at an unblocked hour (Wed 14:55 UTC), # huge bar pre-injected 250 bars back (verified by seed search). def _bc_actionable_dfs(): end = "2026-07-08 14:55" primary = _inject_big_bar(make_xau_bars(end=end, drift=0.8, seed=0)) m15 = make_xau_bars(end="2026-07-08 14:45", freq="15min", drift=0.8, seed=100) h1 = make_xau_bars(end="2026-07-08 14:00", freq="60min", drift=0.8, seed=200) return {Timeframe.M5: primary, Timeframe.M15: m15, Timeframe.H1: h1} @test("BC detect: quiet fixture → None; injected 8x bar → (its ts, ratio ≥ 3)") def _(): agg = _bc_agg(k=3.0) quiet = make_xau_bars(end="2026-07-08 14:55") assert agg._detect_big_candle(quiet) is None, \ "quiet bars must not trip the detector" spiked = _inject_big_bar(quiet, pos=-250) hit = agg._detect_big_candle(spiked) assert hit is not None, "injected burst bar not detected" ts, ratio = hit assert ts == spiked.index[-250], f"wrong offender bar: {ts}" assert ratio >= 3.0, f"ratio {ratio} below threshold" @test("BC detect: burst bar as LAST row still trips (prior-ATR shift)") def _(): # The huge bar inflates the concurrent Wilder ATR; the shift(1) # denominator keeps the very newest burst detectable. agg = _bc_agg(k=3.0) spiked = _inject_big_bar(make_xau_bars(end="2026-07-08 14:55"), pos=-1) hit = agg._detect_big_candle(spiked) assert hit is not None and hit[0] == spiked.index[-1], f"last-row burst missed: {hit}" @test("BC detect: bar older than the lookback window ignored; short df safe") def _(): agg = _bc_agg(k=3.0) # M5 window = 288 bars (+42 ewm warm-up tail). A burst at -350 is # outside even the warm-up slice of a 400-bar df. old_spike = _inject_big_bar(make_xau_bars(n=400, end="2026-07-08 14:55"), pos=-350) assert agg._detect_big_candle(old_spike) is None, \ "bar outside the lookback window must not block" # Short history: no raise, graceful no-trip on quiet bars. assert agg._detect_big_candle(make_xau_bars(n=60, end="2026-07-08 14:55")) is None @test("BC gate 6h: actionable A-grade → warning + D + setup killed") def _(): dfs = _bc_actionable_dfs() control = _bc_agg(k=0.0).analyze_from_dataframes(XAUUSD, dfs, use_news=False) assert control.signal_quality == "A" and control.trade_setup is not None, \ f"fixture drifted: expected actionable A, got {control.signal_quality}" assert not any("BIG CANDLE" in w for w in control.warnings), \ "disabled gate must stay silent" gated = _bc_agg(k=3.0).analyze_from_dataframes(XAUUSD, dfs, use_news=False) assert any(w.startswith("BIG CANDLE FILTER") for w in gated.warnings), \ f"gate did not fire: {gated.warnings}" assert gated.signal_quality == "D", f"expected D, got {gated.signal_quality}" assert gated.trade_setup is None, "setup must be killed by the gate" @test("BC gate 6h: session-blocked F stays F (warning only, no F→D)") def _(): end = "2026-07-08 16:55" # blocked hour → gate 6b sets F dfs = { Timeframe.M5: _inject_big_bar(make_xau_bars(end=end)), Timeframe.M15: make_xau_bars(end="2026-07-08 16:45", freq="15min"), Timeframe.H1: make_xau_bars(end="2026-07-08 16:00", freq="60min"), } report = _bc_agg(k=3.0).analyze_from_dataframes(XAUUSD, dfs, use_news=False) assert any("SESSION FILTER" in w for w in report.warnings) assert any(w.startswith("BIG CANDLE FILTER") for w in report.warnings), \ "detection must run regardless of grade (PB/CP suppression channel)" assert report.signal_quality == "F", \ f"blocked bar must stay F, got {report.signal_quality}" @test("BC gate 6h: XAU_PB and XAU_CP entry paths suppressed") def _(): # The D-downgrade alone would NOT stop them: PB accepts D (check is # != 'F') and CP even F — the warning is the suppression channel. dfs = _bc_actionable_dfs() def run(k): agg = _bc_agg(k=k) agg.config.xau_strategy.cp_enabled = True # Force-enable PB: this test exercises gate mechanics, not the # tuned M5 default (PB off since 2026-08-04, xauusd.py docstring). agg.config.xau_strategy.m5_pullback_enabled = True calls = [] agg._try_m5_pullback = lambda *a, **kw: calls.append("pb") agg._try_chart_pattern = lambda *a, **kw: calls.append("cp") # Keep the setup slot empty so steps 9/10 are actually reached. agg._generate_trade_setup = lambda *a, **kw: None agg._generate_xau_atr_trade_setup = lambda *a, **kw: None agg.analyze_from_dataframes(XAUUSD, dfs, use_news=False) return calls assert run(k=0.0) == ["pb", "cp"], "control must reach both entry paths" assert run(k=3.0) == [], "tripped gate must suppress both entry paths" @test("BC gate: disabled by default in every factory TF config") def _(): from pair_signals.xauusd import make_config as xau_make_config for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4): cfg = xau_make_config(tf) assert cfg.xau_strategy.big_candle_atr_multiple == 0.0, \ f"{tf}: gate must ship default-OFF (validate-first rollout)" assert cfg.xau_strategy.big_candle_lookback_hours == 24.0 @test("BC backtester: --big-candle / --big-candle-hours parse, default None") def _(): from gpu_backtest import _parse_args args = _parse_args(["--pair", "XAUUSD", "--big-candle", "3.0", "--big-candle-hours", "12"]) assert args.big_candle == 3.0 and args.big_candle_hours == 12.0 args2 = _parse_args(["--pair", "XAUUSD"]) assert args2.big_candle is None and args2.big_candle_hours is None # ══════════════════════════════════════════════════════════════════════ # MODULE 18: --set research override # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 18: --set override ═══") @test("--set: nested float/bool/JSON-list overrides apply with typing") def _(): from gpu_backtest import _apply_config_overrides cfg = PlatformConfig() _apply_config_overrides(cfg, [ "xau_strategy.m5_min_abs_score=25", "xau_strategy.m5_require_mtf=false", 'xau_strategy.sell_blocked_hours_utc=[15,17]', "signals.min_tp_cost_multiple=20.5", ]) assert cfg.xau_strategy.m5_min_abs_score == 25 assert cfg.xau_strategy.m5_require_mtf is False assert cfg.xau_strategy.sell_blocked_hours_utc == [15, 17] assert cfg.signals.min_tp_cost_multiple == 20.5 @test("--set: unknown path / section leaf / missing '=' all fail loudly") def _(): # A typo'd path MUST NOT become a silent no-op run: dataclass # instances happily accept setattr of any name. from gpu_backtest import _apply_config_overrides cfg = PlatformConfig() for bad in ("xau_strategy.m5_min_abs_scroe=25", # typo leaf "xau_stragety.m5_min_abs_score=25", # typo section "xau_strategy=5", # section, not leaf "no_equals_sign"): try: _apply_config_overrides(cfg, [bad]) except SystemExit: pass else: raise AssertionError(f"'{bad}' must raise SystemExit") @test("--set: bare-string fallback for non-JSON values") def _(): from gpu_backtest import _apply_config_overrides cfg = PlatformConfig() _apply_config_overrides(cfg, ["technical.rsi_mode=classic"]) assert cfg.technical.rsi_mode == "classic" @test("--set: CLI flag parses as repeatable list, default empty") def _(): from gpu_backtest import _parse_args args = _parse_args(["--pair", "XAUUSD", "--set", "xau_strategy.m5_min_abs_score=25", "--set", "signals.tp_floor_rr=1.0"]) assert args.set == ["xau_strategy.m5_min_abs_score=25", "signals.tp_floor_rr=1.0"] assert _parse_args(["--pair", "XAUUSD"]).set == [] # ══════════════════════════════════════════════════════════════════════ # MODULE 19: DeepSeek news enrichment (llm_client + news_engine seams) # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 19: DeepSeek news enrichment ═══") import datetime as _dt_m19 import logging import requests import llm_client as lc from llm_client import DeepSeekClient from news_engine import (EconomicEvent, FundamentalContext, NewsEngine, NewsSentiment) def _stub_resp(payload, status=200): return type("R", (), { "status_code": status, "text": json.dumps(payload), "json": lambda self: payload, })() def _chat_body(content_obj): return {"choices": [{"message": {"content": json.dumps(content_obj)}}]} def _counting_session(payload, status=200): class S: calls = 0 def post(self, url, json=None, timeout=None): S.calls += 1 return _stub_resp(payload, status) return S() @test("llm_client: response parsing and availability gating") def _(): parse = DeepSeekClient._parse_response assert parse(_chat_body({"a": 1})) == {"a": 1} assert parse({"choices": []}) is None assert parse({"error": "x"}) is None assert parse({"choices": [{"message": {"content": "not json"}}]}) is None assert parse({"choices": [{"message": {"content": "[1,2]"}}]}) is None # keyless / disabled clients never touch the network for client in (DeepSeekClient(api_key=""), DeepSeekClient(api_key="k", enabled=False)): assert client.available is False s = _counting_session(_chat_body({"a": 1})) client._session = s assert client.chat_json("sys", {"q": 1}) is None assert type(s).calls == 0 @test("llm_client: daily cap blocks requests and resets on UTC date roll") def _(): client = DeepSeekClient(api_key="k", daily_cap=2) s = _counting_session(_chat_body({"ok": True})) client._session = s assert client.chat_json("sys", {}) == {"ok": True} assert client.chat_json("sys", {}) == {"ok": True} assert client.chat_json("sys", {}) is None, "third call must hit the cap" assert type(s).calls == 2, "capped call must make no HTTP request" # UTC date roll resets the counter client._count_day = (_dt_m19.datetime.now(_dt_m19.timezone.utc).date() - _dt_m19.timedelta(days=1)) assert client.chat_json("sys", {}) == {"ok": True} assert type(s).calls == 3 @test("llm_client: HTTP error and malformed body return None") def _(): client = DeepSeekClient(api_key="k") client._session = _counting_session({"detail": "quota"}, status=429) assert client.chat_json("sys", {}) is None client._session = _counting_session({"unexpected": "shape"}) assert client.chat_json("sys", {}) is None class _StubLLM: """Drop-in for the llm_client singleton.""" def __init__(self, reply): self.reply = reply self.calls = 0 self.last_payload = None available = True def chat_json(self, system_prompt, user_payload): self.calls += 1 self.last_payload = user_payload return self.reply def _m19_pubdate(hours_ago=0.5): """RFC-2822 pubDate bytes `hours_ago` in the past (RSS fixtures must be dated since 2026-09-04: undated items are dropped).""" from email.utils import format_datetime when = (_dt_m19.datetime.now(_dt_m19.timezone.utc) - _dt_m19.timedelta(hours=hours_ago)) return format_datetime(when).encode() @test("NewsEngine: RSS age window is 4 h; older and undated items are dropped") def _(): assert NewsConfig().headline_max_age_seconds == 14400, "4 h (2026-09-04)" cfg = NewsConfig() cfg.rss_feeds = ["https://feed.example/rss"] ne = NewsEngine(cfg) saved = NewsEngine._source_health NewsEngine._source_health = {} rss = (b"" b"Gold surges as Fed signals rate cuts" b"" + _m19_pubdate(3) + b"" b"Gold slips on strong dollar data" b"" + _m19_pubdate(5) + b"" b"Dollar steady before Fed minutes" b"") class _Sess: def get(self, url, timeout=None): return type("R", (), {"status_code": 200, "content": rss})() ne._session = _Sess() try: heads = ne._fetch_news_sentiment_raw() assert [h.headline for h in heads] == \ ["Gold surges as Fed signals rate cuts"], heads finally: NewsEngine._source_health = saved @test("NewsEngine: neutral headlines are dropped before the per-currency mean") def _(): from news_engine import _is_neutral_headline, NEUTRAL_HEADLINE_THRESHOLD now = _dt_m19.datetime.now(_dt_m19.timezone.utc) def head(overall, by, cur): return NewsSentiment(headline="h", source="s", timestamp=now, currencies_mentioned=cur, sentiment_score=overall, keywords=[], sentiment_by_currency=by) assert NEUTRAL_HEADLINE_THRESHOLD == 0.1 assert _is_neutral_headline(head(0.0, {"XAU": 0.0}, ["XAU"])) assert not _is_neutral_headline(head(0.0, {"XAU": -0.7}, ["XAU"])), \ "overall 0.0 but gold -0.7 leans one way" assert _is_neutral_headline(head(float("nan"), {"XAU": float("nan")}, ["XAU"])) assert _is_neutral_headline(head(0.5, {}, [])), "no currencies → neutral" assert _is_neutral_headline(head(0.05, {"USD": 0.05, "XAU": 0.09}, ["USD", "XAU"])) ne = NewsEngine(NewsConfig()) ne._fetch_economic_calendar = lambda currencies: [] ne._fetch_news_sentiment = lambda: [ head(0.0, {"XAU": 0.0}, ["XAU"]), # neutral → dropped head(0.0, {"XAU": -0.7}, ["XAU"]), # gold-bearish → kept head(0.3, {"USD": 0.3}, ["USD"]), # dollar-bullish → kept head(float("nan"), {"USD": float("nan")}, ["USD"]), # unscored → dropped ] NewsEngine._llm_summary_cache = (0.0, "") try: lc._client = _StubLLM(None) ctx = ne.get_fundamental_context() finally: lc._client = None NewsEngine._llm_summary_cache = (0.0, "") assert len(ctx.news_headlines) == 2, [h.sentiment_by_currency for h in ctx.news_headlines] assert abs(ctx.news_sentiment["XAU"] - (-0.7)) < 1e-9, \ "mean over survivors only (the 0.0 must not dilute it)" assert abs(ctx.news_sentiment["USD"] - 0.3) < 1e-9 @test("NewsEngine: summary prompt is beginner-language; payload newest-first with gold direction; 400-char cap") def _(): from news_engine import _LLM_SUMMARY_SYSTEM, _gold_direction_label assert "never traded" in _LLM_SUMMARY_SYSTEM assert "no acronyms" in _LLM_SUMMARY_SYSTEM assert "max 55 words" in _LLM_SUMMARY_SYSTEM assert "the US central bank" in _LLM_SUMMARY_SYSTEM # worked example carries the style now = _dt_m19.datetime.now(_dt_m19.timezone.utc) def head(title, ts, by, cur, overall=0.0): return NewsSentiment(headline=title, source="s", timestamp=ts, currencies_mentioned=cur, sentiment_score=overall, keywords=[], sentiment_by_currency=by) older = head("old gold story", now - _dt_m19.timedelta(hours=3), {"XAU": 0.6}, ["XAU"]) newer = head("dollar jumps", now, {"USD": 0.5}, ["USD"], overall=0.5) assert _gold_direction_label(older) == "up" assert _gold_direction_label(newer) == "down", "dollar-bullish → gold down" ctx = FundamentalContext(news_headlines=[older, newer]) NewsEngine._llm_summary_cache = (0.0, "") try: stub = _StubLLM({"summary": "x" * 450}) lc._client = stub out = NewsEngine(NewsConfig())._llm_market_summary(ctx) assert len(out) == 400, len(out) sent = stub.last_payload["headlines"] assert [h["headline"] for h in sent] == ["dollar jumps", "old gold story"], \ "newest first" assert [h["gold"] for h in sent] == ["down", "up"] assert set(sent[0]) == {"headline", "gold"} finally: NewsEngine._llm_summary_cache = (0.0, "") lc._client = None @test("NewsEngine: _llm_headline_scores validates, clamps, rejects mismatch") def _(): ne = NewsEngine(NewsConfig()) try: lc._client = _StubLLM({"scores": [[5, -3], [0.2, 0.1]]}) assert ne._llm_headline_scores(["a", "b"]) == [(1.0, -1.0), (0.2, 0.1)] lc._client = _StubLLM({"scores": [[0.1, 0.1]]}) assert ne._llm_headline_scores(["a", "b"]) is None, "length mismatch" lc._client = _StubLLM({"scores": [["hot", 0], [0.3]]}) assert ne._llm_headline_scores(["a", "b"]) is None, "non-numeric/short" lc._client = _StubLLM(None) assert ne._llm_headline_scores(["a"]) is None, "failed call" finally: lc._client = None @test("NewsEngine: LLM sentiment overrides keyword scores per currency") def _(): now = _dt_m19.datetime.now(_dt_m19.timezone.utc) ne = NewsEngine(NewsConfig()) NewsEngine._llm_sent_cache = {} try: lc._client = _StubLLM({"ok": True}) # available, unused (stubbed below) h = NewsSentiment( headline="Fed signals rate hike", source="s", timestamp=now, currencies_mentioned=["USD", "XAU"], sentiment_score=1.0, keywords=["rate hike"], sentiment_by_currency={"USD": 1.0, "XAU": -1.0}) ne._llm_headline_scores = lambda texts: [(0.4, -0.7)] * len(texts) ne._apply_llm_sentiment([h]) assert h.sentiment_score == 0.4 assert h.sentiment_by_currency == {"USD": 0.4, "XAU": -0.7} assert h.keywords == ["rate hike"], "keywords stay keyword-derived" finally: NewsEngine._llm_sent_cache = {} lc._client = None @test("NewsEngine: LLM failure keeps keyword sentiment untouched") def _(): now = _dt_m19.datetime.now(_dt_m19.timezone.utc) ne = NewsEngine(NewsConfig()) NewsEngine._llm_sent_cache = {} try: lc._client = _StubLLM({"ok": True}) h = NewsSentiment( headline="Fed signals rate hike", source="s", timestamp=now, currencies_mentioned=["USD"], sentiment_score=0.6, keywords=[], sentiment_by_currency={"USD": 0.6}) ne._llm_headline_scores = lambda texts: None ne._apply_llm_sentiment([h]) assert h.sentiment_score == 0.6 assert h.sentiment_by_currency == {"USD": 0.6} # unavailable client: not even the scores call happens lc._client = None ne._llm_headline_scores = lambda texts: (_ for _ in ()).throw( AssertionError("must not be called when unavailable")) ne._apply_llm_sentiment([h]) finally: NewsEngine._llm_sent_cache = {} lc._client = None @test("NewsEngine: LLM sentiment cache only sends new headlines") def _(): now = _dt_m19.datetime.now(_dt_m19.timezone.utc) ne = NewsEngine(NewsConfig()) NewsEngine._llm_sent_cache = {} sent_batches = [] def make(h): return NewsSentiment( headline=h, source="s", timestamp=now, currencies_mentioned=["XAU"], sentiment_score=0.0, keywords=[], sentiment_by_currency={"XAU": 0.0}) try: lc._client = _StubLLM({"ok": True}) def scores(texts): sent_batches.append(list(texts)) return [(0.5, 0.5)] * len(texts) ne._llm_headline_scores = scores ne._apply_llm_sentiment([make("headline one"), make("headline two")]) ne._apply_llm_sentiment([make("headline one"), make("headline two"), make("headline three")]) assert sent_batches == [["headline one", "headline two"], ["headline three"]], sent_batches finally: NewsEngine._llm_sent_cache = {} lc._client = None @test("NewsEngine: LLM enriches surprise description, numeric fallback intact") def _(): now = _dt_m19.datetime.now(_dt_m19.timezone.utc) ne = NewsEngine(NewsConfig()) NewsEngine._llm_event_cache = {} released = EconomicEvent( time=now - _dt_m19.timedelta(minutes=10), currency="USD", impact="high", event="Nonfarm Payrolls", forecast="200K", actual="300K") ctx = FundamentalContext(events_today=[released]) try: # failure first: description must equal today's numeric form lc._client = _StubLLM(None) risk, desc = ne.get_event_risk_level("XAU/USD", ctx) assert risk == "caution" assert desc.endswith("(actual 300K vs forecast 200K)"), desc # then success: LLM direction appended to the description only lc._client = _StubLLM({"gold_direction": "bearish", "reason": "strong jobs lift yields"}) risk, desc = ne.get_event_risk_level("XAU/USD", ctx) assert risk == "caution" assert desc.endswith("— gold bearish: strong jobs lift yields"), desc # payload carries the precomputed comparison (300K > 200K) so the # model never re-compares the raw numbers (2026-08-06 validation) assert lc._client.last_payload["vs_forecast"] == "above forecast", \ lc._client.last_payload # bogus direction → numeric fallback NewsEngine._llm_event_cache = {} lc._client = _StubLLM({"gold_direction": "sideways"}) _, desc = ne.get_event_risk_level("XAU/USD", ctx) assert desc.endswith("(actual 300K vs forecast 200K)"), desc finally: NewsEngine._llm_event_cache = {} lc._client = None @test("NewsEngine: market summary cached across engine instances") def _(): now = _dt_m19.datetime.now(_dt_m19.timezone.utc) NewsEngine._llm_summary_cache = (0.0, "") ctx = FundamentalContext(news_headlines=[NewsSentiment( headline="Gold steady", source="s", timestamp=now, currencies_mentioned=["XAU"], sentiment_score=0.0, keywords=[])]) try: stub = _StubLLM({"summary": "Soft dollar supports gold."}) lc._client = stub assert NewsEngine(NewsConfig())._llm_market_summary(ctx) == \ "Soft dollar supports gold." # second engine instance within the TTL: served from cache, no call assert NewsEngine(NewsConfig())._llm_market_summary(ctx) == \ "Soft dollar supports gold." assert stub.calls == 1 # failure caches "" so a broken API is retried next window, not per scan NewsEngine._llm_summary_cache = (0.0, "") lc._client = _StubLLM(None) assert NewsEngine(NewsConfig())._llm_market_summary(ctx) == "" assert NewsEngine._llm_summary_cache[1] == "" finally: NewsEngine._llm_summary_cache = (0.0, "") lc._client = None @test("SignalReport.news_summary flows to Telegram, resets when news off") def _(): import telegram_bot as tb 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), } ctx = FundamentalContext(llm_summary="Test macro blurb.") report = agg.analyze_from_dataframes( "XAU/USD", dfs, context=ctx, use_news=True) assert report.news_summary == "Test macro blurb." assert "Test macro blurb." in tb.format_signal_message(report) report = agg.analyze_from_dataframes( "XAU/USD", dfs, context=ctx, use_news=False) assert report.news_summary == "" @test("news defaults: LLM never gates signals (2026-07 audit lock)") def _(): cfg = NewsConfig() assert cfg.event_risk_caps_signals is False assert cfg.sentiment_affects_signals is False @test("llm_client: health stamps track last success and failure") def _(): client = DeepSeekClient(api_key="k", daily_cap=10) assert client.last_ok == 0.0 and client.last_error is None client._session = _counting_session(_chat_body({"a": 1})) assert client.chat_json("sys", {}) == {"a": 1} assert client.last_ok > 0 and client.last_error is None client._session = _counting_session({"detail": "quota"}, status=429) assert client.chat_json("sys", {}) is None assert client.last_error is not None ts, kind = client.last_error assert kind == "http_429" and ts >= client.last_ok class _LogCapture(logging.Handler): def __init__(self): super().__init__() self.records = [] def emit(self, record): self.records.append(record) @test("NewsEngine: _record_source logs transitions only, tracks state") def _(): import news_engine as ne_mod cap = _LogCapture() ne_mod.logger.addHandler(cap) old_level = ne_mod.logger.level ne_mod.logger.setLevel(logging.INFO) saved = NewsEngine._source_health NewsEngine._source_health = {} try: NewsEngine._record_source("t:src", False, "HTTP 503") warns = [r for r in cap.records if r.levelno == logging.WARNING] assert len(warns) == 1 and "HTTP 503" in warns[0].getMessage() assert NewsEngine.source_is_failing( NewsEngine._source_health["t:src"]) # repeat failure must NOT warn again (DEBUG only) NewsEngine._record_source("t:src", False, "HTTP 503") warns = [r for r in cap.records if r.levelno == logging.WARNING] assert len(warns) == 1 # recovery logs INFO and clears the failing state NewsEngine._record_source("t:src", True, extra="42 kept") infos = [r for r in cap.records if r.levelno == logging.INFO] assert any("recovered" in r.getMessage() for r in infos) entry = NewsEngine._source_health["t:src"] assert not NewsEngine.source_is_failing(entry) assert entry["extra"] == "42 kept" finally: ne_mod.logger.removeHandler(cap) ne_mod.logger.setLevel(old_level) NewsEngine._source_health = saved @test("NewsEngine: calendar fallback source is visible in health state") def _(): ne = NewsEngine(NewsConfig()) saved = NewsEngine._source_health NewsEngine._source_health = {} now = _dt_m19.datetime.now(_dt_m19.timezone.utc) ev = EconomicEvent(time=now, currency="USD", impact="high", event="X") try: ne._try_fetch_calendar_api = lambda: [] ne._try_fetch_myfxbook_rss = lambda: [ev] assert ne._fetch_calendar_raw() == [ev] sh = NewsEngine._source_health assert NewsEngine.source_is_failing(sh["calendar:forexfactory"]) assert not NewsEngine.source_is_failing(sh["calendar"]) assert "MyFXBook" in sh["calendar"]["extra"] # both dead → calendar itself failing ne._try_fetch_myfxbook_rss = lambda: [] assert ne._fetch_calendar_raw() == [] assert NewsEngine.source_is_failing(sh["calendar"]) finally: NewsEngine._source_health = saved @test("NewsEngine: per-feed RSS health recorded, aggregate survives") def _(): cfg = NewsConfig() cfg.rss_feeds = ["https://good.example/rss", "https://bad.example/rss"] ne = NewsEngine(cfg) saved = NewsEngine._source_health NewsEngine._source_health = {} rss = (b"" b"Fed signals rate cuts as dollar slides" b"" + _m19_pubdate() + b"" b"") class _Sess: def get(self, url, timeout=None): if "bad.example" in url: raise requests.ConnectionError("refused") return type("R", (), {"status_code": 200, "content": rss})() ne._session = _Sess() try: heads = ne._fetch_news_sentiment_raw() assert len(heads) == 1, heads sh = NewsEngine._source_health assert not NewsEngine.source_is_failing(sh["rss:good.example"]) assert NewsEngine.source_is_failing(sh["rss:bad.example"]) assert "refused" in sh["rss:bad.example"]["detail"] agg = sh["headlines"] assert not NewsEngine.source_is_failing(agg) assert "1/2 feeds" in agg["extra"], agg finally: NewsEngine._source_health = saved @test("NewsEngine: RSS parser captures link/description/publisher") def _(): cfg = NewsConfig() cfg.rss_feeds = ["https://news.google.com/rss/search?q=gold"] ne = NewsEngine(cfg) saved = NewsEngine._source_health NewsEngine._source_health = {} # Google-News-shaped item: redirect , HTML-escaped , # carrying the real publisher. rss = (b"" b"Gold surges as Fed signals rate cuts - Reuters" b"" + _m19_pubdate() + b"" b"https://news.google.com/rss/articles/CBMiAbC?oc=5" b"<a href=\"https://x\">Gold &amp; dollar " b"diverge as the Fed turns dovish.</a>" b"Reuters" b"") class _Sess: def get(self, url, timeout=None): return type("R", (), {"status_code": 200, "content": rss})() ne._session = _Sess() try: heads = ne._fetch_news_sentiment_raw() assert len(heads) == 1, heads h = heads[0] assert h.link == "https://news.google.com/rss/articles/CBMiAbC?oc=5" # tags stripped, entities (incl. double-encoded &amp;) decoded assert h.description == "Gold & dollar diverge as the Fed turns dovish." assert h.publisher == "Reuters" finally: NewsEngine._source_health = saved @test("NewsEngine: enrichment fields default None / reject unsafe values") def _(): cfg = NewsConfig() cfg.rss_feeds = ["https://feed.example/rss"] ne = NewsEngine(cfg) saved = NewsEngine._source_health NewsEngine._source_health = {} long_link = b"https://a.example/" + b"x" * 2100 stamp = b"" + _m19_pubdate() + b"" rss = (b"" # bare item: no link/description/source at all b"Dollar steady before Fed minutes" + stamp + b"" # javascript: link + description that merely echoes the title b"Gold slips on strong dollar data" + stamp + b"javascript:alert(1)" b"Gold slips on strong dollar data" # over-long link b"Fed officials split on rate cuts" + stamp + b"" + long_link + b"" b"") class _Sess: def get(self, url, timeout=None): return type("R", (), {"status_code": 200, "content": rss})() ne._session = _Sess() try: heads = ne._fetch_news_sentiment_raw() assert len(heads) == 3, heads by_title = {h.headline: h for h in heads} bare = by_title["Dollar steady before Fed minutes"] assert bare.link is None and bare.description is None \ and bare.publisher is None js = by_title["Gold slips on strong dollar data"] assert js.link is None # non-http(s) scheme rejected assert js.description is None # identical to title → dropped assert by_title["Fed officials split on rate cuts"].link is None finally: NewsEngine._source_health = saved @test("NewsEngine: _clean_html_text strips/unescapes/truncates") def _(): from news_engine import _clean_html_text, _safe_link assert _clean_html_text(None) is None assert _clean_html_text("") is None assert _clean_html_text("

") is None assert _clean_html_text("

Fed holds\n rates

") == "Fed holds rates" assert _clean_html_text("&lt;b&gt;x&lt;/b&gt; & y") == "x & y" long = _clean_html_text("a" * 600) assert len(long) == 501 and long.endswith("…") assert _safe_link(" https://a.example/x ") == "https://a.example/x" assert _safe_link("ftp://a.example/x") is None assert _safe_link("javascript:alert(1)") is None assert _safe_link("https://" + "x" * 2100) is None assert _safe_link(None) is None @test("/status renders news/calendar/AI health with ⚠️ degradations") def _(): import telegram_bot as tb saved = NewsEngine._source_health t0 = time.time() NewsEngine._source_health = { "calendar": {"ok": t0, "err": None, "detail": "", "extra": "42 events via MyFXBook"}, "calendar:forexfactory": {"ok": None, "err": t0, "detail": "HTTP 503", "extra": ""}, "headlines": {"ok": t0, "err": None, "detail": "", "extra": "38 headlines from 3/4 feeds"}, "rss:fxstreet.com": {"ok": None, "err": t0, "detail": "timeout", "extra": ""}, } stub = _StubLLM(None) stub.last_ok = 0.0 stub.last_error = (t0, "http_429") stub._count = 87 stub._daily_cap = 500 stub._count_day = _dt_m19.datetime.now(_dt_m19.timezone.utc).date() lc._client = stub try: text = tb._status_text() assert "⚠️ Calendar: 42 events via MyFXBook — ForexFactory" in text assert "HTTP 503" in text assert "38 headlines from 3/4 feeds" in text assert "⚠️ fxstreet.com: timeout" in text assert "⚠️ DeepSeek: last call failed (http_429" in text # healthy path: FF serving, DeepSeek clean → no warnings NewsEngine._source_health = { "calendar": {"ok": t0, "err": None, "detail": "", "extra": "42 events via ForexFactory"}, "headlines": {"ok": t0, "err": None, "detail": "", "extra": "38 headlines from 4/4 feeds"}, } stub.last_ok = t0 text = tb._status_text() assert "✅ Calendar: 42 events via ForexFactory" in text assert "✅ DeepSeek: 87/500 calls today" in text assert "⚠️" not in text.split("*News & AI:*")[1].split("*Alert")[0] # empty state (pre-first-scan) renders the neutral line NewsEngine._source_health = {} assert "no news cycle yet" in tb._status_text() # yesterday's exhausted counter must NOT show as cap-exhausted # after the UTC date roll (counter resets lazily on next call) stub._count = 500 stub._count_day = stub._count_day - _dt_m19.timedelta(days=1) text = tb._status_text() assert "cap" not in text and "0/500" in text, text finally: NewsEngine._source_health = saved lc._client = None # ══════════════════════════════════════════════════════════════════════ # MODULE 20: dashboard_publisher.py (static web dashboard) # ══════════════════════════════════════════════════════════════════════ print("\n═══ Module 20: dashboard_publisher.py ═══") import tempfile import dashboard_publisher as dpub from engine import TechnicalAnalysis, IndicatorSignal def make_fake_dashboard_report(): """SignalReport with every serialization hazard: NaN value, inf key level, weight-0 ATR, a poisoned '_secret' indicator, a dynamic pandas attr, and a full TradeSetup.""" analysis = TechnicalAnalysis( symbol="XAU/USD", timeframe="1h", timestamp="2026-08-03 10:00:00+00:00") names = ["ema_trend", "rsi", "macd", "bollinger", "adx", "roc", "keltner", "divergence", "candlestick"] for i, name in enumerate(names): analysis.indicators[name] = IndicatorSignal( name=name, value=float(i), signal=SignalStrength.BUY if i % 2 == 0 else SignalStrength.SELL, confidence=60.0, description=f"{name} desc", weight=1.0) analysis.indicators["rsi"].value = float("nan") analysis.indicators["atr"] = IndicatorSignal( name="atr", value=12.5, signal=SignalStrength.NEUTRAL, confidence=0.0, description="ATR context", weight=0.0) analysis.indicators["_secret"] = IndicatorSignal( name="_secret", value=1.0, signal=SignalStrength.NEUTRAL, confidence=0.0, description="must not serialize", weight=1.0) analysis._rsi_series = pd.Series([1.0, 2.0]) # dynamic attr, must not leak analysis.key_levels = {"atr": 12.5, "support": 3300.0, "broken": float("inf")} analysis.composite_signal = SignalStrength.BUY analysis.composite_score = 42.0 analysis.confidence = 61.5 analysis.pattern_signals = ["BULLISH_ENGULFING"] report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-08-03 10:00:00+00:00") report.final_signal = SignalStrength.BUY report.final_score = 55.0 report.final_confidence = 70.0 report.signal_quality = "B" report.primary_signal = SignalStrength.BUY report.primary_score = 50.0 report.primary_analysis = analysis report.confirmation_analyses = [analysis] report.mtf_signals = {"1h": SignalStrength.BUY, "4h": SignalStrength.NEUTRAL} report.mtf_scores = {"1h": 50.0, "4h": 10.0} report.exhaustion_count = 1 report.current_price = 3350.0 report.trade_setup = TradeSetup( pair="XAU/USD", display_name="XAU/USD", direction="BUY", entry_price=3350.0, stop_loss=3340.0, take_profit_1=3360.0, take_profit_2=3370.0, risk_reward=2.0, risk_pips=100.0, reward_pips=200.0, pip_value=0.1, strategy="XAU_ATR", trail_arm_r=1.0, trail_dist_r=0.85) return report @test("dashboard: report_to_webapp_dict round-trips allow_nan=False") def _(): d = dpub.report_to_webapp_dict(make_fake_dashboard_report()) json.dumps(d, allow_nan=False) # raises if NaN/inf leaked assert d["signal"] == "BUY" and d["primary_signal"] == "BUY" assert d["mtf_scores"] == {"1h": 50.0, "4h": 10.0} assert d["exhaustion_count"] == 1 assert d["trade_setup"]["strategy"] == "XAU_ATR" pa = d["primary_analysis"] assert pa["composite_signal"] == "BUY" assert pa["indicators"]["rsi"]["value"] is None # NaN → None assert pa["key_levels"]["broken"] is None # inf → None assert len(d["confirmation_analyses"]) == 1 @test("dashboard: ATR is context not grid; _-keys excluded") def _(): pa = dpub.report_to_webapp_dict(make_fake_dashboard_report())["primary_analysis"] assert "atr" not in pa["indicators"] assert "_secret" not in pa["indicators"] assert "_rsi_series" not in pa assert pa["atr_context"]["value"] == 12.5 assert len(pa["indicators"]) == 9 @test("dashboard: missing divergence and weekend report tolerated") def _(): report = make_fake_dashboard_report() del report.primary_analysis.indicators["divergence"] pa = dpub.report_to_webapp_dict(report)["primary_analysis"] assert len(pa["indicators"]) == 8 and "divergence" not in pa["indicators"] weekend = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") # primary_analysis=None d = dpub.report_to_webapp_dict(weekend) json.dumps(d, allow_nan=False) assert d["primary_analysis"] is None assert d["confirmation_analyses"] == [] @test("dashboard: due_timeframes uses RAW wake time") def _(): def due(h, m): wake = datetime(2026, 8, 3, h, m, 10, tzinfo=timezone.utc) return [tf.value for tf in dpub.due_timeframes(wake)] # The v1 regression: 12:00:10 must classify as the 12:00 boundary # (all four due), not 11:55. assert due(12, 0) == ["5min", "15min", "1h", "4h"] assert due(13, 0) == ["5min", "15min", "1h"] assert due(12, 15) == ["5min", "15min"] assert due(12, 5) == ["5min"] assert due(0, 0) == ["5min", "15min", "1h", "4h"] @test("dashboard: build_snapshot_payload warming/error/next_close") def _(): now = datetime(2026, 8, 3, 12, 2, 30, tzinfo=timezone.utc) # Monday with patch("providers.base._is_forex_weekend", return_value=False): payload = dpub.build_snapshot_payload({}, now) assert payload["symbol"] == "XAU/USD" assert payload["market_closed"] is False assert set(payload["timeframes"]) == {"5min", "15min", "1h", "4h"} for entry in payload["timeframes"].values(): assert entry["status"] == "warming" and entry["report"] is None assert entry["next_close_epoch"] > payload["server_time_epoch"] assert entry["next_scan_epoch"] == entry["next_close_epoch"] + 10 # M5 closes at 12:05 → 150 s away from 12:02:30. assert abs(payload["timeframes"]["5min"]["next_close_epoch"] - (now.timestamp() + 150)) < 1e-6 # An errored TF keeps its stale report + error badge. stale = {"status": "error", "scanned_at": "earlier", "error": "ValueError: boom", "error_at": "now", "report": {"signal": "BUY"}} with patch("providers.base._is_forex_weekend", return_value=True): payload = dpub.build_snapshot_payload({"5min": stale}, now) assert payload["market_closed"] is True m5 = payload["timeframes"]["5min"] assert m5["status"] == "error" and m5["report"] == {"signal": "BUY"} assert m5["error"] == "ValueError: boom" json.dumps(payload, allow_nan=False) @test("dashboard: event_to_dict includes computed properties") def _(): ev = EconomicEvent( time=datetime(2026, 8, 7, 12, 30, tzinfo=timezone.utc), currency="USD", impact="high", event="Non-Farm Payrolls", forecast="180K", previous="147K", actual="228K") d = dpub.event_to_dict(ev) assert d["event"] == "Non-Farm Payrolls" assert d["actual"] == "228K" # strings stay strings assert d["surprise"] == "positive" # property, added explicitly assert "minutes_until" not in d # frozen-at-publish value, dropped assert d["time"] == "2026-08-07T12:30:00+00:00" json.dumps(d, allow_nan=False) @test("dashboard: build_news_payload sorts headlines, survives NaN") def _(): now = datetime(2026, 8, 3, 12, 0, tzinfo=timezone.utc) old = NewsSentiment( headline="Old story", source="http://feed/a", timestamp=now - timedelta(hours=5), currencies_mentioned=["USD"], sentiment_score=float("nan"), keywords=[], sentiment_by_currency={}) new = NewsSentiment( headline="Gold surges", source="http://feed/b", timestamp=now - timedelta(minutes=10), currencies_mentioned=["XAU"], sentiment_score=0.6, keywords=["surge"], sentiment_by_currency={"XAU": 0.6}) ctx = FundamentalContext( news_headlines=[old, new], news_sentiment={"XAU": 0.6, "USD": -0.1}, llm_summary="Macro blurb.", calendar_available=True) events = [EconomicEvent(time=now + timedelta(hours=3), currency="USD", impact="medium", event="ISM")] payload = dpub.build_news_payload(ctx, events, now) json.dumps(payload, allow_nan=False) assert payload["llm_summary"] == "Macro blurb." assert [h["headline"] for h in payload["headlines"]] == \ ["Gold surges", "Old story"] # newest first assert payload["headlines"][1]["score"] is None # NaN → None assert payload["events"][0]["event"] == "ISM" assert payload["sentiment"] == {"XAU": 0.6, "USD": -0.1} # Every headline carries the 40-hex id the PHP endpoint whitelists on. import re as _re assert all(_re.fullmatch(r"[0-9a-f]{40}", h["id"]) for h in payload["headlines"]) @test("dashboard: headline_to_dict id matches LLM cache scheme + enrichment") def _(): import hashlib h = NewsSentiment( headline="Gold surges", source="http://feed/b", timestamp=datetime(2026, 8, 7, 10, 0, tzinfo=timezone.utc), currencies_mentioned=["XAU"], sentiment_score=0.6, keywords=["surge"], sentiment_by_currency={"XAU": 0.6}, link="https://x.example/a", description="Body.", publisher="Reuters") d = dpub.headline_to_dict(h) # Same sha1(headline) scheme as NewsEngine._llm_sent_cache — the id is # the join key news_summary.php validates against. assert d["id"] == hashlib.sha1(b"Gold surges").hexdigest() assert d["link"] == "https://x.example/a" assert d["description"] == "Body." assert d["publisher"] == "Reuters" # Pre-enrichment NewsSentiment (defaults) still serializes cleanly. bare = NewsSentiment( headline="Old", source="s", timestamp=None, currencies_mentioned=[], sentiment_score=0.0, keywords=[]) d2 = dpub.headline_to_dict(bare) assert d2["link"] is None and d2["description"] is None \ and d2["publisher"] is None json.dumps([d, d2], allow_nan=False) @test("dashboard: install_static installs page + PHP endpoint atomically") def _(): with tempfile.TemporaryDirectory() as tmp: pub = dpub.DashboardPublisher(out_dir=tmp) pub.install_static() assert sorted(os.listdir(tmp)) == \ ["dashboard.css", "dashboard.html", "dashboard.js", "news_summary.php"] # all installed, no .tmp @test("dashboard: static UI is Bootstrap + jQuery + Chart.js on a CDN") def _(): # The page is served as-is (no build step), so its four libraries are # pinned