083a4e8d63
One class per provider (TwelveData, Oanda, Tiingo), all subclassing BaseDataProvider which owns cache, validation/cleaning, freshness + boundary guarantees, fetch_range pagination, and streaming stubs — subclasses implement only _fetch_raw. config.json data_providers.active selects the live feed; data_provider.py / oanda_provider.py stay as back-compat shims. Module 16 provider-abstraction tests (253 total green). Claude-Session: https://claude.ai/code/session_01Mc7XKYyPySVXiUpcN82QEK
4312 lines
161 KiB
Python
4312 lines
161 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
ForexBot2 — Comprehensive QA Test Suite
|
||
=========================================
|
||
Tests every function in the codebase without requiring live API connections.
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
import time
|
||
import threading
|
||
import 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"):
|
||
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 named zones carry their real offset")
|
||
def _():
|
||
# EST is UTC−5: 12:00 EST == 17:00 UTC. The old "%Z" path accepted the
|
||
# abbreviation but stamped UTC — a silent 5-hour shift.
|
||
dt = NewsEngine._parse_rss_date("Mon, 20 Jul 2026 12:00:00 EST")
|
||
assert dt is not None and dt.tzinfo is not None
|
||
assert dt.astimezone(timezone.utc).hour == 17
|
||
# GMT stays a no-op; without-seconds (MyFXBook) form still parses.
|
||
dt2 = NewsEngine._parse_rss_date("Mon, 20 Jul 2026 12:00 GMT")
|
||
assert dt2 is not None and dt2.astimezone(timezone.utc).hour == 12
|
||
|
||
|
||
@test("NewsEngine._parse_rss_date ISO format")
|
||
def _():
|
||
dt = NewsEngine._parse_rss_date("2025-05-12T14:30:00+00:00")
|
||
assert dt is not None
|
||
assert dt.year == 2025
|
||
|
||
|
||
@test("NewsEngine._parse_rss_date naive format assumes UTC")
|
||
def _():
|
||
dt = NewsEngine._parse_rss_date("2025-05-12 14:30:00")
|
||
assert dt is not None
|
||
assert dt.tzinfo == timezone.utc
|
||
|
||
|
||
@test("NewsEngine._parse_rss_date None input")
|
||
def _():
|
||
assert NewsEngine._parse_rss_date(None) is None
|
||
|
||
|
||
@test("NewsEngine._parse_rss_date garbage input")
|
||
def _():
|
||
assert NewsEngine._parse_rss_date("not-a-date") is None
|
||
|
||
|
||
@test("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 grades from the soonest event")
|
||
def _():
|
||
# Far event listed FIRST: an unsorted feed must still yield danger
|
||
# for the nearer event, not caution for the first match.
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
context.events_upcoming_1h = [
|
||
EconomicEvent(
|
||
time=datetime.now(timezone.utc) + timedelta(minutes=55),
|
||
currency="USD", impact="high", event="GDP",
|
||
),
|
||
EconomicEvent(
|
||
time=datetime.now(timezone.utc) + timedelta(minutes=10),
|
||
currency="USD", impact="high", event="NFP",
|
||
),
|
||
]
|
||
risk, desc = ne.get_event_risk_level("XAU/USD", context)
|
||
assert risk == "danger"
|
||
assert "NFP" in desc
|
||
|
||
|
||
@test("NewsEngine.get_event_risk_level clear when no events")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
risk, desc = ne.get_event_risk_level("XAU/USD", context)
|
||
assert risk == "clear"
|
||
|
||
|
||
@test("NewsEngine.get_event_risk_level ignores unrelated currency events")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
context.events_upcoming_1h = [
|
||
EconomicEvent(
|
||
time=datetime.now(timezone.utc) + timedelta(minutes=15),
|
||
currency="AUD", impact="high", event="RBA Rate",
|
||
)
|
||
]
|
||
risk, _ = ne.get_event_risk_level("XAU/USD", context)
|
||
assert risk == "clear"
|
||
|
||
|
||
@test("NewsEngine.get_pair_sentiment with positive base sentiment")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
context.news_sentiment = {"XAU": 0.5, "USD": -0.1}
|
||
score, desc = ne.get_pair_sentiment("XAU/USD", context)
|
||
assert score > 0, "XAU bullish + USD bearish should be net bullish for XAU/USD"
|
||
assert "bullish" in desc.lower()
|
||
|
||
|
||
@test("NewsEngine.get_pair_sentiment with negative pair sentiment")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
context.news_sentiment = {"XAU": -0.5, "USD": 0.3}
|
||
score, desc = ne.get_pair_sentiment("XAU/USD", context)
|
||
assert score < 0
|
||
|
||
|
||
@test("NewsEngine.get_pair_sentiment neutral")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
context.news_sentiment = {"XAU": 0.0, "USD": 0.0}
|
||
score, desc = ne.get_pair_sentiment("XAU/USD", context)
|
||
assert score == 0.0
|
||
assert "neutral" in desc.lower()
|
||
|
||
|
||
@test("NewsEngine._extract_currencies no USD false positive from 'versus'")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
currencies = ne._extract_currencies(
|
||
"Canada building permits for May -1.7% versus 2.4% estimate"
|
||
)
|
||
assert "USD" not in currencies, f"'versus' must not tag USD, got {currencies}"
|
||
assert "CAD" in currencies
|
||
|
||
|
||
@test("NewsEngine._extract_currencies no AUD false positive from 'audit'")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
currencies = ne._extract_currencies("Treasury audit finds accounting errors")
|
||
assert "AUD" not in currencies
|
||
|
||
|
||
@test("EconomicEvent.surprise from actual vs forecast")
|
||
def _():
|
||
now = datetime.now(timezone.utc)
|
||
beat = EconomicEvent(time=now, currency="USD", impact="high",
|
||
event="NFP", forecast="900K", actual="1.1M")
|
||
assert beat.surprise == "positive"
|
||
|
||
miss = EconomicEvent(time=now, currency="USD", impact="high",
|
||
event="CPI y/y", forecast="0.3%", actual="0.2%")
|
||
assert miss.surprise == "negative"
|
||
|
||
inline = EconomicEvent(time=now, currency="USD", impact="high",
|
||
event="CPI y/y", forecast="0.3%", actual="0.3%")
|
||
assert inline.surprise == "inline"
|
||
|
||
pending = EconomicEvent(time=now, currency="USD", impact="high",
|
||
event="CPI y/y", forecast="0.3%")
|
||
assert pending.surprise is None
|
||
|
||
negative = EconomicEvent(time=now, currency="USD", impact="high",
|
||
event="Permits m/m", forecast="2.4%", actual="-1.7%")
|
||
assert negative.surprise == "negative"
|
||
|
||
|
||
@test("NewsEngine.get_event_risk_level surprise widens post-event window")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
released_at = datetime.now(timezone.utc) - timedelta(minutes=30)
|
||
|
||
# Surprise print 30 min ago: outside the 15-min inline buffer but
|
||
# inside the 45-min surprise buffer → still caution.
|
||
context = FundamentalContext()
|
||
context.events_today = [EconomicEvent(
|
||
time=released_at, currency="USD", impact="high",
|
||
event="CPI m/m", forecast="0.3%", actual="0.5%",
|
||
)]
|
||
risk, desc = ne.get_event_risk_level("XAU/USD", context)
|
||
assert risk == "caution", f"Expected caution after surprise, got {risk}"
|
||
assert "surprise" in desc
|
||
|
||
# Same timing but inline print → clear.
|
||
context.events_today = [EconomicEvent(
|
||
time=released_at, currency="USD", impact="high",
|
||
event="CPI m/m", forecast="0.3%", actual="0.3%",
|
||
)]
|
||
risk, _ = ne.get_event_risk_level("XAU/USD", context)
|
||
assert risk == "clear", f"Inline release 30min ago should be clear, got {risk}"
|
||
|
||
|
||
@test("NewsEngine gold-inverted sentiment for policy keywords")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
headline = "Fed signals rate hike as hawkish stance hardens"
|
||
# Currency-bullish for the dollar...
|
||
assert ne._analyze_headline_sentiment(headline) > 0
|
||
assert ne._analyze_headline_sentiment(headline, "USD") > 0
|
||
# ...but bearish for gold.
|
||
assert ne._analyze_headline_sentiment(headline, "XAU") < 0
|
||
|
||
dovish = "Fed turns dovish, rate cut expected"
|
||
assert ne._analyze_headline_sentiment(dovish) < 0
|
||
assert ne._analyze_headline_sentiment(dovish, "XAU") > 0
|
||
|
||
# Non-policy keywords keep their direction for gold.
|
||
data = "Gold surges to record high"
|
||
assert ne._analyze_headline_sentiment(data, "XAU") > 0
|
||
|
||
|
||
@test("NewsEngine calendar cache avoids refetch within TTL")
|
||
def _():
|
||
NewsEngine._calendar_cache = (0.0, [])
|
||
try:
|
||
ne = NewsEngine(NewsConfig())
|
||
calls = {"n": 0}
|
||
|
||
def fake_fetch():
|
||
calls["n"] += 1
|
||
return [EconomicEvent(
|
||
time=datetime.now(timezone.utc), currency="USD",
|
||
impact="low", event="Test Event",
|
||
)]
|
||
|
||
ne._try_fetch_calendar_api = fake_fetch
|
||
ne._try_fetch_myfxbook_rss = lambda: []
|
||
|
||
first = ne._fetch_economic_calendar(["USD", "XAU"])
|
||
second = ne._fetch_economic_calendar(["USD", "XAU"])
|
||
assert calls["n"] == 1, f"Expected 1 fetch, got {calls['n']}"
|
||
assert len(first) == 1 and len(second) == 1
|
||
|
||
# A second engine instance (new scan) must also hit the cache.
|
||
ne2 = NewsEngine(NewsConfig())
|
||
ne2._try_fetch_calendar_api = fake_fetch
|
||
ne2._try_fetch_myfxbook_rss = lambda: []
|
||
third = ne2._fetch_economic_calendar(["USD", "XAU"])
|
||
assert calls["n"] == 1, "New engine instance should reuse class-level cache"
|
||
assert len(third) == 1
|
||
finally:
|
||
NewsEngine._calendar_cache = (0.0, [])
|
||
|
||
|
||
@test("FundamentalContext defaults")
|
||
def _():
|
||
ctx = FundamentalContext()
|
||
assert ctx.events_today == []
|
||
assert ctx.events_upcoming_1h == []
|
||
assert ctx.high_impact_warning is False
|
||
assert ctx.calendar_available is False
|
||
assert ctx.news_sentiment == {}
|
||
assert ctx.news_headlines == []
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 5: signal_aggregator.py
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 5: signal_aggregator.py ═══")
|
||
|
||
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup
|
||
|
||
|
||
@test("SignalReport defaults")
|
||
def _():
|
||
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
assert r.final_signal == SignalStrength.NEUTRAL
|
||
assert r.final_score == 0.0
|
||
assert r.primary_signal == SignalStrength.NEUTRAL
|
||
assert r.trade_setup is None
|
||
assert r.mtf_agreement is False
|
||
assert r.signal_quality == ""
|
||
assert r.bullish_reasons == []
|
||
assert r.bearish_reasons == []
|
||
assert r.warnings == []
|
||
|
||
|
||
@test("TradeSetup dataclass creation")
|
||
def _():
|
||
ts = TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||
entry_price=1.1000, stop_loss=1.0950, take_profit_1=1.1100,
|
||
take_profit_2=1.1200, risk_reward=2.0, risk_pips=50, reward_pips=100,
|
||
pip_value=0.0001,
|
||
)
|
||
assert ts.direction == "BUY"
|
||
assert ts.risk_reward == 2.0
|
||
assert ts.entry_price > ts.stop_loss
|
||
assert ts.trail_arm_r is None and ts.trail_dist_r is None # unstamped default
|
||
|
||
|
||
@test("trade setups carry trail params from the producing config")
|
||
def _():
|
||
config = PlatformConfig()
|
||
config.signals.trail_arm_r = 0.70
|
||
config.signals.trail_dist_r = 0.85
|
||
agg = SignalAggregator(config)
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.final_signal = SignalStrength.BUY
|
||
report.final_score = 40.0
|
||
report.signal_quality = "A+"
|
||
report.current_price = 3300.0
|
||
ts = agg._generate_xau_atr_trade_setup(report, 10.0)
|
||
assert ts is not None, "A+ setup with ATR 10 should generate"
|
||
assert ts.trail_arm_r == 0.70, "setup must carry the producing config's arm"
|
||
assert ts.trail_dist_r == 0.85, "setup must carry the producing config's dist"
|
||
|
||
|
||
@test("SignalAggregator._check_mtf_agreement with 2 bullish")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.mtf_signals = {
|
||
"15min": SignalStrength.BUY,
|
||
"1h": SignalStrength.STRONG_BUY,
|
||
"4h": SignalStrength.NEUTRAL,
|
||
}
|
||
assert agg._check_mtf_agreement(report) is True
|
||
|
||
|
||
@test("SignalAggregator._check_mtf_agreement with mixed signals")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.mtf_signals = {
|
||
"15min": SignalStrength.BUY,
|
||
"1h": SignalStrength.SELL,
|
||
"4h": SignalStrength.NEUTRAL,
|
||
}
|
||
assert agg._check_mtf_agreement(report) is False
|
||
|
||
|
||
@test("SignalAggregator._check_mtf_agreement with single signal")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.mtf_signals = {"1h": SignalStrength.BUY}
|
||
assert agg._check_mtf_agreement(report) is False
|
||
|
||
|
||
@test("SignalAggregator._latest_adx computes valid value")
|
||
def _():
|
||
df = make_ohlcv(300)
|
||
adx = SignalAggregator._latest_adx(df)
|
||
assert adx is not None
|
||
assert adx >= 0
|
||
|
||
|
||
@test("SignalAggregator._latest_adx with insufficient data")
|
||
def _():
|
||
df = make_ohlcv(10)
|
||
adx = SignalAggregator._latest_adx(df)
|
||
assert adx is None
|
||
|
||
|
||
@test("SignalAggregator.analyze_from_dataframes produces valid report")
|
||
def _():
|
||
config = PlatformConfig()
|
||
config.primary_timeframe = Timeframe.H1
|
||
config.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
|
||
agg = SignalAggregator(config)
|
||
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert isinstance(report, SignalReport)
|
||
assert report.symbol == "XAU/USD"
|
||
assert report.display_name == "XAU/USD"
|
||
assert report.final_signal in list(SignalStrength)
|
||
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
|
||
assert report.current_price > 0
|
||
|
||
|
||
@test("SignalAggregator._compute_final_signal quality grades")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
|
||
assert report.action_recommendation != ""
|
||
|
||
|
||
@test("Event-risk signal gating obeys news.event_risk_caps_signals")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
|
||
def recompute(risk, caps):
|
||
config.news.event_risk_caps_signals = caps
|
||
report.event_risk = risk
|
||
report.event_description = "USD Test Event in 10min"
|
||
report.warnings = []
|
||
agg._compute_final_signal(report)
|
||
return report.final_confidence
|
||
|
||
baseline = recompute("clear", True)
|
||
# default XAU behavior (caps off): danger warns but does not gut
|
||
conf_off = recompute("danger", False)
|
||
assert conf_off == baseline, "flag off must not change confidence"
|
||
assert any("EVENT RISK" in w for w in report.warnings), "warning must persist"
|
||
# caps on: danger hard-caps confidence at 30
|
||
conf_on = recompute("danger", True)
|
||
assert conf_on <= 30, f"danger with caps on must cap conf at 30, got {conf_on}"
|
||
# caution with caps off is also warning-only
|
||
conf_caut_off = recompute("caution", False)
|
||
assert conf_caut_off == baseline
|
||
assert any("Upcoming event" in w for w in report.warnings)
|
||
# PlatformConfig default is off (XAU-tuned)
|
||
assert PlatformConfig().news.event_risk_caps_signals is False
|
||
|
||
|
||
@test("Sentiment signal gating obeys news.sentiment_affects_signals")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
|
||
def recompute(sentiment, affects):
|
||
config.news.sentiment_affects_signals = affects
|
||
report.news_sentiment = sentiment
|
||
report.warnings = []
|
||
agg._compute_final_signal(report)
|
||
return report.final_confidence
|
||
|
||
score_sign = 1 if report.primary_score >= 0 else -1
|
||
aligned, conflicting = 0.5 * score_sign, -0.5 * score_sign
|
||
|
||
baseline = recompute(0.0, False)
|
||
# default: neither aligned sentiment nor conflict moves confidence
|
||
assert recompute(aligned, False) == baseline, "aligned sentiment must not add confidence"
|
||
assert recompute(conflicting, False) == baseline, "conflicting sentiment must not penalize"
|
||
assert any("CONFLICTS" in w for w in report.warnings), "conflict warning must persist"
|
||
# flag on: aligned sentiment adds confidence again
|
||
assert recompute(aligned, True) > baseline, "flag on must restore the alignment bonus"
|
||
# PlatformConfig default is off (news is informational only)
|
||
assert PlatformConfig().news.sentiment_affects_signals is False
|
||
|
||
|
||
@test("SignalAggregator._generate_trade_setup BUY direction")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, trend=0.003, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, trend=0.003, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, trend=0.003, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
if report.trade_setup:
|
||
ts = report.trade_setup
|
||
if ts.direction == "BUY":
|
||
assert ts.stop_loss < ts.entry_price, "BUY SL should be below entry"
|
||
assert ts.take_profit_1 > ts.entry_price, "BUY TP should be above entry"
|
||
assert ts.risk_reward >= config.signals.min_rr_ratio
|
||
|
||
|
||
@test("SignalAggregator._collect_reasons populates reasons")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
total_reasons = len(report.bullish_reasons) + len(report.bearish_reasons)
|
||
assert total_reasons > 0, "Should have at least one reason"
|
||
|
||
|
||
@test("SignalAggregator session filter blocks outside hours")
|
||
def _():
|
||
config = PlatformConfig()
|
||
config.backtest.session_filter = True
|
||
config.xau_strategy.blocked_hours_utc = list(range(24))
|
||
config.xau_strategy.enabled = False
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_xau_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_xau_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_xau_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert report.signal_quality == "F"
|
||
|
||
|
||
@test("SignalAggregator with missing primary timeframe")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert report.signal_quality == "F"
|
||
assert any("unavailable" in w.lower() for w in report.warnings)
|
||
|
||
|
||
@test("SignalAggregator XAU strategy check (conditions not met)")
|
||
def _():
|
||
config = PlatformConfig()
|
||
config.xau_strategy.enabled = True
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_xau_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_xau_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_xau_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert isinstance(report, SignalReport)
|
||
|
||
|
||
@test("SignalAggregator event risk affects confidence")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
context = FundamentalContext()
|
||
context.events_upcoming_1h = [
|
||
EconomicEvent(
|
||
time=datetime.now(timezone.utc) + timedelta(minutes=10),
|
||
currency="USD", impact="high", event="NFP",
|
||
)
|
||
]
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, context=context, use_news=True)
|
||
assert any("EVENT RISK" in w or "event" in w.lower() for w in report.warnings)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 6: main.py
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 6: main.py ═══")
|
||
|
||
from main import (
|
||
signal_color, quality_color, _compact_reason,
|
||
report_to_dict, build_config, 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, _indicator_summary,
|
||
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,
|
||
)
|
||
|
||
|
||
@test("_escape_md escapes markdown characters")
|
||
def _():
|
||
assert _escape_md("hello *world*") == "hello \\*world\\*"
|
||
assert _escape_md("test_value") == "test\\_value"
|
||
assert _escape_md("`code`") == "\\`code\\`"
|
||
assert _escape_md("[link]") == "\\[link\\]"
|
||
|
||
|
||
@test("_signal_label formatting")
|
||
def _():
|
||
assert _signal_label(SignalStrength.STRONG_BUY) == "STRONG BUY"
|
||
assert _signal_label(SignalStrength.STRONG_SELL) == "STRONG SELL"
|
||
assert _signal_label(SignalStrength.NEUTRAL) == "NEUTRAL"
|
||
assert _signal_label(SignalStrength.BUY) == "BUY"
|
||
assert _signal_label(SignalStrength.SELL) == "SELL"
|
||
|
||
|
||
@test("_mtf_line formatting with signals")
|
||
def _():
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.mtf_signals = {
|
||
"15min": SignalStrength.BUY,
|
||
"1h": SignalStrength.NEUTRAL,
|
||
}
|
||
report.mtf_scores = {"15min": 25.0, "1h": 5.0}
|
||
line = _mtf_line(report)
|
||
assert "15min" in line
|
||
assert "1h" in line
|
||
assert "BUY" in line
|
||
|
||
|
||
@test("_mtf_line empty when no signals")
|
||
def _():
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
assert _mtf_line(report) == ""
|
||
|
||
|
||
@test("format_signal_message produces valid text")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
msg = format_signal_message(report)
|
||
assert isinstance(msg, str)
|
||
assert len(msg) > 0
|
||
assert "XAU/USD" in msg
|
||
assert "Score" in msg
|
||
assert "Regime" in msg
|
||
assert "Not financial advice" in msg
|
||
|
||
|
||
@test("format_signal_message renders stamped per-TF trail plan")
|
||
def _():
|
||
def _setup(**extra):
|
||
return TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||
entry_price=3300.0, stop_loss=3290.0, take_profit_1=3315.0,
|
||
take_profit_2=3325.0, risk_reward=1.5, risk_pips=100,
|
||
reward_pips=150, pip_value=0.10, **extra,
|
||
)
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.final_signal = SignalStrength.BUY
|
||
report.signal_quality = "A"
|
||
# Stamped setup (M15-style overrides) must render the stamped values…
|
||
report.trade_setup = _setup(trail_arm_r=0.70, trail_dist_r=0.85)
|
||
msg = format_signal_message(report)
|
||
assert "+0.7R" in msg, "must render the stamped arm, not the 0.6 default"
|
||
assert "$8.50" in msg, "must render the stamped dist × risk, not the default"
|
||
# …while a legacy unstamped setup falls back to platform defaults.
|
||
report.trade_setup = _setup()
|
||
msg = format_signal_message(report)
|
||
assert "+0.6R" in msg
|
||
assert "$7.50" in msg
|
||
|
||
|
||
@test("format_signal_message renders 100-pip informational TP")
|
||
def _():
|
||
def _setup(direction="BUY", **extra):
|
||
return TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction=direction,
|
||
entry_price=3300.0, stop_loss=3290.0, take_profit_1=3315.0,
|
||
take_profit_2=3325.0, risk_reward=1.5, risk_pips=100,
|
||
reward_pips=150, pip_value=0.10, **extra,
|
||
)
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.final_signal = SignalStrength.BUY
|
||
report.signal_quality = "A"
|
||
# BUY: 100 pips × $0.10 = $10 above entry; risk $10 → exactly 1.0R.
|
||
report.trade_setup = _setup()
|
||
msg = format_signal_message(report)
|
||
assert "TP 100p" in msg
|
||
assert "3310.00" in msg, "BUY 100p TP must be entry + $10.00"
|
||
assert "≈1.0R" in msg
|
||
# SELL mirror: $10 below entry.
|
||
report.final_signal = SignalStrength.SELL
|
||
report.trade_setup = _setup(direction="SELL")
|
||
msg = format_signal_message(report)
|
||
assert "TP 100p" in msg
|
||
assert "3290.00" in msg, "SELL 100p TP must be entry - $10.00"
|
||
# No setup → no informational TP line.
|
||
report.trade_setup = None
|
||
msg = format_signal_message(report)
|
||
assert "TP 100p" not in msg
|
||
|
||
|
||
@test("_indicator_summary condenses indicators to one arrow line")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
summary = _indicator_summary(report)
|
||
assert "EMA" in summary
|
||
assert "RSI" in summary
|
||
assert summary.count("\n") == 0, "summary must be a single line"
|
||
msg = format_signal_message(report)
|
||
assert summary in msg
|
||
assert "[Bullish]" not in msg and "[Bearish]" not in msg and "[Neutral]" not in msg
|
||
|
||
|
||
@test("_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("streaming symbol mapping covers every scanned pair")
|
||
def _():
|
||
# iter_ticks maps the provider's WS symbols (display names) back to
|
||
# canonical pair symbols — the mapping must be invertible for every
|
||
# scanned pair or ticks would be silently dropped.
|
||
from telegram_bot import SCAN_PAIRS
|
||
ws_to_pair = {PAIR_INFO[p]["display"]: p for p in SCAN_PAIRS}
|
||
for p in SCAN_PAIRS:
|
||
assert ws_to_pair[PAIR_INFO[p]["display"]] == p
|
||
assert ws_to_pair["XAU/USD"] == XAUUSD
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 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
|
||
# …and loads the regime-filter TF like live analyze_pair does, or the
|
||
# method silently no-ops for M5/M15 primaries (confirm maps lack H4).
|
||
assert "regime_filter_timeframe not in timeframes" in src, \
|
||
"replay must fetch/slice the regime-filter TF when the gate is on"
|
||
|
||
|
||
@test("disabled_indicators=['macd'] removes the pillar but keeps the histogram")
|
||
def _():
|
||
from engine import TechnicalEngine
|
||
from config import TechnicalConfig
|
||
df = make_xau_ohlcv(n=300, seed=5)
|
||
result = TechnicalEngine(TechnicalConfig(disabled_indicators=["macd"])).analyze(
|
||
df, "XAU/USD", "1h")
|
||
assert "macd" not in result.indicators, "disabled MACD still scored"
|
||
assert getattr(result, "_macd_histogram", None) is not None, \
|
||
"histogram must survive for divergence"
|
||
baseline = TechnicalEngine(TechnicalConfig()).analyze(df, "XAU/USD", "1h")
|
||
assert "macd" in baseline.indicators
|
||
|
||
|
||
@test("Outlier filter drops the spike bar but keeps the snap-back bar")
|
||
def _():
|
||
provider = 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("providers.base._is_forex_weekend", return_value=False):
|
||
# (a) snapshot cached mid-candle 5 min ago, missing the
|
||
# just-closed bar: TTL says fresh, boundary check must refetch.
|
||
provider._cache[key] = stale
|
||
provider._cache_timestamps[key] = time.time() - 300
|
||
with patch.object(provider, "_fetch_twelve_data",
|
||
return_value=fresh.copy()) as mock_fetch:
|
||
served = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
|
||
# (b) cache now boundary-complete: served without any fetch.
|
||
with patch.object(provider, "_fetch_twelve_data",
|
||
side_effect=AssertionError("must not refetch")):
|
||
served2 = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
|
||
if pd.Timestamp.now(tz="UTC").floor("15min") != started:
|
||
continue # boundary crossed mid-test — retry once
|
||
assert mock_fetch.called, "stale pre-boundary snapshot was served from cache"
|
||
assert served.index[-1] == last_closed
|
||
assert served2.index[-1] == last_closed
|
||
break
|
||
|
||
|
||
@test("Fresh fetch missing the just-closed bar retries once, then warns")
|
||
def _():
|
||
def bars_ending(last):
|
||
idx = pd.date_range(end=last, periods=300, freq="15min", tz="UTC")
|
||
v = np.linspace(3000.0, 3100.0, 300)
|
||
return pd.DataFrame({"Open": v, "High": v + 1, "Low": v - 1,
|
||
"Close": v, "Volume": 1000.0}, index=idx)
|
||
|
||
for _attempt in range(2):
|
||
provider = ForexDataProvider(PlatformConfig())
|
||
started = pd.Timestamp.now(tz="UTC").floor("15min")
|
||
last_closed = started - pd.Timedelta(minutes=15)
|
||
lagging = bars_ending(last_closed - pd.Timedelta(minutes=15))
|
||
complete = bars_ending(last_closed)
|
||
with patch("providers.base._is_forex_weekend", return_value=False), \
|
||
patch.object(ForexDataProvider, "FRESH_FETCH_RETRY_SECONDS", 0.0):
|
||
# (a) lagging first response → one retry returns the closed bar.
|
||
with patch.object(provider, "_fetch_twelve_data",
|
||
side_effect=[lagging.copy(), complete.copy()]) as mf:
|
||
served = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
|
||
# (b) complete first response → no retry at all.
|
||
provider._cache.clear()
|
||
provider._cache_timestamps.clear()
|
||
with patch.object(provider, "_fetch_twelve_data",
|
||
return_value=complete.copy()) as mf2:
|
||
provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
|
||
if pd.Timestamp.now(tz="UTC").floor("15min") != started:
|
||
continue # boundary crossed mid-test — retry once
|
||
assert mf.call_count == 2, "lagging response must trigger exactly one retry"
|
||
assert served.index[-1] == last_closed, "retry result must be served"
|
||
assert mf2.call_count == 1, "boundary-complete response must not retry"
|
||
break
|
||
|
||
|
||
@test("Alert dedup: drift dedups, materially-moved setup re-alerts, no early commit")
|
||
def _():
|
||
import telegram_bot as tb
|
||
from signal_aggregator import SignalReport, TradeSetup
|
||
|
||
def rpt(entry, sl):
|
||
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
|
||
r.final_signal = SignalStrength.BUY
|
||
r.signal_quality = "B"
|
||
r.trade_setup = TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||
entry_price=entry, stop_loss=sl, take_profit_1=entry + 12,
|
||
take_profit_2=entry + 20, risk_reward=1.5,
|
||
risk_pips=80.0, reward_pips=120.0, pip_value=0.10,
|
||
)
|
||
return r
|
||
|
||
key = "TEST_ALERT_FP"
|
||
tb._last_alert_fp.pop(key, None)
|
||
tb._last_alert_ts.pop(key, None)
|
||
tb._last_alert_setup.pop(key, None)
|
||
r1 = rpt(3400.0, 3392.0) # risk $8 → re-alert threshold $4
|
||
assert tb._should_alert(key, r1)
|
||
assert tb._should_alert(key, r1), "_should_alert must not commit state itself"
|
||
tb._commit_alert(key, r1)
|
||
assert not tb._should_alert(key, r1), "identical signal must dedup"
|
||
assert not tb._should_alert(key, rpt(3400.5, 3392.5)), \
|
||
"sub-threshold drift must dedup"
|
||
assert not tb._should_alert(key, rpt(3401.0, 3392.6)), \
|
||
"SL-distance drift (ATR/anchor wobble) must not re-fingerprint"
|
||
assert tb._should_alert(key, rpt(3412.0, 3404.0)), \
|
||
"setup moved 1.5x risk — must re-alert"
|
||
assert tb._should_alert(key, rpt(3395.5, 3387.5)), \
|
||
"setup moved half risk AGAINST the entry — must re-alert too"
|
||
tb._last_alert_fp.pop(key, None)
|
||
tb._last_alert_ts.pop(key, None)
|
||
tb._last_alert_setup.pop(key, None)
|
||
|
||
|
||
@test("Backtester --confirm-tfs defaults to None (factory's live map)")
|
||
def _():
|
||
from gpu_backtest import _parse_args
|
||
args = _parse_args(["--pair", "XAUUSD"])
|
||
assert args.confirm_tfs is None
|
||
|
||
|
||
@test("Backtest entries re-anchor to the first RAW bar after the signal bar")
|
||
def _():
|
||
from gpu_backtest import Trade, _reanchor_entries_to_raw
|
||
idx = pd.date_range("2026-01-05 10:00", periods=6, freq="5min", tz="UTC")
|
||
opens = np.array([100.0, 101.0, 110.0, 102.0, 103.0, 104.0])
|
||
sim = pd.DataFrame({"Open": opens, "High": opens + 1,
|
||
"Low": opens - 1, "Close": opens}, index=idx)
|
||
|
||
def mk_trade(entry_time, entry_price):
|
||
return Trade(pair="XAU/USD", entry_time=entry_time, entry_price=entry_price,
|
||
sl=entry_price - 8, tp1=entry_price + 12, tp2=entry_price + 20,
|
||
direction="BUY", pip_value=0.10, risk_pips=80.0,
|
||
reward_pips=120.0, rr_planned=1.5, quality="B",
|
||
final_score=30.0, final_confidence=60.0,
|
||
market_regime="trending_up")
|
||
|
||
# Cleaned frame lacked the spike at idx[2], so the replay entered at idx[3].
|
||
t = mk_trade(idx[3], 102.0)
|
||
_reanchor_entries_to_raw([t], [idx[1]], sim)
|
||
assert t.entry_time == idx[2] and t.entry_price == 110.0
|
||
assert t.sl == 102.0 and t.tp1 == 122.0 and t.tp2 == 130.0, \
|
||
"SL/TP distances must be preserved relative to the corrected entry"
|
||
|
||
# Entry already the first raw bar after the signal → untouched.
|
||
t2 = mk_trade(idx[2], 110.0)
|
||
_reanchor_entries_to_raw([t2], [idx[1]], sim)
|
||
assert t2.entry_time == idx[2] and t2.entry_price == 110.0 and t2.sl == 102.0
|
||
|
||
|
||
@test("Max drawdown counts the decline from flat starting equity")
|
||
def _():
|
||
from gpu_backtest import Trade, compute_metrics
|
||
|
||
def mk(r):
|
||
t = Trade(pair="XAU/USD",
|
||
entry_time=pd.Timestamp("2026-01-05 10:00", tz="UTC"),
|
||
entry_price=3300.0, sl=3292.0, tp1=3312.0, tp2=3320.0,
|
||
direction="BUY", pip_value=0.10, risk_pips=80.0,
|
||
reward_pips=120.0, rr_planned=1.5, quality="B",
|
||
final_score=30.0, final_confidence=60.0,
|
||
market_regime="trending_up")
|
||
t.exit_reason = "SL" if r < 0 else "TP1"
|
||
t.r_multiple = r
|
||
return t
|
||
|
||
# Opening losing streak: equity 0 → −3R with no prior peak. The old
|
||
# high-water started at the first trade's cum (−1) and reported 2R.
|
||
m = compute_metrics([mk(-1.0), mk(-1.0), mk(-1.0)])
|
||
assert m.max_drawdown_r == 3.0, f"expected 3.0, got {m.max_drawdown_r}"
|
||
# Unchanged when a peak precedes the streak.
|
||
m2 = compute_metrics([mk(2.0), mk(-1.0), mk(-1.0), mk(-1.0)])
|
||
assert m2.max_drawdown_r == 3.0, f"expected 3.0, got {m2.max_drawdown_r}"
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 12: Chart-pattern entry (XAU_CP)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 12: chart patterns (XAU_CP) ═══")
|
||
|
||
from chart_patterns import detect_chart_pattern
|
||
from config import XauStrategyConfig
|
||
|
||
|
||
def make_cp_df(closes, end="2026-07-08 14:00", freq="1h", wick=1.5):
|
||
"""Deterministic OHLCV from an explicit close path (fixed end time:
|
||
Wed, label hour 14, close hour 15 — clear of every session block)."""
|
||
idx = pd.date_range(end=end, periods=len(closes), freq=freq, tz="UTC")
|
||
c = np.asarray(closes, dtype=float)
|
||
o = np.concatenate([[c[0]], c[:-1]])
|
||
return pd.DataFrame({"Open": o, "High": c + wick, "Low": c - wick,
|
||
"Close": c, "Volume": 1000.0}, index=idx)
|
||
|
||
|
||
def mirror_cp_df(df, pivot=6000.0):
|
||
"""Price-mirror a fixture: bullish pattern → exact bearish twin."""
|
||
return pd.DataFrame({
|
||
"Open": pivot - df["Open"], "High": pivot - df["Low"],
|
||
"Low": pivot - df["High"], "Close": pivot - df["Close"],
|
||
"Volume": df["Volume"]}, index=df.index)
|
||
|
||
|
||
def double_bottom_closes():
|
||
"""L1@30 (2984) and L2@65 (2984.5), neckline peak 3000 @45,
|
||
below-neckline drift, breakout close 3004 on the last bar."""
|
||
return (list(np.linspace(2988, 3013, 15))
|
||
+ list(np.linspace(3011, 2984, 16))
|
||
+ list(np.linspace(2986, 3000, 15))
|
||
+ list(np.linspace(2999, 2984.5, 20))
|
||
+ list(np.linspace(2986, 2998, 13))
|
||
+ [3004.0])
|
||
|
||
|
||
def hs_top_closes():
|
||
"""S1@20 (3000), head@38 (3010), S2@62 (2999), neckline ~2989.5,
|
||
breakdown close 2988 on the last bar."""
|
||
return (list(np.linspace(2982, 3000, 21))
|
||
+ list(np.linspace(2998.5, 2990, 7))
|
||
+ list(np.linspace(2992, 3010, 11))
|
||
+ list(np.linspace(3008, 2991, 8))
|
||
+ list(np.linspace(2992, 2999, 16))
|
||
+ list(np.linspace(2998.5, 2991, 16))
|
||
+ [2988.0])
|
||
|
||
|
||
def bull_flag_closes():
|
||
"""Pole 60→69 (3000→3025), flag drift 70→78, breakout close 3028.5."""
|
||
return (list(np.linspace(3000, 2996, 60))
|
||
+ list(np.linspace(2999, 3025, 10))
|
||
+ list(np.linspace(3023, 3018, 9))
|
||
+ [3028.5])
|
||
|
||
|
||
_CP_CFG = XauStrategyConfig()
|
||
|
||
|
||
@test("CP: double bottom detected on breakout bar with correct geometry")
|
||
def _():
|
||
df = make_cp_df(double_bottom_closes())
|
||
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
|
||
assert hit is not None, "double bottom not detected"
|
||
assert hit["pattern"] == "double_bottom" and hit["direction"] == "BUY"
|
||
assert abs(hit["anchor"] - 2982.5) < 1e-9, f"anchor {hit['anchor']}"
|
||
assert abs(hit["trigger"] - 3001.5) < 1e-9, f"trigger {hit['trigger']}"
|
||
assert abs(hit["height"] - 19.0) < 1e-9, f"height {hit['height']}"
|
||
|
||
|
||
@test("CP: double top detected on mirrored fixture")
|
||
def _():
|
||
df = mirror_cp_df(make_cp_df(double_bottom_closes()))
|
||
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
|
||
assert hit is not None and hit["pattern"] == "double_top"
|
||
assert hit["direction"] == "SELL"
|
||
assert abs(hit["anchor"] - (6000 - 2982.5)) < 1e-9
|
||
assert abs(hit["trigger"] - (6000 - 3001.5)) < 1e-9
|
||
|
||
|
||
@test("CP: trigger discipline — no signal before breakout close")
|
||
def _():
|
||
df = make_cp_df(double_bottom_closes()).iloc[:-1]
|
||
assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None
|
||
|
||
|
||
@test("CP: trigger discipline — no re-fire on the bar after breakout")
|
||
def _():
|
||
df = make_cp_df(double_bottom_closes() + [3006.0])
|
||
assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None
|
||
|
||
|
||
@test("CP: double bottom rejected when lows differ beyond tolerance")
|
||
def _():
|
||
closes = double_bottom_closes()
|
||
closes[46:66] = list(np.linspace(2999, 2979, 20)) # L2 5.5 below L1
|
||
df = make_cp_df(closes)
|
||
assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None
|
||
|
||
|
||
@test("CP: double bottom invalidated by a lower low after L2")
|
||
def _():
|
||
closes = double_bottom_closes()
|
||
closes[77] = 2978.0 # undercuts both bottoms before the breakout
|
||
df = make_cp_df(closes)
|
||
assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None
|
||
|
||
|
||
@test("CP: head & shoulders top detected (SELL, right-shoulder anchor)")
|
||
def _():
|
||
df = make_cp_df(hs_top_closes())
|
||
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
|
||
assert hit is not None, "H&S top not detected"
|
||
assert hit["pattern"] == "hs_top" and hit["direction"] == "SELL"
|
||
assert abs(hit["anchor"] - 3000.5) < 1e-9, f"anchor {hit['anchor']}"
|
||
assert abs(hit["trigger"] - 2989.5) < 1e-9, f"trigger {hit['trigger']}"
|
||
|
||
|
||
@test("CP: inverse head & shoulders detected on mirrored fixture")
|
||
def _():
|
||
df = mirror_cp_df(make_cp_df(hs_top_closes()))
|
||
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
|
||
assert hit is not None and hit["pattern"] == "inv_hs"
|
||
assert hit["direction"] == "BUY"
|
||
|
||
|
||
@test("CP: bull flag detected (pole, shallow retrace, new-high close)")
|
||
def _():
|
||
df = make_cp_df(bull_flag_closes())
|
||
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
|
||
assert hit is not None, "bull flag not detected"
|
||
assert hit["pattern"] == "bull_flag" and hit["direction"] == "BUY"
|
||
assert abs(hit["trigger"] - 3026.5) < 1e-9
|
||
assert hit["height"] > 15.0 # pole ≥ cp_flag_pole_atr × atr
|
||
|
||
|
||
@test("CP: bear flag detected on mirrored fixture")
|
||
def _():
|
||
df = mirror_cp_df(make_cp_df(bull_flag_closes()))
|
||
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
|
||
assert hit is not None and hit["pattern"] == "bear_flag"
|
||
assert hit["direction"] == "SELL"
|
||
|
||
|
||
@test("CP: cp_patterns subset filter excludes disabled detectors")
|
||
def _():
|
||
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"},
|
||
"data_providers": {"active": "twelve_data",
|
||
"twelve_data": {"api_key": "json-key"}},
|
||
"scanning": {"pairs": ["XAU/USD"], "timeframes": ["M15", "H1"]},
|
||
"pairs": {"XAU/USD": {"blocked_hours_utc": [3, 4]}},
|
||
"log_level": "WARNING",
|
||
}
|
||
|
||
|
||
@test("app_config: valid config.json maps every field")
|
||
def _():
|
||
app = load_app_config(_write_json(_FULL_CFG))
|
||
assert app.telegram_token == "t0k"
|
||
assert app.telegram_chat_id == "111"
|
||
assert app.telegram_channel_id == "-100222"
|
||
assert app.alert_min_quality == "B"
|
||
assert app.active_provider == "twelve_data"
|
||
assert app.provider_settings["twelve_data"]["api_key"] == "json-key"
|
||
assert app.scan_pairs == [XAUUSD]
|
||
assert app.scan_timeframes == [Timeframe.M15, Timeframe.H1]
|
||
assert app.pair_overrides[XAUUSD]["blocked_hours_utc"] == [3, 4]
|
||
assert app.log_level == "WARNING"
|
||
|
||
|
||
@test("app_config: missing file returns pure defaults")
|
||
def _():
|
||
app = load_app_config("/nonexistent/config.json")
|
||
assert app == AppConfig(), "absent config.json must yield pure defaults"
|
||
|
||
|
||
@test("app_config: malformed JSON raises AppConfigError")
|
||
def _():
|
||
path = _write_json("{not json")
|
||
try:
|
||
load_app_config(path)
|
||
assert False, "should have raised"
|
||
except AppConfigError as e:
|
||
assert "not valid JSON" in str(e)
|
||
|
||
|
||
@test("app_config: unknown keys raise AppConfigError")
|
||
def _():
|
||
for payload in (
|
||
{"telgram": {}}, # top-level typo
|
||
{"telegram": {"tokn": "x"}}, # section typo
|
||
{"pairs": {"XAU/USD": {"blocked_hours": [1]}}}, # pair-block typo
|
||
{"scanning": {"pair": ["XAU/USD"]}}, # scanning typo
|
||
):
|
||
try:
|
||
load_app_config(_write_json(payload))
|
||
assert False, f"should have raised for {payload}"
|
||
except AppConfigError as e:
|
||
assert "unknown key" in str(e).lower()
|
||
|
||
|
||
@test("app_config: invalid values raise AppConfigError")
|
||
def _():
|
||
bad_payloads = [
|
||
{"pairs": {"XAU/USD": {"blocked_hours_utc": [24]}}}, # hour range
|
||
{"pairs": {"XAU/USD": {"blocked_hours_utc": "3"}}}, # not a list
|
||
{"pairs": {"EUR/USD": {"blocked_hours_utc": [3]}}}, # unknown pair
|
||
{"scanning": {"pairs": ["EUR/USD"]}}, # unknown pair
|
||
{"scanning": {"timeframes": ["M7"]}}, # bad TF name
|
||
{"scanning": {"timeframes": ["W1"]}}, # unscannable TF
|
||
{"scanning": {"pairs": 5}}, # pairs not a list
|
||
{"scanning": {"timeframes": 5}}, # timeframes not a list
|
||
{"data_providers": {"active": "polygon",
|
||
"twelve_data": {"api_key": "k"}}}, # no block
|
||
{"telegram": {"alert_min_quality": "Z"}}, # bad quality
|
||
{"log_level": "CHATTY"}, # bad level
|
||
]
|
||
for payload in bad_payloads:
|
||
try:
|
||
load_app_config(_write_json(payload))
|
||
assert False, f"should have raised for {payload}"
|
||
except AppConfigError:
|
||
pass
|
||
|
||
|
||
@test("app_config: env precedence env → config.json → default")
|
||
def _():
|
||
app = load_app_config(_write_json(_FULL_CFG))
|
||
with patch.dict(os.environ, {}, clear=True):
|
||
assert telegram_token(app) == "t0k"
|
||
assert telegram_chat_id(app) == "111"
|
||
assert telegram_channel_id(app) == "-100222"
|
||
assert provider_api_key(app) == "json-key"
|
||
with patch.dict(os.environ, {"TELEGRAM_TOKEN": "env-tok",
|
||
"TELEGRAM_CHAT_ID": "999",
|
||
"TWELVE_DATA_API_KEY": "env-key"}):
|
||
assert telegram_token(app) == "env-tok"
|
||
assert telegram_chat_id(app) == "999"
|
||
assert provider_api_key(app) == "env-key"
|
||
# channel: env set-but-empty means "disable feed" and must beat JSON
|
||
with patch.dict(os.environ, {"TELEGRAM_CHANNEL_ID": ""}):
|
||
assert telegram_channel_id(app) == ""
|
||
|
||
|
||
@test("app_config: non-object sections raise AppConfigError")
|
||
def _():
|
||
for payload in (
|
||
{"telegram": None},
|
||
{"telegram": "oops"},
|
||
{"scanning": 5},
|
||
{"pairs": ["oops"]},
|
||
{"pairs": {"XAU/USD": ["blocked_hours_utc"]}},
|
||
):
|
||
try:
|
||
load_app_config(_write_json(payload))
|
||
assert False, f"should have raised for {payload}"
|
||
except AppConfigError:
|
||
pass
|
||
|
||
|
||
@test("app_config: apply_pair_overrides stamps both blocked-hours mirrors")
|
||
def _():
|
||
app = AppConfig(pair_overrides={XAUUSD: {"blocked_hours_utc": [3, 4]}},
|
||
scan_pairs=[XAUUSD],
|
||
scan_timeframes=[Timeframe.M15])
|
||
cfg = apply_pair_overrides(PlatformConfig(), app)
|
||
assert cfg.xau_strategy.blocked_hours_utc == [3, 4]
|
||
assert cfg.backtest.blocked_hours_utc == [3, 4]
|
||
assert cfg.scan_timeframes == [Timeframe.M15]
|
||
# the two mirrors must be independent lists, not aliases
|
||
cfg.xau_strategy.blocked_hours_utc.append(5)
|
||
assert cfg.backtest.blocked_hours_utc == [3, 4]
|
||
|
||
|
||
@test("app_config: M5 factory appends hour 9 on top of a JSON base")
|
||
def _():
|
||
from pair_signals import PAIR_PRODUCERS
|
||
app = AppConfig(pair_overrides={XAUUSD: {"blocked_hours_utc": [3, 4]}},
|
||
provider_settings={"twelve_data": {"api_key": "test-key"}})
|
||
set_app_config(app)
|
||
try:
|
||
cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.M5, None)
|
||
finally:
|
||
set_app_config(_TEST_APP)
|
||
assert cfg.xau_strategy.blocked_hours_utc == [3, 4, 9], \
|
||
"validated M5 hour-9 delta must land on top of the JSON base"
|
||
assert cfg.backtest.blocked_hours_utc == [3, 4], \
|
||
"mirror carries the base only (per-TF delta is XAU-strategy-side)"
|
||
|
||
|
||
@test("app_config: config.json.example is valid and behavior-neutral")
|
||
def _():
|
||
import dataclasses
|
||
from pair_signals import PAIR_PRODUCERS
|
||
example = load_app_config(
|
||
os.path.join(os.path.dirname(__file__), "config.json.example"))
|
||
for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4):
|
||
baseline = PAIR_PRODUCERS[XAUUSD](tf, None) # under _TEST_APP
|
||
set_app_config(example)
|
||
try:
|
||
with_example = PAIR_PRODUCERS[XAUUSD](tf, None)
|
||
finally:
|
||
set_app_config(_TEST_APP)
|
||
assert dataclasses.asdict(baseline) == dataclasses.asdict(with_example), \
|
||
f"example config.json must be byte-identical to defaults on {tf}"
|
||
|
||
|
||
@test("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: .env loader + telegram_bot resilience
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
print("\n═══ Module 14: .env loader + telegram_bot resilience ═══")
|
||
|
||
from app_config import load_dotenv
|
||
|
||
|
||
@test("app_config: load_dotenv parses lines, never overrides real env")
|
||
def _():
|
||
fh = tempfile.NamedTemporaryFile(
|
||
"w", suffix=".env", delete=False, encoding="utf-8")
|
||
fh.write("# comment\n\nPLAIN=abc\nexport EXPORTED=def\n"
|
||
'QUOTED="with spaces"\nSINGLE=\'sq\'\nEXISTING=from-dotenv\n'
|
||
"NOT A KV LINE\n"
|
||
"COMMENTED=abc123 # prod key\n"
|
||
'QUOTED_HASH="keep #this"\n'
|
||
"HASH_IN_VALUE=abc#123\n")
|
||
fh.close()
|
||
with patch.dict(os.environ, {"EXISTING": "from-shell"}, clear=True):
|
||
load_dotenv(fh.name)
|
||
assert os.environ["PLAIN"] == "abc"
|
||
assert os.environ["EXPORTED"] == "def"
|
||
assert os.environ["QUOTED"] == "with spaces"
|
||
assert os.environ["SINGLE"] == "sq"
|
||
assert os.environ["EXISTING"] == "from-shell", \
|
||
"a real shell export must beat .env"
|
||
assert os.environ["COMMENTED"] == "abc123", \
|
||
"inline comment must be stripped, not absorbed into the value"
|
||
assert os.environ["QUOTED_HASH"] == "keep #this", \
|
||
"quoted values keep their hashes"
|
||
assert os.environ["HASH_IN_VALUE"] == "abc#123", \
|
||
"a hash with no preceding whitespace is part of the value"
|
||
load_dotenv("/nonexistent/.env") # missing file is a silent no-op
|
||
|
||
|
||
@test("telegram_bot: log rotation is 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()
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 15: Oanda provider / executor / daemon
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 15: Oanda provider / executor / daemon ═══")
|
||
|
||
import oanda_provider
|
||
import oanda_executor
|
||
import oanda_daemon
|
||
|
||
|
||
@test("oanda_provider: _is_forex_weekend matches data_provider's")
|
||
def _():
|
||
from data_provider import _is_forex_weekend as dp_weekend
|
||
from oanda_provider import _is_forex_weekend as oa_weekend
|
||
import pandas as pd
|
||
for ts_str in [
|
||
"2026-07-17 12:00", # Friday noon
|
||
"2026-07-17 22:30", # Friday after close
|
||
"2026-07-18 15:00", # Saturday
|
||
"2026-07-19 12:00", # Sunday before open
|
||
"2026-07-19 22:30", # Sunday after open
|
||
"2026-07-20 08:00", # Monday
|
||
]:
|
||
ts = pd.Timestamp(ts_str, tz="UTC")
|
||
assert dp_weekend(ts) == oa_weekend(ts), \
|
||
f"Weekend check mismatch at {ts_str}"
|
||
|
||
|
||
@test("oanda_provider: OandaDataProvider parses Oanda candle JSON into correct DataFrame")
|
||
def _():
|
||
sample_candles = [
|
||
{
|
||
"complete": True,
|
||
"time": "2026-07-20T10:00:00.000000000Z",
|
||
"mid": {"o": "2400.00", "h": "2405.00", "l": "2398.00", "c": "2403.00"},
|
||
"bid": {"o": "2399.85", "h": "2404.85", "l": "2397.85", "c": "2402.85"},
|
||
"ask": {"o": "2400.15", "h": "2405.15", "l": "2398.15", "c": "2403.15"},
|
||
"volume": 1234,
|
||
},
|
||
{
|
||
"complete": True,
|
||
"time": "2026-07-20T11:00:00.000000000Z",
|
||
"mid": {"o": "2403.00", "h": "2408.00", "l": "2401.00", "c": "2406.00"},
|
||
"bid": {"o": "2402.85", "h": "2407.85", "l": "2400.85", "c": "2405.85"},
|
||
"ask": {"o": "2403.15", "h": "2408.15", "l": "2401.15", "c": "2406.15"},
|
||
"volume": 5678,
|
||
},
|
||
{
|
||
"complete": False,
|
||
"time": "2026-07-20T12:00:00.000000000Z",
|
||
"mid": {"o": "2406.00", "h": "2406.50", "l": "2405.50", "c": "2406.20"},
|
||
"bid": {"o": "2405.85", "h": "2406.35", "l": "2405.35", "c": "2406.05"},
|
||
"ask": {"o": "2406.15", "h": "2406.65", "l": "2405.65", "c": "2406.35"},
|
||
"volume": 100,
|
||
},
|
||
]
|
||
|
||
mock_resp = type("MockResp", (), {
|
||
"status_code": 200,
|
||
"json": lambda self: {"candles": sample_candles, "instrument": "XAU_USD"},
|
||
"raise_for_status": lambda self: None,
|
||
})()
|
||
|
||
provider2 = oanda_provider.OandaDataProvider.__new__(
|
||
oanda_provider.OandaDataProvider)
|
||
provider2.config = PlatformConfig()
|
||
provider2._base_url = "https://test"
|
||
provider2._cache = {}
|
||
provider2._cache_timestamps = {}
|
||
provider2._cache_lock = threading.Lock()
|
||
provider2._spread_cache = {}
|
||
provider2._session = type("S", (), {
|
||
"get": lambda self, *a, **k: mock_resp,
|
||
"headers": {},
|
||
})()
|
||
|
||
df = provider2._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
|
||
|
||
assert len(df) == 2, f"Expected 2 complete bars, got {len(df)}"
|
||
assert list(df.columns) == ["Open", "High", "Low", "Close", "Volume"], \
|
||
f"Wrong columns: {list(df.columns)}"
|
||
assert df.index.name == "Datetime"
|
||
assert df.index.tz is not None, "Index must be tz-aware"
|
||
assert df.iloc[0]["Open"] == 2400.0
|
||
assert df.iloc[1]["Close"] == 2406.0
|
||
assert df.iloc[0]["Volume"] == 1234
|
||
|
||
spread = provider2._spread_cache.get("XAU/USD")
|
||
assert spread is not None, "Spread should be cached"
|
||
assert abs(spread - 0.30) < 0.01, f"Expected spread ~0.30, got {spread}"
|
||
|
||
|
||
@test("oanda_provider: instrument name mapping")
|
||
def _():
|
||
provider = oanda_provider.OandaDataProvider.__new__(
|
||
oanda_provider.OandaDataProvider)
|
||
assert provider._to_oanda_instrument("XAU/USD") == "XAU_USD"
|
||
assert provider._to_oanda_instrument("EUR/USD") == "EUR_USD"
|
||
|
||
|
||
@test("oanda_provider: granularity mapping covers all scan timeframes")
|
||
def _():
|
||
for tf in [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]:
|
||
assert tf in oanda_provider.OandaDataProvider.OANDA_GRANULARITY, \
|
||
f"{tf} missing from OANDA_GRANULARITY"
|
||
|
||
|
||
@test("oanda_provider: get_spread returns default when no cache")
|
||
def _():
|
||
provider = oanda_provider.OandaDataProvider.__new__(
|
||
oanda_provider.OandaDataProvider)
|
||
provider._spread_cache = {}
|
||
spread = provider.get_spread("XAU/USD")
|
||
assert spread == 0.30, f"Expected default spread 0.30, got {spread}"
|
||
|
||
|
||
@test("oanda_executor: market order body construction")
|
||
def _():
|
||
executor = oanda_executor.OandaExecutor.__new__(
|
||
oanda_executor.OandaExecutor)
|
||
executor._account_id = "test-account"
|
||
executor._base_url = "https://test"
|
||
executor._lot_size = 1
|
||
executor._price_precision = 2
|
||
executor._session = None
|
||
|
||
captured = {}
|
||
|
||
def mock_request(self, method, url, **kwargs):
|
||
captured["method"] = method
|
||
captured["url"] = url
|
||
captured["json"] = kwargs.get("json")
|
||
return type("R", (), {
|
||
"status_code": 201,
|
||
"json": lambda self: {
|
||
"orderFillTransaction": {
|
||
"price": "2400.50",
|
||
"tradeOpened": {"tradeID": "123"},
|
||
}
|
||
},
|
||
})()
|
||
|
||
executor._session = type("S", (), {
|
||
"request": mock_request,
|
||
"headers": {},
|
||
})()
|
||
|
||
result = executor.place_market_order(
|
||
instrument="XAU_USD",
|
||
direction="BUY",
|
||
stop_loss=2392.50,
|
||
take_profit=2420.00,
|
||
)
|
||
|
||
body = captured["json"]
|
||
order = body["order"]
|
||
assert order["type"] == "MARKET"
|
||
assert order["instrument"] == "XAU_USD"
|
||
assert order["units"] == "1"
|
||
# XAU price precision is 2 — Oanda rejects excess decimals.
|
||
assert order["stopLossOnFill"]["price"] == "2392.50"
|
||
assert order["takeProfitOnFill"]["price"] == "2420.00"
|
||
|
||
fill = result.get("orderFillTransaction")
|
||
assert fill is not None
|
||
assert fill["tradeOpened"]["tradeID"] == "123"
|
||
|
||
|
||
@test("oanda_executor: SELL order uses negative units")
|
||
def _():
|
||
executor = oanda_executor.OandaExecutor.__new__(
|
||
oanda_executor.OandaExecutor)
|
||
executor._account_id = "test-account"
|
||
executor._base_url = "https://test"
|
||
executor._lot_size = 1
|
||
executor._price_precision = 2
|
||
|
||
captured = {}
|
||
|
||
def mock_request(self, method, url, **kwargs):
|
||
captured["json"] = kwargs.get("json")
|
||
return type("R", (), {
|
||
"status_code": 201,
|
||
"json": lambda self: {
|
||
"orderFillTransaction": {
|
||
"price": "2400.50",
|
||
"tradeOpened": {"tradeID": "456"},
|
||
}
|
||
},
|
||
})()
|
||
|
||
executor._session = type("S", (), {
|
||
"request": mock_request,
|
||
"headers": {},
|
||
})()
|
||
|
||
executor.place_market_order(
|
||
instrument="XAU_USD", direction="SELL", stop_loss=2410.00)
|
||
|
||
order = captured["json"]["order"]
|
||
assert order["units"] == "-1", f"SELL should be -1, got {order['units']}"
|
||
assert "takeProfitOnFill" not in order, "No TP should be set when None"
|
||
|
||
|
||
@test("oanda_executor: 4xx error raises immediately (no retry)")
|
||
def _():
|
||
executor = oanda_executor.OandaExecutor.__new__(
|
||
oanda_executor.OandaExecutor)
|
||
executor._account_id = "test"
|
||
executor._base_url = "https://test"
|
||
executor._lot_size = 1
|
||
call_count = {"n": 0}
|
||
|
||
def mock_request(self, method, url, **kwargs):
|
||
call_count["n"] += 1
|
||
return type("R", (), {
|
||
"status_code": 400,
|
||
"text": "INVALID_ORDER",
|
||
})()
|
||
|
||
executor._session = type("S", (), {
|
||
"request": mock_request,
|
||
"headers": {},
|
||
})()
|
||
|
||
try:
|
||
executor._request_with_retry("POST", "https://test/orders")
|
||
assert False, "Should have raised OandaAPIError"
|
||
except oanda_executor.OandaAPIError as e:
|
||
assert e.status_code == 400
|
||
assert call_count["n"] == 1, "4xx should not retry"
|
||
|
||
|
||
@test("oanda_daemon: trailing stop ratchet matches backtester logic")
|
||
def _():
|
||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
||
|
||
info_buy = {
|
||
"direction": "BUY",
|
||
"entry": 2400.0,
|
||
"sl_initial": 2384.0,
|
||
"current_sl": 2384.0,
|
||
"risk": 16.0,
|
||
"peak": 2400.0,
|
||
"trail_armed": False,
|
||
"trail_arm_r": 0.6,
|
||
"trail_dist_r": 0.75,
|
||
}
|
||
|
||
# Bar 1: price moves up but not enough to arm (< 0.6R = $9.60)
|
||
new_sl = daemon._compute_trail(info_buy, bar_high=2408.0, bar_low=2399.0)
|
||
assert new_sl is None, "Should not arm yet (excursion 0.5R)"
|
||
assert info_buy["peak"] == 2408.0
|
||
|
||
# Bar 2: excursion reaches 0.625R = $10 — arms, trail = 2408 - 0.75*16 = 2396
|
||
new_sl = daemon._compute_trail(info_buy, bar_high=2410.0, bar_low=2403.0)
|
||
assert new_sl is not None, "Should arm at 0.625R"
|
||
assert abs(new_sl - 2398.0) < 0.01, f"Expected trail ~2398, got {new_sl}"
|
||
assert info_buy["trail_armed"] is True
|
||
|
||
# Bar 3: price goes higher, trail ratchets up
|
||
info_buy["current_sl"] = new_sl
|
||
new_sl2 = daemon._compute_trail(info_buy, bar_high=2420.0, bar_low=2410.0)
|
||
assert new_sl2 is not None
|
||
assert new_sl2 > new_sl, "Trail must ratchet up"
|
||
expected = 2420.0 - 0.75 * 16.0 # 2408.0
|
||
assert abs(new_sl2 - expected) < 0.01, f"Expected {expected}, got {new_sl2}"
|
||
|
||
# Bar 4: price pulls back — trail must NOT ratchet down
|
||
info_buy["current_sl"] = new_sl2
|
||
new_sl3 = daemon._compute_trail(info_buy, bar_high=2412.0, bar_low=2405.0)
|
||
assert new_sl3 is None, "Trail must not ratchet backwards"
|
||
|
||
|
||
@test("oanda_daemon: trailing stop for SELL direction")
|
||
def _():
|
||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
||
|
||
info_sell = {
|
||
"direction": "SELL",
|
||
"entry": 2400.0,
|
||
"sl_initial": 2416.0,
|
||
"current_sl": 2416.0,
|
||
"risk": 16.0,
|
||
"peak": 2400.0,
|
||
"trail_armed": False,
|
||
"trail_arm_r": 0.6,
|
||
"trail_dist_r": 0.75,
|
||
}
|
||
|
||
# Not enough excursion
|
||
new_sl = daemon._compute_trail(info_sell, bar_high=2401.0, bar_low=2393.0)
|
||
assert new_sl is None, "Should not arm yet"
|
||
assert info_sell["peak"] == 2393.0
|
||
|
||
# Arms: peak at 2390, excursion = 10/16 = 0.625R
|
||
new_sl = daemon._compute_trail(info_sell, bar_high=2395.0, bar_low=2390.0)
|
||
assert new_sl is not None
|
||
expected = 2390.0 + 0.75 * 16.0 # 2402.0
|
||
assert abs(new_sl - expected) < 0.01, f"Expected {expected}, got {new_sl}"
|
||
assert info_sell["trail_armed"] is True
|
||
|
||
# Ratchets down
|
||
info_sell["current_sl"] = new_sl
|
||
new_sl2 = daemon._compute_trail(info_sell, bar_high=2392.0, bar_low=2380.0)
|
||
assert new_sl2 is not None
|
||
assert new_sl2 < new_sl, "SELL trail must ratchet down"
|
||
|
||
|
||
@test("oanda_daemon: _should_execute respects quality gate")
|
||
def _():
|
||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
||
daemon._quality_gate = "B"
|
||
daemon._state = {"trades": {}, "last_entries": {}}
|
||
daemon._state_lock = threading.Lock()
|
||
|
||
from signal_aggregator import TradeSetup
|
||
setup = TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||
entry_price=2400.0, stop_loss=2384.0,
|
||
take_profit_1=2416.0, take_profit_2=2432.0,
|
||
risk_reward=1.0, risk_pips=160.0, reward_pips=160.0,
|
||
pip_value=0.10)
|
||
|
||
report_c = SignalReport(
|
||
symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-07-20")
|
||
report_c.final_signal = SignalStrength.BUY
|
||
report_c.signal_quality = "C"
|
||
report_c.trade_setup = setup
|
||
|
||
assert not daemon._should_execute(report_c, Timeframe.H1), \
|
||
"C should be blocked by gate=B"
|
||
|
||
report_a = SignalReport(
|
||
symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-07-20")
|
||
report_a.final_signal = SignalStrength.BUY
|
||
report_a.signal_quality = "A"
|
||
report_a.trade_setup = setup
|
||
|
||
assert daemon._should_execute(report_a, Timeframe.H1), \
|
||
"A should pass gate=B"
|
||
|
||
|
||
@test("oanda_daemon: _should_execute blocks when position exists")
|
||
def _():
|
||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
||
daemon._quality_gate = "C"
|
||
daemon._state = {"trades": {"12345": {"direction": "BUY"}},
|
||
"last_entries": {}}
|
||
daemon._state_lock = threading.Lock()
|
||
|
||
from signal_aggregator import TradeSetup
|
||
setup = TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||
entry_price=2400.0, stop_loss=2384.0,
|
||
take_profit_1=2416.0, take_profit_2=2432.0,
|
||
risk_reward=1.0, risk_pips=160.0, reward_pips=160.0,
|
||
pip_value=0.10)
|
||
|
||
report = SignalReport(
|
||
symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-07-20")
|
||
report.final_signal = SignalStrength.BUY
|
||
report.signal_quality = "A"
|
||
report.trade_setup = setup
|
||
|
||
assert not daemon._should_execute(report, Timeframe.H1), \
|
||
"Should block when a position already exists"
|
||
|
||
|
||
@test("oanda_daemon: NEUTRAL signal is never executed")
|
||
def _():
|
||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
||
daemon._quality_gate = "C"
|
||
daemon._state = {"trades": {}, "last_entries": {}}
|
||
daemon._state_lock = threading.Lock()
|
||
|
||
report = SignalReport(
|
||
symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-07-20")
|
||
report.final_signal = SignalStrength.NEUTRAL
|
||
report.signal_quality = "F"
|
||
report.trade_setup = None
|
||
|
||
assert not daemon._should_execute(report, Timeframe.H1)
|
||
|
||
|
||
def _make_daemon_report(quality="A", direction="BUY"):
|
||
from signal_aggregator import TradeSetup
|
||
setup = TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction=direction,
|
||
entry_price=2400.0, stop_loss=2384.0,
|
||
take_profit_1=2416.0, take_profit_2=2432.0,
|
||
risk_reward=1.0, risk_pips=160.0, reward_pips=160.0,
|
||
pip_value=0.10, trail_arm_r=0.6, trail_dist_r=0.85)
|
||
report = SignalReport(
|
||
symbol="XAU/USD", display_name="XAU/USD",
|
||
timestamp="2026-07-20 10:00:00+00:00")
|
||
report.final_signal = (SignalStrength.BUY if direction == "BUY"
|
||
else SignalStrength.SELL)
|
||
report.signal_quality = quality
|
||
report.trade_setup = setup
|
||
return report
|
||
|
||
|
||
@test("oanda_daemon: 5-bar entry cooldown blocks re-entry (backtest parity)")
|
||
def _():
|
||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
||
daemon._quality_gate = "C"
|
||
daemon._state = {"trades": {}, "last_entries": {}}
|
||
daemon._state_lock = threading.Lock()
|
||
|
||
report = _make_daemon_report()
|
||
|
||
# Entry 2 minutes ago on M5 → inside 5×5min cooldown → blocked
|
||
recent = (datetime.now(timezone.utc) - timedelta(minutes=2)).isoformat()
|
||
daemon._state["last_entries"]["5min"] = recent
|
||
assert not daemon._should_execute(report, Timeframe.M5), \
|
||
"Entry 2min ago must block M5 (cooldown 25min)"
|
||
|
||
# Entry 30 minutes ago on M5 → past 5×5min cooldown → allowed
|
||
old = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
|
||
daemon._state["last_entries"]["5min"] = old
|
||
assert daemon._should_execute(report, Timeframe.M5), \
|
||
"Entry 30min ago must clear the 25min M5 cooldown"
|
||
|
||
# M5 cooldown must not block H1
|
||
daemon._state["last_entries"] = {"5min": recent}
|
||
assert daemon._should_execute(report, Timeframe.H1), \
|
||
"Cooldown is per-timeframe"
|
||
|
||
|
||
@test("oanda_daemon: orders carry NO take-profit (trailing book parity)")
|
||
def _():
|
||
import tempfile
|
||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
||
daemon._state = {"trades": {}, "last_entries": {}}
|
||
daemon._state_lock = threading.Lock()
|
||
daemon._entry_lock = threading.Lock()
|
||
|
||
captured = {}
|
||
|
||
class MockExecutor:
|
||
def place_market_order(self, instrument, direction, stop_loss,
|
||
take_profit=None, units=None):
|
||
captured["take_profit"] = take_profit
|
||
captured["stop_loss"] = stop_loss
|
||
return {
|
||
"orderFillTransaction": {
|
||
"price": "2400.60",
|
||
"tradeOpened": {"tradeID": "777"},
|
||
}
|
||
}
|
||
|
||
daemon._executor = MockExecutor()
|
||
|
||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||
tmp_path = f.name
|
||
orig_state_file = oanda_daemon.STATE_FILE
|
||
try:
|
||
oanda_daemon.STATE_FILE = tmp_path
|
||
daemon._execute_trade(_make_daemon_report(), Timeframe.M15)
|
||
finally:
|
||
oanda_daemon.STATE_FILE = orig_state_file
|
||
os.unlink(tmp_path)
|
||
|
||
assert captured.get("take_profit") is None, \
|
||
"Backtested trailing model has no hard TP — order must not carry one"
|
||
assert captured["stop_loss"] == 2384.0
|
||
trade = daemon._state["trades"]["777"]
|
||
assert trade["entry"] == 2400.60, "Risk must be computed from actual fill"
|
||
assert abs(trade["risk"] - 16.60) < 0.01
|
||
assert trade["trail_arm_r"] == 0.6 and trade["trail_dist_r"] == 0.85, \
|
||
"Trail params must come from the setup stamp (per-TF factory values)"
|
||
assert daemon._state["last_entries"]["15min"], \
|
||
"Entry must be recorded for the cooldown"
|
||
|
||
|
||
@test("oanda_daemon: entry-lock recheck prevents double entry from concurrent scans")
|
||
def _():
|
||
import tempfile
|
||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
||
# A concurrent TF loop already opened a trade after our _should_execute
|
||
daemon._state = {"trades": {"111": {"direction": "BUY"}},
|
||
"last_entries": {}}
|
||
daemon._state_lock = threading.Lock()
|
||
daemon._entry_lock = threading.Lock()
|
||
|
||
placed = {"n": 0}
|
||
|
||
class MockExecutor:
|
||
def place_market_order(self, *a, **k):
|
||
placed["n"] += 1
|
||
return {}
|
||
|
||
daemon._executor = MockExecutor()
|
||
|
||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||
tmp_path = f.name
|
||
orig_state_file = oanda_daemon.STATE_FILE
|
||
try:
|
||
oanda_daemon.STATE_FILE = tmp_path
|
||
daemon._execute_trade(_make_daemon_report(), Timeframe.M5)
|
||
finally:
|
||
oanda_daemon.STATE_FILE = orig_state_file
|
||
os.unlink(tmp_path)
|
||
|
||
assert placed["n"] == 0, \
|
||
"Recheck under the entry lock must skip when a position exists"
|
||
|
||
|
||
@test("oanda_daemon: same closed bar is never processed twice")
|
||
def _():
|
||
import tempfile
|
||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
||
daemon._max_hold_bars = 300
|
||
daemon._price_precision = 2
|
||
daemon._state_lock = threading.Lock()
|
||
|
||
bar_time = pd.Timestamp("2026-07-20 10:00:00", tz="UTC")
|
||
df = pd.DataFrame(
|
||
{"Open": [2400.0], "High": [2405.0], "Low": [2398.0],
|
||
"Close": [2403.0], "Volume": [100.0]},
|
||
index=pd.DatetimeIndex([bar_time], name="Datetime"))
|
||
|
||
class MockProvider:
|
||
def fetch_ohlcv(self, symbol, tf):
|
||
return df
|
||
|
||
daemon._provider = MockProvider()
|
||
daemon._executor = None # trail must not arm on this bar
|
||
|
||
daemon._state = {
|
||
"trades": {
|
||
"555": {
|
||
"tf": "5min", "direction": "BUY", "entry": 2400.0,
|
||
"sl_initial": 2384.0, "current_sl": 2384.0, "risk": 16.0,
|
||
"peak": 2400.0, "trail_armed": False,
|
||
"trail_arm_r": 0.6, "trail_dist_r": 0.85,
|
||
"bars_held": 0, "last_bar_ts": None, "signal_bar_ts": None,
|
||
}
|
||
},
|
||
"last_entries": {},
|
||
}
|
||
|
||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||
tmp_path = f.name
|
||
orig_state_file = oanda_daemon.STATE_FILE
|
||
try:
|
||
oanda_daemon.STATE_FILE = tmp_path
|
||
daemon._manage_trailing(Timeframe.M5)
|
||
daemon._manage_trailing(Timeframe.M5) # same bar re-served (weekend/lag)
|
||
daemon._manage_trailing(Timeframe.M5)
|
||
finally:
|
||
oanda_daemon.STATE_FILE = orig_state_file
|
||
os.unlink(tmp_path)
|
||
|
||
assert daemon._state["trades"]["555"]["bars_held"] == 1, \
|
||
"A re-served bar must not inflate bars_held (weekend timeout bug)"
|
||
|
||
|
||
@test("oanda_daemon: failed timeout-close keeps the trade tracked")
|
||
def _():
|
||
import tempfile
|
||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
||
daemon._max_hold_bars = 1
|
||
daemon._price_precision = 2
|
||
daemon._state_lock = threading.Lock()
|
||
|
||
bar_time = pd.Timestamp("2026-07-20 10:00:00", tz="UTC")
|
||
df = pd.DataFrame(
|
||
{"Open": [2400.0], "High": [2405.0], "Low": [2398.0],
|
||
"Close": [2403.0], "Volume": [100.0]},
|
||
index=pd.DatetimeIndex([bar_time], name="Datetime"))
|
||
|
||
class MockProvider:
|
||
def fetch_ohlcv(self, symbol, tf):
|
||
return df
|
||
|
||
class MockExecutor:
|
||
def close_trade(self, trade_id):
|
||
raise oanda_executor.OandaAPIError("market halted", 503)
|
||
|
||
daemon._provider = MockProvider()
|
||
daemon._executor = MockExecutor()
|
||
|
||
daemon._state = {
|
||
"trades": {
|
||
"666": {
|
||
"tf": "5min", "direction": "BUY", "entry": 2400.0,
|
||
"sl_initial": 2384.0, "current_sl": 2384.0, "risk": 16.0,
|
||
"peak": 2400.0, "trail_armed": False,
|
||
"trail_arm_r": 0.6, "trail_dist_r": 0.85,
|
||
"bars_held": 0, "last_bar_ts": None, "signal_bar_ts": None,
|
||
}
|
||
},
|
||
"last_entries": {},
|
||
}
|
||
|
||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||
tmp_path = f.name
|
||
orig_state_file = oanda_daemon.STATE_FILE
|
||
try:
|
||
oanda_daemon.STATE_FILE = tmp_path
|
||
daemon._manage_trailing(Timeframe.M5)
|
||
finally:
|
||
oanda_daemon.STATE_FILE = orig_state_file
|
||
os.unlink(tmp_path)
|
||
|
||
assert "666" in daemon._state["trades"], \
|
||
"A failed broker close must keep the trade tracked for retry"
|
||
|
||
|
||
@test("app_config: invalid oanda section values raise AppConfigError")
|
||
def _():
|
||
import tempfile
|
||
for bad in (
|
||
{"oanda": {"quality_gate": "Z"}},
|
||
{"oanda": {"lot_size": 0}},
|
||
{"oanda": {"max_hold_bars": -5}},
|
||
{"oanda": {"unknown_key": 1}},
|
||
):
|
||
with tempfile.NamedTemporaryFile(
|
||
mode="w", suffix=".json", delete=False) as f:
|
||
json.dump(bad, f)
|
||
tmp_path = f.name
|
||
try:
|
||
app_config.load_app_config(tmp_path)
|
||
assert False, f"Should have raised for {bad}"
|
||
except app_config.AppConfigError:
|
||
pass
|
||
finally:
|
||
os.unlink(tmp_path)
|
||
|
||
|
||
@test("oanda_daemon: boundary timing matches telegram_bot")
|
||
def _():
|
||
from telegram_bot import _seconds_until_boundary as tb_boundary
|
||
from oanda_daemon import _seconds_until_boundary as od_boundary
|
||
for interval in (5, 15, 60, 240):
|
||
tb_val = tb_boundary(interval, buffer=10)
|
||
od_val = od_boundary(interval, buffer=10)
|
||
assert abs(tb_val - od_val) < 0.01, \
|
||
f"Boundary timing mismatch for {interval}min: {tb_val} vs {od_val}"
|
||
|
||
|
||
@test("oanda_daemon: state file round-trip")
|
||
def _():
|
||
import tempfile
|
||
state = {
|
||
"trades": {
|
||
"999": {
|
||
"tf": "M15",
|
||
"direction": "BUY",
|
||
"entry": 2400.0,
|
||
"sl_initial": 2384.0,
|
||
"current_sl": 2392.0,
|
||
"risk": 16.0,
|
||
"peak": 2412.0,
|
||
"trail_armed": True,
|
||
"trail_arm_r": 0.6,
|
||
"trail_dist_r": 0.75,
|
||
"bars_held": 25,
|
||
}
|
||
}
|
||
}
|
||
with tempfile.NamedTemporaryFile(
|
||
mode="w", suffix=".json", delete=False) as f:
|
||
tmp_path = f.name
|
||
|
||
orig_state_file = oanda_daemon.STATE_FILE
|
||
try:
|
||
oanda_daemon.STATE_FILE = tmp_path
|
||
oanda_daemon._save_state(state)
|
||
loaded = oanda_daemon._load_state()
|
||
assert loaded["trades"]["999"]["entry"] == 2400.0
|
||
assert loaded["trades"]["999"]["trail_armed"] is True
|
||
assert loaded["trades"]["999"]["bars_held"] == 25
|
||
finally:
|
||
oanda_daemon.STATE_FILE = orig_state_file
|
||
os.unlink(tmp_path)
|
||
|
||
|
||
@test("app_config: oanda in _KNOWN_TOP (no unknown-key error)")
|
||
def _():
|
||
assert "oanda" in app_config._KNOWN_TOP, \
|
||
"'oanda' must be in _KNOWN_TOP to avoid config.json validation errors"
|
||
|
||
|
||
@test("app_config: oanda resolver helpers return defaults without config")
|
||
def _():
|
||
url = app_config.oanda_base_url()
|
||
assert "oanda.com" in url, f"Expected Oanda URL, got {url}"
|
||
|
||
|
||
@test("oanda_provider: HTTP error surfaces status code and body")
|
||
def _():
|
||
mock_resp = type("MockResp", (), {
|
||
"status_code": 401,
|
||
"text": '{"errorMessage":"Insufficient authorization to perform request"}',
|
||
})()
|
||
|
||
provider = oanda_provider.OandaDataProvider.__new__(
|
||
oanda_provider.OandaDataProvider)
|
||
provider._base_url = "https://test"
|
||
provider._spread_cache = {}
|
||
provider._session = type("S", (), {
|
||
"get": lambda self, *a, **k: mock_resp,
|
||
"headers": {},
|
||
})()
|
||
|
||
try:
|
||
provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
|
||
assert False, "Should have raised OandaDataError"
|
||
except oanda_provider.OandaDataError as e:
|
||
msg = str(e)
|
||
assert "401" in msg, f"Status code missing from error: {msg}"
|
||
assert "Insufficient authorization" in msg, \
|
||
f"Body detail missing from error: {msg}"
|
||
|
||
|
||
@test("oanda_provider: boundary-retry failure keeps original data")
|
||
def _():
|
||
provider = oanda_provider.OandaDataProvider.__new__(
|
||
oanda_provider.OandaDataProvider)
|
||
provider.config = PlatformConfig()
|
||
provider._cache = {}
|
||
provider._cache_timestamps = {}
|
||
provider._cache_lock = threading.Lock()
|
||
provider._spread_cache = {}
|
||
provider.FRESH_FETCH_RETRY_SECONDS = 0 # instance override — no sleep
|
||
|
||
call_count = {"n": 0}
|
||
idx = pd.date_range("2026-07-01", periods=250, freq="5min", tz="UTC")
|
||
original_df = pd.DataFrame({
|
||
"Open": 2400.0, "High": 2401.0, "Low": 2399.0,
|
||
"Close": 2400.5, "Volume": 100.0,
|
||
}, index=idx)
|
||
|
||
def _fetch_oanda_candles(symbol, timeframe, count):
|
||
call_count["n"] += 1
|
||
if call_count["n"] == 1:
|
||
return original_df
|
||
raise oanda_provider.OandaDataError("boom")
|
||
|
||
def _validate_and_clean(df, symbol, timeframe):
|
||
return df
|
||
|
||
def _has_latest_closed_bar(df, timeframe):
|
||
return False # forces the retry path every call
|
||
|
||
def _is_data_fresh(df, symbol, timeframe):
|
||
return True
|
||
|
||
# Plain functions assigned to the instance bypass the descriptor
|
||
# protocol — they run unbound, so they take exactly the params
|
||
# fetch_ohlcv passes (no self).
|
||
provider._fetch_oanda_candles = _fetch_oanda_candles
|
||
provider._validate_and_clean = _validate_and_clean
|
||
provider._has_latest_closed_bar = _has_latest_closed_bar
|
||
provider._is_data_fresh = _is_data_fresh
|
||
|
||
result = provider.fetch_ohlcv("XAU/USD", Timeframe.M5)
|
||
|
||
assert call_count["n"] == 2, \
|
||
f"Expected original fetch + one retry attempt, got {call_count['n']} calls"
|
||
assert len(result) == 250, \
|
||
f"Retry failure must keep the original 250-bar df, got {len(result)}"
|
||
|
||
|
||
@test("oanda_executor: non-JSON response raises OandaAPIError with status")
|
||
def _():
|
||
def _bad_json(self):
|
||
raise ValueError("Expecting value: line 1 column 1 (char 0)")
|
||
|
||
mock_resp = type("MockResp", (), {
|
||
"status_code": 200,
|
||
"text": "<html>gateway error</html>",
|
||
"json": _bad_json,
|
||
})()
|
||
|
||
executor = oanda_executor.OandaExecutor.__new__(
|
||
oanda_executor.OandaExecutor)
|
||
executor._account_id = "test-account"
|
||
executor._base_url = "https://test"
|
||
executor._session = type("S", (), {
|
||
"request": lambda self, method, url, **kwargs: mock_resp,
|
||
"headers": {},
|
||
})()
|
||
|
||
try:
|
||
executor.get_open_trades()
|
||
assert False, "Should have raised OandaAPIError"
|
||
except oanda_executor.OandaAPIError as e:
|
||
assert e.status_code == 200, f"Expected status 200, got {e.status_code}"
|
||
assert "non-JSON" in str(e), f"Expected non-JSON in message: {e}"
|
||
|
||
|
||
@test("oanda_daemon: boot fails fast on rejected credentials (4xx)")
|
||
def _():
|
||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
||
|
||
class MockExecutor401:
|
||
def get_account_summary(self):
|
||
raise oanda_executor.OandaAPIError("unauthorized", 401)
|
||
|
||
daemon._executor = MockExecutor401()
|
||
try:
|
||
daemon._verify_account_access()
|
||
assert False, "A 4xx credential rejection must raise SystemExit"
|
||
except SystemExit:
|
||
pass
|
||
|
||
class MockExecutorConnFail:
|
||
def get_account_summary(self):
|
||
raise oanda_executor.OandaAPIError("conn refused", 0)
|
||
|
||
daemon._executor = MockExecutorConnFail()
|
||
daemon._verify_account_access() # transient — must NOT raise
|
||
|
||
|
||
@test("oanda_daemon: heartbeat never raises and logs on account failure")
|
||
def _():
|
||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
||
daemon._state = {"trades": {}, "last_entries": {}}
|
||
daemon._state_lock = threading.Lock()
|
||
|
||
class MockExecutorDown:
|
||
def get_account_summary(self):
|
||
raise oanda_executor.OandaAPIError("down", 503)
|
||
|
||
daemon._executor = MockExecutorDown()
|
||
daemon._heartbeat_once() # must not raise
|
||
|
||
class MockExecutorUp:
|
||
def get_account_summary(self):
|
||
return {"balance": "1000"}
|
||
|
||
daemon._executor = MockExecutorUp()
|
||
daemon._heartbeat_once() # must not raise
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 16: Provider abstraction (providers/ package)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 16: Provider abstraction ═══")
|
||
|
||
from providers import (
|
||
BaseDataProvider, PROVIDER_REGISTRY, TwelveDataProvider,
|
||
make_data_provider,
|
||
)
|
||
from providers.oanda import OandaDataProvider as _POanda
|
||
from providers.tiingo import TiingoDataError, TiingoDataProvider
|
||
|
||
|
||
@test("providers: registry holds all providers, active default builds TD")
|
||
def _():
|
||
assert PROVIDER_REGISTRY["twelve_data"] is TwelveDataProvider
|
||
assert PROVIDER_REGISTRY["oanda"] is _POanda
|
||
assert PROVIDER_REGISTRY["tiingo"] is TiingoDataProvider
|
||
dp = make_data_provider(PlatformConfig())
|
||
assert isinstance(dp, TwelveDataProvider)
|
||
assert isinstance(dp, BaseDataProvider)
|
||
|
||
|
||
@test("providers: single knob — data_providers.active selects the class")
|
||
def _():
|
||
from app_config import AppConfig
|
||
for name, cls in PROVIDER_REGISTRY.items():
|
||
fake = AppConfig()
|
||
fake.active_provider = name
|
||
fake.provider_settings = {name: {"api_key": ""}}
|
||
with patch("providers.get_app_config", return_value=fake):
|
||
dp = make_data_provider(PlatformConfig())
|
||
assert type(dp) is cls, f"active={name} built {type(dp).__name__}"
|
||
# An unknown name must fail loudly, not fall back silently.
|
||
from app_config import AppConfigError
|
||
fake = AppConfig()
|
||
fake.active_provider = "nonsense"
|
||
with patch("providers.get_app_config", return_value=fake):
|
||
try:
|
||
make_data_provider(PlatformConfig())
|
||
assert False, "unknown provider must raise"
|
||
except AppConfigError as e:
|
||
assert "nonsense" in str(e)
|
||
|
||
|
||
@test("providers: shared behavior is defined ONCE on the base class")
|
||
def _():
|
||
# The pre-refactor bug this guards against: Oanda's provider was a
|
||
# copy-paste of the TD one, so shared-logic fixes had to be made
|
||
# twice. Every shared method must now BE the base implementation.
|
||
for meth in ("fetch_ohlcv", "fetch_range", "_validate_and_clean",
|
||
"_is_data_fresh", "_has_latest_closed_bar",
|
||
"_drop_forming_candle", "clear_cache", "get_pair_info"):
|
||
for cls in PROVIDER_REGISTRY.values():
|
||
assert getattr(cls, meth) is getattr(BaseDataProvider, meth), \
|
||
f"{cls.__name__} overrides shared method {meth}"
|
||
assert TwelveDataProvider.CANDLE_SECONDS is BaseDataProvider.CANDLE_SECONDS
|
||
assert _POanda.BARS_PER_DAY is BaseDataProvider.BARS_PER_DAY
|
||
assert TiingoDataProvider.CANDLE_SECONDS is BaseDataProvider.CANDLE_SECONDS
|
||
|
||
|
||
@test("providers: back-compat shims re-export the real classes")
|
||
def _():
|
||
import data_provider as dp_shim
|
||
import oanda_provider as oa_shim
|
||
assert dp_shim.ForexDataProvider is TwelveDataProvider
|
||
assert dp_shim.make_data_provider is make_data_provider
|
||
assert oa_shim.OandaDataProvider is _POanda
|
||
from providers.base import DataIntegrityError as base_die
|
||
assert dp_shim.DataIntegrityError is base_die
|
||
# Provider-specific errors are catchable as the shared type.
|
||
assert issubclass(oa_shim.OandaDataError, base_die)
|
||
|
||
|
||
@test("providers: fetch_range paginates, dedupes, and trims to the window")
|
||
def _():
|
||
class FakeProvider(BaseDataProvider):
|
||
name = "fake"
|
||
|
||
def __init__(self, config):
|
||
super().__init__(config)
|
||
idx = pd.date_range("2026-06-01", "2026-06-30 23:00",
|
||
freq="1h", tz="UTC")
|
||
v = np.linspace(3000.0, 3100.0, len(idx))
|
||
self.series = pd.DataFrame(
|
||
{"Open": v, "High": v + 1, "Low": v - 1,
|
||
"Close": v, "Volume": 100.0}, index=idx)
|
||
self.series.index.name = "Datetime"
|
||
self.calls = 0
|
||
|
||
def _fetch_raw(self, symbol, timeframe, outputsize,
|
||
start=None, end=None):
|
||
self.calls += 1
|
||
df = self.series
|
||
if start is not None:
|
||
df = df[df.index >= pd.Timestamp(start)]
|
||
if end is not None:
|
||
df = df[df.index <= pd.Timestamp(end)]
|
||
# Overlap by a bar to prove concat dedupes.
|
||
return df.tail(min(len(df), 200))
|
||
|
||
# Keep the test date-stable: nothing here is "forming".
|
||
def _drop_forming_candle(self, df, timeframe):
|
||
return df
|
||
|
||
fp = FakeProvider(PlatformConfig())
|
||
start = datetime(2026, 6, 5, tzinfo=timezone.utc)
|
||
end = datetime(2026, 6, 25, tzinfo=timezone.utc)
|
||
df = fp.fetch_range("XAU/USD", Timeframe.H1, start, end)
|
||
|
||
assert fp.calls > 1, "a 20-day H1 window must take multiple chunks"
|
||
assert df.index.is_monotonic_increasing
|
||
assert not df.index.duplicated().any()
|
||
assert df.index[0] >= pd.Timestamp(start)
|
||
assert df.index[-1] <= pd.Timestamp(end)
|
||
expected = fp.series[(fp.series.index >= pd.Timestamp(start))
|
||
& (fp.series.index <= pd.Timestamp(end))]
|
||
assert len(df) == len(expected), \
|
||
f"window must be gapless: {len(df)} vs {len(expected)}"
|
||
|
||
|
||
@test("providers: streaming capability flags and base refusal")
|
||
def _():
|
||
assert TwelveDataProvider.supports_streaming is True
|
||
assert _POanda.supports_streaming is False
|
||
assert TiingoDataProvider.supports_streaming is False
|
||
|
||
class Minimal(BaseDataProvider):
|
||
name = "minimal"
|
||
|
||
def _fetch_raw(self, symbol, timeframe, outputsize,
|
||
start=None, end=None):
|
||
raise NotImplementedError
|
||
|
||
m = Minimal(PlatformConfig())
|
||
assert m.supports_streaming is False
|
||
try:
|
||
m.iter_ticks(["XAU/USD"])
|
||
assert False, "base iter_ticks must refuse"
|
||
except NotImplementedError:
|
||
pass
|
||
|
||
|
||
@test("providers: TD WS price-message parsing")
|
||
def _():
|
||
parse = TwelveDataProvider._parse_ws_price
|
||
assert parse('{"event":"price","symbol":"XAU/USD","price":"3345.5"}') \
|
||
== ("XAU/USD", 3345.5)
|
||
assert parse('{"event":"subscribe-status","fails":[]}') is None
|
||
assert parse('{"event":"heartbeat"}') is None
|
||
assert parse('not json{{') is None
|
||
assert parse('{"event":"price","symbol":"XAU/USD"}') is None # no price
|
||
assert parse('{"event":"price","symbol":"XAU/USD","price":"abc"}') is None
|
||
|
||
|
||
@test("providers: Oanda date-window fetch omits count and sends from/to")
|
||
def _():
|
||
captured = {}
|
||
|
||
def mock_get(self, url, params=None, timeout=None):
|
||
captured["params"] = params
|
||
return type("R", (), {
|
||
"status_code": 200,
|
||
"json": lambda self: {"candles": [{
|
||
"complete": True,
|
||
"time": "2026-06-02T10:00:00.000000000Z",
|
||
"mid": {"o": "3000", "h": "3001", "l": "2999", "c": "3000.5"},
|
||
"bid": {"c": "3000.2"}, "ask": {"c": "3000.8"},
|
||
"volume": 10,
|
||
}]},
|
||
})()
|
||
|
||
provider = _POanda.__new__(_POanda)
|
||
provider.config = PlatformConfig()
|
||
provider._base_url = "https://test"
|
||
provider._spread_cache = {}
|
||
provider._session = type("S", (), {"get": mock_get})()
|
||
|
||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||
end = datetime(2026, 6, 3, tzinfo=timezone.utc)
|
||
df = provider._fetch_raw("XAU/USD", Timeframe.H1, 5000, start, end)
|
||
assert "count" not in captured["params"], \
|
||
"v20 rejects count combined with from/to"
|
||
assert captured["params"]["from"].startswith("2026-06-01")
|
||
assert captured["params"]["to"].startswith("2026-06-03")
|
||
assert len(df) == 1
|
||
|
||
# Live path (no window) keeps sending count only.
|
||
provider._fetch_raw("XAU/USD", Timeframe.H1, 300)
|
||
assert captured["params"]["count"] == 300
|
||
assert "from" not in captured["params"]
|
||
|
||
|
||
def _tiingo_provider(response):
|
||
"""TiingoDataProvider with a canned session response (no network)."""
|
||
provider = TiingoDataProvider.__new__(TiingoDataProvider)
|
||
provider.config = PlatformConfig()
|
||
provider._api_key = "test-key"
|
||
provider._spread_cache = {}
|
||
captured = {}
|
||
|
||
def mock_get(self, url, params=None, timeout=None):
|
||
captured["url"] = url
|
||
captured["params"] = params
|
||
return response
|
||
|
||
provider._session = type("S", (), {"get": mock_get})()
|
||
return provider, captured
|
||
|
||
|
||
def _tiingo_resp(status_code, payload):
|
||
return type("R", (), {
|
||
"status_code": status_code,
|
||
"json": lambda self: payload,
|
||
"text": json.dumps(payload) if not isinstance(payload, str) else payload,
|
||
})()
|
||
|
||
|
||
@test("providers: Tiingo parses FX JSON into the normalized frame")
|
||
def _():
|
||
payload = [
|
||
{"date": "2026-07-20T11:00:00.000Z", "ticker": "xauusd",
|
||
"open": 2403.0, "high": 2408.0, "low": 2401.0, "close": 2406.0},
|
||
# Deliberately out of order — provider must sort oldest-first.
|
||
{"date": "2026-07-20T10:00:00.000Z", "ticker": "xauusd",
|
||
"open": 2400.0, "high": 2405.0, "low": 2398.0, "close": 2403.0},
|
||
]
|
||
provider, captured = _tiingo_provider(_tiingo_resp(200, payload))
|
||
df = provider._fetch_raw("XAU/USD", Timeframe.H1, 300)
|
||
|
||
assert list(df.columns) == ["Open", "High", "Low", "Close", "Volume"]
|
||
assert df.index.name == "Datetime"
|
||
assert df.index.tz is not None, "Index must be tz-aware UTC"
|
||
assert df.index.is_monotonic_increasing, "must be oldest-first"
|
||
assert df.iloc[0]["Open"] == 2400.0 and df.iloc[1]["Close"] == 2406.0
|
||
assert (df["Volume"] == 0.0).all(), "FX endpoint has no volume"
|
||
assert captured["url"].endswith("/tiingo/fx/xauusd/prices")
|
||
assert captured["params"]["resampleFreq"] == "1hour"
|
||
assert "startDate" in captured["params"], \
|
||
"live fetch must derive a start window from the bar count"
|
||
assert "token" not in captured["params"], \
|
||
"key must travel in the auth header, never the URL"
|
||
|
||
|
||
@test("providers: Tiingo date-window fetch sends startDate AND endDate")
|
||
def _():
|
||
payload = [{"date": "2026-06-02T10:00:00.000Z",
|
||
"open": 1, "high": 2, "low": 0, "close": 1}]
|
||
provider, captured = _tiingo_provider(_tiingo_resp(200, payload))
|
||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||
end = datetime(2026, 6, 3, tzinfo=timezone.utc)
|
||
provider._fetch_raw("XAU/USD", Timeframe.H1, 5000, start, end)
|
||
assert captured["params"]["startDate"].startswith("2026-06-01")
|
||
assert captured["params"]["endDate"].startswith("2026-06-03")
|
||
|
||
|
||
@test("providers: Tiingo fails LOUDLY on every bad-response shape")
|
||
def _():
|
||
cases = [
|
||
(_tiingo_resp(429, "quota"), "rate limit"),
|
||
(_tiingo_resp(401, "denied"), "auth failed"),
|
||
(_tiingo_resp(500, "boom"), "HTTP 500"),
|
||
(_tiingo_resp(200, []), "Empty candle response"),
|
||
(_tiingo_resp(200, [{"open": 1}]), "missing 'date'"),
|
||
(_tiingo_resp(200, [{"date": "2026-07-20T10:00:00Z",
|
||
"close": "n/a"}]), "no numeric closes"),
|
||
]
|
||
for resp, needle in cases:
|
||
provider, _cap = _tiingo_provider(resp)
|
||
try:
|
||
provider._fetch_raw("XAU/USD", Timeframe.H1, 300)
|
||
assert False, f"must raise for: {needle}"
|
||
except TiingoDataError as e:
|
||
assert needle.lower() in str(e).lower(), \
|
||
f"error must name the cause ({needle}): {e}"
|
||
|
||
# Missing key and unsupported timeframe are named errors too.
|
||
provider, _cap = _tiingo_provider(_tiingo_resp(200, []))
|
||
provider._api_key = None
|
||
try:
|
||
provider._fetch_raw("XAU/USD", Timeframe.H1, 300)
|
||
assert False, "missing key must raise"
|
||
except TiingoDataError as e:
|
||
assert "TIINGO_API_KEY" in str(e)
|
||
provider._api_key = "k"
|
||
try:
|
||
provider._fetch_raw("XAU/USD", Timeframe.W1, 300)
|
||
assert False, "unsupported TF must raise"
|
||
except TiingoDataError as e:
|
||
assert "Unsupported timeframe" in str(e)
|
||
# And everything above is catchable as the shared base error.
|
||
assert issubclass(TiingoDataError, DataIntegrityError)
|
||
|
||
|
||
@test("providers: per-provider env keys (TIINGO_API_KEY vs TWELVE_DATA_API_KEY)")
|
||
def _():
|
||
from app_config import AppConfig, provider_api_key
|
||
app = AppConfig()
|
||
app.active_provider = "tiingo"
|
||
app.provider_settings = {"tiingo": {"api_key": "json-tiingo"},
|
||
"twelve_data": {"api_key": "json-td"}}
|
||
with patch.dict(os.environ, {"TIINGO_API_KEY": "env-tiingo",
|
||
"TWELVE_DATA_API_KEY": "env-td"}):
|
||
assert provider_api_key(app) == "env-tiingo"
|
||
assert provider_api_key(app, provider="twelve_data") == "env-td"
|
||
with patch.dict(os.environ, {}, clear=False):
|
||
os.environ.pop("TIINGO_API_KEY", None)
|
||
assert provider_api_key(app) == "json-tiingo"
|
||
# The TD env var must never leak into another provider's key.
|
||
with patch.dict(os.environ, {"TWELVE_DATA_API_KEY": "env-td"}):
|
||
assert provider_api_key(app, provider="tiingo") == "json-tiingo"
|
||
|
||
|
||
@test("providers: aggregator bar-close clock uses the base-class table")
|
||
def _():
|
||
# signal_aggregator computes bar-CLOSE hours (session filter, SELL
|
||
# hour gate) from BaseDataProvider.CANDLE_SECONDS — a provider swap
|
||
# must never change the session clock.
|
||
import signal_aggregator as sa
|
||
assert sa.BaseDataProvider.CANDLE_SECONDS[Timeframe.M15] == 900
|
||
assert not hasattr(sa, "ForexDataProvider"), \
|
||
"aggregator must not depend on a concrete provider class"
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 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)
|