diff --git a/data_provider.py b/data_provider.py index bb2950c..33c717a 100755 --- a/data_provider.py +++ b/data_provider.py @@ -267,6 +267,9 @@ class ForexDataProvider: df = df.ffill(limit=3) df = df.dropna(subset=["Close"]) + df = df.sort_index() + df = df[~df.index.duplicated(keep="last")] + now = pd.Timestamp.utcnow() future_mask = df.index > now + pd.Timedelta(minutes=5) if future_mask.any(): @@ -306,9 +309,6 @@ class ForexDataProvider: f"(need {min_required}). Some indicators will be partial." ) - df = df.sort_index() - df = df[~df.index.duplicated(keep="last")] - cleaned = original_len - len(df) if cleaned > 0: logger.info(f"{symbol}: Cleaned {cleaned}/{original_len} bars") diff --git a/telegram_bot.py b/telegram_bot.py index e777b4a..8746b5d 100755 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -131,15 +131,32 @@ AUTOSCAN_CONFIRMATIONS = { _executor = ThreadPoolExecutor(max_workers=4) -# ── Telegram rate limiter (1 msg/sec per chat) ──────────────────────── +# ── Telegram rate limiter with flood control ───────────────────────── _send_lock = asyncio.Lock() _last_send_per_chat: Dict[int, float] = {} -_TELEGRAM_MIN_INTERVAL = 1.0 +_TELEGRAM_MIN_INTERVAL = 1.5 _TELEGRAM_SEND_TIMEOUT = 30 +_flood_until: float = 0.0 + + +def _is_flood_blocked() -> bool: + return time.time() < _flood_until + + +def _handle_retry_after(retry_seconds: int) -> None: + global _flood_until + _flood_until = time.time() + retry_seconds + logger.warning( + f"Telegram flood control: pausing ALL sends for {retry_seconds}s " + f"(until {datetime.fromtimestamp(_flood_until, tz=timezone.utc).strftime('%H:%M:%S UTC')})" + ) async def _rate_limited_send(bot, chat_id: int, text: str, **kwargs) -> Optional[object]: + if _is_flood_blocked(): + logger.debug(f"Send skipped — flood backoff active ({_flood_until - time.time():.0f}s left)") + return None async with _send_lock: now = time.time() last = _last_send_per_chat.get(chat_id, 0) @@ -156,12 +173,17 @@ async def _rate_limited_send(bot, chat_id: int, text: str, **kwargs) -> Optional except asyncio.TimeoutError: logger.error(f"Telegram send_message timed out for chat {chat_id}") return None - except Exception: - logger.exception(f"Telegram send_message failed for chat {chat_id}") + except Exception as exc: + if "retry" in str(exc).lower() and hasattr(exc, "retry_after"): + _handle_retry_after(int(exc.retry_after)) + else: + logger.exception(f"Telegram send_message failed for chat {chat_id}") return None async def _rate_limited_edit(bot, text: str, chat_id: int, message_id: int, **kwargs) -> bool: + if _is_flood_blocked(): + return False try: await asyncio.wait_for( bot.edit_message_text(text, chat_id=chat_id, message_id=message_id, **kwargs), @@ -172,6 +194,9 @@ async def _rate_limited_edit(bot, text: str, chat_id: int, message_id: int, **kw logger.error(f"Telegram edit_message timed out for chat {chat_id}") return False except Exception as exc: + if "retry" in str(exc).lower() and hasattr(exc, "retry_after"): + _handle_retry_after(int(exc.retry_after)) + return False if "message is not modified" not in str(exc).lower(): logger.warning(f"Telegram edit_message failed for chat {chat_id}: {exc}") return False @@ -445,19 +470,27 @@ def _format_stats_dashboard() -> str: _dashboard_msg_ids: Dict[int, int] = {} _dashboard_task: Optional[asyncio.Task] = None _last_dashboard_refresh: float = 0 -_DASHBOARD_THROTTLE = 5 +_DASHBOARD_THROTTLE = 30 +_DASHBOARD_LOOP_INTERVAL = 30 _dashboard_lock = asyncio.Lock() +_dashboard_edit_failures: Dict[int, int] = {} +_DASHBOARD_MAX_EDIT_FAILURES = 3 async def _send_dashboard(bot, chat_id: int) -> None: + if _is_flood_blocked(): + return text = _format_stats_dashboard() msg = await _rate_limited_send(bot, chat_id, text, parse_mode=ParseMode.MARKDOWN) if msg: _dashboard_msg_ids[chat_id] = msg.message_id + _dashboard_edit_failures[chat_id] = 0 async def _refresh_dashboard(bot, force: bool = False) -> None: global _last_dashboard_refresh + if _is_flood_blocked(): + return async with _dashboard_lock: now = time.time() if not force and (now - _last_dashboard_refresh) < _DASHBOARD_THROTTLE: @@ -475,13 +508,20 @@ async def _refresh_dashboard(bot, force: bool = False) -> None: parse_mode=ParseMode.MARKDOWN, ) if not ok: - await _send_dashboard(bot, chat_id) + failures = _dashboard_edit_failures.get(chat_id, 0) + 1 + _dashboard_edit_failures[chat_id] = failures + if failures >= _DASHBOARD_MAX_EDIT_FAILURES: + logger.info(f"Dashboard edit failed {failures}x for chat {chat_id}, sending new message") + _dashboard_edit_failures[chat_id] = 0 + await _send_dashboard(bot, chat_id) + else: + _dashboard_edit_failures[chat_id] = 0 async def _dashboard_loop(bot) -> None: try: while _autoscan_chats: - await asyncio.sleep(5) + await asyncio.sleep(_DASHBOARD_LOOP_INTERVAL) if not _autoscan_chats: break await _refresh_dashboard(bot) diff --git a/test_all.py b/test_all.py new file mode 100644 index 0000000..4badacb --- /dev/null +++ b/test_all.py @@ -0,0 +1,1747 @@ +#!/usr/bin/env python3 +""" +ForexBot2 — Comprehensive QA Test Suite +========================================= +Tests every function in the codebase without requiring live API connections. +""" + +import sys +import os +import time +import traceback +from datetime import datetime, timedelta, timezone +from unittest.mock import patch, MagicMock +from io import StringIO + +import numpy as np +import pandas as pd + +sys.path.insert(0, os.path.dirname(__file__)) + +PASS = 0 +FAIL = 0 +ERRORS = [] + + +def test(name): + def decorator(func): + global PASS, FAIL + try: + func() + PASS += 1 + print(f" ✓ {name}") + except Exception as e: + FAIL += 1 + tb = traceback.format_exc() + ERRORS.append((name, str(e), tb)) + print(f" ✗ {name}: {e}") + return func + return decorator + + +def make_ohlcv(n=300, start_price=1.1000, trend=0.0001, seed=42): + """Generate realistic OHLCV data for testing.""" + np.random.seed(seed) + dates = pd.date_range( + end=pd.Timestamp.now(tz="UTC").floor("h"), + periods=n, freq="h" + ) + close = start_price + np.cumsum(np.random.randn(n) * 0.001 + trend) + high = close + np.abs(np.random.randn(n) * 0.0005) + low = close - np.abs(np.random.randn(n) * 0.0005) + open_ = close + np.random.randn(n) * 0.0003 + volume = np.random.randint(100, 10000, n).astype(float) + df = pd.DataFrame({ + "Open": open_, "High": high, "Low": low, + "Close": close, "Volume": volume, + }, index=dates) + df.index.name = "Datetime" + return df + + +def make_xau_ohlcv(n=300, start_price=2400.0, seed=42): + """Generate XAU/USD OHLCV data.""" + np.random.seed(seed) + dates = pd.date_range( + end=pd.Timestamp.now(tz="UTC").floor("h"), + periods=n, freq="h" + ) + close = start_price + np.cumsum(np.random.randn(n) * 2.0 + 0.05) + high = close + np.abs(np.random.randn(n) * 1.0) + low = close - np.abs(np.random.randn(n) * 1.0) + open_ = close + np.random.randn(n) * 0.5 + volume = np.random.randint(100, 10000, n).astype(float) + df = pd.DataFrame({ + "Open": open_, "High": high, "Low": low, + "Close": close, "Volume": volume, + }, index=dates) + df.index.name = "Datetime" + return df + + +# ══════════════════════════════════════════════════════════════════════ +# MODULE 1: config.py +# ══════════════════════════════════════════════════════════════════════ + +print("\n═══ Module 1: config.py ═══") + +from config import ( + Timeframe, SignalStrength, MarketSession, + PlatformConfig, TechnicalConfig, SignalConfig, NewsConfig, + FilterConfig, XauStrategyConfig, EurStrategyConfig, GbpStrategyConfig, + USDJPY, EURUSD, GBPUSD, XAUUSD, PAIR_INFO, +) + + +@test("Timeframe enum values") +def _(): + assert Timeframe.M1.value == "1min" + assert Timeframe.M5.value == "5min" + assert Timeframe.M15.value == "15min" + assert Timeframe.M30.value == "30min" + assert Timeframe.H1.value == "1h" + assert Timeframe.H4.value == "4h" + assert Timeframe.D1.value == "1d" + assert Timeframe.W1.value == "1wk" + + +@test("SignalStrength enum values and ordering") +def _(): + assert SignalStrength.STRONG_BUY.value == 2 + assert SignalStrength.BUY.value == 1 + assert SignalStrength.NEUTRAL.value == 0 + assert SignalStrength.SELL.value == -1 + assert SignalStrength.STRONG_SELL.value == -2 + assert SignalStrength.STRONG_BUY.value > SignalStrength.BUY.value + assert SignalStrength.SELL.value > SignalStrength.STRONG_SELL.value + + +@test("MarketSession enum values") +def _(): + assert MarketSession.SYDNEY.value == "sydney" + assert MarketSession.TOKYO.value == "tokyo" + assert MarketSession.LONDON.value == "london" + assert MarketSession.NEW_YORK.value == "new_york" + assert MarketSession.OVERLAP_LONDON_NY.value == "london_ny_overlap" + + +@test("PAIR_INFO completeness and correctness") +def _(): + assert USDJPY in PAIR_INFO + assert EURUSD in PAIR_INFO + assert GBPUSD in PAIR_INFO + assert XAUUSD in PAIR_INFO + for pair, info in PAIR_INFO.items(): + assert "display" in info, f"Missing 'display' for {pair}" + assert "pip_value" in info, f"Missing 'pip_value' for {pair}" + assert "is_jpy_pair" in info, f"Missing 'is_jpy_pair' for {pair}" + assert info["pip_value"] > 0, f"pip_value must be > 0 for {pair}" + assert PAIR_INFO[USDJPY]["is_jpy_pair"] is True + assert PAIR_INFO[EURUSD]["is_jpy_pair"] is False + assert PAIR_INFO[GBPUSD]["is_jpy_pair"] is False + assert PAIR_INFO[XAUUSD]["is_jpy_pair"] is False + assert PAIR_INFO[USDJPY]["pip_value"] == 0.01 + assert PAIR_INFO[EURUSD]["pip_value"] == 0.0001 + 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 == "classic" + assert tc.macd_as_pillar is False + assert "divergence" in tc.disabled_indicators + + +@test("SignalConfig defaults") +def _(): + sc = SignalConfig() + assert sc.min_confidence == 55.0 + assert sc.min_rr_ratio == 1.2 + assert sc.atr_sl_multiplier == 1.5 + assert sc.default_rr_ratio == 2.0 + assert sc.cooldown_bars == 5 + assert "ema_trend" in sc.weights + assert "rsi" in sc.weights + + +@test("PlatformConfig defaults") +def _(): + pc = PlatformConfig() + assert pc.symbol == USDJPY + 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 EURUSD in pc.scan_pairs + assert GBPUSD in pc.scan_pairs + assert XAUUSD in pc.scan_pairs + + +@test("XauStrategyConfig defaults") +def _(): + xc = XauStrategyConfig() + assert xc.enabled is True + assert xc.rsi_entry_threshold == 38.0 + assert xc.tp_atr_multiplier == 0.9 + assert xc.sl_atr_multiplier == 2.0 + assert isinstance(xc.blocked_hours_utc, list) + + +@test("EurStrategyConfig defaults") +def _(): + ec = EurStrategyConfig() + assert ec.enabled is True + assert ec.rsi_lower == 30.0 + assert ec.rsi_upper == 55.0 + assert ec.bb_max_position == 0.75 + assert ec.tp_atr_multiplier == 0.7 + assert ec.sl_atr_multiplier == 1.0 + + +@test("GbpStrategyConfig defaults") +def _(): + gc = GbpStrategyConfig() + assert gc.enabled is True + assert gc.rsi_lower == 30.0 + assert gc.rsi_upper == 55.0 + assert gc.bb_min_position == 0.0 + assert gc.bb_max_position == 0.75 + assert gc.tp_atr_multiplier == 0.7 + assert gc.sl_atr_multiplier == 1.0 + + +@test("FilterConfig defaults") +def _(): + fc = FilterConfig() + assert fc.session_filter is True + assert fc.regime_filter is False + assert fc.sell_filter is True + assert "XAU/USD" in fc.sell_allowed_pairs + + +@test("PlatformConfig dataclass mutable default independence") +def _(): + c1 = PlatformConfig() + c2 = PlatformConfig() + c1.scan_pairs.append("TEST") + assert "TEST" not in c2.scan_pairs, "Mutable defaults should be independent" + + +# ══════════════════════════════════════════════════════════════════════ +# MODULE 2: data_provider.py +# ══════════════════════════════════════════════════════════════════════ + +print("\n═══ Module 2: data_provider.py ═══") + +from data_provider import ForexDataProvider, DataIntegrityError + + +@test("ForexDataProvider._to_td_symbol conversion") +def _(): + assert ForexDataProvider._to_td_symbol("EURUSD=X") == "EUR/USD" + assert ForexDataProvider._to_td_symbol("USDJPY=X") == "USD/JPY" + assert ForexDataProvider._to_td_symbol("GBPUSD=X") == "GBP/USD" + assert ForexDataProvider._to_td_symbol("XAU/USD") == "XAU/USD" + + +@test("ForexDataProvider TD_INTERVALS complete mapping") +def _(): + dp = ForexDataProvider(PlatformConfig()) + for tf in Timeframe: + assert tf in dp.TD_INTERVALS, f"Missing TD_INTERVALS mapping for {tf}" + + +@test("ForexDataProvider BARS_PER_DAY mapping") +def _(): + dp = ForexDataProvider(PlatformConfig()) + for tf in Timeframe: + assert tf in dp.BARS_PER_DAY, f"Missing BARS_PER_DAY for {tf}" + assert dp.BARS_PER_DAY[Timeframe.H1] == 24 + assert dp.BARS_PER_DAY[Timeframe.M15] == 96 + assert dp.BARS_PER_DAY[Timeframe.D1] == 1 + + +@test("ForexDataProvider CANDLE_SECONDS mapping") +def _(): + dp = ForexDataProvider(PlatformConfig()) + for tf in Timeframe: + assert tf in dp.CANDLE_SECONDS, f"Missing CANDLE_SECONDS for {tf}" + assert dp.CANDLE_SECONDS[Timeframe.M1] == 60 + assert dp.CANDLE_SECONDS[Timeframe.H1] == 3600 + assert dp.CANDLE_SECONDS[Timeframe.D1] == 86400 + + +@test("ForexDataProvider._validate_and_clean basic valid data") +def _(): + dp = ForexDataProvider(PlatformConfig()) + df = make_ohlcv(300) + result = dp._validate_and_clean(df, "EURUSD=X", Timeframe.H1) + assert len(result) > 0 + assert result.index.is_monotonic_increasing + assert not result.index.has_duplicates + + +@test("ForexDataProvider._validate_and_clean empty df raises error") +def _(): + dp = ForexDataProvider(PlatformConfig()) + try: + dp._validate_and_clean(pd.DataFrame(), "EURUSD=X", Timeframe.H1) + assert False, "Should have raised DataIntegrityError" + except DataIntegrityError: + pass + + +@test("ForexDataProvider._validate_and_clean None df raises error") +def _(): + dp = ForexDataProvider(PlatformConfig()) + try: + dp._validate_and_clean(None, "EURUSD=X", Timeframe.H1) + assert False, "Should have raised DataIntegrityError" + except DataIntegrityError: + pass + + +@test("ForexDataProvider._validate_and_clean fixes OHLC inconsistencies") +def _(): + dp = ForexDataProvider(PlatformConfig()) + df = make_ohlcv(300) + df.iloc[10, df.columns.get_loc("High")] = df.iloc[10]["Low"] - 1 + result = dp._validate_and_clean(df, "EURUSD=X", Timeframe.H1) + assert (result["High"] >= result["Low"]).all() + + +@test("ForexDataProvider._validate_and_clean drops future timestamps") +def _(): + dp = ForexDataProvider(PlatformConfig()) + df = make_ohlcv(300) + future = pd.Timestamp.now(tz="UTC") + pd.Timedelta(hours=10) + df.loc[future] = df.iloc[-1] + result = dp._validate_and_clean(df, "EURUSD=X", Timeframe.H1) + assert (result.index <= pd.Timestamp.utcnow() + pd.Timedelta(minutes=5)).all() + + +@test("ForexDataProvider._validate_and_clean removes duplicates") +def _(): + dp = ForexDataProvider(PlatformConfig()) + df = make_ohlcv(300) + dup = pd.concat([df, df.iloc[-5:]]) + assert dup.index.has_duplicates, "Setup: should have duplicates" + result = dp._validate_and_clean(dup, "EURUSD=X", Timeframe.H1) + assert not result.index.has_duplicates, "Should remove duplicate indices" + + +@test("ForexDataProvider._validate_and_clean forward fills NaN (limit 3)") +def _(): + dp = ForexDataProvider(PlatformConfig()) + df = make_ohlcv(300) + df.iloc[100, df.columns.get_loc("Close")] = np.nan + df.iloc[101, df.columns.get_loc("Close")] = np.nan + result = dp._validate_and_clean(df, "EURUSD=X", Timeframe.H1) + assert not result["Close"].isna().any() + + +@test("ForexDataProvider._is_data_fresh weekend override") +def _(): + dp = ForexDataProvider(PlatformConfig()) + # Create data that ends on a Friday, call _is_data_fresh on a Saturday + friday = pd.Timestamp("2025-05-09 16:00:00", tz="UTC") + old_dates = pd.date_range(end=friday, periods=300, freq="h") + df = make_ohlcv(300) + df.index = old_dates + # Simulate calling on Saturday by patching pd.Timestamp.now + saturday = pd.Timestamp("2025-05-10 12:00:00", tz="UTC") + original_now = pd.Timestamp.now + with patch.object(pd.Timestamp, "now", return_value=saturday): + result = dp._is_data_fresh(df, "EURUSD=X", Timeframe.H1) + assert result is True, "Weekend data should always be considered fresh" + + +@test("ForexDataProvider._is_data_fresh returns False for empty df") +def _(): + dp = ForexDataProvider(PlatformConfig()) + assert dp._is_data_fresh(pd.DataFrame(), "EURUSD=X", Timeframe.H1) is False + assert dp._is_data_fresh(None, "EURUSD=X", Timeframe.H1) is False + + +@test("ForexDataProvider.clear_cache clears all entries") +def _(): + dp = ForexDataProvider(PlatformConfig()) + dp._cache["test_key"] = make_ohlcv(10) + dp._cache_timestamps["test_key"] = time.time() + count = dp.clear_cache() + assert count == 1 + assert len(dp._cache) == 0 + assert len(dp._cache_timestamps) == 0 + + +@test("ForexDataProvider.get_pair_info returns correct info") +def _(): + dp = ForexDataProvider(PlatformConfig()) + info = dp.get_pair_info(EURUSD) + assert info["symbol"] == EURUSD + assert info["display"] == "EUR/USD" + assert info["pip_value"] == 0.0001 + + info_unknown = dp.get_pair_info("UNKNOWN") + assert info_unknown["display"] == "USD/JPY" + + +@test("ForexDataProvider._throttle rate limiting logic") +def _(): + dp = ForexDataProvider(PlatformConfig()) + dp._request_times.clear() + for _ in range(10): + dp._throttle(credits=1) + assert len(dp._request_times) == 10 + + +@test("ForexDataProvider caching - in-memory cache returns same data") +def _(): + dp = ForexDataProvider(PlatformConfig()) + df = make_ohlcv(300) + dp._cache["EURUSD=X_1h"] = df + dp._cache_timestamps["EURUSD=X_1h"] = time.time() + cached = dp._cache.get("EURUSD=X_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, "EURUSD=X", "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, "EURUSD=X", "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, "EURUSD=X", "1h") + assert isinstance(result, TechnicalAnalysis) + assert result.symbol == "EURUSD=X" + 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, "EURUSD=X", "1h") + assert "ema_trend" in result.indicators + ema_ind = result.indicators["ema_trend"] + assert isinstance(ema_ind, IndicatorSignal) + assert ema_ind.name == "EMA Trend" + assert ema_ind.signal in list(SignalStrength) + assert "ema_fast" in result.key_levels + assert "ema_medium" in result.key_levels + assert "ema_slow" in result.key_levels + + +@test("TechnicalEngine EMA-2 mode computes EMA50/200") +def _(): + config = TechnicalConfig(ema_mode="2ema") + engine = TechnicalEngine(config) + df = make_ohlcv(300) + result = engine.analyze(df, "EURUSD=X", "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, "EURUSD=X", "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, "EURUSD=X", "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, "EURUSD=X", "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, "EURUSD=X", "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, "EURUSD=X", "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 (not as pillar by default)") +def _(): + engine = TechnicalEngine(TechnicalConfig()) + df = make_ohlcv(300) + result = engine.analyze(df, "EURUSD=X", "1h") + assert "macd" not in result.indicators + assert hasattr(result, "_macd_histogram") + + +@test("TechnicalEngine MACD as pillar when enabled") +def _(): + config = TechnicalConfig(macd_as_pillar=True) + engine = TechnicalEngine(config) + df = make_ohlcv(300) + result = engine.analyze(df, "EURUSD=X", "1h") + assert "macd" in result.indicators + macd = result.indicators["macd"] + assert macd.weight == 1.5 + + +@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, "EURUSD=X", "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, "EURUSD=X", "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, "EURUSD=X", "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, "EURUSD=X", "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, "EURUSD=X", "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, "EURUSD=X", "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 (disabled by default)") +def _(): + engine = TechnicalEngine(TechnicalConfig()) + df = make_ohlcv(300) + result = engine.analyze(df, "EURUSD=X", "1h") + assert "divergence" not in result.indicators + + +@test("TechnicalEngine divergence detection (enabled)") +def _(): + config = TechnicalConfig(disabled_indicators=[]) + engine = TechnicalEngine(config) + df = make_ohlcv(300) + result = engine.analyze(df, "EURUSD=X", "1h") + # divergence may or may not be detected, just ensure no crash + + +@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, "EURUSD=X", "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, "EURUSD=X", "1h") + assert isinstance(result, TechnicalAnalysis) + + +# ══════════════════════════════════════════════════════════════════════ +# MODULE 4: news_engine.py +# ══════════════════════════════════════════════════════════════════════ + +print("\n═══ Module 4: news_engine.py ═══") + +from news_engine import NewsEngine, FundamentalContext, EconomicEvent, NewsSentiment + + +@test("NewsEngine._parse_pair with slash") +def _(): + base, quote = NewsEngine._parse_pair("XAU/USD") + assert base == "XAU" + assert quote == "USD" + + +@test("NewsEngine._parse_pair with =X suffix") +def _(): + base, quote = NewsEngine._parse_pair("EURUSD=X") + assert base == "EUR" + assert quote == "USD" + + +@test("NewsEngine._parse_pair for JPY pair") +def _(): + base, quote = NewsEngine._parse_pair("USDJPY=X") + assert base == "USD" + assert quote == "JPY" + + +@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("EUR/USD drops as Fed hawkish, ECB dovish") + assert "USD" in currencies + assert "EUR" in currencies + + +@test("NewsEngine._extract_keywords") +def _(): + ne = NewsEngine(NewsConfig()) + kws = ne._extract_keywords("Hawkish Fed rate hike surprises with positive surprise") + assert "rate hike" in kws + assert "hawkish" in kws + assert "positive surprise" in kws + + +@test("NewsEngine._parse_rss_date RFC822 format") +def _(): + dt = NewsEngine._parse_rss_date("Mon, 12 May 2025 14:30:00 +0000") + assert dt is not None + assert dt.year == 2025 + assert dt.month == 5 + assert dt.day == 12 + assert dt.tzinfo is not None + + +@test("NewsEngine._parse_rss_date ISO format") +def _(): + dt = NewsEngine._parse_rss_date("2025-05-12T14:30:00+00:00") + assert dt is not None + assert dt.year == 2025 + + +@test("NewsEngine._parse_rss_date naive format assumes UTC") +def _(): + dt = NewsEngine._parse_rss_date("2025-05-12 14:30:00") + assert dt is not None + assert dt.tzinfo == timezone.utc + + +@test("NewsEngine._parse_rss_date None input") +def _(): + assert NewsEngine._parse_rss_date(None) is None + + +@test("NewsEngine._parse_rss_date garbage input") +def _(): + assert NewsEngine._parse_rss_date("not-a-date") is None + + +@test("NewsEngine._get_affected_pairs") +def _(): + pairs = NewsEngine._get_affected_pairs("USD") + assert "EURUSD=X" in pairs + assert "USDJPY=X" in pairs + assert "GBPUSD=X" in pairs + assert "XAU/USD" in pairs + + pairs_eur = NewsEngine._get_affected_pairs("EUR") + assert "EURUSD=X" in pairs_eur + + pairs_unknown = NewsEngine._get_affected_pairs("ARS") + assert pairs_unknown == [] + + +@test("EconomicEvent properties") +def _(): + future_time = datetime.now(timezone.utc) + timedelta(minutes=30) + event = EconomicEvent( + time=future_time, currency="USD", + impact="high", event="NFP", + ) + assert event.is_upcoming is True + assert 25 < event.minutes_until < 35 + assert event.is_high_impact is True + + past_time = datetime.now(timezone.utc) - timedelta(minutes=10) + past_event = EconomicEvent( + time=past_time, currency="EUR", + impact="low", event="CPI", + ) + assert past_event.is_upcoming is False + assert past_event.is_high_impact is False + + +@test("NewsEngine.get_event_risk_level danger zone") +def _(): + ne = NewsEngine(NewsConfig()) + context = FundamentalContext() + context.events_upcoming_1h = [ + EconomicEvent( + time=datetime.now(timezone.utc) + timedelta(minutes=15), + currency="USD", impact="high", event="NFP", + ) + ] + risk, desc = ne.get_event_risk_level("EURUSD=X", 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("EURUSD=X", context) + assert risk == "caution" + + +@test("NewsEngine.get_event_risk_level clear when no events") +def _(): + ne = NewsEngine(NewsConfig()) + context = FundamentalContext() + risk, desc = ne.get_event_risk_level("EURUSD=X", 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("EURUSD=X", context) + assert risk == "clear" + + +@test("NewsEngine.get_pair_sentiment with positive base sentiment") +def _(): + ne = NewsEngine(NewsConfig()) + context = FundamentalContext() + context.news_sentiment = {"EUR": 0.5, "USD": -0.1} + score, desc = ne.get_pair_sentiment("EURUSD=X", context) + assert score > 0, "EUR bullish + USD bearish should be net bullish for EUR/USD" + assert "bullish" in desc.lower() + + +@test("NewsEngine.get_pair_sentiment with negative pair sentiment") +def _(): + ne = NewsEngine(NewsConfig()) + context = FundamentalContext() + context.news_sentiment = {"EUR": -0.5, "USD": 0.3} + score, desc = ne.get_pair_sentiment("EURUSD=X", context) + assert score < 0 + + +@test("NewsEngine.get_pair_sentiment neutral") +def _(): + ne = NewsEngine(NewsConfig()) + context = FundamentalContext() + context.news_sentiment = {"EUR": 0.0, "USD": 0.0} + score, desc = ne.get_pair_sentiment("EURUSD=X", context) + assert score == 0.0 + assert "neutral" in desc.lower() + + +@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="EURUSD=X", display_name="EUR/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="EURUSD=X", display_name="EUR/USD", direction="BUY", + entry_price=1.1000, stop_loss=1.0950, take_profit_1=1.1100, + take_profit_2=1.1200, risk_reward=2.0, risk_pips=50, reward_pips=100, + pip_value=0.0001, + ) + assert ts.direction == "BUY" + assert ts.risk_reward == 2.0 + assert ts.entry_price > ts.stop_loss + + +@test("SignalAggregator._check_mtf_agreement with 2 bullish") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + report = SignalReport(symbol="EURUSD=X", display_name="EUR/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="EURUSD=X", display_name="EUR/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="EURUSD=X", display_name="EUR/USD", timestamp="now") + report.mtf_signals = {"1h": SignalStrength.BUY} + assert agg._check_mtf_agreement(report) is False + + +@test("SignalAggregator._forex_sell_qualifies always returns False") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + report = SignalReport(symbol="EURUSD=X", display_name="EUR/USD", timestamp="now") + assert agg._forex_sell_qualifies(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("EURUSD=X", dfs, use_news=False) + assert isinstance(report, SignalReport) + assert report.symbol == "EURUSD=X" + assert report.display_name == "EUR/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("EURUSD=X", dfs, use_news=False) + assert report.signal_quality in ("A+", "A", "B", "C", "D", "F") + assert report.action_recommendation != "" + + +@test("SignalAggregator SELL suppression for forex pairs") +def _(): + config = PlatformConfig() + config.backtest.sell_filter = True + agg = SignalAggregator(config) + dfs = { + Timeframe.H1: make_ohlcv(300, trend=-0.005, seed=42), + Timeframe.M15: make_ohlcv(300, trend=-0.005, seed=43), + Timeframe.H4: make_ohlcv(300, trend=-0.005, seed=44), + } + report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + if report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL): + # Should have been suppressed + pass # sell suppression happened during _compute_final_signal + # At minimum, verify no crash + + +@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("EURUSD=X", 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("EURUSD=X", 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.backtest.blocked_hours_utc = list(range(24)) # block all hours + 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("EURUSD=X", 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("EURUSD=X", 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 EUR strategy check (conditions not met)") +def _(): + config = PlatformConfig() + config.eur_strategy.enabled = True + 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("EURUSD=X", dfs, use_news=False) + assert isinstance(report, SignalReport) + + +@test("SignalAggregator GBP strategy check (conditions not met)") +def _(): + config = PlatformConfig() + config.gbp_strategy.enabled = True + 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("GBPUSD=X", 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("EURUSD=X", dfs, context=context, use_news=True) + assert any("EVENT RISK" in w or "event" in w.lower() for w in report.warnings) + + +# ══════════════════════════════════════════════════════════════════════ +# MODULE 6: main.py +# ══════════════════════════════════════════════════════════════════════ + +print("\n═══ Module 6: main.py ═══") + +from main import ( + signal_color, quality_color, _compact_reason, + report_to_dict, build_config, Colors, +) + + +@test("signal_color maps all SignalStrength values") +def _(): + for sig in SignalStrength: + color = signal_color(sig) + assert isinstance(color, str) + + +@test("quality_color maps all quality grades") +def _(): + for q in ("A+", "A", "B", "C", "D", "F"): + color = quality_color(q) + assert isinstance(color, str) + + +@test("_compact_reason strips STRONG prefix") +def _(): + result = _compact_reason("STRONG: EMA Trend: Full bullish alignment") + assert not result.startswith("STRONG: ") + + +@test("_compact_reason strips indicator prefix") +def _(): + result = _compact_reason("RSI: RSI 45.3 — Neutral") + assert not result.startswith("RSI: ") + + +@test("_compact_reason strips detail after pipe") +def _(): + result = _compact_reason("EMA alignment: 3/4 | Golden cross (fast/medium)") + assert "|" not in result + + +@test("_compact_reason handles plain text") +def _(): + result = _compact_reason("Simple reason text") + assert result == "Simple reason text" + + +@test("report_to_dict basic conversion") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = { + Timeframe.H1: make_ohlcv(300, seed=42), + Timeframe.M15: make_ohlcv(300, seed=43), + Timeframe.H4: make_ohlcv(300, seed=44), + } + report = agg.analyze_from_dataframes("EURUSD=X", 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"] == "EURUSD=X" + assert d["display_name"] == "EUR/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("EURUSD=X", 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("EURUSD=X", 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: + timeframe = "H4" + config = build_config(FakeArgs()) + assert config.primary_timeframe == Timeframe.H4 + assert Timeframe.H1 in config.confirmation_timeframes + assert Timeframe.D1 in config.confirmation_timeframes + + +@test("build_config all valid timeframes") +def _(): + for tf_str in ["M5", "M15", "M30", "H1", "H4", "D1"]: + class FakeArgs: + timeframe = tf_str + config = build_config(FakeArgs()) + assert config.primary_timeframe is not None + + +# ══════════════════════════════════════════════════════════════════════ +# MODULE 7: telegram_bot.py +# ══════════════════════════════════════════════════════════════════════ + +print("\n═══ Module 7: telegram_bot.py ═══") + +from telegram_bot import ( + _escape_md, _signal_label, _mtf_line, + format_signal_message, _format_scan_summary, + _seconds_until_boundary, _check_level_breach, + _detect_correlated_exposure, _format_stats_dashboard, + scan_keyboard, SCAN_BUTTONS, SCAN_LOOKUP, + _update_level_cache, _last_prices, _level_cache, _level_cache_ts, + _WS_TO_PAIR, +) + + +@test("_escape_md escapes markdown characters") +def _(): + assert _escape_md("hello *world*") == "hello \\*world\\*" + assert _escape_md("test_value") == "test\\_value" + assert _escape_md("`code`") == "\\`code\\`" + assert _escape_md("[link]") == "\\[link\\]" + + +@test("_signal_label formatting") +def _(): + assert _signal_label(SignalStrength.STRONG_BUY) == "STRONG BUY" + assert _signal_label(SignalStrength.STRONG_SELL) == "STRONG SELL" + assert _signal_label(SignalStrength.NEUTRAL) == "NEUTRAL" + assert _signal_label(SignalStrength.BUY) == "BUY" + assert _signal_label(SignalStrength.SELL) == "SELL" + + +@test("_mtf_line formatting with signals") +def _(): + report = SignalReport(symbol="EURUSD=X", display_name="EUR/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="EURUSD=X", display_name="EUR/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("EURUSD=X", dfs, use_news=False) + msg = format_signal_message(report) + assert isinstance(msg, str) + assert len(msg) > 0 + assert "EUR/USD" in msg + assert "Signal Analysis" in msg + assert "Price" in msg + assert "Indicators" in msg + + +@test("_format_scan_summary produces valid text") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = { + Timeframe.H1: make_ohlcv(300, seed=42), + Timeframe.M15: make_ohlcv(300, seed=43), + Timeframe.H4: make_ohlcv(300, seed=44), + } + reports = [agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False)] + summary = _format_scan_summary(reports, Timeframe.H1) + assert "Auto-Scan" in summary + assert "EUR/USD" in summary + + +@test("_seconds_until_boundary returns positive value") +def _(): + result = _seconds_until_boundary(15, buffer=10) + assert result > 0 + assert result <= 15 * 60 + 10 + 1 + + +@test("_seconds_until_boundary for M5 interval") +def _(): + result = _seconds_until_boundary(5, buffer=10) + assert result > 0 + assert result <= 5 * 60 + 10 + 1 + + +@test("_seconds_until_boundary for H1 interval") +def _(): + result = _seconds_until_boundary(60, buffer=10) + assert result > 0 + assert result <= 60 * 60 + 10 + 1 + + +@test("_seconds_until_boundary for H4 interval") +def _(): + result = _seconds_until_boundary(240, buffer=10) + assert result > 0 + assert result <= 240 * 60 + 10 + 1 + + +@test("_check_level_breach detects upward cross") +def _(): + _level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000} + _level_cache_ts["TEST_PAIR"] = time.time() + _last_prices["TEST_PAIR"] = 1.0999 + result = _check_level_breach("TEST_PAIR", 1.1001) + assert result == "swing_high_1" + # cleanup + del _level_cache["TEST_PAIR"] + del _level_cache_ts["TEST_PAIR"] + del _last_prices["TEST_PAIR"] + + +@test("_check_level_breach detects downward cross") +def _(): + _level_cache["TEST_PAIR"] = {"swing_low_1": 1.0900} + _level_cache_ts["TEST_PAIR"] = time.time() + _last_prices["TEST_PAIR"] = 1.0901 + result = _check_level_breach("TEST_PAIR", 1.0899) + assert result == "swing_low_1" + del _level_cache["TEST_PAIR"] + del _level_cache_ts["TEST_PAIR"] + del _last_prices["TEST_PAIR"] + + +@test("_check_level_breach no breach when price stays same side") +def _(): + _level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000} + _level_cache_ts["TEST_PAIR"] = time.time() + _last_prices["TEST_PAIR"] = 1.0990 + result = _check_level_breach("TEST_PAIR", 1.0995) + assert result is None + del _level_cache["TEST_PAIR"] + del _level_cache_ts["TEST_PAIR"] + del _last_prices["TEST_PAIR"] + + +@test("_check_level_breach returns None for expired cache") +def _(): + _level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000} + _level_cache_ts["TEST_PAIR"] = time.time() - 7200 # 2h old + _last_prices["TEST_PAIR"] = 1.0999 + result = _check_level_breach("TEST_PAIR", 1.1001) + assert result is None + del _level_cache["TEST_PAIR"] + del _level_cache_ts["TEST_PAIR"] + del _last_prices["TEST_PAIR"] + + +@test("_check_level_breach returns None when no previous price") +def _(): + _level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000} + _level_cache_ts["TEST_PAIR"] = time.time() + if "TEST_PAIR" in _last_prices: + del _last_prices["TEST_PAIR"] + result = _check_level_breach("TEST_PAIR", 1.1001) + assert result is None + del _level_cache["TEST_PAIR"] + del _level_cache_ts["TEST_PAIR"] + + +@test("_detect_correlated_exposure warns on same-direction signals") +def _(): + reports = [ + SignalReport(symbol=EURUSD, display_name="EUR/USD", timestamp="now", + final_signal=SignalStrength.BUY), + SignalReport(symbol=GBPUSD, display_name="GBP/USD", timestamp="now", + final_signal=SignalStrength.BUY), + ] + warnings = _detect_correlated_exposure(reports) + assert len(warnings) > 0 + assert "Same exposure" in warnings[0] + + +@test("_detect_correlated_exposure no warning for opposite directions") +def _(): + reports = [ + SignalReport(symbol=EURUSD, display_name="EUR/USD", timestamp="now", + final_signal=SignalStrength.BUY), + SignalReport(symbol=GBPUSD, display_name="GBP/USD", timestamp="now", + final_signal=SignalStrength.SELL), + ] + warnings = _detect_correlated_exposure(reports) + assert len(warnings) == 0 + + +@test("_detect_correlated_exposure no warning for neutral signals") +def _(): + reports = [ + SignalReport(symbol=EURUSD, display_name="EUR/USD", timestamp="now", + final_signal=SignalStrength.NEUTRAL), + SignalReport(symbol=GBPUSD, display_name="GBP/USD", timestamp="now", + final_signal=SignalStrength.NEUTRAL), + ] + warnings = _detect_correlated_exposure(reports) + assert len(warnings) == 0 + + +@test("SCAN_BUTTONS and SCAN_LOOKUP consistency") +def _(): + assert len(SCAN_BUTTONS) == 9 # 3 pairs × 3 TFs + for btn in SCAN_BUTTONS: + assert btn["cb"] in SCAN_LOOKUP + assert "symbol" in btn + assert "tf" in btn + assert "label" in btn + + +@test("scan_keyboard returns InlineKeyboardMarkup") +def _(): + kb = scan_keyboard() + assert kb is not None + + +@test("_update_level_cache populates cache from reports") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = { + Timeframe.H1: make_ohlcv(300, seed=42), + Timeframe.M15: make_ohlcv(300, seed=43), + Timeframe.H4: make_ohlcv(300, seed=44), + } + report = agg.analyze_from_dataframes("EURUSD=X", 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 "EURUSD=X" in _level_cache or len(report.primary_analysis.key_levels) == 0 + _level_cache.clear() + _level_cache_ts.clear() + + +@test("_WS_TO_PAIR mapping is correct") +def _(): + assert _WS_TO_PAIR["EUR/USD"] == EURUSD + assert _WS_TO_PAIR["XAU/USD"] == XAUUSD + + +@test("_format_stats_dashboard returns waiting message when empty") +def _(): + import telegram_bot + old_stats = telegram_bot._signal_stats.copy() + telegram_bot._signal_stats.clear() + text = _format_stats_dashboard() + assert "Waiting" in text or "waiting" in text.lower() + telegram_bot._signal_stats.update(old_stats) + + +# ══════════════════════════════════════════════════════════════════════ +# 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, "EURUSD=X", "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("EURUSD=X", dfs, use_news=False) + d = report_to_dict(report) + assert isinstance(d, dict) + assert d["symbol"] == "EURUSD=X" + + +@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("EURUSD=X", dfs, use_news=False) + msg = format_signal_message(report) + assert isinstance(msg, str) + assert len(msg) > 100 + + +@test("Multiple pair analysis consistency") +def _(): + config = PlatformConfig() + for pair, seed_base in [(EURUSD, 10), (GBPUSD, 20), (XAUUSD, 30)]: + agg = SignalAggregator(config) + if pair == XAUUSD: + 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), + } + else: + dfs = { + Timeframe.H1: make_ohlcv(300, seed=seed_base), + Timeframe.M15: make_ohlcv(300, seed=seed_base+1), + Timeframe.H4: make_ohlcv(300, seed=seed_base+2), + } + report = agg.analyze_from_dataframes(pair, dfs, use_news=False) + assert report.symbol == pair + 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, "EURUSD=X", "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, "EURUSD=X", "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(), "EURUSD=X", "1h") + r2 = engine.analyze(df.copy(), "EURUSD=X", "1h") + assert r1.composite_score == r2.composite_score + assert r1.composite_signal == r2.composite_signal + assert r1.market_regime == r2.market_regime + + +# ══════════════════════════════════════════════════════════════════════ +# SUMMARY +# ══════════════════════════════════════════════════════════════════════ + +print(f"\n{'═' * 60}") +print(f" RESULTS: {PASS} passed, {FAIL} failed, {PASS + FAIL} total") +print(f"{'═' * 60}") + +if ERRORS: + print("\n FAILURES:") + for name, err, tb in ERRORS: + print(f"\n ✗ {name}") + print(f" Error: {err}") + for line in tb.strip().split("\n")[-3:]: + print(f" {line}") + +print() +sys.exit(1 if FAIL > 0 else 0)