Files
ForexBot2/test_all.py
T
Harwinder Sekhon 91b4a6bb5e Tiingo bid/ask spread monitor + logging hardening
- tiingo_provider.py: /tiingo/fx/top quotes (10s TTL cache, graceful
  None on any failure); spread log writer. Signals never consume
  bid/ask; warn-only by design (no historical bid/ask to validate a
  gate against — live == backtest rule).
- app_config: repo-root .env loader (setdefault; shell env still wins),
  tiingo_api_key resolver (TIINGO_API_KEY/TIINGO → config.json),
  telegram.channel_spread_detail flag (default false).
- telegram_bot: 5-min spread sampler → logs/spread_log.csv (weekend-
  aware), alert-time quote refresh, bid/ask + wide-spread warning in
  signal messages, /status spread line. Raw numbers to owner DM always;
  channel only while channel_spread_detail=true (Tiingo free tier is
  internal-use-only; flip before public launch). Free-tier limit math:
  worst case ~41 req/h vs 50/h cap, one pair.
- Logging: 3h rotation x 240 backups (30-day retention), full-date
  stamps, force=True root handlers; candle loops + sampler survive
  failed cycles; breach-task exceptions logged via done-callback;
  PTB error handler; sys/threading excepthooks in main().
- test_all.py: Module 14 (tiingo, dotenv, spread routing, loop
  resilience, rotation) — 215 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVRmy6KspMs96o5PTPYFAB
2026-07-22 17:18:14 -04:00

3166 lines
118 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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__))
# ── 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", "TIINGO_API_KEY", "TIINGO"):
os.environ.pop(_k, None)
_TEST_APP = app_config.AppConfig(
provider_settings={"twelve_data": {"api_key": "test-key"}}
)
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: data_provider.py
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 2: data_provider.py ═══")
from data_provider import ForexDataProvider, DataIntegrityError
@test("ForexDataProvider._to_td_symbol conversion")
def _():
assert ForexDataProvider._to_td_symbol("XAU/USD") == "XAU/USD"
@test("ForexDataProvider TD_INTERVALS complete mapping")
def _():
dp = ForexDataProvider(PlatformConfig())
for tf in Timeframe:
assert tf in dp.TD_INTERVALS, f"Missing TD_INTERVALS mapping for {tf}"
@test("ForexDataProvider BARS_PER_DAY mapping")
def _():
dp = ForexDataProvider(PlatformConfig())
for tf in Timeframe:
assert tf in dp.BARS_PER_DAY, f"Missing BARS_PER_DAY for {tf}"
assert dp.BARS_PER_DAY[Timeframe.H1] == 24
assert dp.BARS_PER_DAY[Timeframe.M15] == 96
assert dp.BARS_PER_DAY[Timeframe.D1] == 1
@test("ForexDataProvider CANDLE_SECONDS mapping")
def _():
dp = ForexDataProvider(PlatformConfig())
for tf in Timeframe:
assert tf in dp.CANDLE_SECONDS, f"Missing CANDLE_SECONDS for {tf}"
assert dp.CANDLE_SECONDS[Timeframe.M1] == 60
assert dp.CANDLE_SECONDS[Timeframe.H1] == 3600
assert dp.CANDLE_SECONDS[Timeframe.D1] == 86400
@test("ForexDataProvider._validate_and_clean basic valid data")
def _():
dp = ForexDataProvider(PlatformConfig())
df = make_ohlcv(300)
result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1)
assert len(result) > 0
assert result.index.is_monotonic_increasing
assert not result.index.has_duplicates
@test("ForexDataProvider._validate_and_clean empty df raises error")
def _():
dp = ForexDataProvider(PlatformConfig())
try:
dp._validate_and_clean(pd.DataFrame(), "XAU/USD", Timeframe.H1)
assert False, "Should have raised DataIntegrityError"
except DataIntegrityError:
pass
@test("ForexDataProvider._validate_and_clean None df raises error")
def _():
dp = ForexDataProvider(PlatformConfig())
try:
dp._validate_and_clean(None, "XAU/USD", Timeframe.H1)
assert False, "Should have raised DataIntegrityError"
except DataIntegrityError:
pass
@test("ForexDataProvider._validate_and_clean fixes OHLC inconsistencies")
def _():
dp = ForexDataProvider(PlatformConfig())
df = make_ohlcv(300)
df.iloc[10, df.columns.get_loc("High")] = df.iloc[10]["Low"] - 1
result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1)
assert (result["High"] >= result["Low"]).all()
@test("ForexDataProvider._validate_and_clean drops future timestamps")
def _():
dp = ForexDataProvider(PlatformConfig())
df = make_ohlcv(300)
future = pd.Timestamp.now(tz="UTC") + pd.Timedelta(hours=10)
df.loc[future] = df.iloc[-1]
result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1)
assert (result.index <= pd.Timestamp.utcnow() + pd.Timedelta(minutes=5)).all()
@test("ForexDataProvider._validate_and_clean removes duplicates")
def _():
dp = ForexDataProvider(PlatformConfig())
df = make_ohlcv(300)
dup = pd.concat([df, df.iloc[-5:]])
assert dup.index.has_duplicates, "Setup: should have duplicates"
result = dp._validate_and_clean(dup, "XAU/USD", Timeframe.H1)
assert not result.index.has_duplicates, "Should remove duplicate indices"
@test("ForexDataProvider._validate_and_clean forward fills NaN (limit 3)")
def _():
dp = ForexDataProvider(PlatformConfig())
df = make_ohlcv(300)
df.iloc[100, df.columns.get_loc("Close")] = np.nan
df.iloc[101, df.columns.get_loc("Close")] = np.nan
result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1)
assert not result["Close"].isna().any()
@test("ForexDataProvider._is_data_fresh weekend override")
def _():
dp = ForexDataProvider(PlatformConfig())
# Create data that ends on a Friday, call _is_data_fresh on a Saturday
friday = pd.Timestamp("2025-05-09 16:00:00", tz="UTC")
old_dates = pd.date_range(end=friday, periods=300, freq="h")
df = make_ohlcv(300)
df.index = old_dates
# Simulate calling on Saturday by patching pd.Timestamp.now
saturday = pd.Timestamp("2025-05-10 12:00:00", tz="UTC")
original_now = pd.Timestamp.now
with patch.object(pd.Timestamp, "now", return_value=saturday):
result = dp._is_data_fresh(df, "XAU/USD", Timeframe.H1)
assert result is True, "Weekend data should always be considered fresh"
@test("ForexDataProvider._is_data_fresh returns False for empty df")
def _():
dp = ForexDataProvider(PlatformConfig())
assert dp._is_data_fresh(pd.DataFrame(), "XAU/USD", Timeframe.H1) is False
assert dp._is_data_fresh(None, "XAU/USD", Timeframe.H1) is False
@test("ForexDataProvider.clear_cache clears all entries")
def _():
dp = ForexDataProvider(PlatformConfig())
dp._cache["test_key"] = make_ohlcv(10)
dp._cache_timestamps["test_key"] = time.time()
count = dp.clear_cache()
assert count == 1
assert len(dp._cache) == 0
assert len(dp._cache_timestamps) == 0
@test("ForexDataProvider.get_pair_info returns correct info")
def _():
dp = ForexDataProvider(PlatformConfig())
info = dp.get_pair_info(XAUUSD)
assert info["symbol"] == XAUUSD
assert info["display"] == "XAU/USD"
assert info["pip_value"] == 0.10
info_unknown = dp.get_pair_info("UNKNOWN")
assert info_unknown["display"] == "XAU/USD"
@test("ForexDataProvider._throttle rate limiting logic")
def _():
dp = ForexDataProvider(PlatformConfig())
dp._request_times.clear()
for _ in range(10):
dp._throttle(credits=1)
assert len(dp._request_times) == 10
@test("ForexDataProvider caching - in-memory cache returns same data")
def _():
dp = ForexDataProvider(PlatformConfig())
df = make_ohlcv(300)
dp._cache["XAU/USD_1h"] = df
dp._cache_timestamps["XAU/USD_1h"] = time.time()
cached = dp._cache.get("XAU/USD_1h")
assert cached is df
# ══════════════════════════════════════════════════════════════════════
# MODULE 3: engine.py
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 3: engine.py ═══")
from engine import TechnicalEngine, TechnicalAnalysis, IndicatorSignal
@test("TechnicalEngine.analyze returns TechnicalAnalysis with insufficient data")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(10)
result = engine.analyze(df, "XAU/USD", "1h")
assert isinstance(result, TechnicalAnalysis)
assert result.confidence == 0.0
@test("TechnicalEngine.analyze returns TechnicalAnalysis with None df")
def _():
engine = TechnicalEngine(TechnicalConfig())
result = engine.analyze(None, "XAU/USD", "1h")
assert isinstance(result, TechnicalAnalysis)
assert result.confidence == 0.0
@test("TechnicalEngine.analyze returns valid result with good data")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert isinstance(result, TechnicalAnalysis)
assert result.symbol == "XAU/USD"
assert result.timeframe == "1h"
assert result.composite_signal in list(SignalStrength)
assert -100 <= result.composite_score <= 100
assert 0 <= result.confidence <= 100
@test("TechnicalEngine EMA-4 mode computes all 4 EMAs")
def _():
config = TechnicalConfig(ema_mode="4ema")
engine = TechnicalEngine(config)
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "ema_trend" in result.indicators
ema_ind = result.indicators["ema_trend"]
assert isinstance(ema_ind, IndicatorSignal)
assert ema_ind.name == "EMA Trend"
assert ema_ind.signal in list(SignalStrength)
assert "ema_fast" in result.key_levels
assert "ema_medium" in result.key_levels
assert "ema_slow" in result.key_levels
@test("TechnicalEngine EMA-2 mode computes EMA50/200")
def _():
config = TechnicalConfig(ema_mode="2ema")
engine = TechnicalEngine(config)
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "ema_trend" in result.indicators
assert "ema_trend" in result.key_levels or "ema_slow" in result.key_levels
@test("TechnicalEngine RSI computation")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "rsi" in result.indicators
rsi = result.indicators["rsi"]
assert 0 <= rsi.value <= 100, f"RSI value {rsi.value} out of range"
assert rsi.signal in list(SignalStrength)
assert rsi.weight == 1.5
@test("TechnicalEngine RSI with NaN handling")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
df["Close"] = df["Close"].iloc[0] # flat price = 0 delta
result = engine.analyze(df, "XAU/USD", "1h")
rsi = result.indicators.get("rsi")
if rsi:
assert not pd.isna(rsi.value), "RSI should handle NaN gracefully"
@test("TechnicalEngine Bollinger Bands computation")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "bollinger" in result.indicators
bb = result.indicators["bollinger"]
assert isinstance(bb.value, float)
assert "bb_upper" in result.key_levels
assert "bb_lower" in result.key_levels
assert "bb_mid" in result.key_levels
assert result.key_levels["bb_upper"] > result.key_levels["bb_lower"]
@test("TechnicalEngine ADX computation")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "adx" in result.indicators
adx = result.indicators["adx"]
assert adx.value >= 0, f"ADX value {adx.value} should be >= 0"
assert "+DI" in adx.description
assert "-DI" in adx.description
@test("TechnicalEngine ATR computation and volatility regime")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "atr" in result.indicators
atr = result.indicators["atr"]
assert atr.value > 0, "ATR should be positive"
assert atr.weight == 0.0, "ATR weight should be 0 (not directional)"
assert result.volatility_regime in ("low", "normal", "high", "extreme")
assert "atr" in result.key_levels
assert "atr_pct" in result.key_levels
@test("TechnicalEngine MACD as pillar (enabled by default)")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "macd" in result.indicators
macd = result.indicators["macd"]
assert macd.weight == 1.5
@test("TechnicalEngine MACD not as pillar when disabled")
def _():
config = TechnicalConfig(macd_as_pillar=False)
engine = TechnicalEngine(config)
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "macd" not in result.indicators
assert hasattr(result, "_macd_histogram")
@test("TechnicalEngine disabled indicators are skipped")
def _():
config = TechnicalConfig(disabled_indicators=["rsi", "bollinger", "divergence"])
engine = TechnicalEngine(config)
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "rsi" not in result.indicators
assert "bollinger" not in result.indicators
@test("TechnicalEngine candlestick pattern detection")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
# Create a hammer pattern: long lower wick, small body, no upper wick
idx = -1
close_val = df["Close"].iloc[idx]
df.iloc[idx, df.columns.get_loc("Open")] = close_val - 0.0001
df.iloc[idx, df.columns.get_loc("Close")] = close_val
df.iloc[idx, df.columns.get_loc("High")] = close_val + 0.00005
df.iloc[idx, df.columns.get_loc("Low")] = close_val - 0.001
result = engine.analyze(df, "XAU/USD", "1h")
# Pattern may or may not be detected depending on threshold, just ensure no crash
@test("TechnicalEngine swing point detection")
def _():
engine = TechnicalEngine(TechnicalConfig())
series = pd.Series([10, 8, 6, 4, 6, 8, 10, 8, 6, 4, 6, 8, 10])
lows = engine._find_swing_points(series, mode="low", window=2)
highs = engine._find_swing_points(series, mode="high", window=2)
assert len(lows) > 0, "Should find swing lows in V-pattern data"
assert any(v == 4 for _, v in lows), "Should find value 4 as swing low"
assert len(highs) > 0, "Should find swing highs"
@test("TechnicalEngine swing points with NaN values")
def _():
engine = TechnicalEngine(TechnicalConfig())
series = pd.Series([10, 8, np.nan, 4, 6, 8, 10, 8, 6, 4, 6, 8, 10])
lows = engine._find_swing_points(series, mode="low", window=2)
# Should not crash
@test("TechnicalEngine market regime classification")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300, trend=0.001) # strong uptrend
result = engine.analyze(df, "XAU/USD", "1h")
assert result.market_regime in ("trending_up", "trending_down", "trending", "ranging")
@test("TechnicalEngine composite score within bounds")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert -100 <= result.composite_score <= 100
assert result.composite_signal in list(SignalStrength)
@test("TechnicalEngine regime-aware RSI reinterpretation")
def _():
config = TechnicalConfig(rsi_mode="regime_aware")
engine = TechnicalEngine(config)
df = make_ohlcv(300, trend=0.001)
result = engine.analyze(df, "XAU/USD", "1h")
rsi = result.indicators.get("rsi")
assert rsi is not None
@test("TechnicalEngine structure S/R levels detection")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
swing_keys = [k for k in result.key_levels if k.startswith("swing_")]
assert len(swing_keys) >= 0 # may or may not have swings
@test("TechnicalEngine divergence detection (enabled by default)")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
# divergence may or may not be detected depending on data, just ensure no crash
@test("TechnicalEngine divergence detection (disabled)")
def _():
config = TechnicalConfig(disabled_indicators=["divergence"])
engine = TechnicalEngine(config)
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "divergence" not in result.indicators
@test("TechnicalEngine EMA-2 golden cross detection")
def _():
config = TechnicalConfig(ema_mode="2ema")
engine = TechnicalEngine(config)
df = make_ohlcv(300, trend=0.002)
result = engine.analyze(df, "XAU/USD", "1h")
assert "ema_trend" in result.indicators
@test("TechnicalEngine with very short data")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(31)
result = engine.analyze(df, "XAU/USD", "1h")
assert isinstance(result, TechnicalAnalysis)
@test("TechnicalEngine ROC computation")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300, trend=0.002)
result = engine.analyze(df, "XAU/USD", "1h")
assert "roc" in result.indicators
roc = result.indicators["roc"]
assert roc.weight == 1.5
assert roc.signal in list(SignalStrength)
@test("TechnicalEngine Keltner Channels computation")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "keltner" in result.indicators
kc = result.indicators["keltner"]
assert kc.weight == 1.5
assert "kc_upper" in result.key_levels
assert "kc_lower" in result.key_levels
assert "kc_mid" in result.key_levels
assert result.key_levels["kc_upper"] > result.key_levels["kc_lower"]
@test("TechnicalEngine ADX slope computation")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300, trend=0.002)
result = engine.analyze(df, "XAU/USD", "1h")
assert "adx" in result.indicators
assert hasattr(result, "_adx_slope")
@test("TechnicalEngine transitioning regime detection")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert result.market_regime in (
"trending_up", "trending_down", "trending", "ranging", "transitioning"
)
@test("TechnicalEngine gold-specific composite weights")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_xau_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert -100 <= result.composite_score <= 100
assert result.composite_signal in list(SignalStrength)
@test("TechnicalEngine EMA ribbon compression detection")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "ema_trend" in result.indicators
# ══════════════════════════════════════════════════════════════════════
# MODULE 4: news_engine.py
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 4: news_engine.py ═══")
from news_engine import NewsEngine, FundamentalContext, EconomicEvent, NewsSentiment
@test("NewsEngine._parse_pair with slash")
def _():
base, quote = NewsEngine._parse_pair("XAU/USD")
assert base == "XAU"
assert quote == "USD"
@test("NewsEngine._parse_pair with =X suffix")
def _():
base, quote = NewsEngine._parse_pair("EURUSD=X")
assert base == "EUR"
assert quote == "USD"
@test("NewsEngine._parse_pair for XAU pair")
def _():
base, quote = NewsEngine._parse_pair("XAU/USD")
assert base == "XAU"
assert quote == "USD"
@test("NewsEngine._analyze_headline_sentiment bullish")
def _():
ne = NewsEngine(NewsConfig())
score = ne._analyze_headline_sentiment("Fed hawkish rate hike boosts dollar rally")
assert score > 0, f"Expected positive sentiment, got {score}"
@test("NewsEngine._analyze_headline_sentiment bearish")
def _():
ne = NewsEngine(NewsConfig())
score = ne._analyze_headline_sentiment("Recession fears drive dovish rate cut expectations")
assert score < 0, f"Expected negative sentiment, got {score}"
@test("NewsEngine._analyze_headline_sentiment neutral")
def _():
ne = NewsEngine(NewsConfig())
score = ne._analyze_headline_sentiment("Markets remain quiet ahead of data release")
assert score == 0.0
@test("NewsEngine._extract_currencies USD")
def _():
ne = NewsEngine(NewsConfig())
currencies = ne._extract_currencies("Federal Reserve raises interest rates, dollar soars")
assert "USD" in currencies
@test("NewsEngine._extract_currencies EUR")
def _():
ne = NewsEngine(NewsConfig())
currencies = ne._extract_currencies("ECB meeting pushes euro higher")
assert "EUR" in currencies
@test("NewsEngine._extract_currencies GBP")
def _():
ne = NewsEngine(NewsConfig())
currencies = ne._extract_currencies("Bank of England holds rates, pound steady")
assert "GBP" in currencies
@test("NewsEngine._extract_currencies XAU")
def _():
ne = NewsEngine(NewsConfig())
currencies = ne._extract_currencies("Gold prices surge as safe haven demand rises")
assert "XAU" in currencies
@test("NewsEngine._extract_currencies multiple")
def _():
ne = NewsEngine(NewsConfig())
currencies = ne._extract_currencies("Gold drops as Fed hawkish, dollar surges")
assert "USD" in currencies
assert "XAU" in currencies
@test("NewsEngine._extract_keywords")
def _():
ne = NewsEngine(NewsConfig())
kws = ne._extract_keywords("Hawkish Fed rate hike surprises with positive surprise")
assert "rate hike" in kws
assert "hawkish" in kws
assert "positive surprise" in kws
@test("NewsEngine._parse_rss_date RFC822 format")
def _():
dt = NewsEngine._parse_rss_date("Mon, 12 May 2025 14:30:00 +0000")
assert dt is not None
assert dt.year == 2025
assert dt.month == 5
assert dt.day == 12
assert dt.tzinfo is not None
@test("NewsEngine._parse_rss_date ISO format")
def _():
dt = NewsEngine._parse_rss_date("2025-05-12T14:30:00+00:00")
assert dt is not None
assert dt.year == 2025
@test("NewsEngine._parse_rss_date naive format assumes UTC")
def _():
dt = NewsEngine._parse_rss_date("2025-05-12 14:30:00")
assert dt is not None
assert dt.tzinfo == timezone.utc
@test("NewsEngine._parse_rss_date None input")
def _():
assert NewsEngine._parse_rss_date(None) is None
@test("NewsEngine._parse_rss_date garbage input")
def _():
assert NewsEngine._parse_rss_date("not-a-date") is None
@test("NewsEngine._get_affected_pairs")
def _():
pairs = NewsEngine._get_affected_pairs("USD")
assert "XAU/USD" in pairs
pairs_xau = NewsEngine._get_affected_pairs("XAU")
assert "XAU/USD" in pairs_xau
pairs_unknown = NewsEngine._get_affected_pairs("ARS")
assert pairs_unknown == []
@test("EconomicEvent properties")
def _():
future_time = datetime.now(timezone.utc) + timedelta(minutes=30)
event = EconomicEvent(
time=future_time, currency="USD",
impact="high", event="NFP",
)
assert event.is_upcoming is True
assert 25 < event.minutes_until < 35
assert event.is_high_impact is True
past_time = datetime.now(timezone.utc) - timedelta(minutes=10)
past_event = EconomicEvent(
time=past_time, currency="EUR",
impact="low", event="CPI",
)
assert past_event.is_upcoming is False
assert past_event.is_high_impact is False
@test("NewsEngine.get_event_risk_level danger zone")
def _():
ne = NewsEngine(NewsConfig())
context = FundamentalContext()
context.events_upcoming_1h = [
EconomicEvent(
time=datetime.now(timezone.utc) + timedelta(minutes=15),
currency="USD", impact="high", event="NFP",
)
]
risk, desc = ne.get_event_risk_level("XAU/USD", context)
assert risk == "danger"
assert "NFP" in desc
@test("NewsEngine.get_event_risk_level caution zone")
def _():
ne = NewsEngine(NewsConfig())
context = FundamentalContext()
context.events_upcoming_1h = [
EconomicEvent(
time=datetime.now(timezone.utc) + timedelta(minutes=45),
currency="USD", impact="high", event="GDP",
)
]
risk, desc = ne.get_event_risk_level("XAU/USD", context)
assert risk == "caution"
@test("NewsEngine.get_event_risk_level clear when no events")
def _():
ne = NewsEngine(NewsConfig())
context = FundamentalContext()
risk, desc = ne.get_event_risk_level("XAU/USD", context)
assert risk == "clear"
@test("NewsEngine.get_event_risk_level ignores unrelated currency events")
def _():
ne = NewsEngine(NewsConfig())
context = FundamentalContext()
context.events_upcoming_1h = [
EconomicEvent(
time=datetime.now(timezone.utc) + timedelta(minutes=15),
currency="AUD", impact="high", event="RBA Rate",
)
]
risk, _ = ne.get_event_risk_level("XAU/USD", context)
assert risk == "clear"
@test("NewsEngine.get_pair_sentiment with positive base sentiment")
def _():
ne = NewsEngine(NewsConfig())
context = FundamentalContext()
context.news_sentiment = {"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("FundamentalContext defaults")
def _():
ctx = FundamentalContext()
assert ctx.events_today == []
assert ctx.events_upcoming_1h == []
assert ctx.high_impact_warning is False
assert ctx.calendar_available is False
assert ctx.news_sentiment == {}
assert ctx.news_headlines == []
# ══════════════════════════════════════════════════════════════════════
# MODULE 5: signal_aggregator.py
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 5: signal_aggregator.py ═══")
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup
@test("SignalReport defaults")
def _():
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
assert r.final_signal == SignalStrength.NEUTRAL
assert r.final_score == 0.0
assert r.primary_signal == SignalStrength.NEUTRAL
assert r.trade_setup is None
assert r.mtf_agreement is False
assert r.signal_quality == ""
assert r.bullish_reasons == []
assert r.bearish_reasons == []
assert r.warnings == []
@test("TradeSetup dataclass creation")
def _():
ts = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=1.1000, stop_loss=1.0950, take_profit_1=1.1100,
take_profit_2=1.1200, risk_reward=2.0, risk_pips=50, reward_pips=100,
pip_value=0.0001,
)
assert ts.direction == "BUY"
assert ts.risk_reward == 2.0
assert ts.entry_price > ts.stop_loss
@test("SignalAggregator._check_mtf_agreement with 2 bullish")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.mtf_signals = {
"15min": SignalStrength.BUY,
"1h": SignalStrength.STRONG_BUY,
"4h": SignalStrength.NEUTRAL,
}
assert agg._check_mtf_agreement(report) is True
@test("SignalAggregator._check_mtf_agreement with mixed signals")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.mtf_signals = {
"15min": SignalStrength.BUY,
"1h": SignalStrength.SELL,
"4h": SignalStrength.NEUTRAL,
}
assert agg._check_mtf_agreement(report) is False
@test("SignalAggregator._check_mtf_agreement with single signal")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.mtf_signals = {"1h": SignalStrength.BUY}
assert agg._check_mtf_agreement(report) is False
@test("SignalAggregator._latest_adx computes valid value")
def _():
df = make_ohlcv(300)
adx = SignalAggregator._latest_adx(df)
assert adx is not None
assert adx >= 0
@test("SignalAggregator._latest_adx with insufficient data")
def _():
df = make_ohlcv(10)
adx = SignalAggregator._latest_adx(df)
assert adx is None
@test("SignalAggregator.analyze_from_dataframes produces valid report")
def _():
config = PlatformConfig()
config.primary_timeframe = Timeframe.H1
config.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
assert isinstance(report, SignalReport)
assert report.symbol == "XAU/USD"
assert report.display_name == "XAU/USD"
assert report.final_signal in list(SignalStrength)
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
assert report.current_price > 0
@test("SignalAggregator._compute_final_signal quality grades")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
assert report.action_recommendation != ""
@test("Event-risk signal gating obeys news.event_risk_caps_signals")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
def recompute(risk, caps):
config.news.event_risk_caps_signals = caps
report.event_risk = risk
report.event_description = "USD Test Event in 10min"
report.warnings = []
agg._compute_final_signal(report)
return report.final_confidence
baseline = recompute("clear", True)
# default XAU behavior (caps off): danger warns but does not gut
conf_off = recompute("danger", False)
assert conf_off == baseline, "flag off must not change confidence"
assert any("EVENT RISK" in w for w in report.warnings), "warning must persist"
# caps on: danger hard-caps confidence at 30
conf_on = recompute("danger", True)
assert conf_on <= 30, f"danger with caps on must cap conf at 30, got {conf_on}"
# caution with caps off is also warning-only
conf_caut_off = recompute("caution", False)
assert conf_caut_off == baseline
assert any("Upcoming event" in w for w in report.warnings)
# PlatformConfig default is off (XAU-tuned)
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, Colors,
)
@test("signal_color maps all SignalStrength values")
def _():
for sig in SignalStrength:
color = signal_color(sig)
assert isinstance(color, str)
@test("quality_color maps all quality grades")
def _():
for q in ("A+", "A", "B", "C", "D", "F"):
color = quality_color(q)
assert isinstance(color, str)
@test("_compact_reason strips STRONG prefix")
def _():
result = _compact_reason("STRONG: EMA Trend: Full bullish alignment")
assert not result.startswith("STRONG: ")
@test("_compact_reason strips indicator prefix")
def _():
result = _compact_reason("RSI: RSI 45.3 — Neutral")
assert not result.startswith("RSI: ")
@test("_compact_reason strips detail after pipe")
def _():
result = _compact_reason("EMA alignment: 3/4 | Golden cross (fast/medium)")
assert "|" not in result
@test("_compact_reason handles plain text")
def _():
result = _compact_reason("Simple reason text")
assert result == "Simple reason text"
@test("report_to_dict basic conversion")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
d = report_to_dict(report)
assert "symbol" in d
assert "signal" in d
assert "score" in d
assert "confidence" in d
assert "quality" in d
assert "action" in d
assert "market_regime" in d
assert "volatility_regime" in d
assert "mtf_agreement" in d
assert "mtf_signals" in d
assert d["symbol"] == "XAU/USD"
assert d["display_name"] == "XAU/USD"
@test("report_to_dict includes trade_setup when present")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, trend=0.003, seed=42),
Timeframe.M15: make_ohlcv(300, trend=0.003, seed=43),
Timeframe.H4: make_ohlcv(300, trend=0.003, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
d = report_to_dict(report)
if report.trade_setup:
assert "trade_setup" in d
ts = d["trade_setup"]
assert "direction" in ts
assert "entry" in ts
assert "stop_loss" in ts
assert "take_profit_1" in ts
assert "risk_reward" in ts
@test("report_to_dict includes indicators")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
d = report_to_dict(report)
assert "indicators" in d
assert "key_levels" in d
for name, ind_dict in d["indicators"].items():
assert "signal" in ind_dict
assert "confidence" in ind_dict
assert "description" in ind_dict
@test("build_config sets correct timeframe and confirmations")
def _():
class FakeArgs:
pair = "XAUUSD"
timeframe = "H4"
config = build_config(FakeArgs())
assert config.primary_timeframe == Timeframe.H4
assert Timeframe.H1 in config.confirmation_timeframes
assert Timeframe.D1 in config.confirmation_timeframes
@test("build_config all valid timeframes")
def _():
for tf_str in ["M5", "M15", "M30", "H1", "H4", "D1"]:
class FakeArgs:
pair = "XAUUSD"
timeframe = tf_str
config = build_config(FakeArgs())
assert config.primary_timeframe is not None
# ══════════════════════════════════════════════════════════════════════
# MODULE 7: telegram_bot.py
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 7: telegram_bot.py ═══")
from telegram_bot import (
_escape_md, _signal_label, _mtf_line,
format_signal_message, _format_scan_summary,
_seconds_until_boundary, _check_level_breach,
scan_keyboard, SCAN_BUTTONS, SCAN_LOOKUP,
_update_level_cache, _last_prices, _level_cache, _level_cache_ts,
_WS_TO_PAIR,
)
@test("_escape_md escapes markdown characters")
def _():
assert _escape_md("hello *world*") == "hello \\*world\\*"
assert _escape_md("test_value") == "test\\_value"
assert _escape_md("`code`") == "\\`code\\`"
assert _escape_md("[link]") == "\\[link\\]"
@test("_signal_label formatting")
def _():
assert _signal_label(SignalStrength.STRONG_BUY) == "STRONG BUY"
assert _signal_label(SignalStrength.STRONG_SELL) == "STRONG SELL"
assert _signal_label(SignalStrength.NEUTRAL) == "NEUTRAL"
assert _signal_label(SignalStrength.BUY) == "BUY"
assert _signal_label(SignalStrength.SELL) == "SELL"
@test("_mtf_line formatting with signals")
def _():
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.mtf_signals = {
"15min": SignalStrength.BUY,
"1h": SignalStrength.NEUTRAL,
}
report.mtf_scores = {"15min": 25.0, "1h": 5.0}
line = _mtf_line(report)
assert "15min" in line
assert "1h" in line
assert "BUY" in line
@test("_mtf_line empty when no signals")
def _():
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
assert _mtf_line(report) == ""
@test("format_signal_message produces valid text")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
msg = format_signal_message(report)
assert isinstance(msg, str)
assert len(msg) > 0
assert "XAU/USD" in msg
assert "Signal Analysis" in msg
assert "Price" in msg
assert "Indicators" in msg
@test("_format_scan_summary produces valid text")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
reports = [agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)]
summary = _format_scan_summary(reports, Timeframe.H1)
assert "Auto-Scan" in summary
assert "XAU/USD" in summary
@test("_seconds_until_boundary returns positive value")
def _():
result = _seconds_until_boundary(15, buffer=10)
assert result > 0
assert result <= 15 * 60 + 10 + 1
@test("_seconds_until_boundary for M5 interval")
def _():
result = _seconds_until_boundary(5, buffer=10)
assert result > 0
assert result <= 5 * 60 + 10 + 1
@test("_seconds_until_boundary for H1 interval")
def _():
result = _seconds_until_boundary(60, buffer=10)
assert result > 0
assert result <= 60 * 60 + 10 + 1
@test("_seconds_until_boundary for H4 interval")
def _():
result = _seconds_until_boundary(240, buffer=10)
assert result > 0
assert result <= 240 * 60 + 10 + 1
@test("_check_level_breach detects upward cross")
def _():
_level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000}
_level_cache_ts["TEST_PAIR"] = time.time()
_last_prices["TEST_PAIR"] = 1.0999
result = _check_level_breach("TEST_PAIR", 1.1001)
assert result == "swing_high_1"
# cleanup
del _level_cache["TEST_PAIR"]
del _level_cache_ts["TEST_PAIR"]
del _last_prices["TEST_PAIR"]
@test("_check_level_breach detects downward cross")
def _():
_level_cache["TEST_PAIR"] = {"swing_low_1": 1.0900}
_level_cache_ts["TEST_PAIR"] = time.time()
_last_prices["TEST_PAIR"] = 1.0901
result = _check_level_breach("TEST_PAIR", 1.0899)
assert result == "swing_low_1"
del _level_cache["TEST_PAIR"]
del _level_cache_ts["TEST_PAIR"]
del _last_prices["TEST_PAIR"]
@test("_check_level_breach no breach when price stays same side")
def _():
_level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000}
_level_cache_ts["TEST_PAIR"] = time.time()
_last_prices["TEST_PAIR"] = 1.0990
result = _check_level_breach("TEST_PAIR", 1.0995)
assert result is None
del _level_cache["TEST_PAIR"]
del _level_cache_ts["TEST_PAIR"]
del _last_prices["TEST_PAIR"]
@test("_check_level_breach returns None for expired cache")
def _():
_level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000}
_level_cache_ts["TEST_PAIR"] = time.time() - 7200 # 2h old
_last_prices["TEST_PAIR"] = 1.0999
result = _check_level_breach("TEST_PAIR", 1.1001)
assert result is None
del _level_cache["TEST_PAIR"]
del _level_cache_ts["TEST_PAIR"]
del _last_prices["TEST_PAIR"]
@test("_check_level_breach returns None when no previous price")
def _():
_level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000}
_level_cache_ts["TEST_PAIR"] = time.time()
if "TEST_PAIR" in _last_prices:
del _last_prices["TEST_PAIR"]
result = _check_level_breach("TEST_PAIR", 1.1001)
assert result is None
del _level_cache["TEST_PAIR"]
del _level_cache_ts["TEST_PAIR"]
@test("SCAN_BUTTONS and SCAN_LOOKUP consistency")
def _():
from telegram_bot import SCAN_PAIRS
# 4 TF buttons + one "Scan All" per scanned pair
assert len(SCAN_BUTTONS) == 5 * len(SCAN_PAIRS)
for btn in SCAN_BUTTONS:
assert btn["cb"] in SCAN_LOOKUP
assert "symbol" in btn
assert btn["symbol"] in SCAN_PAIRS
assert "tf" in btn
assert "label" in btn
@test("scan_keyboard returns InlineKeyboardMarkup")
def _():
kb = scan_keyboard()
assert kb is not None
@test("_update_level_cache populates cache from reports")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
_level_cache.clear()
_level_cache_ts.clear()
_update_level_cache([report])
if report.primary_analysis and report.primary_analysis.key_levels:
assert "XAU/USD" in _level_cache or len(report.primary_analysis.key_levels) == 0
_level_cache.clear()
_level_cache_ts.clear()
@test("_WS_TO_PAIR mapping covers every scanned pair")
def _():
from telegram_bot import SCAN_PAIRS
for p in SCAN_PAIRS:
assert _WS_TO_PAIR[PAIR_INFO[p]["display"]] == p
assert _WS_TO_PAIR["XAU/USD"] == XAUUSD
# ══════════════════════════════════════════════════════════════════════
# MODULE 8: Integration / Cross-module tests
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 8: Integration Tests ═══")
@test("Full pipeline: config → engine → aggregator → report → dict")
def _():
config = PlatformConfig()
engine = TechnicalEngine(config.technical)
df = make_ohlcv(300, seed=99)
analysis = engine.analyze(df, "XAU/USD", "1h")
assert isinstance(analysis, TechnicalAnalysis)
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=99),
Timeframe.M15: make_ohlcv(300, seed=100),
Timeframe.H4: make_ohlcv(300, seed=101),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
d = report_to_dict(report)
assert isinstance(d, dict)
assert d["symbol"] == "XAU/USD"
@test("Full pipeline: XAU/USD analysis")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_xau_ohlcv(300, seed=42),
Timeframe.M15: make_xau_ohlcv(300, seed=43),
Timeframe.H4: make_xau_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
assert report.display_name == "XAU/USD"
assert report.current_price > 1000 # XAU should be in the thousands
@test("Full pipeline: format Telegram message from report")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
msg = format_signal_message(report)
assert isinstance(msg, str)
assert len(msg) > 100
@test("XAU/USD analysis with different seeds")
def _():
config = PlatformConfig()
for seed_base in [10, 20, 30]:
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_xau_ohlcv(300, seed=seed_base),
Timeframe.M15: make_xau_ohlcv(300, seed=seed_base+1),
Timeframe.H4: make_xau_ohlcv(300, seed=seed_base+2),
}
report = agg.analyze_from_dataframes(XAUUSD, dfs, use_news=False)
assert report.symbol == XAUUSD
assert report.current_price > 0
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
@test("Edge case: all EMAs at same price (flat market)")
def _():
engine = TechnicalEngine(TechnicalConfig())
dates = pd.date_range(end=pd.Timestamp.now(tz="UTC").floor("h"), periods=300, freq="h")
df = pd.DataFrame({
"Open": 1.1, "High": 1.1001, "Low": 1.0999, "Close": 1.1, "Volume": 1000.0,
}, index=dates)
df.index.name = "Datetime"
result = engine.analyze(df, "XAU/USD", "1h")
assert result.composite_signal == SignalStrength.NEUTRAL
@test("Edge case: extreme volatility data")
def _():
engine = TechnicalEngine(TechnicalConfig())
np.random.seed(77)
dates = pd.date_range(end=pd.Timestamp.now(tz="UTC").floor("h"), periods=300, freq="h")
close = 1.1 + np.cumsum(np.random.randn(300) * 0.01)
high = close + np.abs(np.random.randn(300) * 0.005)
low = close - np.abs(np.random.randn(300) * 0.005)
df = pd.DataFrame({
"Open": close + np.random.randn(300) * 0.003,
"High": high, "Low": low, "Close": close,
"Volume": np.random.randint(100, 50000, 300).astype(float),
}, index=dates)
df.index.name = "Datetime"
result = engine.analyze(df, "XAU/USD", "1h")
assert isinstance(result, TechnicalAnalysis)
assert result.volatility_regime in ("low", "normal", "high", "extreme")
@test("Determinism: same data produces same results")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300, seed=42)
r1 = engine.analyze(df.copy(), "XAU/USD", "1h")
r2 = engine.analyze(df.copy(), "XAU/USD", "1h")
assert r1.composite_score == r2.composite_score
assert r1.composite_signal == r2.composite_signal
assert r1.market_regime == r2.market_regime
print("\n═══ Module 9: pair_signals registry ═══")
@test("pair_signals registry includes XAU/USD only")
def _():
import pair_signals
pairs = pair_signals.get_supported_pairs()
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",
pip_value=0.10,
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
if tf == Timeframe.M5:
# Documented per-TF overrides (see xauusd.py docstring):
# hour 9 UTC blocked + BUY-only chart patterns on M5 only.
inline_cfg.xau_strategy.blocked_hours_utc = (
inline_cfg.xau_strategy.blocked_hours_utc + [9]
)
inline_cfg.xau_strategy.cp_enabled = True
inline_cfg.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"]
inline_cfg.signals.trail_arm_r = 0.60
inline_cfg.signals.trail_dist_r = 0.85
assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \
f"XAU factory config differs from inline construction for {tf}"
@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
@test("disabled_indicators=['macd'] removes the pillar but keeps the histogram")
def _():
from engine import TechnicalEngine
from config import TechnicalConfig
df = make_xau_ohlcv(n=300, seed=5)
result = TechnicalEngine(TechnicalConfig(disabled_indicators=["macd"])).analyze(
df, "XAU/USD", "1h")
assert "macd" not in result.indicators, "disabled MACD still scored"
assert getattr(result, "_macd_histogram", None) is not None, \
"histogram must survive for divergence"
baseline = TechnicalEngine(TechnicalConfig()).analyze(df, "XAU/USD", "1h")
assert "macd" in baseline.indicators
@test("Outlier filter drops the spike bar but keeps the snap-back bar")
def _():
provider = ForexDataProvider(PlatformConfig())
df = make_ohlcv(n=300, seed=3)
k = 150
spike_ts, after_ts = df.index[k], df.index[k + 1]
spike_val = df["Close"].iloc[k - 1] * 1.05 # ~+5% on a ~0.1%-σ series
df.loc[spike_ts, ["Open", "High", "Low", "Close"]] = spike_val
cleaned = provider._validate_and_clean(df, "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("Cache: pre-boundary snapshot refetched; boundary-complete snapshot served")
def _():
def bars_ending(last):
idx = pd.date_range(end=last, periods=300, freq="15min", tz="UTC")
v = np.linspace(3000.0, 3100.0, 300)
return pd.DataFrame({"Open": v, "High": v + 1, "Low": v - 1,
"Close": v, "Volume": 1000.0}, index=idx)
for _attempt in range(2):
provider = ForexDataProvider(PlatformConfig())
started = pd.Timestamp.now(tz="UTC").floor("15min")
last_closed = started - pd.Timedelta(minutes=15)
stale = bars_ending(last_closed - pd.Timedelta(minutes=15))
fresh = bars_ending(last_closed)
key = "XAU/USD_15min"
with patch("data_provider._is_forex_weekend", return_value=False):
# (a) snapshot cached mid-candle 5 min ago, missing the
# just-closed bar: TTL says fresh, boundary check must refetch.
provider._cache[key] = stale
provider._cache_timestamps[key] = time.time() - 300
with patch.object(provider, "_fetch_twelve_data",
return_value=fresh.copy()) as mock_fetch:
served = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
# (b) cache now boundary-complete: served without any fetch.
with patch.object(provider, "_fetch_twelve_data",
side_effect=AssertionError("must not refetch")):
served2 = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
if pd.Timestamp.now(tz="UTC").floor("15min") != started:
continue # boundary crossed mid-test — retry once
assert mock_fetch.called, "stale pre-boundary snapshot was served from cache"
assert served.index[-1] == last_closed
assert served2.index[-1] == last_closed
break
@test("Alert dedup: drift dedups, materially-moved setup re-alerts, no early commit")
def _():
import telegram_bot as tb
from signal_aggregator import SignalReport, TradeSetup
def rpt(entry, sl):
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
r.final_signal = SignalStrength.BUY
r.signal_quality = "B"
r.trade_setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=entry, stop_loss=sl, take_profit_1=entry + 12,
take_profit_2=entry + 20, risk_reward=1.5,
risk_pips=80.0, reward_pips=120.0, pip_value=0.10,
)
return r
key = "TEST_ALERT_FP"
tb._last_alert_fp.pop(key, None)
tb._last_alert_ts.pop(key, None)
r1 = rpt(3400.0, 3392.0) # risk $8 → $4 buckets
assert tb._should_alert(key, r1)
assert tb._should_alert(key, r1), "_should_alert must not commit state itself"
tb._commit_alert(key, r1)
assert not tb._should_alert(key, r1), "identical signal must dedup"
assert not tb._should_alert(key, rpt(3400.5, 3392.5)), \
"sub-bucket drift must dedup"
assert tb._should_alert(key, rpt(3412.0, 3404.0)), \
"setup moved 1.5x risk — must re-alert"
tb._last_alert_fp.pop(key, None)
tb._last_alert_ts.pop(key, None)
@test("Backtester --confirm-tfs defaults to None (factory's live map)")
def _():
from gpu_backtest import _parse_args
args = _parse_args(["--pair", "XAUUSD"])
assert args.confirm_tfs is None
@test("Backtest entries re-anchor to the first RAW bar after the signal bar")
def _():
from gpu_backtest import Trade, _reanchor_entries_to_raw
idx = pd.date_range("2026-01-05 10:00", periods=6, freq="5min", tz="UTC")
opens = np.array([100.0, 101.0, 110.0, 102.0, 103.0, 104.0])
sim = pd.DataFrame({"Open": opens, "High": opens + 1,
"Low": opens - 1, "Close": opens}, index=idx)
def mk_trade(entry_time, entry_price):
return Trade(pair="XAU/USD", entry_time=entry_time, entry_price=entry_price,
sl=entry_price - 8, tp1=entry_price + 12, tp2=entry_price + 20,
direction="BUY", pip_value=0.10, risk_pips=80.0,
reward_pips=120.0, rr_planned=1.5, quality="B",
final_score=30.0, final_confidence=60.0,
market_regime="trending_up")
# Cleaned frame lacked the spike at idx[2], so the replay entered at idx[3].
t = mk_trade(idx[3], 102.0)
_reanchor_entries_to_raw([t], [idx[1]], sim)
assert t.entry_time == idx[2] and t.entry_price == 110.0
assert t.sl == 102.0 and t.tp1 == 122.0 and t.tp2 == 130.0, \
"SL/TP distances must be preserved relative to the corrected entry"
# Entry already the first raw bar after the signal → untouched.
t2 = mk_trade(idx[2], 110.0)
_reanchor_entries_to_raw([t2], [idx[1]], sim)
assert t2.entry_time == idx[2] and t2.entry_price == 110.0 and t2.sl == 102.0
# ══════════════════════════════════════════════════════════════════════
# 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 _():
from dataclasses import replace
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.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: 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)
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
assert rep.trade_setup is None
@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", "channel_spread_detail": True},
"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.channel_spread_detail is True
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
{"telegram": {"channel_spread_detail": "yes"}}, # not a bool
{"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("data_provider: api key resolves env → config.json (no code default)")
def _():
from data_provider import ForexDataProvider
app = AppConfig(provider_settings={"twelve_data": {"api_key": "json-key"}})
set_app_config(app)
try:
with patch.dict(os.environ, {}, clear=True):
dp = ForexDataProvider(PlatformConfig())
assert dp._api_key == "json-key"
with patch.dict(os.environ, {"TWELVE_DATA_API_KEY": "env-key"}):
dp = ForexDataProvider(PlatformConfig())
assert dp._api_key == "env-key"
set_app_config(AppConfig()) # no key anywhere
with patch.dict(os.environ, {}, clear=True):
dp = ForexDataProvider(PlatformConfig())
assert dp._api_key is None and dp._td is None
finally:
set_app_config(_TEST_APP)
@test("data_provider: make_data_provider dispatches on active provider")
def _():
from data_provider import ForexDataProvider, make_data_provider
assert isinstance(make_data_provider(PlatformConfig()), ForexDataProvider)
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("config.py: hardcoded twelve_data_api_key field is gone")
def _():
assert not hasattr(PlatformConfig(), "twelve_data_api_key"), \
"credentials must not live in committed source"
@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: tiingo_provider.py (bid/ask spread monitor)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 14: tiingo_provider.py ═══")
from tiingo_provider import (
Quote, TiingoQuoteProvider, WIDE_SPREAD_MULTIPLE, log_quote,
tiingo_ticker,
)
from app_config import load_dotenv, tiingo_api_key
def _tiingo_response(payload):
resp = MagicMock()
resp.json.return_value = payload
resp.raise_for_status.return_value = None
return resp
_TIINGO_ROW = [{
"ticker": "xauusd",
"quoteTimestamp": "2026-07-22T12:00:00+00:00",
"bidPrice": 2400.10, "bidSize": 1000000.0,
"askPrice": 2400.40, "askSize": 1000000.0,
"midPrice": 2400.25,
}]
@test("tiingo: ticker mapping XAU/USD → xauusd")
def _():
assert tiingo_ticker("XAU/USD") == "xauusd"
assert tiingo_ticker("EUR/USD") == "eurusd"
@test("tiingo: quote parsed — spread = ask − bid, canonical symbol")
def _():
provider = TiingoQuoteProvider("k")
with patch("tiingo_provider.requests.get",
return_value=_tiingo_response(_TIINGO_ROW)) as mock_get:
q = provider.get_quote("XAU/USD")
assert q is not None
assert q.symbol == "XAU/USD"
assert abs(q.bid - 2400.10) < 1e-9 and abs(q.ask - 2400.40) < 1e-9
assert abs(q.spread - 0.30) < 1e-9
assert abs(q.mid - 2400.25) < 1e-9
assert q.quote_time is not None and q.quote_time.tzinfo is not None
assert q.age_seconds() < 5
params = mock_get.call_args.kwargs["params"]
assert params["tickers"] == "xauusd" and params["token"] == "k"
@test("tiingo: mid falls back to (bid+ask)/2 when midPrice missing")
def _():
row = [dict(_TIINGO_ROW[0], midPrice=None)]
provider = TiingoQuoteProvider("k")
with patch("tiingo_provider.requests.get",
return_value=_tiingo_response(row)):
q = provider.get_quote("XAU/USD")
assert q is not None and abs(q.mid - 2400.25) < 1e-9
@test("tiingo: TTL cache dedupes calls; ttl=0 refetches")
def _():
provider = TiingoQuoteProvider("k")
with patch("tiingo_provider.requests.get",
return_value=_tiingo_response(_TIINGO_ROW)) as mock_get:
provider.get_quote("XAU/USD")
provider.get_quote("XAU/USD")
assert mock_get.call_count == 1, "second call within TTL must hit cache"
provider = TiingoQuoteProvider("k", quote_ttl=0.0)
with patch("tiingo_provider.requests.get",
return_value=_tiingo_response(_TIINGO_ROW)) as mock_get:
provider.get_quote("XAU/USD")
provider.get_quote("XAU/USD")
assert mock_get.call_count == 2
@test("tiingo: every failure path returns None (never raises)")
def _():
provider = TiingoQuoteProvider("k", quote_ttl=0.0)
# network error
with patch("tiingo_provider.requests.get",
side_effect=Exception("boom")):
assert provider.get_quote("XAU/USD") is None
# HTTP error
resp = _tiingo_response(_TIINGO_ROW)
resp.raise_for_status.side_effect = Exception("401")
with patch("tiingo_provider.requests.get", return_value=resp):
assert provider.get_quote("XAU/USD") is None
# empty / non-list / null-price payloads
for payload in ([], {"detail": "unknown ticker"},
[dict(_TIINGO_ROW[0], bidPrice=None)],
[dict(_TIINGO_ROW[0], askPrice=None)]):
with patch("tiingo_provider.requests.get",
return_value=_tiingo_response(payload)):
assert provider.get_quote("XAU/USD") is None, \
f"payload {payload!r} must yield None"
@test("tiingo: log_quote writes header once then appends rows")
def _():
q = Quote(symbol=XAUUSD, bid=2400.10, ask=2400.40, mid=2400.25,
spread=0.30, quote_time=None, fetched_at=time.time())
with tempfile.TemporaryDirectory() as td:
path = os.path.join(td, "sub", "spread_log.csv")
log_quote(q, path)
log_quote(q, path)
with open(path, encoding="utf-8") as fh:
lines = fh.read().strip().splitlines()
assert lines[0] == "ts_utc,symbol,bid,ask,mid,spread"
assert len(lines) == 3, "header + one row per log_quote call"
cells = lines[1].split(",")
assert cells[1] == XAUUSD and float(cells[5]) == 0.30
@test("app_config: tiingo_api_key precedence env → env-short → json → ''")
def _():
app = AppConfig(provider_settings={
"twelve_data": {"api_key": "td"},
"tiingo": {"api_key": "json-tiingo"},
})
with patch.dict(os.environ, {}, clear=True):
assert tiingo_api_key(app) == "json-tiingo"
with patch.dict(os.environ, {"TIINGO": "short-env"}, clear=True):
assert tiingo_api_key(app) == "short-env"
with patch.dict(os.environ, {"TIINGO": "short-env",
"TIINGO_API_KEY": "long-env"}, clear=True):
assert tiingo_api_key(app) == "long-env"
with patch.dict(os.environ, {}, clear=True):
assert tiingo_api_key(AppConfig()) == "", "no key anywhere = disabled"
@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")
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"
load_dotenv("/nonexistent/.env") # missing file is a silent no-op
@test("telegram_bot: _spread_lines fresh/wide/stale/absent")
def _():
import telegram_bot as tb
try:
# fresh, normal spread → bid/ask line only
tb._last_quotes[XAUUSD] = Quote(
symbol=XAUUSD, bid=2400.10, ask=2400.40, mid=2400.25,
spread=0.30, quote_time=None, fetched_at=time.time())
lines = tb._spread_lines(XAUUSD)
assert len(lines) == 1 and "Bid/Ask" in lines[0]
assert "spread $0.30" in lines[0]
# wide spread (≥ 2× the $0.30 assumption) → warning appended
tb._last_quotes[XAUUSD] = Quote(
symbol=XAUUSD, bid=2400.10, ask=2401.00, mid=2400.55,
spread=0.90, quote_time=None, fetched_at=time.time())
lines = tb._spread_lines(XAUUSD)
assert len(lines) == 2 and "Wide spread" in lines[1]
assert "3.0×" in lines[1]
# stale quote → nothing
tb._last_quotes[XAUUSD] = Quote(
symbol=XAUUSD, bid=2400.10, ask=2400.40, mid=2400.25,
spread=0.30, quote_time=None,
fetched_at=time.time() - tb._QUOTE_MAX_AGE - 1)
assert tb._spread_lines(XAUUSD) == []
# no quote at all → nothing
tb._last_quotes.pop(XAUUSD, None)
assert tb._spread_lines(XAUUSD) == []
finally:
tb._last_quotes.pop(XAUUSD, None)
@test("telegram_bot: channel variant never leaks Tiingo numbers")
def _():
# Tiingo free tier is internal-use-only: the subscriber channel may
# see a number-free caution, never bid/ask/spread values.
import telegram_bot as tb
try:
# normal spread → channel variant shows nothing at all
tb._last_quotes[XAUUSD] = Quote(
symbol=XAUUSD, bid=2400.10, ask=2400.40, mid=2400.25,
spread=0.30, quote_time=None, fetched_at=time.time())
assert tb._spread_lines(XAUUSD, include_quote=False) == []
# wide spread → one caution line with no prices in it
tb._last_quotes[XAUUSD] = Quote(
symbol=XAUUSD, bid=2400.10, ask=2401.00, mid=2400.55,
spread=0.90, quote_time=None, fetched_at=time.time())
lines = tb._spread_lines(XAUUSD, include_quote=False)
assert len(lines) == 1 and "unusually wide" in lines[0]
assert "2400" not in lines[0] and "0.90" not in lines[0] \
and "$" not in lines[0], "channel line must carry no data values"
# format_signal_message plumbs the flag through
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)
owner_msg = tb.format_signal_message(report, spread_detail=True)
channel_msg = tb.format_signal_message(report, spread_detail=False)
assert "Bid/Ask" in owner_msg
assert "Bid/Ask" not in channel_msg
finally:
tb._last_quotes.pop(XAUUSD, None)
@test("telegram_bot: _spread_detail_for routes by chat + internal flag")
def _():
import telegram_bot as tb
saved_channel, saved_flag = tb.CHANNEL_ID, tb._CHANNEL_SPREAD_DETAIL
try:
tb.CHANNEL_ID = -100222
tb._CHANNEL_SPREAD_DETAIL = False # default: channel is public
assert tb._spread_detail_for(111) is True, "owner DM gets raw quotes"
assert tb._spread_detail_for(-100222) is False, \
"public channel must get the number-free variant"
tb._CHANNEL_SPREAD_DETAIL = True # internal-testing channel
assert tb._spread_detail_for(-100222) is True
assert tb._spread_detail_for(111) is True
finally:
tb.CHANNEL_ID, tb._CHANNEL_SPREAD_DETAIL = saved_channel, saved_flag
@test("telegram_bot: log rotation is 3-hourly 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() == "H" and h.interval == 3 * 3600, \
"rotation must fire every 3 hours"
assert h.backupCount == 240, "240 × 3h = 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: no Tiingo key → provider off, message unchanged")
def _():
import telegram_bot as tb
assert tb._tiingo is None, "hermetic env has no Tiingo key"
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 = tb.format_signal_message(report)
assert "Bid/Ask" not in msg and "Wide spread" not in msg
# ══════════════════════════════════════════════════════════════════════
# 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)