Files
ForexBot2/test_all.py
T
Harwinder Sekhon cf2ad73343 research: 3-year stake-sizing campaign + violent-move studies (nothing changes signals)
Walk-forward "second opinion" stake multiplier (meta-labelling on 65 decision-time
features, 3yr Oanda books @ $0.65) FAILED the pre-registered rule on M5 (clean null)
and M15 (lift inside the label-shuffle null, explained by BUY-vs-SELL). Nothing that
changes numbers ships; the 12-month M5 book re-run is byte-identical to the pinned one.

Behaviour-neutral plumbing:
- position_sizing.build_features (65 features; multiplier path returns 1.0, no artifact)
- SignalReport.penalty_tags / penalty_total / momentum_fading (record-keeping)
- TradeSetup.stake_multiplier = None (reserved)
- gpu_backtest --export-features (default export stays the pinned 63 columns; extras
  forced to the tail even when the first trade lacks an indicator)

Research tools (backtest/research): book_report, burst_map, burst_precursors,
open_trade_bursts, calendar_impact, burst_indicator_map, sizing_model, watch_rule_eval.
Findings recorded in backtest/research/sizing_campaign_2026-09.md: M5 stretch measures
are positive in both halves; every open-trade protection against violent bars loses on
both TFs; high-impact USD releases cause 20-31% of violent bars but trades held through
them do better; violent bars come when price is already at an extreme, not from calm.

Module 29 tests (13). Suite: 435 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWV2yLt3YXHHGVYHSQU2EE
2026-09-05 21:19:24 -04:00

9689 lines
394 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
ForexBot2 — Comprehensive QA Test Suite
=========================================
Tests every function in the codebase without requiring live API connections.
"""
import sys
import os
import time
import threading
import tempfile
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", "DEEPSEEK_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: providers/twelve_data.py
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 2: providers/twelve_data.py ═══")
from providers import TwelveDataProvider, DataIntegrityError
@test("TwelveDataProvider._to_td_symbol conversion")
def _():
assert TwelveDataProvider._to_td_symbol("XAU/USD") == "XAU/USD"
@test("TwelveDataProvider TD_INTERVALS complete mapping")
def _():
dp = TwelveDataProvider(PlatformConfig())
for tf in Timeframe:
assert tf in dp.TD_INTERVALS, f"Missing TD_INTERVALS mapping for {tf}"
@test("TwelveDataProvider BARS_PER_DAY mapping")
def _():
dp = TwelveDataProvider(PlatformConfig())
for tf in Timeframe:
assert tf in dp.BARS_PER_DAY, f"Missing BARS_PER_DAY for {tf}"
assert dp.BARS_PER_DAY[Timeframe.H1] == 24
assert dp.BARS_PER_DAY[Timeframe.M15] == 96
assert dp.BARS_PER_DAY[Timeframe.D1] == 1
@test("TwelveDataProvider CANDLE_SECONDS mapping")
def _():
dp = TwelveDataProvider(PlatformConfig())
for tf in Timeframe:
assert tf in dp.CANDLE_SECONDS, f"Missing CANDLE_SECONDS for {tf}"
assert dp.CANDLE_SECONDS[Timeframe.M1] == 60
assert dp.CANDLE_SECONDS[Timeframe.H1] == 3600
assert dp.CANDLE_SECONDS[Timeframe.D1] == 86400
@test("TwelveDataProvider._validate_and_clean basic valid data")
def _():
dp = TwelveDataProvider(PlatformConfig())
df = make_ohlcv(300)
result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1)
assert len(result) > 0
assert result.index.is_monotonic_increasing
assert not result.index.has_duplicates
@test("TwelveDataProvider._validate_and_clean empty df raises error")
def _():
dp = TwelveDataProvider(PlatformConfig())
try:
dp._validate_and_clean(pd.DataFrame(), "XAU/USD", Timeframe.H1)
assert False, "Should have raised DataIntegrityError"
except DataIntegrityError:
pass
@test("TwelveDataProvider._validate_and_clean None df raises error")
def _():
dp = TwelveDataProvider(PlatformConfig())
try:
dp._validate_and_clean(None, "XAU/USD", Timeframe.H1)
assert False, "Should have raised DataIntegrityError"
except DataIntegrityError:
pass
@test("TwelveDataProvider._validate_and_clean fixes OHLC inconsistencies")
def _():
dp = TwelveDataProvider(PlatformConfig())
df = make_ohlcv(300)
df.iloc[10, df.columns.get_loc("High")] = df.iloc[10]["Low"] - 1
result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1)
assert (result["High"] >= result["Low"]).all()
@test("TwelveDataProvider._validate_and_clean drops future timestamps")
def _():
dp = TwelveDataProvider(PlatformConfig())
df = make_ohlcv(300)
future = pd.Timestamp.now(tz="UTC") + pd.Timedelta(hours=10)
df.loc[future] = df.iloc[-1]
result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1)
assert (result.index <= pd.Timestamp.utcnow() + pd.Timedelta(minutes=5)).all()
@test("TwelveDataProvider._validate_and_clean removes duplicates")
def _():
dp = TwelveDataProvider(PlatformConfig())
df = make_ohlcv(300)
dup = pd.concat([df, df.iloc[-5:]])
assert dup.index.has_duplicates, "Setup: should have duplicates"
result = dp._validate_and_clean(dup, "XAU/USD", Timeframe.H1)
assert not result.index.has_duplicates, "Should remove duplicate indices"
@test("TwelveDataProvider._validate_and_clean forward fills NaN (limit 3)")
def _():
dp = TwelveDataProvider(PlatformConfig())
df = make_ohlcv(300)
df.iloc[100, df.columns.get_loc("Close")] = np.nan
df.iloc[101, df.columns.get_loc("Close")] = np.nan
result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1)
assert not result["Close"].isna().any()
@test("TwelveDataProvider._is_data_fresh weekend override")
def _():
dp = TwelveDataProvider(PlatformConfig())
# Create data that ends on a Friday, call _is_data_fresh on a Saturday
friday = pd.Timestamp("2025-05-09 16:00:00", tz="UTC")
old_dates = pd.date_range(end=friday, periods=300, freq="h")
df = make_ohlcv(300)
df.index = old_dates
# Simulate calling on Saturday by patching pd.Timestamp.now
saturday = pd.Timestamp("2025-05-10 12:00:00", tz="UTC")
original_now = pd.Timestamp.now
with patch.object(pd.Timestamp, "now", return_value=saturday):
result = dp._is_data_fresh(df, "XAU/USD", Timeframe.H1)
assert result is True, "Weekend data should always be considered fresh"
@test("TwelveDataProvider._is_data_fresh returns False for empty df")
def _():
dp = TwelveDataProvider(PlatformConfig())
assert dp._is_data_fresh(pd.DataFrame(), "XAU/USD", Timeframe.H1) is False
assert dp._is_data_fresh(None, "XAU/USD", Timeframe.H1) is False
@test("TwelveDataProvider.clear_cache clears all entries")
def _():
dp = TwelveDataProvider(PlatformConfig())
dp._cache["test_key"] = make_ohlcv(10)
dp._cache_timestamps["test_key"] = time.time()
count = dp.clear_cache()
assert count == 1
assert len(dp._cache) == 0
assert len(dp._cache_timestamps) == 0
@test("TwelveDataProvider.get_pair_info returns correct info")
def _():
dp = TwelveDataProvider(PlatformConfig())
info = dp.get_pair_info(XAUUSD)
assert info["symbol"] == XAUUSD
assert info["display"] == "XAU/USD"
assert info["pip_value"] == 0.10
info_unknown = dp.get_pair_info("UNKNOWN")
assert info_unknown["display"] == "XAU/USD"
@test("TwelveDataProvider._throttle rate limiting logic")
def _():
dp = TwelveDataProvider(PlatformConfig())
dp._request_times.clear()
for _ in range(10):
dp._throttle(credits=1)
assert len(dp._request_times) == 10
@test("TwelveDataProvider caching - in-memory cache returns same data")
def _():
dp = TwelveDataProvider(PlatformConfig())
df = make_ohlcv(300)
dp._cache["XAU/USD_1h"] = df
dp._cache_timestamps["XAU/USD_1h"] = time.time()
cached = dp._cache.get("XAU/USD_1h")
assert cached is df
# ══════════════════════════════════════════════════════════════════════
# MODULE 3: engine.py
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 3: engine.py ═══")
from engine import TechnicalEngine, TechnicalAnalysis, IndicatorSignal
@test("TechnicalEngine.analyze returns TechnicalAnalysis with insufficient data")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(10)
result = engine.analyze(df, "XAU/USD", "1h")
assert isinstance(result, TechnicalAnalysis)
assert result.confidence == 0.0
@test("TechnicalEngine.analyze returns TechnicalAnalysis with None df")
def _():
engine = TechnicalEngine(TechnicalConfig())
result = engine.analyze(None, "XAU/USD", "1h")
assert isinstance(result, TechnicalAnalysis)
assert result.confidence == 0.0
@test("TechnicalEngine.analyze returns valid result with good data")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert isinstance(result, TechnicalAnalysis)
assert result.symbol == "XAU/USD"
assert result.timeframe == "1h"
assert result.composite_signal in list(SignalStrength)
assert -100 <= result.composite_score <= 100
assert 0 <= result.confidence <= 100
@test("TechnicalEngine EMA-4 mode computes all 4 EMAs")
def _():
config = TechnicalConfig(ema_mode="4ema")
engine = TechnicalEngine(config)
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "ema_trend" in result.indicators
ema_ind = result.indicators["ema_trend"]
assert isinstance(ema_ind, IndicatorSignal)
assert ema_ind.name == "EMA Trend"
assert ema_ind.signal in list(SignalStrength)
assert "ema_fast" in result.key_levels
assert "ema_medium" in result.key_levels
assert "ema_slow" in result.key_levels
@test("EMA-4 partial alignment squashes to NEUTRAL with ema_partial_neutral")
def _():
import numpy as np
# Long decline then a medium rally: price>EMA9>EMA21>EMA50 but
# EMA50<EMA200 -> alignment 2/4 = partial BUY under legacy scoring.
closes = np.concatenate([np.linspace(3000, 2400, 400),
np.linspace(2400, 2560, 55)])
idx = pd.date_range("2026-01-01", periods=len(closes), freq="5min", tz="UTC")
df = pd.DataFrame({"Open": closes, "High": closes + 1.0,
"Low": closes - 1.0, "Close": closes,
"Volume": 1000.0}, index=idx)
assert TechnicalConfig().ema_partial_neutral is False # legacy default
base = TechnicalEngine(TechnicalConfig()).analyze(
df, "XAU/USD", "5min").indicators["ema_trend"]
assert base.signal == SignalStrength.BUY and 0.2 < base.value <= 0.6, \
f"fixture must yield a partial-alignment BUY, got {base.signal} {base.value}"
ind = TechnicalEngine(TechnicalConfig(ema_partial_neutral=True)).analyze(
df, "XAU/USD", "5min").indicators["ema_trend"]
assert ind.signal == SignalStrength.NEUTRAL and ind.confidence == 40
# Full alignment is untouched by the knob.
up = np.linspace(2000, 3000, 380)
df2 = pd.DataFrame({"Open": up, "High": up + 1.0, "Low": up - 1.0,
"Close": up, "Volume": 1000.0},
index=pd.date_range("2026-01-01", periods=380,
freq="5min", tz="UTC"))
strong = TechnicalEngine(TechnicalConfig(ema_partial_neutral=True)).analyze(
df2, "XAU/USD", "5min").indicators["ema_trend"]
assert strong.signal == SignalStrength.STRONG_BUY
@test("TechnicalEngine EMA-2 mode computes EMA50/200")
def _():
config = TechnicalConfig(ema_mode="2ema")
engine = TechnicalEngine(config)
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "ema_trend" in result.indicators
assert "ema_trend" in result.key_levels or "ema_slow" in result.key_levels
@test("TechnicalEngine RSI computation")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "rsi" in result.indicators
rsi = result.indicators["rsi"]
assert 0 <= rsi.value <= 100, f"RSI value {rsi.value} out of range"
assert rsi.signal in list(SignalStrength)
assert rsi.weight == 1.5
@test("TechnicalEngine RSI with NaN handling")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
df["Close"] = df["Close"].iloc[0] # flat price = 0 delta
result = engine.analyze(df, "XAU/USD", "1h")
rsi = result.indicators.get("rsi")
if rsi:
assert not pd.isna(rsi.value), "RSI should handle NaN gracefully"
@test("TechnicalEngine Bollinger Bands computation")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "bollinger" in result.indicators
bb = result.indicators["bollinger"]
assert isinstance(bb.value, float)
assert "bb_upper" in result.key_levels
assert "bb_lower" in result.key_levels
assert "bb_mid" in result.key_levels
assert result.key_levels["bb_upper"] > result.key_levels["bb_lower"]
@test("TechnicalEngine ADX computation")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "adx" in result.indicators
adx = result.indicators["adx"]
assert adx.value >= 0, f"ADX value {adx.value} should be >= 0"
assert "+DI" in adx.description
assert "-DI" in adx.description
@test("TechnicalEngine ATR computation and volatility regime")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "atr" in result.indicators
atr = result.indicators["atr"]
assert atr.value > 0, "ATR should be positive"
assert atr.weight == 0.0, "ATR weight should be 0 (not directional)"
assert result.volatility_regime in ("low", "normal", "high", "extreme")
assert "atr" in result.key_levels
assert "atr_pct" in result.key_levels
@test("TechnicalEngine MACD as pillar (enabled by default)")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "macd" in result.indicators
macd = result.indicators["macd"]
assert macd.weight == 1.5
@test("TechnicalEngine MACD not as pillar when disabled")
def _():
config = TechnicalConfig(macd_as_pillar=False)
engine = TechnicalEngine(config)
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "macd" not in result.indicators
assert hasattr(result, "_macd_histogram")
@test("TechnicalEngine disabled indicators are skipped")
def _():
config = TechnicalConfig(disabled_indicators=["rsi", "bollinger", "divergence"])
engine = TechnicalEngine(config)
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "rsi" not in result.indicators
assert "bollinger" not in result.indicators
@test("TechnicalEngine candlestick pattern detection")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
# Create a hammer pattern: long lower wick, small body, no upper wick
idx = -1
close_val = df["Close"].iloc[idx]
df.iloc[idx, df.columns.get_loc("Open")] = close_val - 0.0001
df.iloc[idx, df.columns.get_loc("Close")] = close_val
df.iloc[idx, df.columns.get_loc("High")] = close_val + 0.00005
df.iloc[idx, df.columns.get_loc("Low")] = close_val - 0.001
result = engine.analyze(df, "XAU/USD", "1h")
# Pattern may or may not be detected depending on threshold, just ensure no crash
@test("TechnicalEngine swing point detection")
def _():
engine = TechnicalEngine(TechnicalConfig())
series = pd.Series([10, 8, 6, 4, 6, 8, 10, 8, 6, 4, 6, 8, 10])
lows = engine._find_swing_points(series, mode="low", window=2)
highs = engine._find_swing_points(series, mode="high", window=2)
assert len(lows) > 0, "Should find swing lows in V-pattern data"
assert any(v == 4 for _, v in lows), "Should find value 4 as swing low"
assert len(highs) > 0, "Should find swing highs"
@test("TechnicalEngine swing points with NaN values")
def _():
engine = TechnicalEngine(TechnicalConfig())
series = pd.Series([10, 8, np.nan, 4, 6, 8, 10, 8, 6, 4, 6, 8, 10])
lows = engine._find_swing_points(series, mode="low", window=2)
# Should not crash
@test("TechnicalEngine market regime classification")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300, trend=0.001) # strong uptrend
result = engine.analyze(df, "XAU/USD", "1h")
assert result.market_regime in ("trending_up", "trending_down", "trending", "ranging")
@test("TechnicalEngine composite score within bounds")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert -100 <= result.composite_score <= 100
assert result.composite_signal in list(SignalStrength)
@test("TechnicalEngine regime-aware RSI reinterpretation")
def _():
config = TechnicalConfig(rsi_mode="regime_aware")
engine = TechnicalEngine(config)
df = make_ohlcv(300, trend=0.001)
result = engine.analyze(df, "XAU/USD", "1h")
rsi = result.indicators.get("rsi")
assert rsi is not None
@test("TechnicalEngine structure S/R levels detection")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
swing_keys = [k for k in result.key_levels if k.startswith("swing_")]
assert len(swing_keys) >= 0 # may or may not have swings
@test("TechnicalEngine divergence detection (enabled by default)")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
# divergence may or may not be detected depending on data, just ensure no crash
@test("TechnicalEngine divergence detection (disabled)")
def _():
config = TechnicalConfig(disabled_indicators=["divergence"])
engine = TechnicalEngine(config)
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "divergence" not in result.indicators
@test("TechnicalEngine EMA-2 golden cross detection")
def _():
config = TechnicalConfig(ema_mode="2ema")
engine = TechnicalEngine(config)
df = make_ohlcv(300, trend=0.002)
result = engine.analyze(df, "XAU/USD", "1h")
assert "ema_trend" in result.indicators
@test("TechnicalEngine with very short data")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(31)
result = engine.analyze(df, "XAU/USD", "1h")
assert isinstance(result, TechnicalAnalysis)
@test("TechnicalEngine ROC computation")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300, trend=0.002)
result = engine.analyze(df, "XAU/USD", "1h")
assert "roc" in result.indicators
roc = result.indicators["roc"]
assert roc.weight == 1.5
assert roc.signal in list(SignalStrength)
@test("TechnicalEngine Keltner Channels computation")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "keltner" in result.indicators
kc = result.indicators["keltner"]
assert kc.weight == 1.5
assert "kc_upper" in result.key_levels
assert "kc_lower" in result.key_levels
assert "kc_mid" in result.key_levels
assert result.key_levels["kc_upper"] > result.key_levels["kc_lower"]
@test("TechnicalEngine ADX slope computation")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300, trend=0.002)
result = engine.analyze(df, "XAU/USD", "1h")
assert "adx" in result.indicators
assert hasattr(result, "_adx_slope")
@test("TechnicalEngine transitioning regime detection")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert result.market_regime in (
"trending_up", "trending_down", "trending", "ranging", "transitioning"
)
@test("TechnicalEngine gold-specific composite weights")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_xau_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert -100 <= result.composite_score <= 100
assert result.composite_signal in list(SignalStrength)
@test("TechnicalEngine EMA ribbon compression detection")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300)
result = engine.analyze(df, "XAU/USD", "1h")
assert "ema_trend" in result.indicators
# ══════════════════════════════════════════════════════════════════════
# MODULE 4: news_engine.py
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 4: news_engine.py ═══")
from news_engine import NewsEngine, FundamentalContext, EconomicEvent, NewsSentiment
@test("NewsEngine._parse_pair with slash")
def _():
base, quote = NewsEngine._parse_pair("XAU/USD")
assert base == "XAU"
assert quote == "USD"
@test("NewsEngine._parse_pair 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("NewsEngine.get_week_events filters, sorts, and rides the cache")
def _():
NewsEngine._calendar_cache = (0.0, [])
try:
ne = NewsEngine(NewsConfig())
calls = {"n": 0}
now = datetime.now(timezone.utc)
def fake_fetch():
calls["n"] += 1
return [
EconomicEvent(time=now + timedelta(hours=5), currency="USD",
impact="high", event="NFP"),
EconomicEvent(time=now - timedelta(hours=2), currency="USD",
impact="low", event="Past Event"),
EconomicEvent(time=now + timedelta(hours=1), currency="EUR",
impact="high", event="ECB Rate"),
]
ne._try_fetch_calendar_api = fake_fetch
ne._try_fetch_myfxbook_rss = lambda: []
events = ne.get_week_events() # defaults to tracked ["USD", "XAU"]
assert [e.event for e in events] == ["Past Event", "NFP"], \
f"Expected USD-only sorted by time, got {[e.event for e in events]}"
# Second call (and explicit currency widening) must not refetch.
wide = ne.get_week_events(currencies=["USD", "EUR"])
assert calls["n"] == 1, f"Expected 1 fetch, got {calls['n']}"
assert [e.event for e in wide] == ["Past Event", "ECB Rate", "NFP"]
finally:
NewsEngine._calendar_cache = (0.0, [])
@test("FundamentalContext defaults")
def _():
ctx = FundamentalContext()
assert ctx.events_today == []
assert ctx.events_upcoming_1h == []
assert ctx.high_impact_warning is False
assert ctx.calendar_available is False
assert ctx.news_sentiment == {}
assert ctx.news_headlines == []
# ══════════════════════════════════════════════════════════════════════
# MODULE 5: signal_aggregator.py
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 5: signal_aggregator.py ═══")
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup
@test("SignalReport defaults")
def _():
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
assert r.final_signal == SignalStrength.NEUTRAL
assert r.final_score == 0.0
assert r.primary_signal == SignalStrength.NEUTRAL
assert r.trade_setup is None
assert r.mtf_agreement is False
assert r.signal_quality == ""
assert r.bullish_reasons == []
assert r.bearish_reasons == []
assert r.warnings == []
@test("TradeSetup dataclass creation")
def _():
ts = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=1.1000, stop_loss=1.0950, take_profit_1=1.1100,
take_profit_2=1.1200, risk_reward=2.0, risk_pips=50, reward_pips=100,
pip_value=0.0001,
)
assert ts.direction == "BUY"
assert ts.risk_reward == 2.0
assert ts.entry_price > ts.stop_loss
assert ts.trail_arm_r is None and ts.trail_dist_r is None # unstamped default
assert ts.setup_pattern is None and ts.round_trip_cost is None
@test("trade setups carry trail params from the producing config")
def _():
config = PlatformConfig()
config.signals.trail_arm_r = 0.70
config.signals.trail_dist_r = 0.85
agg = SignalAggregator(config)
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.final_signal = SignalStrength.BUY
report.final_score = 40.0
report.signal_quality = "A+"
report.current_price = 3300.0
ts = agg._generate_xau_atr_trade_setup(report, 10.0)
assert ts is not None, "A+ setup with ATR 10 should generate"
assert ts.strategy == "XAU_ATR"
assert ts.round_trip_cost == config.signals.round_trip_cost
assert ts.trail_arm_r == 0.70, "setup must carry the producing config's arm"
assert ts.trail_dist_r == 0.85, "setup must carry the producing config's dist"
@test("SignalAggregator._check_mtf_agreement with 2 bullish")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.mtf_signals = {
"15min": SignalStrength.BUY,
"1h": SignalStrength.STRONG_BUY,
"4h": SignalStrength.NEUTRAL,
}
assert agg._check_mtf_agreement(report) is True
@test("SignalAggregator._check_mtf_agreement with mixed signals")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.mtf_signals = {
"15min": SignalStrength.BUY,
"1h": SignalStrength.SELL,
"4h": SignalStrength.NEUTRAL,
}
assert agg._check_mtf_agreement(report) is False
@test("SignalAggregator._check_mtf_agreement with single signal")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.mtf_signals = {"1h": SignalStrength.BUY}
assert agg._check_mtf_agreement(report) is False
@test("SignalAggregator._latest_adx computes valid value")
def _():
df = make_ohlcv(300)
adx = SignalAggregator._latest_adx(df)
assert adx is not None
assert adx >= 0
@test("SignalAggregator._latest_adx with insufficient data")
def _():
df = make_ohlcv(10)
adx = SignalAggregator._latest_adx(df)
assert adx is None
@test("SignalAggregator.analyze_from_dataframes produces valid report")
def _():
config = PlatformConfig()
config.primary_timeframe = Timeframe.H1
config.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
assert isinstance(report, SignalReport)
assert report.symbol == "XAU/USD"
assert report.display_name == "XAU/USD"
assert report.final_signal in list(SignalStrength)
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
assert report.current_price > 0
@test("SignalAggregator._compute_final_signal quality grades")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
assert report.action_recommendation != ""
@test("Event-risk signal gating obeys news.event_risk_caps_signals")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
def recompute(risk, caps):
config.news.event_risk_caps_signals = caps
report.event_risk = risk
report.event_description = "USD Test Event in 10min"
report.warnings = []
agg._compute_final_signal(report)
return report.final_confidence
baseline = recompute("clear", True)
# default XAU behavior (caps off): danger warns but does not gut
conf_off = recompute("danger", False)
assert conf_off == baseline, "flag off must not change confidence"
assert any("EVENT RISK" in w for w in report.warnings), "warning must persist"
# caps on: danger hard-caps confidence at 30
conf_on = recompute("danger", True)
assert conf_on <= 30, f"danger with caps on must cap conf at 30, got {conf_on}"
# caution with caps off is also warning-only
conf_caut_off = recompute("caution", False)
assert conf_caut_off == baseline
assert any("Upcoming event" in w for w in report.warnings)
# PlatformConfig default is off (XAU-tuned)
assert PlatformConfig().news.event_risk_caps_signals is False
@test("Sentiment signal gating obeys news.sentiment_affects_signals")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
def recompute(sentiment, affects):
config.news.sentiment_affects_signals = affects
report.news_sentiment = sentiment
report.warnings = []
agg._compute_final_signal(report)
return report.final_confidence
score_sign = 1 if report.primary_score >= 0 else -1
aligned, conflicting = 0.5 * score_sign, -0.5 * score_sign
baseline = recompute(0.0, False)
# default: neither aligned sentiment nor conflict moves confidence
assert recompute(aligned, False) == baseline, "aligned sentiment must not add confidence"
assert recompute(conflicting, False) == baseline, "conflicting sentiment must not penalize"
assert any("CONFLICTS" in w for w in report.warnings), "conflict warning must persist"
# flag on: aligned sentiment adds confidence again
assert recompute(aligned, True) > baseline, "flag on must restore the alignment bonus"
# PlatformConfig default is off (news is informational only)
assert PlatformConfig().news.sentiment_affects_signals is False
@test("SignalAggregator._generate_trade_setup BUY direction")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, trend=0.003, seed=42),
Timeframe.M15: make_ohlcv(300, trend=0.003, seed=43),
Timeframe.H4: make_ohlcv(300, trend=0.003, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
if report.trade_setup:
ts = report.trade_setup
if ts.direction == "BUY":
assert ts.stop_loss < ts.entry_price, "BUY SL should be below entry"
assert ts.take_profit_1 > ts.entry_price, "BUY TP should be above entry"
assert ts.risk_reward >= config.signals.min_rr_ratio
@test("SignalAggregator._collect_reasons populates reasons")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
total_reasons = len(report.bullish_reasons) + len(report.bearish_reasons)
assert total_reasons > 0, "Should have at least one reason"
@test("SignalAggregator session filter blocks outside hours")
def _():
config = PlatformConfig()
config.backtest.session_filter = True
config.xau_strategy.blocked_hours_utc = list(range(24))
config.xau_strategy.enabled = False
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_xau_ohlcv(300, seed=42),
Timeframe.M15: make_xau_ohlcv(300, seed=43),
Timeframe.H4: make_xau_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
assert report.signal_quality == "F"
@test("SignalAggregator with missing primary timeframe")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.M15: make_ohlcv(300, seed=43),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
assert report.signal_quality == "F"
assert any("unavailable" in w.lower() for w in report.warnings)
@test("SignalAggregator XAU strategy check (conditions not met)")
def _():
config = PlatformConfig()
config.xau_strategy.enabled = True
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_xau_ohlcv(300, seed=42),
Timeframe.M15: make_xau_ohlcv(300, seed=43),
Timeframe.H4: make_xau_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
assert isinstance(report, SignalReport)
@test("SignalAggregator event risk affects confidence")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
context = FundamentalContext()
context.events_upcoming_1h = [
EconomicEvent(
time=datetime.now(timezone.utc) + timedelta(minutes=10),
currency="USD", impact="high", event="NFP",
)
]
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, context=context, use_news=True)
assert any("EVENT RISK" in w or "event" in w.lower() for w in report.warnings)
# ══════════════════════════════════════════════════════════════════════
# MODULE 6: main.py
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 6: main.py ═══")
from main import (
signal_color, quality_color, _compact_reason,
report_to_dict, build_config, 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, _news_text,
_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 makes chart pattern actions Markdown-safe")
def _():
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.action_recommendation = "BUY — double_bottom pattern completion"
msg = format_signal_message(report)
assert "_BUY — double bottom pattern completion_" in msg
assert "double_bottom" not in msg
@test("format_signal_message renders stamped per-TF trail plan")
def _():
def _setup(**extra):
return TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=3300.0, stop_loss=3290.0, take_profit_1=3315.0,
take_profit_2=3325.0, risk_reward=1.5, risk_pips=100,
reward_pips=150, pip_value=0.10, **extra,
)
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.final_signal = SignalStrength.BUY
report.signal_quality = "A"
# Stamped setup (M15-style overrides) must render the stamped values…
report.trade_setup = _setup(trail_arm_r=0.70, trail_dist_r=0.85)
msg = format_signal_message(report)
assert "+0.7R" in msg, "must render the stamped arm, not the 0.6 default"
assert "$8.50" in msg, "must render the stamped dist × risk, not the default"
# …while a legacy unstamped setup falls back to platform defaults.
report.trade_setup = _setup()
msg = format_signal_message(report)
assert "+0.6R" in msg
assert "$7.50" in msg
@test("format_signal_message renders 100-pip informational TP")
def _():
def _setup(direction="BUY", **extra):
return TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction=direction,
entry_price=3300.0, stop_loss=3290.0, take_profit_1=3315.0,
take_profit_2=3325.0, risk_reward=1.5, risk_pips=100,
reward_pips=150, pip_value=0.10, **extra,
)
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.final_signal = SignalStrength.BUY
report.signal_quality = "A"
# BUY: 100 pips × $0.10 = $10 above entry; risk $10 → exactly 1.0R.
report.trade_setup = _setup()
msg = format_signal_message(report)
assert "TP 100p" in msg
assert "3310.00" in msg, "BUY 100p TP must be entry + $10.00"
assert "≈1.0R" in msg
# SELL mirror: $10 below entry.
report.final_signal = SignalStrength.SELL
report.trade_setup = _setup(direction="SELL")
msg = format_signal_message(report)
assert "TP 100p" in msg
assert "3290.00" in msg, "SELL 100p TP must be entry - $10.00"
# No setup → no informational TP line.
report.trade_setup = None
msg = format_signal_message(report)
assert "TP 100p" not in msg
@test("_indicator_summary condenses indicators to one arrow line")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
summary = _indicator_summary(report)
assert "EMA" in summary
assert "RSI" in summary
assert summary.count("\n") == 0, "summary must be a single line"
msg = format_signal_message(report)
assert summary in msg
assert "[Bullish]" not in msg and "[Bearish]" not in msg and "[Neutral]" not in msg
@test("_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
# NOTE 2026-08-04 (audit H34): the level cache is keyed symbol → TF →
# levels and _check_level_breach returns "<tf> <name>" — these tests
# were updated to the new shape in the same change.
@test("_check_level_breach detects upward cross")
def _():
_level_cache["TEST_PAIR"] = {"15min": {"swing_high_1": 1.1000}}
_level_cache_ts["TEST_PAIR"] = {"15min": time.time()}
_last_prices["TEST_PAIR"] = 1.0999
result = _check_level_breach("TEST_PAIR", 1.1001)
assert result == "15min swing_high_1"
# cleanup
del _level_cache["TEST_PAIR"]
del _level_cache_ts["TEST_PAIR"]
del _last_prices["TEST_PAIR"]
@test("_check_level_breach detects downward cross")
def _():
_level_cache["TEST_PAIR"] = {"15min": {"swing_low_1": 1.0900}}
_level_cache_ts["TEST_PAIR"] = {"15min": time.time()}
_last_prices["TEST_PAIR"] = 1.0901
result = _check_level_breach("TEST_PAIR", 1.0899)
assert result == "15min swing_low_1"
del _level_cache["TEST_PAIR"]
del _level_cache_ts["TEST_PAIR"]
del _last_prices["TEST_PAIR"]
@test("_check_level_breach no breach when price stays same side")
def _():
_level_cache["TEST_PAIR"] = {"15min": {"swing_high_1": 1.1000}}
_level_cache_ts["TEST_PAIR"] = {"15min": time.time()}
_last_prices["TEST_PAIR"] = 1.0990
result = _check_level_breach("TEST_PAIR", 1.0995)
assert result is None
del _level_cache["TEST_PAIR"]
del _level_cache_ts["TEST_PAIR"]
del _last_prices["TEST_PAIR"]
@test("_check_level_breach returns None for expired cache")
def _():
_level_cache["TEST_PAIR"] = {"15min": {"swing_high_1": 1.1000}}
_level_cache_ts["TEST_PAIR"] = {"15min": time.time() - 7200} # 2h old
_last_prices["TEST_PAIR"] = 1.0999
result = _check_level_breach("TEST_PAIR", 1.1001)
assert result is None
del _level_cache["TEST_PAIR"]
del _level_cache_ts["TEST_PAIR"]
del _last_prices["TEST_PAIR"]
@test("_check_level_breach returns None when no previous price")
def _():
_level_cache["TEST_PAIR"] = {"15min": {"swing_high_1": 1.1000}}
_level_cache_ts["TEST_PAIR"] = {"15min": time.time()}
if "TEST_PAIR" in _last_prices:
del _last_prices["TEST_PAIR"]
result = _check_level_breach("TEST_PAIR", 1.1001)
assert result is None
del _level_cache["TEST_PAIR"]
del _level_cache_ts["TEST_PAIR"]
@test("SCAN_BUTTONS and SCAN_LOOKUP consistency")
def _():
from telegram_bot import SCAN_PAIRS
# 4 TF buttons + one "Scan All" per scanned pair
assert len(SCAN_BUTTONS) == 5 * len(SCAN_PAIRS)
for btn in SCAN_BUTTONS:
assert btn["cb"] in SCAN_LOOKUP
assert "symbol" in btn
assert btn["symbol"] in SCAN_PAIRS
assert "tf" in btn
assert "label" in btn
@test("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
@test("_news_text bullish score")
def _():
ctx = FundamentalContext(news_sentiment={"XAU": 0.5, "USD": -0.1})
text = _news_text(ctx, NewsEngine(PlatformConfig().news))
assert "+0.60" in text
assert "Bullish" in text
assert "no scored headlines" not in text
@test("_news_text bearish score")
def _():
ctx = FundamentalContext(news_sentiment={"XAU": -0.4, "USD": 0.1})
text = _news_text(ctx, NewsEngine(PlatformConfig().news))
assert "-0.50" in text
assert "Bearish" in text
@test("_news_text never renders negative zero")
def _():
# Balanced feed: XAU and USD means nearly cancel → net -0.0035,
# which "+.2f" would render as "-0.00" without normalization.
ctx = FundamentalContext(news_sentiment={"XAU": 0.0238, "USD": 0.0273})
text = _news_text(ctx, NewsEngine(PlatformConfig().news))
assert "-0.00" not in text
assert "+0.00" in text
assert "Neutral" in text
@test("_news_text degrades on empty context")
def _():
text = _news_text(FundamentalContext(), NewsEngine(PlatformConfig().news))
assert "+0.00" in text
assert "Neutral" in text
assert "no scored headlines" in text
# ══════════════════════════════════════════════════════════════════════
# MODULE 8: Integration / Cross-module tests
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 8: Integration Tests ═══")
@test("Full pipeline: config → engine → aggregator → report → dict")
def _():
config = PlatformConfig()
engine = TechnicalEngine(config.technical)
df = make_ohlcv(300, seed=99)
analysis = engine.analyze(df, "XAU/USD", "1h")
assert isinstance(analysis, TechnicalAnalysis)
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=99),
Timeframe.M15: make_ohlcv(300, seed=100),
Timeframe.H4: make_ohlcv(300, seed=101),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
d = report_to_dict(report)
assert isinstance(d, dict)
assert d["symbol"] == "XAU/USD"
@test("Full pipeline: XAU/USD analysis")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_xau_ohlcv(300, seed=42),
Timeframe.M15: make_xau_ohlcv(300, seed=43),
Timeframe.H4: make_xau_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
assert report.display_name == "XAU/USD"
assert report.current_price > 1000 # XAU should be in the thousands
@test("Full pipeline: format Telegram message from report")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
msg = format_signal_message(report)
assert isinstance(msg, str)
assert len(msg) > 100
@test("XAU/USD analysis with different seeds")
def _():
config = PlatformConfig()
for seed_base in [10, 20, 30]:
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_xau_ohlcv(300, seed=seed_base),
Timeframe.M15: make_xau_ohlcv(300, seed=seed_base+1),
Timeframe.H4: make_xau_ohlcv(300, seed=seed_base+2),
}
report = agg.analyze_from_dataframes(XAUUSD, dfs, use_news=False)
assert report.symbol == XAUUSD
assert report.current_price > 0
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
@test("Edge case: all EMAs at same price (flat market)")
def _():
engine = TechnicalEngine(TechnicalConfig())
dates = pd.date_range(end=pd.Timestamp.now(tz="UTC").floor("h"), periods=300, freq="h")
df = pd.DataFrame({
"Open": 1.1, "High": 1.1001, "Low": 1.0999, "Close": 1.1, "Volume": 1000.0,
}, index=dates)
df.index.name = "Datetime"
result = engine.analyze(df, "XAU/USD", "1h")
assert result.composite_signal == SignalStrength.NEUTRAL
@test("Edge case: extreme volatility data")
def _():
engine = TechnicalEngine(TechnicalConfig())
np.random.seed(77)
dates = pd.date_range(end=pd.Timestamp.now(tz="UTC").floor("h"), periods=300, freq="h")
close = 1.1 + np.cumsum(np.random.randn(300) * 0.01)
high = close + np.abs(np.random.randn(300) * 0.005)
low = close - np.abs(np.random.randn(300) * 0.005)
df = pd.DataFrame({
"Open": close + np.random.randn(300) * 0.003,
"High": high, "Low": low, "Close": close,
"Volume": np.random.randint(100, 50000, 300).astype(float),
}, index=dates)
df.index.name = "Datetime"
result = engine.analyze(df, "XAU/USD", "1h")
assert isinstance(result, TechnicalAnalysis)
assert result.volatility_regime in ("low", "normal", "high", "extreme")
@test("Determinism: same data produces same results")
def _():
engine = TechnicalEngine(TechnicalConfig())
df = make_ohlcv(300, seed=42)
r1 = engine.analyze(df.copy(), "XAU/USD", "1h")
r2 = engine.analyze(df.copy(), "XAU/USD", "1h")
assert r1.composite_score == r2.composite_score
assert r1.composite_signal == r2.composite_signal
assert r1.market_regime == r2.market_regime
print("\n═══ Module 9: pair_signals registry ═══")
@test("pair_signals registry includes XAU/USD only")
def _():
import pair_signals
pairs = 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, hour 21 UNBLOCKED (2026-07-30
# relaxation campaign) + BUY-only chart patterns on M5 only.
inline_cfg.xau_strategy.blocked_hours_utc = [
h for h in inline_cfg.xau_strategy.blocked_hours_utc if h != 21
] + [9]
inline_cfg.xau_strategy.cp_enabled = True
inline_cfg.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"]
inline_cfg.xau_strategy.m5_pullback_enabled = False
inline_cfg.technical.ema_partial_neutral = True
inline_cfg.signals.trail_arm_r = 0.60
inline_cfg.signals.trail_dist_r = 0.85
# Block SELL entries while regime == "transitioning" on M5
# only (2026-09-03 signal-log campaign) — see docstring.
inline_cfg.xau_strategy.sell_blocked_regimes = ["transitioning"]
if tf == Timeframe.H1:
# Block every H1 SELL (2026-09-03 signal-log campaign) —
# see docstring; H1 shorts were a structural loser.
inline_cfg.xau_strategy.sell_min_quality = []
assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \
f"XAU factory config differs from inline construction for {tf}"
@test("M5 factory disables XAU_PB; other TFs keep the global default")
def _():
# 2026-08-04 campaign (Oanda bars, $0.65 real spread): pullback
# entries are the largest wrong-direction loss source on M5 and
# fail at real cost; disabled in the M5 factory branch only.
from pair_signals import PAIR_PRODUCERS
m5 = PAIR_PRODUCERS[XAUUSD](Timeframe.M5, [Timeframe.M15, Timeframe.H1])
assert m5.xau_strategy.m5_pullback_enabled is False
assert m5.technical.ema_partial_neutral is True # 2026-08-04 override 5
m15 = PAIR_PRODUCERS[XAUUSD](Timeframe.M15, [Timeframe.M5, Timeframe.H1])
assert m15.xau_strategy.m5_pullback_enabled is True, \
"PB flag must stay at the global default off-M5 (inert there)"
assert m15.technical.ema_partial_neutral is False, \
"partial-alignment squash is M5-validated only"
@test("produce_signal raises for unregistered pair")
def _():
from pair_signals import produce_signal
try:
produce_signal("GBP/JPY", Timeframe.H1, None)
assert False, "Expected ValueError for unregistered pair"
except ValueError as e:
assert "GBP/JPY" in str(e)
print("\n═══ Module 9b: Engine Bug Fix Regressions ═══")
# ── 9a. Swing point NaN fix ──────────────────────────────────────────
@test("Swing points: all-NaN neighbors produces no false positives")
def _():
series = pd.Series([np.nan, np.nan, np.nan, 5.0, np.nan, np.nan, np.nan])
lows = TechnicalEngine._find_swing_points(series, mode="low", window=3)
highs = TechnicalEngine._find_swing_points(series, mode="high", window=3)
assert lows == [], f"Expected no lows with all-NaN neighbors, got {lows}"
assert highs == [], f"Expected no highs with all-NaN neighbors, got {highs}"
@test("Swing points: partial NaN neighbors still detect valid swings")
def _():
series = pd.Series([10.0, 8.0, np.nan, 4.0, np.nan, 8.0, 10.0])
lows = TechnicalEngine._find_swing_points(series, mode="low", window=2)
low_vals = [v for _, v in lows]
assert 4.0 in low_vals, f"Expected 4.0 as swing low, got {low_vals}"
@test("Swing points: empty series returns empty list")
def _():
series = pd.Series([], dtype=float)
assert TechnicalEngine._find_swing_points(series, mode="low") == []
assert TechnicalEngine._find_swing_points(series, mode="high") == []
@test("Swing points: monotonic increasing has no interior lows")
def _():
series = pd.Series([float(i) for i in range(20)])
lows = TechnicalEngine._find_swing_points(series, mode="low", window=2)
assert len(lows) == 0, f"Monotonic increase should have no interior lows, got {lows}"
@test("Swing points: V-shape detects low at bottom")
def _():
series = pd.Series([10.0, 8.0, 6.0, 4.0, 6.0, 8.0, 10.0])
lows = TechnicalEngine._find_swing_points(series, mode="low", window=2)
low_vals = [v for _, v in lows]
assert 4.0 in low_vals, f"V-shape should detect 4.0 as low, got {low_vals}"
@test("Swing points: inverted V-shape detects high at top")
def _():
series = pd.Series([4.0, 6.0, 8.0, 10.0, 8.0, 6.0, 4.0])
highs = TechnicalEngine._find_swing_points(series, mode="high", window=2)
high_vals = [v for _, v in highs]
assert 10.0 in high_vals, f"Inverted V should detect 10.0 as high, got {high_vals}"
# ── 9b. ROC div-by-zero fix ──────────────────────────────────────────
@test("ROC: zero close at lookback position does not produce inf")
def _():
df = make_ohlcv(n=100, seed=42)
df.iloc[-11, df.columns.get_loc("Close")] = 0.0
engine = TechnicalEngine(PlatformConfig().technical)
result = engine.analyze(df, "XAU/USD", Timeframe.H1)
if "roc" in result.indicators:
assert np.isfinite(result.indicators["roc"].value), "ROC should not be inf"
# ── 9c. ADX epsilon fix ──────────────────────────────────────────────
@test("ADX: flat data produces finite ADX value")
def _():
n = 100
dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h")
df = pd.DataFrame({
"Open": [100.0]*n, "High": [100.01]*n, "Low": [99.99]*n,
"Close": [100.0]*n, "Volume": [1000.0]*n,
}, index=dates)
engine = TechnicalEngine(PlatformConfig().technical)
result = engine.analyze(df, "XAU/USD", Timeframe.H1)
if "adx" in result.indicators:
assert np.isfinite(result.indicators["adx"].value), "ADX should be finite on flat data"
# ══════════════════════════════════════════════════════════════════════
# MODULE 11: 2026-07-16 audit-fix regressions
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 11: 2026-07-16 audit fixes ═══")
_BT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backtest")
if _BT_DIR not in sys.path:
sys.path.insert(0, _BT_DIR)
def make_xau_bars(n=400, end="2026-07-08 16:55", freq="5min", drift=-0.8, seed=7):
"""Seeded XAU-priced bars ending at a FIXED timestamp (deterministic)."""
rng = np.random.default_rng(seed)
idx = pd.date_range(end=pd.Timestamp(end, tz="UTC"), periods=n, freq=freq)
close = 3400 + np.cumsum(drift + rng.normal(0, 0.5, n))
open_ = np.roll(close, 1)
open_[0] = close[0]
high = np.maximum(open_, close) + rng.uniform(0.5, 2.0, n)
low = np.minimum(open_, close) - rng.uniform(0.5, 2.0, n)
df = pd.DataFrame({
"Open": open_, "High": high, "Low": low,
"Close": close, "Volume": rng.uniform(1e3, 5e3, n),
}, index=idx)
df.index.name = "Datetime"
return df
@test("Gate 6e: session-blocked sell-direction report stays F (no F→D upgrade)")
def _():
# Bar LABELED in a blocked hour (16:xx UTC, Wednesday) with a bearish
# score: gate 6b sets F; before the fix, 6e re-graded it D, which
# re-opened the M5 pullback path (entry check: quality != "F").
from pair_signals.xauusd import make_config as xau_make_config
primary = make_xau_bars(end="2026-07-08 16:55", freq="5min")
m15 = make_xau_bars(end="2026-07-08 16:45", freq="15min")
h1 = make_xau_bars(end="2026-07-08 16:00", freq="60min")
agg = SignalAggregator(xau_make_config(Timeframe.M5, [Timeframe.M15, Timeframe.H1]))
report = agg.analyze_from_dataframes(
XAUUSD, {Timeframe.M5: primary, Timeframe.M15: m15, Timeframe.H1: h1},
use_news=False,
)
assert any("SESSION FILTER" in w for w in report.warnings), \
f"session filter did not fire: {report.warnings}"
assert report.primary_score < 0, "test setup must produce a sell-direction score"
assert report.signal_quality == "F", \
f"blocked bar must stay F, got {report.signal_quality}"
assert report.trade_setup is None
assert not any("SELL FILTER: quality" in w for w in report.warnings), \
"6e must not fire on non-actionable grades"
@test("GPU engine parity: composite + ema_trend identical to live (incl. ribbon)")
def _():
from gpu_backtest import precompute_indicators, GPUTechnicalEngine
from engine import TechnicalEngine
cfg = PlatformConfig()
# Two-phase series: flat chop (EMA ribbon compresses) then a strong
# trend (ribbon expands) — guarantees the sweep crosses both ribbon
# states, the exact logic the GPU engine was missing.
rng = np.random.default_rng(11)
n = 650
drift = np.where(np.arange(n) < 450, 0.0, 1.2)
idx = pd.date_range(end=pd.Timestamp("2026-06-30 12:00", tz="UTC"),
periods=n, freq="60min")
close = 3400 + np.cumsum(drift + rng.normal(0, 0.5, n))
open_ = np.roll(close, 1)
open_[0] = close[0]
high = np.maximum(open_, close) + rng.uniform(0.5, 2.0, n)
low = np.minimum(open_, close) - rng.uniform(0.5, 2.0, n)
df = pd.DataFrame({"Open": open_, "High": high, "Low": low, "Close": close,
"Volume": rng.uniform(1e3, 5e3, n)}, index=idx)
df.index.name = "Datetime"
cache = precompute_indicators(df, cfg.technical)
gpu = GPUTechnicalEngine(cfg.technical, {"1h": cache})
live = TechnicalEngine(cfg.technical)
seen_compressed = seen_expanding = False
for i in range(250, n + 1, 3):
s = df.iloc[:i]
r_live = live.analyze(s, "XAU/USD", "1h")
r_gpu = gpu.analyze(s, "XAU/USD", "1h")
assert r_live.composite_signal == r_gpu.composite_signal, f"signal flip @ {i}"
assert abs(r_live.composite_score - r_gpu.composite_score) < 1e-9, f"score @ {i}"
assert abs(r_live.confidence - r_gpu.confidence) < 1e-9, f"confidence @ {i}"
el = r_live.indicators["ema_trend"]
eg = r_gpu.indicators["ema_trend"]
assert el.confidence == eg.confidence, f"ema_trend conf @ {i}"
assert el.description == eg.description, f"ema_trend desc @ {i}"
bl = r_live.indicators["bollinger"]
bg = r_gpu.indicators["bollinger"]
assert bl.signal == bg.signal and bl.description == bg.description, f"bollinger @ {i}"
al = r_live.indicators["atr"]
ag = r_gpu.indicators["atr"]
assert abs(al.value - ag.value) < 1e-9, f"atr value @ {i}"
assert al.description == ag.description, f"atr description @ {i}"
assert r_live.volatility_regime == r_gpu.volatility_regime, f"vol regime @ {i}"
assert abs(r_live.key_levels["atr"] - r_gpu.key_levels["atr"]) < 1e-9, f"key_levels atr @ {i}"
assert abs(r_live.key_levels["atr_pct"] - r_gpu.key_levels["atr_pct"]) < 1e-9, f"key_levels atr_pct @ {i}"
if "Ribbon COMPRESSED" in el.description:
seen_compressed = True
if "Ribbon EXPANDING" in el.description:
seen_expanding = True
assert seen_compressed, "sweep never saw a compressed ribbon — no teeth"
assert seen_expanding, "sweep never saw a ribbon expansion (+10 conf path) — no teeth"
@test("Regime filter: live gate semantics + backtest calls the same method")
def _():
from signal_aggregator import SignalAggregator
from pair_signals import PAIR_PRODUCERS
cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.M15, [Timeframe.M5, Timeframe.H1])
cfg.backtest.regime_filter = True
agg = SignalAggregator(cfg)
df = make_ohlcv(n=300, start_price=3400.0, trend=0.05, seed=3)
flat = make_ohlcv(n=300, start_price=3400.0, trend=0.0, seed=7)
adx = agg._latest_adx(flat, cfg.technical.adx_period)
assert adx is not None and adx < cfg.backtest.regime_filter_min_adx, \
f"fixture must be choppy (ADX {adx}) or the test has no teeth"
# Low higher-TF ADX → signal downgraded to NEUTRAL/F, setup killed.
report = agg.analyze_from_dataframes(XAUUSD, {Timeframe.M15: df},
context=None, use_news=False)
agg._apply_regime_filter_live(report, {Timeframe.H4: flat})
assert report.final_signal == SignalStrength.NEUTRAL
assert report.signal_quality == "F"
assert report.trade_setup is None
assert any("REGIME FILTER" in w for w in report.warnings)
# Regime TF absent from dfs → warning only, report untouched.
report2 = agg.analyze_from_dataframes(XAUUSD, {Timeframe.M15: df},
context=None, use_news=False)
quality_before = report2.signal_quality
agg._apply_regime_filter_live(report2, {})
assert report2.signal_quality == quality_before
assert any("data unavailable" in w for w in report2.warnings)
# Backtest wiring: the replay applies the SAME method under the SAME
# guard as live analyze_pair (function-identity parity).
import gpu_backtest as _gb
src = open(_gb.__file__, encoding="utf-8").read()
assert "_apply_regime_filter_live(report, slice_dfs)" in src
assert "backtest.regime_filter" in src
# …and loads the regime-filter TF like live analyze_pair does, or the
# method silently no-ops for M5/M15 primaries (confirm maps lack H4).
assert "regime_filter_timeframe not in timeframes" in src, \
"replay must fetch/slice the regime-filter TF when the gate is on"
@test("disabled_indicators=['macd'] removes the pillar but keeps the histogram")
def _():
from engine import TechnicalEngine
from config import TechnicalConfig
df = make_xau_ohlcv(n=300, seed=5)
result = TechnicalEngine(TechnicalConfig(disabled_indicators=["macd"])).analyze(
df, "XAU/USD", "1h")
assert "macd" not in result.indicators, "disabled MACD still scored"
assert getattr(result, "_macd_histogram", None) is not None, \
"histogram must survive for divergence"
baseline = TechnicalEngine(TechnicalConfig()).analyze(df, "XAU/USD", "1h")
assert "macd" in baseline.indicators
@test("Outlier filter drops the spike bar but keeps the snap-back bar")
def _():
provider = TwelveDataProvider(PlatformConfig())
df = make_ohlcv(n=300, seed=3)
k = 150
spike_ts, after_ts = df.index[k], df.index[k + 1]
spike_val = df["Close"].iloc[k - 1] * 1.05 # ~+5% on a ~0.1%-σ series
df.loc[spike_ts, ["Open", "High", "Low", "Close"]] = spike_val
cleaned = provider._validate_and_clean(df, "XAU/USD", Timeframe.H1)
assert spike_ts not in cleaned.index, "spike bar must be removed"
assert after_ts in cleaned.index, \
"snap-back bar after the spike must be kept (two-bar hole regression)"
@test("Cache: pre-boundary snapshot refetched; boundary-complete snapshot served")
def _():
def bars_ending(last):
idx = pd.date_range(end=last, periods=300, freq="15min", tz="UTC")
v = np.linspace(3000.0, 3100.0, 300)
return pd.DataFrame({"Open": v, "High": v + 1, "Low": v - 1,
"Close": v, "Volume": 1000.0}, index=idx)
for _attempt in range(2):
provider = TwelveDataProvider(PlatformConfig())
started = pd.Timestamp.now(tz="UTC").floor("15min")
last_closed = started - pd.Timedelta(minutes=15)
stale = bars_ending(last_closed - pd.Timedelta(minutes=15))
fresh = bars_ending(last_closed)
key = "XAU/USD_15min"
with patch("providers.base._is_forex_weekend", return_value=False):
# (a) snapshot cached mid-candle 5 min ago, missing the
# just-closed bar: TTL says fresh, boundary check must refetch.
provider._cache[key] = stale
provider._cache_timestamps[key] = time.time() - 300
with patch.object(provider, "_fetch_twelve_data",
return_value=fresh.copy()) as mock_fetch:
served = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
# (b) cache now boundary-complete: served without any fetch.
with patch.object(provider, "_fetch_twelve_data",
side_effect=AssertionError("must not refetch")):
served2 = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
if pd.Timestamp.now(tz="UTC").floor("15min") != started:
continue # boundary crossed mid-test — retry once
assert mock_fetch.called, "stale pre-boundary snapshot was served from cache"
assert served.index[-1] == last_closed
assert served2.index[-1] == last_closed
break
@test("Fresh fetch missing the just-closed bar retries once, then warns")
def _():
def bars_ending(last):
idx = pd.date_range(end=last, periods=300, freq="15min", tz="UTC")
v = np.linspace(3000.0, 3100.0, 300)
return pd.DataFrame({"Open": v, "High": v + 1, "Low": v - 1,
"Close": v, "Volume": 1000.0}, index=idx)
for _attempt in range(2):
provider = TwelveDataProvider(PlatformConfig())
started = pd.Timestamp.now(tz="UTC").floor("15min")
last_closed = started - pd.Timedelta(minutes=15)
lagging = bars_ending(last_closed - pd.Timedelta(minutes=15))
complete = bars_ending(last_closed)
with patch("providers.base._is_forex_weekend", return_value=False), \
patch.object(TwelveDataProvider, "FRESH_FETCH_RETRY_SECONDS", 0.0):
# (a) lagging first response → one retry returns the closed bar.
with patch.object(provider, "_fetch_twelve_data",
side_effect=[lagging.copy(), complete.copy()]) as mf:
served = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
# (b) complete first response → no retry at all.
provider._cache.clear()
provider._cache_timestamps.clear()
with patch.object(provider, "_fetch_twelve_data",
return_value=complete.copy()) as mf2:
provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
if pd.Timestamp.now(tz="UTC").floor("15min") != started:
continue # boundary crossed mid-test — retry once
assert mf.call_count == 2, "lagging response must trigger exactly one retry"
assert served.index[-1] == last_closed, "retry result must be served"
assert mf2.call_count == 1, "boundary-complete response must not retry"
break
@test("Alert dedup: drift dedups, materially-moved setup re-alerts, no early commit")
def _():
import telegram_bot as tb
from signal_aggregator import SignalReport, TradeSetup
def rpt(entry, sl):
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
r.final_signal = SignalStrength.BUY
r.signal_quality = "B"
r.trade_setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=entry, stop_loss=sl, take_profit_1=entry + 12,
take_profit_2=entry + 20, risk_reward=1.5,
risk_pips=80.0, reward_pips=120.0, pip_value=0.10,
)
return r
key = "TEST_ALERT_FP"
tb._last_alert_fp.pop(key, None)
tb._last_alert_ts.pop(key, None)
tb._last_alert_setup.pop(key, None)
r1 = rpt(3400.0, 3392.0) # risk $8 → re-alert threshold $4
assert tb._should_alert(key, r1)
assert tb._should_alert(key, r1), "_should_alert must not commit state itself"
tb._commit_alert(key, r1)
assert not tb._should_alert(key, r1), "identical signal must dedup"
assert not tb._should_alert(key, rpt(3400.5, 3392.5)), \
"sub-threshold drift must dedup"
assert not tb._should_alert(key, rpt(3401.0, 3392.6)), \
"SL-distance drift (ATR/anchor wobble) must not re-fingerprint"
assert tb._should_alert(key, rpt(3412.0, 3404.0)), \
"setup moved 1.5x risk — must re-alert"
assert tb._should_alert(key, rpt(3395.5, 3387.5)), \
"setup moved half risk AGAINST the entry — must re-alert too"
tb._last_alert_fp.pop(key, None)
tb._last_alert_ts.pop(key, None)
tb._last_alert_setup.pop(key, None)
tb._last_alert_quality.pop(key, None)
@test("Alert dedup: grade flicker does not re-alert, an upgrade does")
def _():
import telegram_bot as tb
from signal_aggregator import SignalReport, TradeSetup
def rpt(q):
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
r.final_signal = SignalStrength.SELL
r.signal_quality = q
r.trade_setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="SELL",
entry_price=3400.0, stop_loss=3408.0, take_profit_1=3388.0,
take_profit_2=3380.0, risk_reward=1.5, risk_pips=80.0,
reward_pips=120.0, pip_value=0.10)
return r
key = "TEST_ALERT_GRADE"
for d in (tb._last_alert_fp, tb._last_alert_ts, tb._last_alert_setup,
tb._last_alert_quality):
d.pop(key, None)
try:
assert tb._should_alert(key, rpt("B"))
tb._commit_alert(key, rpt("B"))
assert not tb._should_alert(key, rpt("B")), "identical → dedup"
assert tb._should_alert(key, rpt("A")), "B→A upgrade must re-alert"
tb._commit_alert(key, rpt("A"))
assert not tb._should_alert(key, rpt("B")), \
"A→B downgrade (flicker) must NOT re-alert"
assert not tb._should_alert(key, rpt("A")), "A again → dedup"
assert tb._alert_fingerprint(rpt("A")) == tb._alert_fingerprint(rpt("B")), \
"grade is not part of the alert identity"
finally:
for d in (tb._last_alert_fp, tb._last_alert_ts, tb._last_alert_setup,
tb._last_alert_quality):
d.pop(key, None)
@test("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.setup_pattern == "double_bottom"
assert ts.stop_loss < ts.entry_price
assert abs((ts.take_profit_1 - ts.entry_price) - 19.0) < 1e-6, \
"TP1 must be the measured move"
assert rep.signal_quality == "B"
assert rep.bullish_reasons[0].startswith("XAU CHART PATTERN"), \
"tag marker must sit at reasons[0]"
@test("CP gate: accepted setup removes stale vetoes and deduplicates extension context")
def _():
cfg = _cp_config()
df = make_cp_df(double_bottom_closes())
rep = _cp_report(df, cfg)
rep.warnings = [
"SPREAD GATE: old core target failed",
"EXHAUSTED TREND FILTER: C-grade blocked in exhausted trend",
"M5 FILTER: no MTF agreement — M5 signals require confirmation",
"FAR FROM EMA200 (4.0%) — over-extended",
"TREND EXHAUSTION — price extended >3.5 ATR past swing low",
"NO PULLBACK — price 3.0 ATR from EMA21",
"Extended — RSI 78 in uptrend",
"EVENT RISK: CPI in 20m",
"NEAR EMA200 (0.3%) — chop risk",
]
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
assert rep.trade_setup is not None
assert rep.warnings == [
"TREND EXHAUSTION — price extended >3.5 ATR past swing low",
"EVENT RISK: CPI in 20m",
"NEAR EMA200 (0.3%) — chop risk",
], rep.warnings
@test("CP gate: MTF opposition blocks the pattern trade")
def _():
cfg = _cp_config()
df = make_cp_df(double_bottom_closes())
rep = _cp_report(df, cfg, mtf_signals={
"15min": SignalStrength.SELL, "4h": SignalStrength.STRONG_SELL})
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
assert rep.trade_setup is None, "BUY pattern must not fire against consensus"
@test("CP gate: SELL pattern blocked in quiet volatility, allowed in high")
def _():
cfg = _cp_config()
df = mirror_cp_df(make_cp_df(double_bottom_closes()))
rep = _cp_report(df, cfg, vol_regime="normal")
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
assert rep.trade_setup is None, "quiet-vol SELL must be blocked"
rep2 = _cp_report(df, cfg, vol_regime="high")
SignalAggregator(cfg)._try_chart_pattern(rep2, df, 0.10)
assert rep2.trade_setup is not None and rep2.trade_setup.direction == "SELL"
@test("CP gate: SELL blocked-hour (bar close) stops pattern shorts only")
def _():
cfg = _cp_config(sell_blocked_hours_utc=[15]) # fixture closes 15:00
df = mirror_cp_df(make_cp_df(double_bottom_closes()))
rep = _cp_report(df, cfg, vol_regime="high")
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
assert rep.trade_setup is None, "SELL at blocked close hour must not fire"
# BUY side unaffected at the same hour.
df_buy = make_cp_df(double_bottom_closes())
rep_buy = _cp_report(df_buy, cfg)
SignalAggregator(cfg)._try_chart_pattern(rep_buy, df_buy, 0.10)
assert rep_buy.trade_setup is not None
@test("CP gate: M5 chop-transition volatility blocks both directions")
def _():
cfg = _cp_config()
cfg.primary_timeframe = Timeframe.M5
df = make_cp_df(double_bottom_closes())
rep = _cp_report(df, cfg, vol_regime="high") # 'high' blocked on M5
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
assert rep.trade_setup is None
@test("CP gate: spread gate rejects patterns smaller than TP minimum")
def _():
cfg = _cp_config()
cfg.signals.min_tp_cost_multiple = 1000.0 # $300 minimum target
df = make_cp_df(double_bottom_closes())
rep = _cp_report(df, cfg)
rep.warnings = ["SPREAD GATE: prior warning", "NO PULLBACK — context"]
before = list(rep.warnings)
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
assert rep.trade_setup is None
assert rep.warnings == before, "rejected CP must not rewrite diagnostics"
@test("CP pipeline: fires end-to-end when core generators pass")
def _():
cfg = _cp_config()
cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
df = make_cp_df(double_bottom_closes())
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
agg = SignalAggregator(cfg)
with patch.object(SignalAggregator, "_generate_trade_setup",
return_value=None), \
patch.object(SignalAggregator, "_generate_xau_atr_trade_setup",
return_value=None):
rep = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
assert rep.trade_setup is not None, \
f"pipeline did not produce a setup (warnings: {rep.warnings})"
assert rep.trade_setup.strategy == "XAU_CP"
@test("CP pipeline: disabled by default except M5 BUY-only")
def _():
assert XauStrategyConfig().cp_enabled is False
assert PlatformConfig().xau_strategy.cp_enabled is False
from pair_signals import PAIR_PRODUCERS
for tf in (Timeframe.M15, Timeframe.H1, Timeframe.H4):
assert PAIR_PRODUCERS[XAUUSD](tf, None).xau_strategy.cp_enabled is False
m5_cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.M5, None)
assert m5_cfg.xau_strategy.cp_enabled is True
assert m5_cfg.xau_strategy.cp_patterns == ["inv_hs", "double_bottom", "bull_flag"]
cfg = PlatformConfig()
cfg.primary_timeframe = Timeframe.H1
cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
df = make_cp_df(double_bottom_closes())
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
with patch.object(SignalAggregator, "_generate_trade_setup",
return_value=None), \
patch.object(SignalAggregator, "_generate_xau_atr_trade_setup",
return_value=None):
rep = SignalAggregator(cfg).analyze_from_dataframes(
"XAU/USD", dfs, use_news=False)
assert rep.trade_setup is None
@test("CP pipeline: session-blocked bar never hosts a pattern trade")
def _():
cfg = _cp_config()
cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
# Label hour 13 is in blocked_hours_utc → 6b sets F + SESSION FILTER.
df = make_cp_df(double_bottom_closes(), end="2026-07-08 13:00")
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
with patch.object(SignalAggregator, "_generate_trade_setup",
return_value=None), \
patch.object(SignalAggregator, "_generate_xau_atr_trade_setup",
return_value=None):
rep = SignalAggregator(cfg).analyze_from_dataframes(
"XAU/USD", dfs, use_news=False)
assert rep.trade_setup is None
assert rep.signal_quality == "F"
@test("CP backtester: --cp flag enables patterns, tag filter knows XAU_CP")
def _():
from gpu_backtest import _parse_args
args = _parse_args(["--pair", "XAUUSD", "--cp",
"--cp-patterns", "double_bottom,bull_flag"])
assert args.cp is True
assert args.cp_patterns == "double_bottom,bull_flag"
args2 = _parse_args(["--pair", "XAUUSD"])
assert args2.cp is False
@test("Backtester: --trail-arm/--trail-dist research knobs parse, default None")
def _():
from gpu_backtest import _parse_args
args = _parse_args(["--pair", "XAUUSD",
"--trail-arm", "0.45", "--trail-dist", "0.75"])
assert args.trail_arm == 0.45 and args.trail_dist == 0.75
args2 = _parse_args(["--pair", "XAUUSD"])
assert args2.trail_arm is None and args2.trail_dist is None
# ══════════════════════════════════════════════════════════════════════
# MODULE 13: app_config.py (config.json overlay)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 13: app_config.py ═══")
import tempfile
from app_config import (
AppConfig, AppConfigError, load_app_config, set_app_config,
telegram_token, telegram_chat_id, telegram_channel_id,
provider_api_key, apply_pair_overrides,
)
def _write_json(payload) -> str:
fh = tempfile.NamedTemporaryFile(
"w", suffix=".json", delete=False, encoding="utf-8")
if isinstance(payload, str):
fh.write(payload)
else:
json.dump(payload, fh)
fh.close()
return fh.name
_FULL_CFG = {
"telegram": {"token": "t0k", "chat_id": "111", "channel_id": "-100222",
"alert_min_quality": "B"},
"data_providers": {"active": "twelve_data",
"twelve_data": {"api_key": "json-key"}},
"scanning": {"pairs": ["XAU/USD"], "timeframes": ["M15", "H1"]},
"pairs": {"XAU/USD": {"blocked_hours_utc": [3, 4]}},
"log_level": "WARNING",
}
@test("app_config: valid config.json maps every field")
def _():
app = load_app_config(_write_json(_FULL_CFG))
assert app.telegram_token == "t0k"
assert app.telegram_chat_id == "111"
assert app.telegram_channel_id == "-100222"
assert app.alert_min_quality == "B"
assert app.active_provider == "twelve_data"
assert app.provider_settings["twelve_data"]["api_key"] == "json-key"
assert app.scan_pairs == [XAUUSD]
assert app.scan_timeframes == [Timeframe.M15, Timeframe.H1]
assert app.pair_overrides[XAUUSD]["blocked_hours_utc"] == [3, 4]
assert app.log_level == "WARNING"
@test("app_config: missing file returns pure defaults")
def _():
app = load_app_config("/nonexistent/config.json")
assert app == AppConfig(), "absent config.json must yield pure defaults"
@test("app_config: malformed JSON raises AppConfigError")
def _():
path = _write_json("{not json")
try:
load_app_config(path)
assert False, "should have raised"
except AppConfigError as e:
assert "not valid JSON" in str(e)
@test("app_config: unknown keys raise AppConfigError")
def _():
for payload in (
{"telgram": {}}, # top-level typo
{"telegram": {"tokn": "x"}}, # section typo
{"pairs": {"XAU/USD": {"blocked_hours": [1]}}}, # pair-block typo
{"scanning": {"pair": ["XAU/USD"]}}, # scanning typo
):
try:
load_app_config(_write_json(payload))
assert False, f"should have raised for {payload}"
except AppConfigError as e:
assert "unknown key" in str(e).lower()
@test("app_config: invalid values raise AppConfigError")
def _():
bad_payloads = [
{"pairs": {"XAU/USD": {"blocked_hours_utc": [24]}}}, # hour range
{"pairs": {"XAU/USD": {"blocked_hours_utc": "3"}}}, # not a list
{"pairs": {"EUR/USD": {"blocked_hours_utc": [3]}}}, # unknown pair
{"scanning": {"pairs": ["EUR/USD"]}}, # unknown pair
{"scanning": {"timeframes": ["M7"]}}, # bad TF name
{"scanning": {"timeframes": ["W1"]}}, # unscannable TF
{"scanning": {"pairs": 5}}, # pairs not a list
{"scanning": {"timeframes": 5}}, # timeframes not a list
{"data_providers": {"active": "polygon",
"twelve_data": {"api_key": "k"}}}, # no block
{"telegram": {"alert_min_quality": "Z"}}, # bad quality
{"log_level": "CHATTY"}, # bad level
]
for payload in bad_payloads:
try:
load_app_config(_write_json(payload))
assert False, f"should have raised for {payload}"
except AppConfigError:
pass
@test("app_config: env precedence env → config.json → default")
def _():
app = load_app_config(_write_json(_FULL_CFG))
with patch.dict(os.environ, {}, clear=True):
assert telegram_token(app) == "t0k"
assert telegram_chat_id(app) == "111"
assert telegram_channel_id(app) == "-100222"
assert provider_api_key(app) == "json-key"
with patch.dict(os.environ, {"TELEGRAM_TOKEN": "env-tok",
"TELEGRAM_CHAT_ID": "999",
"TWELVE_DATA_API_KEY": "env-key"}):
assert telegram_token(app) == "env-tok"
assert telegram_chat_id(app) == "999"
assert provider_api_key(app) == "env-key"
# channel: env set-but-empty means "disable feed" and must beat JSON
with patch.dict(os.environ, {"TELEGRAM_CHANNEL_ID": ""}):
assert telegram_channel_id(app) == ""
@test("app_config: non-object sections raise AppConfigError")
def _():
for payload in (
{"telegram": None},
{"telegram": "oops"},
{"scanning": 5},
{"pairs": ["oops"]},
{"pairs": {"XAU/USD": ["blocked_hours_utc"]}},
):
try:
load_app_config(_write_json(payload))
assert False, f"should have raised for {payload}"
except AppConfigError:
pass
@test("app_config: apply_pair_overrides stamps both blocked-hours mirrors")
def _():
app = AppConfig(pair_overrides={XAUUSD: {"blocked_hours_utc": [3, 4]}},
scan_pairs=[XAUUSD],
scan_timeframes=[Timeframe.M15])
cfg = apply_pair_overrides(PlatformConfig(), app)
assert cfg.xau_strategy.blocked_hours_utc == [3, 4]
assert cfg.backtest.blocked_hours_utc == [3, 4]
assert cfg.scan_timeframes == [Timeframe.M15]
# the two mirrors must be independent lists, not aliases
cfg.xau_strategy.blocked_hours_utc.append(5)
assert cfg.backtest.blocked_hours_utc == [3, 4]
@test("app_config: M5 factory appends hour 9 on top of a JSON base")
def _():
from pair_signals import PAIR_PRODUCERS
app = AppConfig(pair_overrides={XAUUSD: {"blocked_hours_utc": [3, 4]}},
provider_settings={"twelve_data": {"api_key": "test-key"}})
set_app_config(app)
try:
cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.M5, None)
finally:
set_app_config(_TEST_APP)
assert cfg.xau_strategy.blocked_hours_utc == [3, 4, 9], \
"validated M5 hour-9 delta must land on top of the JSON base"
assert cfg.backtest.blocked_hours_utc == [3, 4], \
"mirror carries the base only (per-TF delta is XAU-strategy-side)"
@test("app_config: config.json.example is valid and behavior-neutral")
def _():
import dataclasses
from pair_signals import PAIR_PRODUCERS
example = load_app_config(
os.path.join(os.path.dirname(__file__), "config.json.example"))
for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4):
baseline = PAIR_PRODUCERS[XAUUSD](tf, None) # under _TEST_APP
set_app_config(example)
try:
with_example = PAIR_PRODUCERS[XAUUSD](tf, None)
finally:
set_app_config(_TEST_APP)
assert dataclasses.asdict(baseline) == dataclasses.asdict(with_example), \
f"example config.json must be byte-identical to defaults on {tf}"
@test("providers: api key resolves env → config.json (no code default)")
def _():
from providers import TwelveDataProvider
app = AppConfig(provider_settings={"twelve_data": {"api_key": "json-key"}})
set_app_config(app)
try:
with patch.dict(os.environ, {}, clear=True):
dp = TwelveDataProvider(PlatformConfig())
assert dp._api_key == "json-key"
with patch.dict(os.environ, {"TWELVE_DATA_API_KEY": "env-key"}):
dp = TwelveDataProvider(PlatformConfig())
assert dp._api_key == "env-key"
set_app_config(AppConfig()) # no key anywhere
with patch.dict(os.environ, {}, clear=True):
dp = TwelveDataProvider(PlatformConfig())
assert dp._api_key is None and dp._td is None
finally:
set_app_config(_TEST_APP)
@test("providers: make_data_provider dispatches on active provider")
def _():
from providers import TwelveDataProvider, make_data_provider
assert isinstance(make_data_provider(PlatformConfig()), TwelveDataProvider)
set_app_config(AppConfig(active_provider="polygon",
provider_settings={"polygon": {"api_key": "k"}}))
try:
make_data_provider(PlatformConfig())
assert False, "unregistered provider must raise"
except AppConfigError as e:
assert "polygon" in str(e)
finally:
set_app_config(_TEST_APP)
@test("providers: make_data_provider explicit name overrides active")
def _():
from providers import (TiingoDataProvider as _Tii,
TwelveDataProvider, make_data_provider)
# active is twelve_data under _TEST_APP; explicit name wins
with patch.dict(os.environ, {"TIINGO_API_KEY": "k"}):
assert isinstance(make_data_provider(PlatformConfig(), "tiingo"), _Tii)
assert isinstance(make_data_provider(PlatformConfig()), TwelveDataProvider)
try:
make_data_provider(PlatformConfig(), "polygon")
assert False, "unregistered explicit name must raise"
except AppConfigError as e:
assert "polygon" in str(e)
@test("app_config: data_providers.streaming scalar selects the tick stream")
def _():
app = load_app_config(_write_json({
"data_providers": {"active": "oanda", "streaming": "tiingo",
"oanda": {}, "tiingo": {"api_key": "k"}}}))
assert app.streaming_provider == "tiingo"
assert set(app.provider_settings) == {"oanda", "tiingo"}, \
"reserved scalars must not become provider blocks"
app = load_app_config(_write_json(
{"data_providers": {"active": "oanda", "oanda": {}}}))
assert app.streaming_provider is None
@test("app_config: streaming provider must be a string with a settings block")
def _():
for payload in (
{"data_providers": {"active": "oanda", "oanda": {},
"streaming": "polygon"}}, # no block
{"data_providers": {"active": "oanda", "oanda": {},
"streaming": 5}}, # not a string
):
try:
load_app_config(_write_json(payload))
assert False, f"should have raised for {payload}"
except AppConfigError:
pass
@test("app_config: deepseek block parses, validates, defaults empty")
def _():
app = load_app_config(_write_json({"deepseek": {
"api_key": "sk-x", "model": "deepseek-v4-flash", "enabled": False,
"timeout_seconds": 5, "daily_request_cap": 100}}))
assert app.deepseek_settings["api_key"] == "sk-x"
assert app.deepseek_settings["enabled"] is False
assert load_app_config(_write_json({})).deepseek_settings == {}
for payload in (
{"deepseek": {"api_keyy": "typo"}}, # unknown key
{"deepseek": {"enabled": "yes"}}, # not a bool
{"deepseek": {"timeout_seconds": 0}}, # not positive
{"deepseek": {"timeout_seconds": True}}, # bool is not a number
{"deepseek": {"daily_request_cap": 2.5}}, # not an int
{"deepseek": {"model": ""}}, # empty model
{"deepseek": "oops"}, # not an object
):
try:
load_app_config(_write_json(payload))
assert False, f"should have raised for {payload}"
except AppConfigError:
pass
@test("app_config: deepseek_api_key resolves env → config.json → empty")
def _():
from app_config import deepseek_api_key
app = load_app_config(_write_json({"deepseek": {"api_key": "json-ds"}}))
with patch.dict(os.environ, {}, clear=True):
assert deepseek_api_key(app) == "json-ds"
with patch.dict(os.environ, {"DEEPSEEK_API_KEY": "env-ds"}):
assert deepseek_api_key(app) == "env-ds"
with patch.dict(os.environ, {}, clear=True):
assert deepseek_api_key(AppConfig()) == ""
@test("config.py: 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()
@test("telegram_bot: _get_streaming_dp resolves streaming name, else shared")
def _():
import telegram_bot as tb
from providers import TiingoDataProvider as _Tii
set_app_config(AppConfig(
active_provider="twelve_data",
streaming_provider="tiingo",
provider_settings={"twelve_data": {"api_key": "k"},
"tiingo": {"api_key": "k"}}))
tb._streaming_dp = None
tb._shared_dp = None
try:
dp = tb._get_streaming_dp()
assert isinstance(dp, _Tii)
assert dp is not tb._get_shared_dp(), \
"streaming provider must be a separate instance"
# unset → falls back to the shared active provider
set_app_config(_TEST_APP)
tb._streaming_dp = None
tb._shared_dp = None
assert tb._get_streaming_dp() is tb._get_shared_dp()
finally:
set_app_config(_TEST_APP)
tb._streaming_dp = None
tb._shared_dp = None
@test("telegram_bot: ws monitor auto-disables on non-streaming or keyless")
def _():
import asyncio
import telegram_bot as tb
class NoStream:
name = "stub"
supports_streaming = False
class KeylessStream:
name = "stub"
supports_streaming = True
_api_key = None
try:
for stub in (NoStream(), KeylessStream()):
tb._streaming_dp = stub
asyncio.run(tb._ws_monitor(None)) # must return, not connect
finally:
tb._streaming_dp = None
# ══════════════════════════════════════════════════════════════════════
# MODULE 15: Oanda provider
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 15: Oanda provider ═══")
import providers.oanda
@test("providers.oanda: OandaDataProvider parses Oanda candle JSON into correct DataFrame")
def _():
sample_candles = [
{
"complete": True,
"time": "2026-07-20T10:00:00.000000000Z",
"mid": {"o": "2400.00", "h": "2405.00", "l": "2398.00", "c": "2403.00"},
"bid": {"o": "2399.85", "h": "2404.85", "l": "2397.85", "c": "2402.85"},
"ask": {"o": "2400.15", "h": "2405.15", "l": "2398.15", "c": "2403.15"},
"volume": 1234,
},
{
"complete": True,
"time": "2026-07-20T11:00:00.000000000Z",
"mid": {"o": "2403.00", "h": "2408.00", "l": "2401.00", "c": "2406.00"},
"bid": {"o": "2402.85", "h": "2407.85", "l": "2400.85", "c": "2405.85"},
"ask": {"o": "2403.15", "h": "2408.15", "l": "2401.15", "c": "2406.15"},
"volume": 5678,
},
{
"complete": False,
"time": "2026-07-20T12:00:00.000000000Z",
"mid": {"o": "2406.00", "h": "2406.50", "l": "2405.50", "c": "2406.20"},
"bid": {"o": "2405.85", "h": "2406.35", "l": "2405.35", "c": "2406.05"},
"ask": {"o": "2406.15", "h": "2406.65", "l": "2405.65", "c": "2406.35"},
"volume": 100,
},
]
mock_resp = type("MockResp", (), {
"status_code": 200,
"json": lambda self: {"candles": sample_candles, "instrument": "XAU_USD"},
"raise_for_status": lambda self: None,
})()
provider2 = providers.oanda.OandaDataProvider.__new__(
providers.oanda.OandaDataProvider)
provider2.config = PlatformConfig()
provider2._base_url = "https://test"
provider2._cache = {}
provider2._cache_timestamps = {}
provider2._cache_outputsize = {}
provider2._cache_lock = threading.Lock()
provider2._inflight = {}
provider2._spread_cache = {}
provider2._session = type("S", (), {
"get": lambda self, *a, **k: mock_resp,
"headers": {},
})()
df = provider2._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
assert len(df) == 2, f"Expected 2 complete bars, got {len(df)}"
assert list(df.columns) == ["Open", "High", "Low", "Close", "Volume"], \
f"Wrong columns: {list(df.columns)}"
assert df.index.name == "Datetime"
assert df.index.tz is not None, "Index must be tz-aware"
assert df.iloc[0]["Open"] == 2400.0
assert df.iloc[1]["Close"] == 2406.0
assert df.iloc[0]["Volume"] == 1234
spread = provider2._spread_cache.get("XAU/USD")
assert spread is not None, "Spread should be cached"
assert abs(spread - 0.30) < 0.01, f"Expected spread ~0.30, got {spread}"
def _make_oanda_provider(responses):
"""Bare OandaDataProvider whose session pops canned responses in order."""
provider = providers.oanda.OandaDataProvider.__new__(
providers.oanda.OandaDataProvider)
provider.config = PlatformConfig()
provider._base_url = "https://test"
provider._cache = {}
provider._cache_timestamps = {}
provider._cache_outputsize = {}
provider._cache_lock = threading.Lock()
provider._inflight = {}
provider._spread_cache = {}
queue = list(responses)
provider._session = type("S", (), {
"get": lambda self, *a, **k: queue.pop(0),
"headers": {},
})()
return provider
def _oanda_resp(status_code, candles=None, text=""):
return type("MockResp", (), {
"status_code": status_code,
"text": text,
"json": lambda self: {"candles": candles or [], "instrument": "XAU_USD"},
})()
_OANDA_OK_CANDLE = {
"complete": True,
"time": "2026-07-20T10:00:00.000000000Z",
"mid": {"o": "2400.00", "h": "2405.00", "l": "2398.00", "c": "2403.00"},
"bid": {"o": "2399.85", "h": "2404.85", "l": "2397.85", "c": "2402.85"},
"ask": {"o": "2400.15", "h": "2405.15", "l": "2398.15", "c": "2403.15"},
"volume": 1234,
}
@test("providers.oanda: transient 401 is retried once and recovers")
def _():
provider = _make_oanda_provider([
_oanda_resp(401, text='{"errorMessage":"Insufficient authorization"}'),
_oanda_resp(200, candles=[_OANDA_OK_CANDLE]),
])
orig_sleep = providers.oanda.time.sleep
slept = []
providers.oanda.time.sleep = lambda s: slept.append(s)
try:
df = provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
finally:
providers.oanda.time.sleep = orig_sleep
assert len(df) == 1, f"Expected recovered fetch with 1 bar, got {len(df)}"
assert slept == [providers.oanda.OandaDataProvider.RETRY_DELAY_S], \
f"Expected one retry sleep, got {slept}"
@test("providers.oanda: persistent 401 raises after retry; 4xx never retried")
def _():
orig_sleep = providers.oanda.time.sleep
providers.oanda.time.sleep = lambda s: None
try:
provider = _make_oanda_provider([
_oanda_resp(401, text='{"errorMessage":"Insufficient authorization"}'),
_oanda_resp(401, text='{"errorMessage":"Insufficient authorization"}'),
])
try:
provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
assert False, "Persistent 401 should raise OandaDataError"
except providers.oanda.OandaDataError as e:
assert "401" in str(e)
# Non-auth 4xx must fail immediately — only one response queued,
# so a retry attempt would IndexError on the empty queue.
provider = _make_oanda_provider([_oanda_resp(400, text="bad request")])
try:
provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
assert False, "HTTP 400 should raise OandaDataError"
except providers.oanda.OandaDataError as e:
assert "400" in str(e)
finally:
providers.oanda.time.sleep = orig_sleep
@test("providers.oanda: instrument name mapping")
def _():
provider = providers.oanda.OandaDataProvider.__new__(
providers.oanda.OandaDataProvider)
assert provider._to_oanda_instrument("XAU/USD") == "XAU_USD"
assert provider._to_oanda_instrument("EUR/USD") == "EUR_USD"
@test("providers.oanda: granularity mapping covers all scan timeframes")
def _():
for tf in [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]:
assert tf in providers.oanda.OandaDataProvider.OANDA_GRANULARITY, \
f"{tf} missing from OANDA_GRANULARITY"
@test("providers.oanda: get_spread returns default when no cache")
def _():
provider = providers.oanda.OandaDataProvider.__new__(
providers.oanda.OandaDataProvider)
provider._spread_cache = {}
spread = provider.get_spread("XAU/USD")
assert spread == 0.30, f"Expected default spread 0.30, got {spread}"
@test("app_config: unknown oanda key raises AppConfigError")
def _():
import tempfile
bad = {"oanda": {"unknown_key": 1}}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False) as f:
json.dump(bad, f)
tmp_path = f.name
try:
app_config.load_app_config(tmp_path)
assert False, f"Should have raised for {bad}"
except app_config.AppConfigError:
pass
finally:
os.unlink(tmp_path)
@test("app_config: oanda in _KNOWN_TOP (no unknown-key error)")
def _():
assert "oanda" in app_config._KNOWN_TOP, \
"'oanda' must be in _KNOWN_TOP to avoid config.json validation errors"
@test("app_config: oanda resolver helpers return defaults without config")
def _():
url = app_config.oanda_base_url()
assert "oanda.com" in url, f"Expected Oanda URL, got {url}"
@test("providers.oanda: HTTP error surfaces status code and body")
def _():
mock_resp = type("MockResp", (), {
"status_code": 401,
"text": '{"errorMessage":"Insufficient authorization to perform request"}',
})()
provider = providers.oanda.OandaDataProvider.__new__(
providers.oanda.OandaDataProvider)
provider._base_url = "https://test"
provider._spread_cache = {}
provider._session = type("S", (), {
"get": lambda self, *a, **k: mock_resp,
"headers": {},
})()
try:
provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
assert False, "Should have raised OandaDataError"
except providers.oanda.OandaDataError as e:
msg = str(e)
assert "401" in msg, f"Status code missing from error: {msg}"
assert "Insufficient authorization" in msg, \
f"Body detail missing from error: {msg}"
@test("providers.oanda: boundary-retry failure keeps original data")
def _():
provider = providers.oanda.OandaDataProvider.__new__(
providers.oanda.OandaDataProvider)
provider.config = PlatformConfig()
provider._cache = {}
provider._cache_timestamps = {}
provider._cache_outputsize = {}
provider._cache_lock = threading.Lock()
provider._inflight = {}
provider._spread_cache = {}
provider.FRESH_FETCH_RETRY_SECONDS = 0 # instance override — no sleep
call_count = {"n": 0}
idx = pd.date_range("2026-07-01", periods=250, freq="5min", tz="UTC")
original_df = pd.DataFrame({
"Open": 2400.0, "High": 2401.0, "Low": 2399.0,
"Close": 2400.5, "Volume": 100.0,
}, index=idx)
def _fetch_oanda_candles(symbol, timeframe, count):
call_count["n"] += 1
if call_count["n"] == 1:
return original_df
raise providers.oanda.OandaDataError("boom")
def _validate_and_clean(df, symbol, timeframe):
return df
def _has_latest_closed_bar(df, timeframe):
return False # forces the retry path every call
def _is_data_fresh(df, symbol, timeframe):
return True
# Plain functions assigned to the instance bypass the descriptor
# protocol — they run unbound, so they take exactly the params
# fetch_ohlcv passes (no self).
provider._fetch_oanda_candles = _fetch_oanda_candles
provider._validate_and_clean = _validate_and_clean
provider._has_latest_closed_bar = _has_latest_closed_bar
provider._is_data_fresh = _is_data_fresh
result = provider.fetch_ohlcv("XAU/USD", Timeframe.M5)
assert call_count["n"] == 2, \
f"Expected original fetch + one retry attempt, got {call_count['n']} calls"
assert len(result) == 250, \
f"Retry failure must keep the original 250-bar df, got {len(result)}"
# ══════════════════════════════════════════════════════════════════════
# MODULE 16: Provider abstraction (providers/ package)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 16: Provider abstraction ═══")
from providers import (
BaseDataProvider, PROVIDER_REGISTRY, TwelveDataProvider,
make_data_provider,
)
from providers.oanda import OandaDataProvider as _POanda
from providers.tiingo import TiingoDataError, TiingoDataProvider
@test("providers: registry holds all providers, active default builds TD")
def _():
assert PROVIDER_REGISTRY["twelve_data"] is TwelveDataProvider
assert PROVIDER_REGISTRY["oanda"] is _POanda
assert PROVIDER_REGISTRY["tiingo"] is TiingoDataProvider
dp = make_data_provider(PlatformConfig())
assert isinstance(dp, TwelveDataProvider)
assert isinstance(dp, BaseDataProvider)
@test("providers: single knob — data_providers.active selects the class")
def _():
from app_config import AppConfig
for name, cls in PROVIDER_REGISTRY.items():
fake = AppConfig()
fake.active_provider = name
fake.provider_settings = {name: {"api_key": ""}}
with patch("providers.get_app_config", return_value=fake):
dp = make_data_provider(PlatformConfig())
assert type(dp) is cls, f"active={name} built {type(dp).__name__}"
# An unknown name must fail loudly, not fall back silently.
from app_config import AppConfigError
fake = AppConfig()
fake.active_provider = "nonsense"
with patch("providers.get_app_config", return_value=fake):
try:
make_data_provider(PlatformConfig())
assert False, "unknown provider must raise"
except AppConfigError as e:
assert "nonsense" in str(e)
@test("providers: shared behavior is defined ONCE on the base class")
def _():
# The pre-refactor bug this guards against: Oanda's provider was a
# copy-paste of the TD one, so shared-logic fixes had to be made
# twice. Every shared method must now BE the base implementation.
for meth in ("fetch_ohlcv", "fetch_range", "_validate_and_clean",
"_is_data_fresh", "_has_latest_closed_bar",
"_drop_forming_candle", "clear_cache", "get_pair_info"):
for cls in PROVIDER_REGISTRY.values():
assert getattr(cls, meth) is getattr(BaseDataProvider, meth), \
f"{cls.__name__} overrides shared method {meth}"
assert TwelveDataProvider.CANDLE_SECONDS is BaseDataProvider.CANDLE_SECONDS
assert _POanda.BARS_PER_DAY is BaseDataProvider.BARS_PER_DAY
assert TiingoDataProvider.CANDLE_SECONDS is BaseDataProvider.CANDLE_SECONDS
@test("providers: fetch_range paginates, dedupes, and trims to the window")
def _():
class FakeProvider(BaseDataProvider):
name = "fake"
def __init__(self, config):
super().__init__(config)
idx = pd.date_range("2026-06-01", "2026-06-30 23:00",
freq="1h", tz="UTC")
v = np.linspace(3000.0, 3100.0, len(idx))
self.series = pd.DataFrame(
{"Open": v, "High": v + 1, "Low": v - 1,
"Close": v, "Volume": 100.0}, index=idx)
self.series.index.name = "Datetime"
self.calls = 0
def _fetch_raw(self, symbol, timeframe, outputsize,
start=None, end=None):
self.calls += 1
df = self.series
if start is not None:
df = df[df.index >= pd.Timestamp(start)]
if end is not None:
df = df[df.index <= pd.Timestamp(end)]
# Overlap by a bar to prove concat dedupes.
return df.tail(min(len(df), 200))
# Keep the test date-stable: nothing here is "forming".
def _drop_forming_candle(self, df, timeframe):
return df
fp = FakeProvider(PlatformConfig())
start = datetime(2026, 6, 5, tzinfo=timezone.utc)
end = datetime(2026, 6, 25, tzinfo=timezone.utc)
df = fp.fetch_range("XAU/USD", Timeframe.H1, start, end)
assert fp.calls > 1, "a 20-day H1 window must take multiple chunks"
assert df.index.is_monotonic_increasing
assert not df.index.duplicated().any()
assert df.index[0] >= pd.Timestamp(start)
assert df.index[-1] <= pd.Timestamp(end)
expected = fp.series[(fp.series.index >= pd.Timestamp(start))
& (fp.series.index <= pd.Timestamp(end))]
assert len(df) == len(expected), \
f"window must be gapless: {len(df)} vs {len(expected)}"
@test("providers: streaming capability flags and base refusal")
def _():
assert TwelveDataProvider.supports_streaming is True
assert _POanda.supports_streaming is False
assert TiingoDataProvider.supports_streaming is True
class Minimal(BaseDataProvider):
name = "minimal"
def _fetch_raw(self, symbol, timeframe, outputsize,
start=None, end=None):
raise NotImplementedError
m = Minimal(PlatformConfig())
assert m.supports_streaming is False
try:
m.iter_ticks(["XAU/USD"])
assert False, "base iter_ticks must refuse"
except NotImplementedError:
pass
@test("providers: TD WS price-message parsing")
def _():
parse = TwelveDataProvider._parse_ws_price
assert parse('{"event":"price","symbol":"XAU/USD","price":"3345.5"}') \
== ("XAU/USD", 3345.5)
assert parse('{"event":"subscribe-status","fails":[]}') is None
assert parse('{"event":"heartbeat"}') is None
assert parse('not json{{') is None
assert parse('{"event":"price","symbol":"XAU/USD"}') is None # no price
assert parse('{"event":"price","symbol":"XAU/USD","price":"abc"}') is None
@test("providers: Tiingo WS quote-message parsing")
def _():
parse = TiingoDataProvider._parse_ws_message
# Field order matches a live-captured frame (2026-08-03):
# ["Q", ticker, ts, bidSize, bid, mid, askSize, ask]
quote = ('{"service":"fx","messageType":"A","data":'
'["Q","xauusd","2026-08-03T17:09:46.084000+00:00",'
'1000000.0,4035.05,4035.1,1000000.0,4035.15]}')
assert parse(quote) == ("xauusd", 4035.1)
# mid null → (bid+ask)/2, ask read from index 7 (index 6 is askSize)
mid_null = ('{"messageType":"A","service":"fx","data":'
'["Q","xauusd","2026-08-03T10:00:00+00:00",'
'1000000,3344.0,null,1000000,3346.0]}')
assert parse(mid_null) == ("xauusd", 3345.0)
# mid and bid null → unusable, skipped
all_null = ('{"messageType":"A","service":"fx","data":'
'["Q","xauusd","2026-08-03T10:00:00+00:00",'
'1000000,null,null,1000000,3346.0]}')
assert parse(all_null) is None
assert parse('{"messageType":"H"}') is None # heartbeat
assert parse('{"messageType":"I","response":{"code":200}}') is None
assert parse('not json{{') is None
assert parse('{"messageType":"A","data":["X","xauusd"]}') is None
# server error (e.g. bad token) must RAISE so the caller's backoff
# applies — Tiingo auths inside the socket, not at the HTTP upgrade
try:
parse('{"messageType":"E","response":'
'{"code":401,"message":"bad token"}}')
assert False, "server error message must raise"
except TiingoDataError:
pass
@test("providers: Tiingo WS ticker mapping round-trips the scanned pairs")
def _():
tp = TiingoDataProvider.__new__(TiingoDataProvider)
for pair in PAIR_INFO:
ticker = tp._to_tiingo_ticker(pair)
assert ticker.islower() and "/" not in ticker
inverse = {tp._to_tiingo_ticker(s): s for s in PAIR_INFO}
assert inverse[ticker] == pair
@test("providers: Oanda date-window fetch omits count and sends from/to")
def _():
captured = {}
def mock_get(self, url, params=None, timeout=None):
captured["params"] = params
return type("R", (), {
"status_code": 200,
"json": lambda self: {"candles": [{
"complete": True,
"time": "2026-06-02T10:00:00.000000000Z",
"mid": {"o": "3000", "h": "3001", "l": "2999", "c": "3000.5"},
"bid": {"c": "3000.2"}, "ask": {"c": "3000.8"},
"volume": 10,
}]},
})()
provider = _POanda.__new__(_POanda)
provider.config = PlatformConfig()
provider._base_url = "https://test"
provider._spread_cache = {}
provider._session = type("S", (), {"get": mock_get})()
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
end = datetime(2026, 6, 3, tzinfo=timezone.utc)
df = provider._fetch_raw("XAU/USD", Timeframe.H1, 5000, start, end)
assert "count" not in captured["params"], \
"v20 rejects count combined with from/to"
assert captured["params"]["from"].startswith("2026-06-01")
assert captured["params"]["to"].startswith("2026-06-03")
assert len(df) == 1
# Live path (no window) keeps sending count only.
provider._fetch_raw("XAU/USD", Timeframe.H1, 300)
assert captured["params"]["count"] == 300
assert "from" not in captured["params"]
def _tiingo_provider(response):
"""TiingoDataProvider with a canned session response (no network)."""
provider = TiingoDataProvider.__new__(TiingoDataProvider)
provider.config = PlatformConfig()
provider._api_key = "test-key"
provider._spread_cache = {}
captured = {}
def mock_get(self, url, params=None, timeout=None):
captured["url"] = url
captured["params"] = params
return response
provider._session = type("S", (), {"get": mock_get})()
return provider, captured
def _tiingo_resp(status_code, payload):
return type("R", (), {
"status_code": status_code,
"json": lambda self: payload,
"text": json.dumps(payload) if not isinstance(payload, str) else payload,
})()
@test("providers: Tiingo parses FX JSON into the normalized frame")
def _():
payload = [
{"date": "2026-07-20T11:00:00.000Z", "ticker": "xauusd",
"open": 2403.0, "high": 2408.0, "low": 2401.0, "close": 2406.0},
# Deliberately out of order — provider must sort oldest-first.
{"date": "2026-07-20T10:00:00.000Z", "ticker": "xauusd",
"open": 2400.0, "high": 2405.0, "low": 2398.0, "close": 2403.0},
]
provider, captured = _tiingo_provider(_tiingo_resp(200, payload))
df = provider._fetch_raw("XAU/USD", Timeframe.H1, 300)
assert list(df.columns) == ["Open", "High", "Low", "Close", "Volume"]
assert df.index.name == "Datetime"
assert df.index.tz is not None, "Index must be tz-aware UTC"
assert df.index.is_monotonic_increasing, "must be oldest-first"
assert df.iloc[0]["Open"] == 2400.0 and df.iloc[1]["Close"] == 2406.0
assert (df["Volume"] == 0.0).all(), "FX endpoint has no volume"
assert captured["url"].endswith("/tiingo/fx/xauusd/prices")
assert captured["params"]["resampleFreq"] == "1hour"
assert "startDate" in captured["params"], \
"live fetch must derive a start window from the bar count"
assert "token" not in captured["params"], \
"key must travel in the auth header, never the URL"
@test("providers: Tiingo date-window fetch sends startDate AND endDate")
def _():
payload = [{"date": "2026-06-02T10:00:00.000Z",
"open": 1, "high": 2, "low": 0, "close": 1}]
provider, captured = _tiingo_provider(_tiingo_resp(200, payload))
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
end = datetime(2026, 6, 3, tzinfo=timezone.utc)
provider._fetch_raw("XAU/USD", Timeframe.H1, 5000, start, end)
assert captured["params"]["startDate"].startswith("2026-06-01")
assert captured["params"]["endDate"].startswith("2026-06-03")
@test("providers: Tiingo fails LOUDLY on every bad-response shape")
def _():
cases = [
(_tiingo_resp(429, "quota"), "rate limit"),
(_tiingo_resp(401, "denied"), "auth failed"),
(_tiingo_resp(500, "boom"), "HTTP 500"),
(_tiingo_resp(200, []), "Empty candle response"),
(_tiingo_resp(200, [{"open": 1}]), "missing 'date'"),
(_tiingo_resp(200, [{"date": "2026-07-20T10:00:00Z",
"close": "n/a"}]), "no numeric closes"),
]
for resp, needle in cases:
provider, _cap = _tiingo_provider(resp)
try:
provider._fetch_raw("XAU/USD", Timeframe.H1, 300)
assert False, f"must raise for: {needle}"
except TiingoDataError as e:
assert needle.lower() in str(e).lower(), \
f"error must name the cause ({needle}): {e}"
# Missing key and unsupported timeframe are named errors too.
provider, _cap = _tiingo_provider(_tiingo_resp(200, []))
provider._api_key = None
try:
provider._fetch_raw("XAU/USD", Timeframe.H1, 300)
assert False, "missing key must raise"
except TiingoDataError as e:
assert "TIINGO_API_KEY" in str(e)
provider._api_key = "k"
try:
provider._fetch_raw("XAU/USD", Timeframe.W1, 300)
assert False, "unsupported TF must raise"
except TiingoDataError as e:
assert "Unsupported timeframe" in str(e)
# And everything above is catchable as the shared base error.
assert issubclass(TiingoDataError, DataIntegrityError)
@test("providers: per-provider env keys (TIINGO_API_KEY vs TWELVE_DATA_API_KEY)")
def _():
from app_config import AppConfig, provider_api_key
app = AppConfig()
app.active_provider = "tiingo"
app.provider_settings = {"tiingo": {"api_key": "json-tiingo"},
"twelve_data": {"api_key": "json-td"}}
with patch.dict(os.environ, {"TIINGO_API_KEY": "env-tiingo",
"TWELVE_DATA_API_KEY": "env-td"}):
assert provider_api_key(app) == "env-tiingo"
assert provider_api_key(app, provider="twelve_data") == "env-td"
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("TIINGO_API_KEY", None)
assert provider_api_key(app) == "json-tiingo"
# The TD env var must never leak into another provider's key.
with patch.dict(os.environ, {"TWELVE_DATA_API_KEY": "env-td"}):
assert provider_api_key(app, provider="tiingo") == "json-tiingo"
@test("providers: aggregator bar-close clock uses the base-class table")
def _():
# signal_aggregator computes bar-CLOSE hours (session filter, SELL
# hour gate) from BaseDataProvider.CANDLE_SECONDS — a provider swap
# must never change the session clock.
import signal_aggregator as sa
assert sa.BaseDataProvider.CANDLE_SECONDS[Timeframe.M15] == 900
assert not hasattr(sa, "TwelveDataProvider"), \
"aggregator must not depend on a concrete provider class"
@test("providers: backtest parquet cache is keyed per provider")
def _():
# Bars differ between feeds (Oanda/TD divergence study 2026-07-24) —
# a provider switch must never serve another feed's cached bars.
# twelve_data keeps the legacy un-prefixed name so pinned-book caches
# stay valid.
from datetime import datetime
from backtest.gpu_backtest import _cache_path
s, e = datetime(2026, 1, 1), datetime(2026, 2, 1)
td = _cache_path(XAUUSD, Timeframe.M15, s, e, "twelve_data")
oa = _cache_path(XAUUSD, Timeframe.M15, s, e, "oanda")
legacy = _cache_path(XAUUSD, Timeframe.M15, s, e)
assert td.name == "XAU_USD_15min_20260101_20260201.parquet", td.name
assert oa.name == "oanda_XAU_USD_15min_20260101_20260201.parquet", oa.name
assert legacy == td, "default must stay the legacy twelve_data key"
assert td != oa, "providers must not share cache files"
# ══════════════════════════════════════════════════════════════════════
# MODULE 17: Big-candle entry gate (gate 6h)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 17: big-candle entry gate ═══")
def _inject_big_bar(df, pos=-250, mult=8.0):
"""Copy df and widen one bar to ~mult x the median range (a burst
candle) at position `pos` — far enough back not to disturb the
near-window indicators (RSI/BB/ATR 14-20 bars, swing S/R 120)."""
out = df.copy()
atr_est = (out["High"] - out["Low"]).median()
ts = out.index[pos]
out.loc[ts, "High"] = out.loc[ts, "Open"] + mult * atr_est
out.loc[ts, "Low"] = out.loc[ts, "Open"] - 0.5 * atr_est
out.loc[ts, "Close"] = out.loc[ts, "Open"] + mult * 0.9 * atr_est
return out
def _bc_agg(k=3.0, tf=Timeframe.M5, confirms=(Timeframe.M15, Timeframe.H1)):
from pair_signals.xauusd import make_config as xau_make_config
cfg = xau_make_config(tf, list(confirms))
cfg.xau_strategy.big_candle_atr_multiple = k
return SignalAggregator(cfg)
# Deterministic ACTIONABLE fixture: grade A BUY with a GENERIC setup
# through the factory M5 config at an unblocked hour (Wed 14:55 UTC),
# huge bar pre-injected 250 bars back (verified by seed search).
def _bc_actionable_dfs():
end = "2026-07-08 14:55"
primary = _inject_big_bar(make_xau_bars(end=end, drift=0.8, seed=0))
m15 = make_xau_bars(end="2026-07-08 14:45", freq="15min", drift=0.8, seed=100)
h1 = make_xau_bars(end="2026-07-08 14:00", freq="60min", drift=0.8, seed=200)
return {Timeframe.M5: primary, Timeframe.M15: m15, Timeframe.H1: h1}
@test("BC detect: quiet fixture → None; injected 8x bar → (its ts, ratio ≥ 3)")
def _():
agg = _bc_agg(k=3.0)
quiet = make_xau_bars(end="2026-07-08 14:55")
assert agg._detect_big_candle(quiet) is None, \
"quiet bars must not trip the detector"
spiked = _inject_big_bar(quiet, pos=-250)
hit = agg._detect_big_candle(spiked)
assert hit is not None, "injected burst bar not detected"
ts, ratio = hit
assert ts == spiked.index[-250], f"wrong offender bar: {ts}"
assert ratio >= 3.0, f"ratio {ratio} below threshold"
@test("BC detect: burst bar as LAST row still trips (prior-ATR shift)")
def _():
# The huge bar inflates the concurrent Wilder ATR; the shift(1)
# denominator keeps the very newest burst detectable.
agg = _bc_agg(k=3.0)
spiked = _inject_big_bar(make_xau_bars(end="2026-07-08 14:55"), pos=-1)
hit = agg._detect_big_candle(spiked)
assert hit is not None and hit[0] == spiked.index[-1], f"last-row burst missed: {hit}"
@test("BC detect: bar older than the lookback window ignored; short df safe")
def _():
agg = _bc_agg(k=3.0)
# M5 window = 288 bars (+42 ewm warm-up tail). A burst at -350 is
# outside even the warm-up slice of a 400-bar df.
old_spike = _inject_big_bar(make_xau_bars(n=400, end="2026-07-08 14:55"), pos=-350)
assert agg._detect_big_candle(old_spike) is None, \
"bar outside the lookback window must not block"
# Short history: no raise, graceful no-trip on quiet bars.
assert agg._detect_big_candle(make_xau_bars(n=60, end="2026-07-08 14:55")) is None
@test("BC gate 6h: actionable A-grade → warning + D + setup killed")
def _():
dfs = _bc_actionable_dfs()
control = _bc_agg(k=0.0).analyze_from_dataframes(XAUUSD, dfs, use_news=False)
assert control.signal_quality == "A" and control.trade_setup is not None, \
f"fixture drifted: expected actionable A, got {control.signal_quality}"
assert not any("BIG CANDLE" in w for w in control.warnings), \
"disabled gate must stay silent"
gated = _bc_agg(k=3.0).analyze_from_dataframes(XAUUSD, dfs, use_news=False)
assert any(w.startswith("BIG CANDLE FILTER") for w in gated.warnings), \
f"gate did not fire: {gated.warnings}"
assert gated.signal_quality == "D", f"expected D, got {gated.signal_quality}"
assert gated.trade_setup is None, "setup must be killed by the gate"
@test("BC gate 6h: session-blocked F stays F (warning only, no F→D)")
def _():
end = "2026-07-08 16:55" # blocked hour → gate 6b sets F
dfs = {
Timeframe.M5: _inject_big_bar(make_xau_bars(end=end)),
Timeframe.M15: make_xau_bars(end="2026-07-08 16:45", freq="15min"),
Timeframe.H1: make_xau_bars(end="2026-07-08 16:00", freq="60min"),
}
report = _bc_agg(k=3.0).analyze_from_dataframes(XAUUSD, dfs, use_news=False)
assert any("SESSION FILTER" in w for w in report.warnings)
assert any(w.startswith("BIG CANDLE FILTER") for w in report.warnings), \
"detection must run regardless of grade (PB/CP suppression channel)"
assert report.signal_quality == "F", \
f"blocked bar must stay F, got {report.signal_quality}"
@test("BC gate 6h: XAU_PB and XAU_CP entry paths suppressed")
def _():
# The D-downgrade alone would NOT stop them: PB accepts D (check is
# != 'F') and CP even F — the warning is the suppression channel.
dfs = _bc_actionable_dfs()
def run(k):
agg = _bc_agg(k=k)
agg.config.xau_strategy.cp_enabled = True
# Force-enable PB: this test exercises gate mechanics, not the
# tuned M5 default (PB off since 2026-08-04, xauusd.py docstring).
agg.config.xau_strategy.m5_pullback_enabled = True
calls = []
agg._try_m5_pullback = lambda *a, **kw: calls.append("pb")
agg._try_chart_pattern = lambda *a, **kw: calls.append("cp")
# Keep the setup slot empty so steps 9/10 are actually reached.
agg._generate_trade_setup = lambda *a, **kw: None
agg._generate_xau_atr_trade_setup = lambda *a, **kw: None
agg.analyze_from_dataframes(XAUUSD, dfs, use_news=False)
return calls
assert run(k=0.0) == ["pb", "cp"], "control must reach both entry paths"
assert run(k=3.0) == [], "tripped gate must suppress both entry paths"
@test("BC gate: disabled by default in every factory TF config")
def _():
from pair_signals.xauusd import make_config as xau_make_config
for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4):
cfg = xau_make_config(tf)
assert cfg.xau_strategy.big_candle_atr_multiple == 0.0, \
f"{tf}: gate must ship default-OFF (validate-first rollout)"
assert cfg.xau_strategy.big_candle_lookback_hours == 24.0
@test("BC backtester: --big-candle / --big-candle-hours parse, default None")
def _():
from gpu_backtest import _parse_args
args = _parse_args(["--pair", "XAUUSD",
"--big-candle", "3.0", "--big-candle-hours", "12"])
assert args.big_candle == 3.0 and args.big_candle_hours == 12.0
args2 = _parse_args(["--pair", "XAUUSD"])
assert args2.big_candle is None and args2.big_candle_hours is None
# ══════════════════════════════════════════════════════════════════════
# MODULE 18: --set research override + diagnose_filters tag freshness
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 18: --set override + diagnose tags ═══")
@test("--set: nested float/bool/JSON-list overrides apply with typing")
def _():
from gpu_backtest import _apply_config_overrides
cfg = PlatformConfig()
_apply_config_overrides(cfg, [
"xau_strategy.m5_min_abs_score=25",
"xau_strategy.m5_require_mtf=false",
'xau_strategy.sell_blocked_hours_utc=[15,17]',
"signals.min_tp_cost_multiple=20.5",
])
assert cfg.xau_strategy.m5_min_abs_score == 25
assert cfg.xau_strategy.m5_require_mtf is False
assert cfg.xau_strategy.sell_blocked_hours_utc == [15, 17]
assert cfg.signals.min_tp_cost_multiple == 20.5
@test("--set: unknown path / section leaf / missing '=' all fail loudly")
def _():
# A typo'd path MUST NOT become a silent no-op run: dataclass
# instances happily accept setattr of any name.
from gpu_backtest import _apply_config_overrides
cfg = PlatformConfig()
for bad in ("xau_strategy.m5_min_abs_scroe=25", # typo leaf
"xau_stragety.m5_min_abs_score=25", # typo section
"xau_strategy=5", # section, not leaf
"no_equals_sign"):
try:
_apply_config_overrides(cfg, [bad])
except SystemExit:
pass
else:
raise AssertionError(f"'{bad}' must raise SystemExit")
@test("--set: bare-string fallback for non-JSON values")
def _():
from gpu_backtest import _apply_config_overrides
cfg = PlatformConfig()
_apply_config_overrides(cfg, ["technical.rsi_mode=classic"])
assert cfg.technical.rsi_mode == "classic"
@test("--set: CLI flag parses as repeatable list, default empty")
def _():
from gpu_backtest import _parse_args
args = _parse_args(["--pair", "XAUUSD",
"--set", "xau_strategy.m5_min_abs_score=25",
"--set", "signals.tp_floor_rr=1.0"])
assert args.set == ["xau_strategy.m5_min_abs_score=25",
"signals.tp_floor_rr=1.0"]
assert _parse_args(["--pair", "XAUUSD"]).set == []
@test("diagnose_filters: every filter tag has a live emitter (staleness guard)")
def _():
import inspect
import diagnose_filters
import signal_aggregator as sa
src = inspect.getsource(sa)
tag_src = inspect.getsource(diagnose_filters.classify_bar)
# Extract the tag strings from the ordered list in classify_bar.
import re
tags = re.findall(r'\("([^"]+)",\s+"[A-Z0-9_]+"\)', tag_src)
assert len(tags) >= 25, f"tag extraction broke: found {len(tags)}"
for tag in tags:
assert tag in src, f"filter tag '{tag}' has no emitter in signal_aggregator.py"
@test("diagnose_filters: one tag per warning, specific prefix wins")
def _():
import diagnose_filters as d
from signal_aggregator import SignalReport
class _R:
pass
r = _R()
r.warnings = [
"M5 FILTER: SELL quality A capped — high-conviction M5 shorts enter at extremes",
"SELL FILTER: 15:00 UTC entry blocked for shorts",
"TREND EXHAUSTION — price extended >3.5 ATR past swing high",
]
r.final_signal = SignalStrength.SELL
r.signal_quality = "D"
r.trade_setup = None
outcome, hits = d.classify_bar(r, True)
assert outcome == "LOW_QUALITY"
assert hits == ["M5_SELL_QUALITY", "SELL_HOUR", "TREND_EXHAUSTION"], hits
# ══════════════════════════════════════════════════════════════════════
# MODULE 19: DeepSeek news enrichment (llm_client + news_engine seams)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 19: DeepSeek news enrichment ═══")
import datetime as _dt_m19
import logging
import requests
import llm_client as lc
from llm_client import DeepSeekClient
from news_engine import (EconomicEvent, FundamentalContext, NewsEngine,
NewsSentiment)
def _stub_resp(payload, status=200):
return type("R", (), {
"status_code": status,
"text": json.dumps(payload),
"json": lambda self: payload,
})()
def _chat_body(content_obj):
return {"choices": [{"message": {"content": json.dumps(content_obj)}}]}
def _counting_session(payload, status=200):
class S:
calls = 0
def post(self, url, json=None, timeout=None):
S.calls += 1
return _stub_resp(payload, status)
return S()
@test("llm_client: response parsing and availability gating")
def _():
parse = DeepSeekClient._parse_response
assert parse(_chat_body({"a": 1})) == {"a": 1}
assert parse({"choices": []}) is None
assert parse({"error": "x"}) is None
assert parse({"choices": [{"message": {"content": "not json"}}]}) is None
assert parse({"choices": [{"message": {"content": "[1,2]"}}]}) is None
# keyless / disabled clients never touch the network
for client in (DeepSeekClient(api_key=""),
DeepSeekClient(api_key="k", enabled=False)):
assert client.available is False
s = _counting_session(_chat_body({"a": 1}))
client._session = s
assert client.chat_json("sys", {"q": 1}) is None
assert type(s).calls == 0
@test("llm_client: daily cap blocks requests and resets on UTC date roll")
def _():
client = DeepSeekClient(api_key="k", daily_cap=2)
s = _counting_session(_chat_body({"ok": True}))
client._session = s
assert client.chat_json("sys", {}) == {"ok": True}
assert client.chat_json("sys", {}) == {"ok": True}
assert client.chat_json("sys", {}) is None, "third call must hit the cap"
assert type(s).calls == 2, "capped call must make no HTTP request"
# UTC date roll resets the counter
client._count_day = (_dt_m19.datetime.now(_dt_m19.timezone.utc).date()
- _dt_m19.timedelta(days=1))
assert client.chat_json("sys", {}) == {"ok": True}
assert type(s).calls == 3
@test("llm_client: HTTP error and malformed body return None")
def _():
client = DeepSeekClient(api_key="k")
client._session = _counting_session({"detail": "quota"}, status=429)
assert client.chat_json("sys", {}) is None
client._session = _counting_session({"unexpected": "shape"})
assert client.chat_json("sys", {}) is None
class _StubLLM:
"""Drop-in for the llm_client singleton."""
def __init__(self, reply):
self.reply = reply
self.calls = 0
self.last_payload = None
available = True
def chat_json(self, system_prompt, user_payload):
self.calls += 1
self.last_payload = user_payload
return self.reply
def _m19_pubdate(hours_ago=0.5):
"""RFC-2822 pubDate bytes `hours_ago` in the past (RSS fixtures must be
dated since 2026-09-04: undated items are dropped)."""
from email.utils import format_datetime
when = (_dt_m19.datetime.now(_dt_m19.timezone.utc)
- _dt_m19.timedelta(hours=hours_ago))
return format_datetime(when).encode()
@test("NewsEngine: RSS age window is 4 h; older and undated items are dropped")
def _():
assert NewsConfig().headline_max_age_seconds == 14400, "4 h (2026-09-04)"
cfg = NewsConfig()
cfg.rss_feeds = ["https://feed.example/rss"]
ne = NewsEngine(cfg)
saved = NewsEngine._source_health
NewsEngine._source_health = {}
rss = (b"<rss><channel>"
b"<item><title>Gold surges as Fed signals rate cuts</title>"
b"<pubDate>" + _m19_pubdate(3) + b"</pubDate></item>"
b"<item><title>Gold slips on strong dollar data</title>"
b"<pubDate>" + _m19_pubdate(5) + b"</pubDate></item>"
b"<item><title>Dollar steady before Fed minutes</title></item>"
b"</channel></rss>")
class _Sess:
def get(self, url, timeout=None):
return type("R", (), {"status_code": 200, "content": rss})()
ne._session = _Sess()
try:
heads = ne._fetch_news_sentiment_raw()
assert [h.headline for h in heads] == \
["Gold surges as Fed signals rate cuts"], heads
finally:
NewsEngine._source_health = saved
@test("NewsEngine: neutral headlines are dropped before the per-currency mean")
def _():
from news_engine import _is_neutral_headline, NEUTRAL_HEADLINE_THRESHOLD
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
def head(overall, by, cur):
return NewsSentiment(headline="h", source="s", timestamp=now,
currencies_mentioned=cur, sentiment_score=overall,
keywords=[], sentiment_by_currency=by)
assert NEUTRAL_HEADLINE_THRESHOLD == 0.1
assert _is_neutral_headline(head(0.0, {"XAU": 0.0}, ["XAU"]))
assert not _is_neutral_headline(head(0.0, {"XAU": -0.7}, ["XAU"])), \
"overall 0.0 but gold -0.7 leans one way"
assert _is_neutral_headline(head(float("nan"), {"XAU": float("nan")}, ["XAU"]))
assert _is_neutral_headline(head(0.5, {}, [])), "no currencies → neutral"
assert _is_neutral_headline(head(0.05, {"USD": 0.05, "XAU": 0.09},
["USD", "XAU"]))
ne = NewsEngine(NewsConfig())
ne._fetch_economic_calendar = lambda currencies: []
ne._fetch_news_sentiment = lambda: [
head(0.0, {"XAU": 0.0}, ["XAU"]), # neutral → dropped
head(0.0, {"XAU": -0.7}, ["XAU"]), # gold-bearish → kept
head(0.3, {"USD": 0.3}, ["USD"]), # dollar-bullish → kept
head(float("nan"), {"USD": float("nan")}, ["USD"]), # unscored → dropped
]
NewsEngine._llm_summary_cache = (0.0, "")
try:
lc._client = _StubLLM(None)
ctx = ne.get_fundamental_context()
finally:
lc._client = None
NewsEngine._llm_summary_cache = (0.0, "")
assert len(ctx.news_headlines) == 2, [h.sentiment_by_currency for h in ctx.news_headlines]
assert abs(ctx.news_sentiment["XAU"] - (-0.7)) < 1e-9, \
"mean over survivors only (the 0.0 must not dilute it)"
assert abs(ctx.news_sentiment["USD"] - 0.3) < 1e-9
@test("NewsEngine: summary prompt is beginner-language; payload newest-first with gold direction; 400-char cap")
def _():
from news_engine import _LLM_SUMMARY_SYSTEM, _gold_direction_label
assert "never traded" in _LLM_SUMMARY_SYSTEM
assert "no acronyms" in _LLM_SUMMARY_SYSTEM
assert "max 55 words" in _LLM_SUMMARY_SYSTEM
assert "the US central bank" in _LLM_SUMMARY_SYSTEM # worked example carries the style
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
def head(title, ts, by, cur, overall=0.0):
return NewsSentiment(headline=title, source="s", timestamp=ts,
currencies_mentioned=cur, sentiment_score=overall,
keywords=[], sentiment_by_currency=by)
older = head("old gold story", now - _dt_m19.timedelta(hours=3),
{"XAU": 0.6}, ["XAU"])
newer = head("dollar jumps", now, {"USD": 0.5}, ["USD"], overall=0.5)
assert _gold_direction_label(older) == "up"
assert _gold_direction_label(newer) == "down", "dollar-bullish → gold down"
ctx = FundamentalContext(news_headlines=[older, newer])
NewsEngine._llm_summary_cache = (0.0, "")
try:
stub = _StubLLM({"summary": "x" * 450})
lc._client = stub
out = NewsEngine(NewsConfig())._llm_market_summary(ctx)
assert len(out) == 400, len(out)
sent = stub.last_payload["headlines"]
assert [h["headline"] for h in sent] == ["dollar jumps", "old gold story"], \
"newest first"
assert [h["gold"] for h in sent] == ["down", "up"]
assert set(sent[0]) == {"headline", "gold"}
finally:
NewsEngine._llm_summary_cache = (0.0, "")
lc._client = None
@test("NewsEngine: _llm_headline_scores validates, clamps, rejects mismatch")
def _():
ne = NewsEngine(NewsConfig())
try:
lc._client = _StubLLM({"scores": [[5, -3], [0.2, 0.1]]})
assert ne._llm_headline_scores(["a", "b"]) == [(1.0, -1.0), (0.2, 0.1)]
lc._client = _StubLLM({"scores": [[0.1, 0.1]]})
assert ne._llm_headline_scores(["a", "b"]) is None, "length mismatch"
lc._client = _StubLLM({"scores": [["hot", 0], [0.3]]})
assert ne._llm_headline_scores(["a", "b"]) is None, "non-numeric/short"
lc._client = _StubLLM(None)
assert ne._llm_headline_scores(["a"]) is None, "failed call"
finally:
lc._client = None
@test("NewsEngine: LLM sentiment overrides keyword scores per currency")
def _():
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
ne = NewsEngine(NewsConfig())
NewsEngine._llm_sent_cache = {}
try:
lc._client = _StubLLM({"ok": True}) # available, unused (stubbed below)
h = NewsSentiment(
headline="Fed signals rate hike", source="s", timestamp=now,
currencies_mentioned=["USD", "XAU"], sentiment_score=1.0,
keywords=["rate hike"],
sentiment_by_currency={"USD": 1.0, "XAU": -1.0})
ne._llm_headline_scores = lambda texts: [(0.4, -0.7)] * len(texts)
ne._apply_llm_sentiment([h])
assert h.sentiment_score == 0.4
assert h.sentiment_by_currency == {"USD": 0.4, "XAU": -0.7}
assert h.keywords == ["rate hike"], "keywords stay keyword-derived"
finally:
NewsEngine._llm_sent_cache = {}
lc._client = None
@test("NewsEngine: LLM failure keeps keyword sentiment untouched")
def _():
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
ne = NewsEngine(NewsConfig())
NewsEngine._llm_sent_cache = {}
try:
lc._client = _StubLLM({"ok": True})
h = NewsSentiment(
headline="Fed signals rate hike", source="s", timestamp=now,
currencies_mentioned=["USD"], sentiment_score=0.6,
keywords=[], sentiment_by_currency={"USD": 0.6})
ne._llm_headline_scores = lambda texts: None
ne._apply_llm_sentiment([h])
assert h.sentiment_score == 0.6
assert h.sentiment_by_currency == {"USD": 0.6}
# unavailable client: not even the scores call happens
lc._client = None
ne._llm_headline_scores = lambda texts: (_ for _ in ()).throw(
AssertionError("must not be called when unavailable"))
ne._apply_llm_sentiment([h])
finally:
NewsEngine._llm_sent_cache = {}
lc._client = None
@test("NewsEngine: LLM sentiment cache only sends new headlines")
def _():
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
ne = NewsEngine(NewsConfig())
NewsEngine._llm_sent_cache = {}
sent_batches = []
def make(h):
return NewsSentiment(
headline=h, source="s", timestamp=now,
currencies_mentioned=["XAU"], sentiment_score=0.0,
keywords=[], sentiment_by_currency={"XAU": 0.0})
try:
lc._client = _StubLLM({"ok": True})
def scores(texts):
sent_batches.append(list(texts))
return [(0.5, 0.5)] * len(texts)
ne._llm_headline_scores = scores
ne._apply_llm_sentiment([make("headline one"), make("headline two")])
ne._apply_llm_sentiment([make("headline one"), make("headline two"),
make("headline three")])
assert sent_batches == [["headline one", "headline two"],
["headline three"]], sent_batches
finally:
NewsEngine._llm_sent_cache = {}
lc._client = None
@test("NewsEngine: LLM enriches surprise description, numeric fallback intact")
def _():
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
ne = NewsEngine(NewsConfig())
NewsEngine._llm_event_cache = {}
released = EconomicEvent(
time=now - _dt_m19.timedelta(minutes=10), currency="USD",
impact="high", event="Nonfarm Payrolls",
forecast="200K", actual="300K")
ctx = FundamentalContext(events_today=[released])
try:
# failure first: description must equal today's numeric form
lc._client = _StubLLM(None)
risk, desc = ne.get_event_risk_level("XAU/USD", ctx)
assert risk == "caution"
assert desc.endswith("(actual 300K vs forecast 200K)"), desc
# then success: LLM direction appended to the description only
lc._client = _StubLLM({"gold_direction": "bearish",
"reason": "strong jobs lift yields"})
risk, desc = ne.get_event_risk_level("XAU/USD", ctx)
assert risk == "caution"
assert desc.endswith("— gold bearish: strong jobs lift yields"), desc
# payload carries the precomputed comparison (300K > 200K) so the
# model never re-compares the raw numbers (2026-08-06 validation)
assert lc._client.last_payload["vs_forecast"] == "above forecast", \
lc._client.last_payload
# bogus direction → numeric fallback
NewsEngine._llm_event_cache = {}
lc._client = _StubLLM({"gold_direction": "sideways"})
_, desc = ne.get_event_risk_level("XAU/USD", ctx)
assert desc.endswith("(actual 300K vs forecast 200K)"), desc
finally:
NewsEngine._llm_event_cache = {}
lc._client = None
@test("NewsEngine: market summary cached across engine instances")
def _():
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
NewsEngine._llm_summary_cache = (0.0, "")
ctx = FundamentalContext(news_headlines=[NewsSentiment(
headline="Gold steady", source="s", timestamp=now,
currencies_mentioned=["XAU"], sentiment_score=0.0, keywords=[])])
try:
stub = _StubLLM({"summary": "Soft dollar supports gold."})
lc._client = stub
assert NewsEngine(NewsConfig())._llm_market_summary(ctx) == \
"Soft dollar supports gold."
# second engine instance within the TTL: served from cache, no call
assert NewsEngine(NewsConfig())._llm_market_summary(ctx) == \
"Soft dollar supports gold."
assert stub.calls == 1
# failure caches "" so a broken API is retried next window, not per scan
NewsEngine._llm_summary_cache = (0.0, "")
lc._client = _StubLLM(None)
assert NewsEngine(NewsConfig())._llm_market_summary(ctx) == ""
assert NewsEngine._llm_summary_cache[1] == ""
finally:
NewsEngine._llm_summary_cache = (0.0, "")
lc._client = None
@test("SignalReport.news_summary flows to Telegram, resets when news off")
def _():
import telegram_bot as tb
config = PlatformConfig()
config.primary_timeframe = Timeframe.H1
config.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
ctx = FundamentalContext(llm_summary="Test macro blurb.")
report = agg.analyze_from_dataframes(
"XAU/USD", dfs, context=ctx, use_news=True)
assert report.news_summary == "Test macro blurb."
assert "Test macro blurb." in tb.format_signal_message(report)
report = agg.analyze_from_dataframes(
"XAU/USD", dfs, context=ctx, use_news=False)
assert report.news_summary == ""
@test("news defaults: LLM never gates signals (2026-07 audit lock)")
def _():
cfg = NewsConfig()
assert cfg.event_risk_caps_signals is False
assert cfg.sentiment_affects_signals is False
@test("llm_client: health stamps track last success and failure")
def _():
client = DeepSeekClient(api_key="k", daily_cap=10)
assert client.last_ok == 0.0 and client.last_error is None
client._session = _counting_session(_chat_body({"a": 1}))
assert client.chat_json("sys", {}) == {"a": 1}
assert client.last_ok > 0 and client.last_error is None
client._session = _counting_session({"detail": "quota"}, status=429)
assert client.chat_json("sys", {}) is None
assert client.last_error is not None
ts, kind = client.last_error
assert kind == "http_429" and ts >= client.last_ok
class _LogCapture(logging.Handler):
def __init__(self):
super().__init__()
self.records = []
def emit(self, record):
self.records.append(record)
@test("NewsEngine: _record_source logs transitions only, tracks state")
def _():
import news_engine as ne_mod
cap = _LogCapture()
ne_mod.logger.addHandler(cap)
old_level = ne_mod.logger.level
ne_mod.logger.setLevel(logging.INFO)
saved = NewsEngine._source_health
NewsEngine._source_health = {}
try:
NewsEngine._record_source("t:src", False, "HTTP 503")
warns = [r for r in cap.records if r.levelno == logging.WARNING]
assert len(warns) == 1 and "HTTP 503" in warns[0].getMessage()
assert NewsEngine.source_is_failing(
NewsEngine._source_health["t:src"])
# repeat failure must NOT warn again (DEBUG only)
NewsEngine._record_source("t:src", False, "HTTP 503")
warns = [r for r in cap.records if r.levelno == logging.WARNING]
assert len(warns) == 1
# recovery logs INFO and clears the failing state
NewsEngine._record_source("t:src", True, extra="42 kept")
infos = [r for r in cap.records if r.levelno == logging.INFO]
assert any("recovered" in r.getMessage() for r in infos)
entry = NewsEngine._source_health["t:src"]
assert not NewsEngine.source_is_failing(entry)
assert entry["extra"] == "42 kept"
finally:
ne_mod.logger.removeHandler(cap)
ne_mod.logger.setLevel(old_level)
NewsEngine._source_health = saved
@test("NewsEngine: calendar fallback source is visible in health state")
def _():
ne = NewsEngine(NewsConfig())
saved = NewsEngine._source_health
NewsEngine._source_health = {}
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
ev = EconomicEvent(time=now, currency="USD", impact="high", event="X")
try:
ne._try_fetch_calendar_api = lambda: []
ne._try_fetch_myfxbook_rss = lambda: [ev]
assert ne._fetch_calendar_raw() == [ev]
sh = NewsEngine._source_health
assert NewsEngine.source_is_failing(sh["calendar:forexfactory"])
assert not NewsEngine.source_is_failing(sh["calendar"])
assert "MyFXBook" in sh["calendar"]["extra"]
# both dead → calendar itself failing
ne._try_fetch_myfxbook_rss = lambda: []
assert ne._fetch_calendar_raw() == []
assert NewsEngine.source_is_failing(sh["calendar"])
finally:
NewsEngine._source_health = saved
@test("NewsEngine: per-feed RSS health recorded, aggregate survives")
def _():
cfg = NewsConfig()
cfg.rss_feeds = ["https://good.example/rss", "https://bad.example/rss"]
ne = NewsEngine(cfg)
saved = NewsEngine._source_health
NewsEngine._source_health = {}
rss = (b"<rss><channel><item>"
b"<title>Fed signals rate cuts as dollar slides</title>"
b"<pubDate>" + _m19_pubdate() + b"</pubDate>"
b"</item></channel></rss>")
class _Sess:
def get(self, url, timeout=None):
if "bad.example" in url:
raise requests.ConnectionError("refused")
return type("R", (), {"status_code": 200, "content": rss})()
ne._session = _Sess()
try:
heads = ne._fetch_news_sentiment_raw()
assert len(heads) == 1, heads
sh = NewsEngine._source_health
assert not NewsEngine.source_is_failing(sh["rss:good.example"])
assert NewsEngine.source_is_failing(sh["rss:bad.example"])
assert "refused" in sh["rss:bad.example"]["detail"]
agg = sh["headlines"]
assert not NewsEngine.source_is_failing(agg)
assert "1/2 feeds" in agg["extra"], agg
finally:
NewsEngine._source_health = saved
@test("NewsEngine: RSS parser captures link/description/publisher")
def _():
cfg = NewsConfig()
cfg.rss_feeds = ["https://news.google.com/rss/search?q=gold"]
ne = NewsEngine(cfg)
saved = NewsEngine._source_health
NewsEngine._source_health = {}
# Google-News-shaped item: redirect <link>, HTML-escaped <description>,
# <source> carrying the real publisher.
rss = (b"<rss><channel><item>"
b"<title>Gold surges as Fed signals rate cuts - Reuters</title>"
b"<pubDate>" + _m19_pubdate() + b"</pubDate>"
b"<link>https://news.google.com/rss/articles/CBMiAbC?oc=5</link>"
b"<description>&lt;a href=\"https://x\"&gt;Gold &amp;amp; dollar "
b"diverge as the Fed turns dovish.&lt;/a&gt;</description>"
b"<source url=\"https://www.reuters.com\">Reuters</source>"
b"</item></channel></rss>")
class _Sess:
def get(self, url, timeout=None):
return type("R", (), {"status_code": 200, "content": rss})()
ne._session = _Sess()
try:
heads = ne._fetch_news_sentiment_raw()
assert len(heads) == 1, heads
h = heads[0]
assert h.link == "https://news.google.com/rss/articles/CBMiAbC?oc=5"
# tags stripped, entities (incl. double-encoded &amp;amp;) decoded
assert h.description == "Gold & dollar diverge as the Fed turns dovish."
assert h.publisher == "Reuters"
finally:
NewsEngine._source_health = saved
@test("NewsEngine: enrichment fields default None / reject unsafe values")
def _():
cfg = NewsConfig()
cfg.rss_feeds = ["https://feed.example/rss"]
ne = NewsEngine(cfg)
saved = NewsEngine._source_health
NewsEngine._source_health = {}
long_link = b"https://a.example/" + b"x" * 2100
stamp = b"<pubDate>" + _m19_pubdate() + b"</pubDate>"
rss = (b"<rss><channel>"
# bare item: no link/description/source at all
b"<item><title>Dollar steady before Fed minutes</title>" + stamp + b"</item>"
# javascript: link + description that merely echoes the title
b"<item><title>Gold slips on strong dollar data</title>" + stamp +
b"<link>javascript:alert(1)</link>"
b"<description>Gold slips on strong dollar data</description></item>"
# over-long link
b"<item><title>Fed officials split on rate cuts</title>" + stamp +
b"<link>" + long_link + b"</link></item>"
b"</channel></rss>")
class _Sess:
def get(self, url, timeout=None):
return type("R", (), {"status_code": 200, "content": rss})()
ne._session = _Sess()
try:
heads = ne._fetch_news_sentiment_raw()
assert len(heads) == 3, heads
by_title = {h.headline: h for h in heads}
bare = by_title["Dollar steady before Fed minutes"]
assert bare.link is None and bare.description is None \
and bare.publisher is None
js = by_title["Gold slips on strong dollar data"]
assert js.link is None # non-http(s) scheme rejected
assert js.description is None # identical to title → dropped
assert by_title["Fed officials split on rate cuts"].link is None
finally:
NewsEngine._source_health = saved
@test("NewsEngine: _clean_html_text strips/unescapes/truncates")
def _():
from news_engine import _clean_html_text, _safe_link
assert _clean_html_text(None) is None
assert _clean_html_text("") is None
assert _clean_html_text("<p></p>") is None
assert _clean_html_text("<p>Fed <b>holds</b>\n rates</p>") == "Fed holds rates"
assert _clean_html_text("&amp;lt;b&amp;gt;x&amp;lt;/b&amp;gt; &amp; y") == "x & y"
long = _clean_html_text("a" * 600)
assert len(long) == 501 and long.endswith("…")
assert _safe_link(" https://a.example/x ") == "https://a.example/x"
assert _safe_link("ftp://a.example/x") is None
assert _safe_link("javascript:alert(1)") is None
assert _safe_link("https://" + "x" * 2100) is None
assert _safe_link(None) is None
@test("/status renders news/calendar/AI health with ⚠️ degradations")
def _():
import telegram_bot as tb
saved = NewsEngine._source_health
t0 = time.time()
NewsEngine._source_health = {
"calendar": {"ok": t0, "err": None, "detail": "",
"extra": "42 events via MyFXBook"},
"calendar:forexfactory": {"ok": None, "err": t0,
"detail": "HTTP 503", "extra": ""},
"headlines": {"ok": t0, "err": None, "detail": "",
"extra": "38 headlines from 3/4 feeds"},
"rss:fxstreet.com": {"ok": None, "err": t0,
"detail": "timeout", "extra": ""},
}
stub = _StubLLM(None)
stub.last_ok = 0.0
stub.last_error = (t0, "http_429")
stub._count = 87
stub._daily_cap = 500
stub._count_day = _dt_m19.datetime.now(_dt_m19.timezone.utc).date()
lc._client = stub
try:
text = tb._status_text()
assert "⚠️ Calendar: 42 events via MyFXBook — ForexFactory" in text
assert "HTTP 503" in text
assert "38 headlines from 3/4 feeds" in text
assert "⚠️ fxstreet.com: timeout" in text
assert "⚠️ DeepSeek: last call failed (http_429" in text
# healthy path: FF serving, DeepSeek clean → no warnings
NewsEngine._source_health = {
"calendar": {"ok": t0, "err": None, "detail": "",
"extra": "42 events via ForexFactory"},
"headlines": {"ok": t0, "err": None, "detail": "",
"extra": "38 headlines from 4/4 feeds"},
}
stub.last_ok = t0
text = tb._status_text()
assert "✅ Calendar: 42 events via ForexFactory" in text
assert "✅ DeepSeek: 87/500 calls today" in text
assert "⚠️" not in text.split("*News & AI:*")[1].split("*Alert")[0]
# empty state (pre-first-scan) renders the neutral line
NewsEngine._source_health = {}
assert "no news cycle yet" in tb._status_text()
# yesterday's exhausted counter must NOT show as cap-exhausted
# after the UTC date roll (counter resets lazily on next call)
stub._count = 500
stub._count_day = stub._count_day - _dt_m19.timedelta(days=1)
text = tb._status_text()
assert "cap" not in text and "0/500" in text, text
finally:
NewsEngine._source_health = saved
lc._client = None
# ══════════════════════════════════════════════════════════════════════
# MODULE 20: dashboard_publisher.py (static web dashboard)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 20: dashboard_publisher.py ═══")
import tempfile
import dashboard_publisher as dpub
from engine import TechnicalAnalysis, IndicatorSignal
def make_fake_dashboard_report():
"""SignalReport with every serialization hazard: NaN value, inf key
level, weight-0 ATR, a poisoned '_secret' indicator, a dynamic
pandas attr, and a full TradeSetup."""
analysis = TechnicalAnalysis(
symbol="XAU/USD", timeframe="1h",
timestamp="2026-08-03 10:00:00+00:00")
names = ["ema_trend", "rsi", "macd", "bollinger", "adx",
"roc", "keltner", "divergence", "candlestick"]
for i, name in enumerate(names):
analysis.indicators[name] = IndicatorSignal(
name=name, value=float(i),
signal=SignalStrength.BUY if i % 2 == 0 else SignalStrength.SELL,
confidence=60.0, description=f"{name} desc", weight=1.0)
analysis.indicators["rsi"].value = float("nan")
analysis.indicators["atr"] = IndicatorSignal(
name="atr", value=12.5, signal=SignalStrength.NEUTRAL,
confidence=0.0, description="ATR context", weight=0.0)
analysis.indicators["_secret"] = IndicatorSignal(
name="_secret", value=1.0, signal=SignalStrength.NEUTRAL,
confidence=0.0, description="must not serialize", weight=1.0)
analysis._rsi_series = pd.Series([1.0, 2.0]) # dynamic attr, must not leak
analysis.key_levels = {"atr": 12.5, "support": 3300.0,
"broken": float("inf")}
analysis.composite_signal = SignalStrength.BUY
analysis.composite_score = 42.0
analysis.confidence = 61.5
analysis.pattern_signals = ["BULLISH_ENGULFING"]
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
timestamp="2026-08-03 10:00:00+00:00")
report.final_signal = SignalStrength.BUY
report.final_score = 55.0
report.final_confidence = 70.0
report.signal_quality = "B"
report.primary_signal = SignalStrength.BUY
report.primary_score = 50.0
report.primary_analysis = analysis
report.confirmation_analyses = [analysis]
report.mtf_signals = {"1h": SignalStrength.BUY,
"4h": SignalStrength.NEUTRAL}
report.mtf_scores = {"1h": 50.0, "4h": 10.0}
report.exhaustion_count = 1
report.current_price = 3350.0
report.trade_setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=3350.0, stop_loss=3340.0, take_profit_1=3360.0,
take_profit_2=3370.0, risk_reward=2.0, risk_pips=100.0,
reward_pips=200.0, pip_value=0.1, strategy="XAU_ATR",
trail_arm_r=1.0, trail_dist_r=0.85)
return report
@test("dashboard: report_to_webapp_dict round-trips allow_nan=False")
def _():
d = dpub.report_to_webapp_dict(make_fake_dashboard_report())
json.dumps(d, allow_nan=False) # raises if NaN/inf leaked
assert d["signal"] == "BUY" and d["primary_signal"] == "BUY"
assert d["mtf_scores"] == {"1h": 50.0, "4h": 10.0}
assert d["exhaustion_count"] == 1
assert d["trade_setup"]["strategy"] == "XAU_ATR"
pa = d["primary_analysis"]
assert pa["composite_signal"] == "BUY"
assert pa["indicators"]["rsi"]["value"] is None # NaN → None
assert pa["key_levels"]["broken"] is None # inf → None
assert len(d["confirmation_analyses"]) == 1
@test("dashboard: ATR is context not grid; _-keys excluded")
def _():
pa = dpub.report_to_webapp_dict(make_fake_dashboard_report())["primary_analysis"]
assert "atr" not in pa["indicators"]
assert "_secret" not in pa["indicators"]
assert "_rsi_series" not in pa
assert pa["atr_context"]["value"] == 12.5
assert len(pa["indicators"]) == 9
@test("dashboard: missing divergence and weekend report tolerated")
def _():
report = make_fake_dashboard_report()
del report.primary_analysis.indicators["divergence"]
pa = dpub.report_to_webapp_dict(report)["primary_analysis"]
assert len(pa["indicators"]) == 8 and "divergence" not in pa["indicators"]
weekend = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
timestamp="now") # primary_analysis=None
d = dpub.report_to_webapp_dict(weekend)
json.dumps(d, allow_nan=False)
assert d["primary_analysis"] is None
assert d["confirmation_analyses"] == []
@test("dashboard: due_timeframes uses RAW wake time")
def _():
def due(h, m):
wake = datetime(2026, 8, 3, h, m, 10, tzinfo=timezone.utc)
return [tf.value for tf in dpub.due_timeframes(wake)]
# The v1 regression: 12:00:10 must classify as the 12:00 boundary
# (all four due), not 11:55.
assert due(12, 0) == ["5min", "15min", "1h", "4h"]
assert due(13, 0) == ["5min", "15min", "1h"]
assert due(12, 15) == ["5min", "15min"]
assert due(12, 5) == ["5min"]
assert due(0, 0) == ["5min", "15min", "1h", "4h"]
@test("dashboard: build_snapshot_payload warming/error/next_close")
def _():
now = datetime(2026, 8, 3, 12, 2, 30, tzinfo=timezone.utc) # Monday
with patch("providers.base._is_forex_weekend", return_value=False):
payload = dpub.build_snapshot_payload({}, now)
assert payload["symbol"] == "XAU/USD"
assert payload["market_closed"] is False
assert set(payload["timeframes"]) == {"5min", "15min", "1h", "4h"}
for entry in payload["timeframes"].values():
assert entry["status"] == "warming" and entry["report"] is None
assert entry["next_close_epoch"] > payload["server_time_epoch"]
assert entry["next_scan_epoch"] == entry["next_close_epoch"] + 10
# M5 closes at 12:05 → 150 s away from 12:02:30.
assert abs(payload["timeframes"]["5min"]["next_close_epoch"]
- (now.timestamp() + 150)) < 1e-6
# An errored TF keeps its stale report + error badge.
stale = {"status": "error", "scanned_at": "earlier",
"error": "ValueError: boom", "error_at": "now",
"report": {"signal": "BUY"}}
with patch("providers.base._is_forex_weekend", return_value=True):
payload = dpub.build_snapshot_payload({"5min": stale}, now)
assert payload["market_closed"] is True
m5 = payload["timeframes"]["5min"]
assert m5["status"] == "error" and m5["report"] == {"signal": "BUY"}
assert m5["error"] == "ValueError: boom"
json.dumps(payload, allow_nan=False)
@test("dashboard: event_to_dict includes computed properties")
def _():
ev = EconomicEvent(
time=datetime(2026, 8, 7, 12, 30, tzinfo=timezone.utc),
currency="USD", impact="high", event="Non-Farm Payrolls",
forecast="180K", previous="147K", actual="228K")
d = dpub.event_to_dict(ev)
assert d["event"] == "Non-Farm Payrolls"
assert d["actual"] == "228K" # strings stay strings
assert d["surprise"] == "positive" # property, added explicitly
assert d["is_high_impact"] is True
assert isinstance(d["minutes_until"], float)
assert d["time"] == "2026-08-07T12:30:00+00:00"
json.dumps(d, allow_nan=False)
@test("dashboard: build_news_payload sorts headlines, survives NaN")
def _():
now = datetime(2026, 8, 3, 12, 0, tzinfo=timezone.utc)
old = NewsSentiment(
headline="Old story", source="http://feed/a",
timestamp=now - timedelta(hours=5),
currencies_mentioned=["USD"], sentiment_score=float("nan"),
keywords=[], sentiment_by_currency={})
new = NewsSentiment(
headline="Gold surges", source="http://feed/b",
timestamp=now - timedelta(minutes=10),
currencies_mentioned=["XAU"], sentiment_score=0.6,
keywords=["surge"], sentiment_by_currency={"XAU": 0.6})
ctx = FundamentalContext(
news_headlines=[old, new],
news_sentiment={"XAU": 0.6, "USD": -0.1},
llm_summary="Macro blurb.", calendar_available=True)
events = [EconomicEvent(time=now + timedelta(hours=3),
currency="USD", impact="medium", event="ISM")]
payload = dpub.build_news_payload(ctx, events, now)
json.dumps(payload, allow_nan=False)
assert payload["llm_summary"] == "Macro blurb."
assert [h["headline"] for h in payload["headlines"]] == \
["Gold surges", "Old story"] # newest first
assert payload["headlines"][1]["score"] is None # NaN → None
assert payload["events"][0]["event"] == "ISM"
assert payload["sentiment"] == {"XAU": 0.6, "USD": -0.1}
# Every headline carries the 40-hex id the PHP endpoint whitelists on.
import re as _re
assert all(_re.fullmatch(r"[0-9a-f]{40}", h["id"])
for h in payload["headlines"])
@test("dashboard: headline_to_dict id matches LLM cache scheme + enrichment")
def _():
import hashlib
h = NewsSentiment(
headline="Gold surges", source="http://feed/b",
timestamp=datetime(2026, 8, 7, 10, 0, tzinfo=timezone.utc),
currencies_mentioned=["XAU"], sentiment_score=0.6,
keywords=["surge"], sentiment_by_currency={"XAU": 0.6},
link="https://x.example/a", description="Body.", publisher="Reuters")
d = dpub.headline_to_dict(h)
# Same sha1(headline) scheme as NewsEngine._llm_sent_cache — the id is
# the join key news_summary.php validates against.
assert d["id"] == hashlib.sha1(b"Gold surges").hexdigest()
assert d["link"] == "https://x.example/a"
assert d["description"] == "Body."
assert d["publisher"] == "Reuters"
# Pre-enrichment NewsSentiment (defaults) still serializes cleanly.
bare = NewsSentiment(
headline="Old", source="s", timestamp=None,
currencies_mentioned=[], sentiment_score=0.0, keywords=[])
d2 = dpub.headline_to_dict(bare)
assert d2["link"] is None and d2["description"] is None \
and d2["publisher"] is None
json.dumps([d, d2], allow_nan=False)
@test("dashboard: install_static installs page + PHP endpoint atomically")
def _():
with tempfile.TemporaryDirectory() as tmp:
pub = dpub.DashboardPublisher(out_dir=tmp)
pub.install_static()
assert sorted(os.listdir(tmp)) == \
["dashboard.css", "dashboard.html", "dashboard.js", "news_summary.php"] # all installed, no .tmp
@test("dashboard: static UI uses CSS gauges and a coordinated refresh loop")
def _():
static_dir = os.path.join(os.path.dirname(__file__), "static")
with open(os.path.join(static_dir, "dashboard.html"), encoding="utf-8") as fh:
html = fh.read()
with open(os.path.join(static_dir, "dashboard.js"), encoding="utf-8") as fh:
js = fh.read()
assert "chart.js" not in html.lower()
assert "gauge.js" not in html
assert "setInterval(poll" not in js
assert "FALLBACK_POLL_MS" in js and "pollInFlight" in js
assert "data-select-tf" in js and "localStorage" in js
@test("dashboard: publish is atomic and leaves no temp file")
def _():
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "snapshot.json")
dpub.publish({"a": 1, "ok": True}, path)
with open(path, encoding="utf-8") as fh:
assert json.load(fh) == {"a": 1, "ok": True}
assert os.listdir(tmp) == ["snapshot.json"] # no .tmp left
@test("dashboard: run_cycle publishes technical snapshot before panels/news")
def _():
# News and advisory panels can take 20-30 s cold — fresh technical
# signals must reach the page first.
pub = dpub.DashboardPublisher(out_dir="/nonexistent")
order = []
pub.scan_tf = lambda tf: order.append(f"scan:{tf.value}")
pub.publish_snapshot = lambda: order.append("snapshot")
pub.attach_panels = lambda tfs: (order.append("panels") or True)
pub.refresh_news = lambda: order.append("news_refresh")
pub.publish_news = lambda: order.append("news_publish")
pub.run_cycle([Timeframe.M5])
assert order == ["scan:5min", "snapshot", "panels", "snapshot", "news_refresh",
"news_publish"], order
@test("dashboard: scan errors keep private exception detail out of snapshots")
def _():
pub = dpub.DashboardPublisher(out_dir="/nonexistent")
pub._provider = lambda: object()
with patch("dashboard_publisher.produce_signal",
side_effect=RuntimeError("provider token leaked")):
pub.scan_tf(Timeframe.M5)
entry = pub.snapshots["5min"]
assert entry["status"] == "error"
assert entry["error"] == "Scan unavailable"
assert "token" not in entry["error"]
@test("dashboard: seconds_until_boundary bounds and constants")
def _():
for interval in (5, 15, 60, 240):
s = dpub.seconds_until_boundary(interval, buffer=10)
assert 10 < s <= interval * 60 + 10, f"{interval}min → {s}"
# Confirmation map mirrors the bot's (kept local by design).
assert dpub.CONFIRMATIONS[Timeframe.M5] == [Timeframe.M15, Timeframe.H1]
assert dpub.CONFIRMATIONS[Timeframe.H4] == [Timeframe.H1, Timeframe.D1]
# ══════════════════════════════════════════════════════════════════════
# MODULE 21: 2026-08-04 audit-fix regressions (execution lifecycle)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 21: 2026-08-04 audit fixes — execution lifecycle ═══")
@test("GPU parity: ALL indicators + composite identical on every prefix")
def _():
# Closes the 2026-08-04 audit gap: the Module 11 parity test never
# compared ADX/MACD/ROC/Keltner/RSI outputs or the warmup band. This
# sweep diffs EVERY indicator signal/conf/value, the composite, and
# both regimes, on every analyzable prefix incl. a flat (zero-DM,
# zero-TR) run that exercises the divisor edge cases.
import gpu_backtest as _gb
from engine import TechnicalEngine as _LiveEng
rng = np.random.default_rng(11)
n = 160
steps = rng.normal(0.3, 2.0, n)
close = 2300 + np.cumsum(steps)
high = close + np.abs(rng.normal(1.0, 0.4, n))
low = close - np.abs(rng.normal(1.0, 0.4, n))
open_ = np.roll(close, 1); open_[0] = close[0]
p = close[79]
for arr in (close, high, low, open_):
arr[80:84] = p # flat run: H=L=O=C
idx = pd.date_range("2026-01-05", periods=n, freq="15min", tz="UTC")
deck = pd.DataFrame({"Open": open_, "High": high, "Low": low,
"Close": close, "Volume": 1.0}, index=idx)
tech = TechnicalConfig()
caches = {"15min": _gb.precompute_indicators(deck, tech)}
live_eng = _LiveEng(tech)
gpu_eng = _gb.GPUTechnicalEngine(tech, caches)
for pos in range(30, n, 1):
sl = deck.iloc[:pos + 1]
ra = live_eng.analyze(sl, "XAU_USD", "15min")
rb = gpu_eng.analyze(sl, "XAU_USD", "15min")
assert set(ra.indicators) == set(rb.indicators), \
f"pos {pos}: indicator sets differ"
for name, a in ra.indicators.items():
b = rb.indicators[name]
assert str(a.signal) == str(b.signal), \
f"pos {pos} {name}: {a.signal} vs {b.signal}"
assert abs(a.confidence - b.confidence) < 1e-6, \
f"pos {pos} {name} conf: {a.confidence} vs {b.confidence}"
assert abs(ra.composite_score - rb.composite_score) < 1e-6, \
f"pos {pos} composite: {ra.composite_score} vs {rb.composite_score}"
assert ra.market_regime == rb.market_regime, f"pos {pos} regime"
assert ra.volatility_regime == rb.volatility_regime, f"pos {pos} vol"
assert gpu_eng.fallback_count == 0, "sweep must never miss the cache"
@test("factory: M5 per-TF hour delta stays XAU-side (pair isolation)")
def _():
# 2026-08-04 audit E18 verdict: the xau_strategy/backtest hour lists
# SHOULD diverge after the M5 factory — the backtest copy is the
# generic non-XAU fallback and must never inherit gold-validated
# deltas. All XAU consumers branch to the xau_strategy copy
# (signal_aggregator.py session gates, gpu_backtest outer filter).
from pair_signals.xauusd import make_config as _mk
cfg = _mk(Timeframe.M5)
assert 9 in cfg.xau_strategy.blocked_hours_utc
assert 21 not in cfg.xau_strategy.blocked_hours_utc
assert 9 not in cfg.backtest.blocked_hours_utc, \
"gold-validated hour-9 delta must NOT leak into the generic copy"
assert 21 in cfg.backtest.blocked_hours_utc
@test("factory: shipped M5 entry-path state is locked (PB off, CP BUY-only)")
def _():
# 2026-08-04 audit: the CP path bypassing exhaustion/M5-score gates is
# VALIDATED behavior (226/586 trades, +38.9R in the pinned 12mo book).
# This test pins the shipped state so any flip is a conscious,
# revalidated decision — not an accident.
from pair_signals.xauusd import make_config as _mk
m5 = _mk(Timeframe.M5)
assert m5.xau_strategy.m5_pullback_enabled is False
assert m5.xau_strategy.cp_enabled is True
assert m5.xau_strategy.cp_patterns == ["inv_hs", "double_bottom", "bull_flag"]
assert m5.technical.ema_partial_neutral is True
for tf in (Timeframe.M15, Timeframe.H1, Timeframe.H4):
cfg = _mk(tf)
assert cfg.xau_strategy.cp_enabled is False, f"{tf}: CP must stay off"
assert cfg.technical.ema_partial_neutral is False
@test("factory: M5 SELL-in-transitioning block + H1 all-SELL block (2026-09-03)")
def _():
# 2026-09-03 signal-log campaign: M5 SELLs in a "transitioning"
# regime and H1 shorts generally were structural losers at $0.65
# real cost — see xauusd.py docstring for the full evidence.
from pair_signals.xauusd import make_config as _mk
m5 = _mk(Timeframe.M5)
assert m5.xau_strategy.sell_blocked_regimes == ["transitioning"]
h1 = _mk(Timeframe.H1)
assert h1.xau_strategy.sell_min_quality == [], \
"H1 must block every SELL (empty allow-list, gate 6e)"
for tf in (Timeframe.M15, Timeframe.H4):
cfg = _mk(tf)
assert cfg.xau_strategy.sell_blocked_regimes == [], \
f"{tf}: sell_blocked_regimes must stay at the default off-M5"
assert cfg.xau_strategy.sell_min_quality == ["A+", "A", "B"], \
f"{tf}: sell_min_quality must stay at the default off-H1"
@test("GPU parity: cache miss falls back LOUDLY (counter + warning)")
def _():
import gpu_backtest as _gb
tech = TechnicalConfig()
idx = pd.date_range("2026-01-05", periods=60, freq="15min", tz="UTC")
rng = np.random.default_rng(3)
close = 2300 + np.cumsum(rng.normal(0, 1.5, 60))
deck = pd.DataFrame({"Open": close, "High": close + 1, "Low": close - 1,
"Close": close, "Volume": 1.0}, index=idx)
caches = {"15min": _gb.precompute_indicators(deck, tech)}
gpu_eng = _gb.GPUTechnicalEngine(tech, caches)
alien = deck.copy()
alien.index = alien.index + pd.Timedelta(minutes=7) # not in cache
import logging as _lg
records = []
h = _lg.Handler(); h.emit = lambda rec: records.append(rec)
_gb.logger.addHandler(h)
old_level = _gb.logger.level
_gb.logger.setLevel(_lg.WARNING)
try:
gpu_eng.analyze(alien, "XAU_USD", "15min")
finally:
_gb.logger.removeHandler(h)
_gb.logger.setLevel(old_level)
assert gpu_eng.fallback_count > 0, "cache miss must increment counter"
assert any("cache miss" in rec.getMessage() for rec in records), \
"first cache miss must emit a WARNING"
def _m21_stub_provider(bars=320, tf=Timeframe.H1):
"""Stub provider whose _fetch_raw serves a deterministic frame and
counts invocations (2026-08-04 audit, Batch E)."""
import providers.base as pbase
class _Stub(pbase.BaseDataProvider):
PROVIDER = "stub21"
calls = []
def _fetch_raw(self, symbol, timeframe, outputsize,
start=None, end=None):
self.calls.append(outputsize)
time_mod.sleep(0.05)
end_ts = pd.Timestamp.now(tz="UTC").floor("1h") - pd.Timedelta(hours=1)
idx = pd.date_range(end=end_ts, periods=min(outputsize, bars),
freq="1h", tz="UTC")
base = 2300 + np.cumsum(np.ones(len(idx)) * 0.1)
return pd.DataFrame(
{"Open": base, "High": base + 1, "Low": base - 1,
"Close": base, "Volume": 1.0},
index=pd.DatetimeIndex(idx, name="Datetime"))
def get_pair_info(self, symbol):
return PAIR_INFO[XAUUSD]
return _Stub(PlatformConfig())
import time as time_mod
@test("provider cache: longer-lookback request is NOT served the short frame")
def _():
from unittest.mock import patch
with patch("providers.base._is_forex_weekend", lambda now: False):
p = _m21_stub_provider()
p.fetch_ohlcv(XAUUSD, Timeframe.H1, lookback_days=1)
assert len(p.calls) == 1 and p.calls[0] == 300
p.fetch_ohlcv(XAUUSD, Timeframe.H1, lookback_days=60)
assert len(p.calls) == 2, \
"a longer-lookback call within TTL must refetch, not serve the " \
"short cached frame"
assert p.calls[1] == 1460
@test("provider cache: concurrent same-key fetches are single-flight")
def _():
from unittest.mock import patch
import threading as _t
with patch("providers.base._is_forex_weekend", lambda now: False):
p = _m21_stub_provider()
barrier = _t.Barrier(4)
results = []
def worker():
barrier.wait()
results.append(p.fetch_ohlcv(XAUUSD, Timeframe.H1))
threads = [_t.Thread(target=worker) for _ in range(4)]
for th in threads: th.start()
for th in threads: th.join()
assert len(results) == 4
assert len(p.calls) == 1, \
f"4 concurrent fetches must produce 1 API call, got {len(p.calls)}"
@test("provider: multi-bar forming/future tail is fully dropped")
def _():
p = _m21_stub_provider()
now = pd.Timestamp.now(tz="UTC")
idx = pd.DatetimeIndex(
[now - pd.Timedelta(hours=3), now - pd.Timedelta(hours=2),
now - pd.Timedelta(minutes=30), now + pd.Timedelta(minutes=30)],
name="Datetime")
df = pd.DataFrame({"Open": 1.0, "High": 2.0, "Low": 0.5, "Close": 1.5,
"Volume": 1.0}, index=idx)
out = p._drop_forming_candle(df, Timeframe.H1)
assert len(out) == 2, \
f"both not-yet-closed bars must be dropped, kept {len(out)}"
@test("provider fetch_range: 3 consecutive chunk ERRORS raise, no silent truncation")
def _():
import providers.base as pbase
class _Err(pbase.BaseDataProvider):
PROVIDER = "err21"
def _fetch_raw(self, symbol, timeframe, outputsize,
start=None, end=None):
raise self.DATA_ERROR("HTTP 429 simulated")
def get_pair_info(self, symbol):
return PAIR_INFO[XAUUSD]
p = _Err(PlatformConfig())
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
end = _dt.now(_tz.utc)
try:
# M5 → ~15-day chunks, so a 90-day window would walk ≥3 chunks.
p.fetch_range(XAUUSD, Timeframe.M5, end - _td(days=90), end)
assert False, "must raise after repeated chunk failures"
except pbase.DataIntegrityError as e:
assert "truncated" in str(e) or "consecutive" in str(e)
@test("provider cleaning: non-positive bar dropped, 10-sigma filter stays armed")
def _():
p = _m21_stub_provider()
n = 260
idx = pd.date_range("2026-01-05", periods=n, freq="1h", tz="UTC")
close = 2300 + np.cumsum(np.random.default_rng(5).normal(0, 1.0, n))
df = pd.DataFrame({"Open": close, "High": close + 1, "Low": close - 1,
"Close": close, "Volume": 1.0}, index=idx)
df.iloc[100, df.columns.get_loc("Close")] = 0.0 # corrupt mid-frame bar
df.iloc[200, df.columns.get_loc("Close")] = close[200] * 1.5 # true spike
out = p._validate_and_clean(df, XAUUSD, Timeframe.H1)
assert (out[["Open", "High", "Low", "Close"]] > 0).all().all(), \
"non-positive bar must be dropped"
assert idx[200] not in out.index, \
"the 10-sigma filter must survive the corrupt bar and still " \
"remove the genuine spike (inf-return kill-switch fixed)"
assert out["Low"].min() > 1000, "no zero may leak into Low via repair"
@test("oanda: malformed candle (missing OHLC field) is skipped, not zeroed")
def _():
from providers.oanda import OandaDataProvider
prov = OandaDataProvider.__new__(OandaDataProvider)
prov._spread_cache = {}
good = {"complete": True, "time": "2026-08-04T10:00:00Z", "volume": 10,
"mid": {"o": "3000.0", "h": "3001.0", "l": "2999.0", "c": "3000.5"}}
bad = {"complete": True, "time": "2026-08-04T11:00:00Z", "volume": 10,
"mid": {"o": "3000.5", "h": "3001.5", "c": "3000.9"}} # no "l"
good2 = {"complete": True, "time": "2026-08-04T12:00:00Z", "volume": 10,
"mid": {"o": "3000.9", "h": "3002.0", "l": "3000.0", "c": "3001.4"}}
class _Resp:
status_code = 200
text = ""
def json(self):
return {"candles": [good, bad, good2]}
class _Sess:
def get(self, url, params=None, timeout=None):
return _Resp()
prov._session = _Sess()
prov._base_url = "http://x"
df = prov._fetch_oanda_candles(XAUUSD, Timeframe.H1, 10)
assert len(df) == 2, "malformed candle must be skipped"
assert (df[["Open", "High", "Low", "Close"]] > 0).all().all(), \
"no 0.0-defaulted prices may appear"
@test("backtester: NaN bar inside a trailing window cannot poison R/metrics")
def _():
import gpu_backtest as _gb
t = _gb.Trade(pair="XAU/USD",
entry_time=pd.Timestamp("2026-01-05 10:00", tz="UTC"),
entry_price=2400.0, sl=2384.0, tp1=2416.0, tp2=2432.0,
direction="BUY", pip_value=0.1, risk_pips=160.0,
reward_pips=160.0, rr_planned=1.0, quality="B",
final_score=30.0, final_confidence=60.0,
market_regime="trending_up")
idx = pd.date_range("2026-01-05 10:00", periods=10, freq="15min", tz="UTC")
o = np.full(10, 2401.0); h = np.full(10, 2402.0)
l = np.full(10, 2399.0); c = np.full(10, 2401.5)
for arr in (o, h, l, c):
arr[3] = np.nan # data gap mid-window
c[9] = np.nan
sim = pd.DataFrame({"Open": o, "High": h, "Low": l, "Close": c,
"Volume": 1.0}, index=idx)
_gb.simulate_trades([t], sim, max_bars=10, spread=0.30,
trail_arm_r=0.7, trail_dist_r=0.75)
assert t.exit_reason == "TIMEOUT" and t.bars_held == 3, \
f"must exit at last valid bar pre-gap, got {t.exit_reason}/{t.bars_held}"
assert np.isfinite(t.r_multiple), "NaN R must never be emitted"
m = _gb.compute_metrics([t])
assert np.isfinite(m.total_r) and np.isfinite(m.avg_r)
@test("backtester: compute_metrics excludes a non-finite R loudly, not NaN-ly")
def _():
import gpu_backtest as _gb
def mk(r):
t = _gb.Trade(pair="XAU/USD",
entry_time=pd.Timestamp("2026-01-05 10:00", tz="UTC"),
entry_price=2400.0, sl=2384.0, tp1=2416.0, tp2=2432.0,
direction="BUY", pip_value=0.1, risk_pips=160.0,
reward_pips=160.0, rr_planned=1.0, quality="B",
final_score=30.0, final_confidence=60.0,
market_regime="trending_up")
t.exit_reason = "SL"; t.bars_held = 5; t.r_multiple = r
return t
m = _gb.compute_metrics([mk(1.0), mk(float("nan")), mk(-1.0)])
assert m.n_trades == 2, "non-finite R trade must be excluded"
assert np.isfinite(m.total_r) and abs(m.total_r) < 1e-9
# ── Batch C: telegram_bot delivery-path fixes (2026-08-04 audit) ─────
# Regression kernels from the H33–H38 verification harness. All hermetic:
# fake bots, fake streaming providers, patched run_signal — no network.
import asyncio as _aio21
import telegram_bot as _tb21
from telegram.error import BadRequest as _BadRequest21
def _m21c_report(tf="15min", quality="A", levels=None,
entry=3300.0, sl=3290.0):
from engine import TechnicalAnalysis
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
r.final_signal = SignalStrength.BUY
r.signal_quality = quality
r.final_score = 30.0
r.final_confidence = 60.0
r.market_regime = "trending_up"
r.volatility_regime = "normal"
r.current_price = entry
r.primary_analysis = TechnicalAnalysis(
symbol="XAU/USD", timeframe=tf, timestamp="t",
key_levels=dict(levels) if levels else {},
)
r.trade_setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=entry, stop_loss=sl, take_profit_1=entry + 15,
take_profit_2=entry + 25, risk_reward=1.5, risk_pips=100.0,
reward_pips=150.0, pip_value=0.10,
trail_arm_r=0.7, trail_dist_r=0.85,
)
return r
class _M21cBot:
"""send_message stub: optionally raise (exc / md_exc for Markdown
attempts only) or block on an asyncio.Event before returning."""
def __init__(self, exc=None, md_exc=None, gate=None):
self.exc = exc
self.md_exc = md_exc
self.gate = gate
self.attempts = []
self.sent = []
async def send_message(self, chat_id, text, parse_mode=None, **kw):
self.attempts.append(parse_mode)
if self.gate is not None:
await self.gate.wait()
if self.exc is not None:
raise self.exc
if self.md_exc is not None and parse_mode is not None:
raise self.md_exc
self.sent.append(text)
def _m21c_reset():
_tb21._scan_cooldowns.clear()
_tb21._pending_breach.clear()
_tb21._alert_locks.clear()
_tb21._last_alert_fp.clear()
_tb21._last_alert_ts.clear()
_tb21._last_alert_setup.clear()
_tb21._last_alert_quality.clear()
_tb21._last_scan.clear()
_tb21._level_cache.clear()
_tb21._level_cache_ts.clear()
_tb21._last_prices.clear()
_tb21._last_price_ts.clear()
_tb21._autoscan_chats.discard(911)
@test("H33a: failed breach rescan shrinks cooldown to the retry window")
def _():
async def main():
_m21c_reset()
_tb21._autoscan_chats.add(911)
calls = {"n": 0}
async def failing(*a, **k):
calls["n"] += 1
raise RuntimeError("simulated rescan failure")
good = _m21c_report()
async def working(*a, **k):
calls["n"] += 1
return good
bot = _M21cBot()
with patch.object(_tb21, "_WS_COOLDOWN", 0.4), \
patch.object(_tb21, "_BREACH_RETRY_SECONDS", 0.05):
with patch.object(_tb21, "run_signal", failing):
await _tb21._handle_breach(bot, "XAU/USD", 3301.0, "swing_high_1")
assert calls["n"] == 1
remaining = _tb21._WS_COOLDOWN - (
time.time() - _tb21._scan_cooldowns["XAU/USD"])
assert remaining <= _tb21._BREACH_RETRY_SECONDS + 0.02, \
f"cooldown must be rolled back to the retry window, " \
f"got {remaining:.2f}s remaining"
assert "XAU/USD" in _tb21._pending_breach, \
"failed rescan must re-park the breach for retry"
await _aio21.sleep(0.07) # retry window elapses
with patch.object(_tb21, "run_signal", working):
await _tb21._handle_breach(bot, "XAU/USD", 3301.0, "swing_high_1")
assert calls["n"] == 2, "post-retry-window breach must rescan"
assert len(bot.sent) == 1, "and the alert must go out"
_m21c_reset()
_aio21.run(main())
@test("H33c: breach reuses this bar's M15 scan; a new bar rescans once")
def _():
async def main():
_m21c_reset()
_tb21._autoscan_chats.add(911)
calls = {"n": 0}
good = _m21c_report()
async def working(*a, **k):
calls["n"] += 1
_tb21._remember_scan("XAU/USD", Timeframe.M15, good)
return good
bot = _M21cBot()
with patch.object(_tb21, "_WS_COOLDOWN", 0.0), \
patch.object(_tb21, "run_signal", working):
# boundary loop already scanned this bar
_tb21._remember_scan("XAU/USD", Timeframe.M15, good)
await _tb21._handle_breach(bot, "XAU/USD", 3301.0, "swing_high_1")
assert calls["n"] == 0, "same bar → the cached report is reused"
assert len(bot.sent) == 1, "reuse still alerts (dedup applies)"
# pretend the cache is from the previous bar
key = "XAU/USD_15min"
epoch, rep = _tb21._last_scan[key]
_tb21._last_scan[key] = (epoch - 900, rep)
await _tb21._handle_breach(bot, "XAU/USD", 3305.0, "swing_high_1")
assert calls["n"] == 1, "new bar → exactly one rescan"
await _tb21._handle_breach(bot, "XAU/USD", 3306.0, "swing_high_1")
assert calls["n"] == 1, "second breach in the new bar reuses it"
_m21c_reset()
_aio21.run(main())
@test("H33b: breach during cooldown is parked and fired after expiry")
def _():
async def main():
_m21c_reset()
_tb21._autoscan_chats.add(911)
_tb21._update_level_cache(
[_m21c_report(levels={"swing_high_1": 3300.0})])
_tb21._scan_cooldowns["XAU/USD"] = time.time() # cooldown active
rescans = {"n": 0}
async def working(*a, **k):
rescans["n"] += 1
return _m21c_report()
bot = _M21cBot()
mid_state = {}
connects = {"n": 0}
class FakeDP:
name = "fake"
supports_streaming = True
_api_key = "k"
async def iter_ticks(self, symbols, on_connect=None):
# No asserts in here: an in-generator failure would be
# swallowed by the monitor's reconnect loop. Record state,
# assert after; the connect counter is a safety valve.
connects["n"] += 1
if connects["n"] > 1:
raise _aio21.CancelledError
yield "XAU/USD", 3299.0 # below level
yield "XAU/USD", 3301.0 # CROSS — inside cooldown → parked
await _aio21.sleep(0) # let the breach task park it
mid_state["rescans"] = rescans["n"]
mid_state["parked"] = "XAU/USD" in _tb21._pending_breach
await _aio21.sleep(0.1) # cooldown (0.08s) expires
yield "XAU/USD", 3302.0 # no crossing — pending must fire
await _aio21.sleep(0.05) # let the fired task run
raise _aio21.CancelledError
with patch.object(_tb21, "_WS_COOLDOWN", 0.08), \
patch.object(_tb21, "run_signal", working), \
patch.object(_tb21, "_get_streaming_dp", lambda: FakeDP()):
await _tb21._ws_monitor(bot)
if _tb21._breach_tasks:
await _aio21.gather(*_tb21._breach_tasks,
return_exceptions=True)
assert connects["n"] == 1, "monitor must stop on the first pass"
assert mid_state == {"rescans": 0, "parked": True}, \
f"cross inside cooldown must park, not rescan: {mid_state}"
assert rescans["n"] == 1, "parked breach must fire after expiry"
assert len(bot.sent) == 1
assert "3301" in bot.sent[0], \
"alert must carry the original crossing price"
assert "XAU/USD" not in _tb21._pending_breach, "pending consumed"
_m21c_reset()
_aio21.run(main())
@test("H34: level cache is per-timeframe — M5 scan no longer wipes H1 levels")
def _():
_m21c_reset()
_tb21._update_level_cache([_m21c_report(
tf="1h", levels={"swing_high_1": 3350.0, "ema_trend": 3310.0})])
_tb21._update_level_cache([_m21c_report(
tf="5min", levels={"swing_high_1": 3311.0})])
cache = _tb21._level_cache["XAU/USD"]
assert set(cache) == {"1h", "5min"}, f"both TF slots must exist: {cache}"
assert cache["1h"]["swing_high_1"] == 3350.0, \
"M5 write must not touch the H1 slot"
# Breach check scans the union: crossing the H1-only level fires
# with a TF-qualified name.
_tb21._last_prices["XAU/USD"] = 3349.0
assert _tb21._check_level_breach("XAU/USD", 3351.0) == "1h swing_high_1"
# A second M5 scan replaces only the M5 slot.
_tb21._update_level_cache([_m21c_report(
tf="5min", levels={"swing_low_1": 3290.0})])
assert _tb21._level_cache["XAU/USD"]["5min"] == {"swing_low_1": 3290.0}
assert _tb21._level_cache["XAU/USD"]["1h"]["swing_high_1"] == 3350.0
_m21c_reset()
@test("H35: Markdown parse failure falls back to plain text, then commits")
def _():
async def main():
_m21c_reset()
_tb21._autoscan_chats.add(911)
rep = _m21c_report()
async def rs(*a, **k):
return rep
key = f"XAU/USD_{Timeframe.M15.value}"
with patch.object(_tb21, "run_signal", rs):
# Markdown attempt fails → plain-text retry succeeds → commit.
bot = _M21cBot(md_exc=_BadRequest21("Can't parse entities"))
await _tb21._scan_timeframe(
bot, Timeframe.M15, [Timeframe.M5, Timeframe.H1])
assert bot.attempts == [_tb21.ParseMode.MARKDOWN, None], \
f"expected md attempt then plain retry, got {bot.attempts}"
assert len(bot.sent) == 1
assert key in _tb21._last_alert_fp, \
"plain-text delivery must commit the fingerprint"
# Both attempts fail → NOT committed (commit-after-send holds).
_m21c_reset()
_tb21._autoscan_chats.add(911)
bot2 = _M21cBot(exc=_BadRequest21("Can't parse entities"))
await _tb21._scan_timeframe(
bot2, Timeframe.M15, [Timeframe.M5, Timeframe.H1])
assert key not in _tb21._last_alert_fp, \
"a fully failed send must stay eligible for the next scan"
# Breach path uses the same fallback helper.
_m21c_reset()
_tb21._autoscan_chats.add(911)
bot3 = _M21cBot(md_exc=_BadRequest21("Can't parse entities"))
await _tb21._handle_breach(bot3, "XAU/USD", 3301.0, "swing_high_1")
assert len(bot3.sent) == 1 and key in _tb21._last_alert_fp
_m21c_reset()
_aio21.run(main())
@test("H36: concurrent scan + breach on one key produce a single alert")
def _():
async def main():
_m21c_reset()
_tb21._autoscan_chats.add(911)
rep = _m21c_report()
async def rs(*a, **k):
return rep
gate = _aio21.Event()
bot = _M21cBot(gate=gate)
with patch.object(_tb21, "run_signal", rs):
t1 = _aio21.create_task(_tb21._scan_timeframe(
bot, Timeframe.M15, [Timeframe.M5, Timeframe.H1]))
await _aio21.sleep(0.02) # scan holds the key lock, blocked in send
t2 = _aio21.create_task(_tb21._handle_breach(
bot, "XAU/USD", 3301.0, "swing_high_1"))
await _aio21.sleep(0.02) # breach is queued on the lock
assert len(bot.attempts) == 1, \
"breach must wait on the alert lock, not race into send"
gate.set()
await _aio21.gather(t1, t2)
assert len(bot.sent) == 1, \
f"same fingerprint must alert exactly once, got {len(bot.sent)}"
_m21c_reset()
_aio21.run(main())
@test("H37: _start_scheduler respawns only the dead TF loop")
def _():
async def main():
saved_tasks = list(_tb21._autoscan_tasks)
saved_ws = _tb21._ws_task
_tb21._autoscan_tasks.clear()
_tb21._ws_task = None
async def fake_loop(bot, tf, interval):
await _aio21.sleep(30)
class NoStream:
name = "stub"
supports_streaming = False
try:
with patch.object(_tb21, "_AUTOSCAN_LOOPS",
[(Timeframe.M5, 5), (Timeframe.M15, 15)]), \
patch.object(_tb21, "_candle_loop", fake_loop), \
patch.object(_tb21, "_get_streaming_dp", lambda: NoStream()):
_tb21._start_scheduler(None)
assert len(_tb21._autoscan_tasks) == 2
m5 = next(t for t in _tb21._autoscan_tasks
if t.get_name() == "candle_loop_M5")
m15 = next(t for t in _tb21._autoscan_tasks
if t.get_name() == "candle_loop_M15")
m5.cancel()
await _aio21.sleep(0.01) # let the cancellation land
assert m5.done() and not m15.done()
_tb21._start_scheduler(None)
names = sorted(t.get_name() for t in _tb21._autoscan_tasks)
assert names == ["candle_loop_M15", "candle_loop_M5"], names
assert m15 in _tb21._autoscan_tasks, \
"the live loop must be kept, not restarted"
assert m5 not in _tb21._autoscan_tasks, \
"the dead loop must be replaced by a fresh task"
finally:
for t in _tb21._autoscan_tasks:
t.cancel()
await _aio21.gather(*_tb21._autoscan_tasks,
return_exceptions=True)
if _tb21._ws_task is not None:
_tb21._ws_task.cancel()
await _aio21.gather(_tb21._ws_task, return_exceptions=True)
_tb21._autoscan_tasks[:] = saved_tasks
_tb21._ws_task = saved_ws
_aio21.run(main())
@test("H38a: clean WS close backs off; backoff resets only after ticks")
def _():
async def main():
_m21c_reset()
_tb21._autoscan_chats.add(911)
sleeps = []
real_asyncio = _aio21
class AsyncioProxy:
def __getattr__(self, name):
return getattr(real_asyncio, name)
@staticmethod
async def sleep(t):
sleeps.append(t)
await real_asyncio.sleep(0)
def make_dp(tick_per_conn):
connects = {"n": 0}
class FakeDP:
name = "fake"
supports_streaming = True
_api_key = "k"
async def iter_ticks(self, symbols, on_connect=None):
connects["n"] += 1
if connects["n"] >= 3:
raise real_asyncio.CancelledError
if tick_per_conn:
yield "XAU/USD", 3300.0
return # clean close, no exception
return FakeDP()
try:
# No ticks: backoff must GROW across clean closes (1, then 2).
with patch.object(_tb21, "_get_streaming_dp",
lambda: make_dp(False)):
_tb21.asyncio = AsyncioProxy()
await _tb21._ws_monitor(_M21cBot())
assert sleeps == [1, 2], \
f"clean closes without data must back off, got {sleeps}"
# One tick per connection: backoff resets, sleeps stay at 1.
sleeps.clear()
with patch.object(_tb21, "_get_streaming_dp",
lambda: make_dp(True)):
await _tb21._ws_monitor(_M21cBot())
assert sleeps == [1, 1], \
f"a connection that produced ticks must reset backoff, " \
f"got {sleeps}"
finally:
_tb21.asyncio = real_asyncio
_m21c_reset()
_aio21.run(main())
@test("H38b: TD iter_ticks fires on_connect on first RECEIVED frame only")
def _():
import types as _types
class FakeWS:
def __init__(self, messages):
self.messages = list(messages)
self.sent = []
async def send(self, m):
self.sent.append(m)
def __aiter__(self):
return self
async def __anext__(self):
if self.messages:
return self.messages.pop(0)
raise StopAsyncIteration
class CM:
def __init__(self, ws):
self.ws = ws
async def __aenter__(self):
return self.ws
async def __aexit__(self, *a):
return False
async def main():
from providers.twelve_data import TwelveDataProvider
fake_mod = _types.ModuleType("websockets")
saved = sys.modules.get("websockets")
sys.modules["websockets"] = fake_mod
try:
td = TwelveDataProvider(PlatformConfig())
# Server sends NOTHING (rejected key / silent close): the
# subscribe frame goes out but on_connect must NOT fire.
ws1 = FakeWS([])
fake_mod.connect = lambda url, **kw: CM(ws1)
fired = []
async for _ in td.iter_ticks(["XAU/USD"],
on_connect=lambda: fired.append(1)):
pass
await _aio21.sleep(0) # let the heartbeat task cancel cleanly
assert len(ws1.sent) == 1, "subscribe frame must have been sent"
assert not fired, \
"on_connect must not fire before any server frame (H38b)"
# First received frame (even a non-price one) → fires.
ws2 = FakeWS([json.dumps({"event": "subscribe-status",
"status": "ok"})])
fake_mod.connect = lambda url, **kw: CM(ws2)
async for _ in td.iter_ticks(["XAU/USD"],
on_connect=lambda: fired.append(1)):
pass
await _aio21.sleep(0)
assert fired == [1], "on_connect must fire on first received frame"
finally:
if saved is not None:
sys.modules["websockets"] = saved
else:
sys.modules.pop("websockets", None)
_aio21.run(main())
@test("scan-failure log carries the real traceback (not 'NoneType: None')")
def _():
async def main():
_m21c_reset()
async def rs(*a, **k):
raise RuntimeError("m21-traceback-marker")
import logging as _lg
records = []
h = _lg.Handler()
h.emit = lambda rec: records.append(rec)
_tb21.logger.addHandler(h)
try:
with patch.object(_tb21, "run_signal", rs):
await _tb21._scan_timeframe(_M21cBot(), Timeframe.M15, [])
finally:
_tb21.logger.removeHandler(h)
fails = [r for r in records if "Auto-scan failed" in r.getMessage()]
assert fails, "the per-pair failure must be logged"
text = _lg.Formatter("%(message)s").format(fails[0])
assert "NoneType: None" not in text
assert "m21-traceback-marker" in text and "RuntimeError" in text, \
f"log must include the real traceback, got: {text[-160:]!r}"
_m21c_reset()
_aio21.run(main())
def _m21_sim_trade(direction="BUY", entry=2400.0, sl=2390.0, tp1=2410.0,
ts="2026-01-05 10:00"):
import gpu_backtest as _gb
return _gb.Trade(pair="XAU/USD", entry_time=pd.Timestamp(ts, tz="UTC"),
entry_price=entry, sl=sl, tp1=tp1, tp2=tp1 + 10,
direction=direction, pip_value=0.1, risk_pips=100.0,
reward_pips=100.0, rr_planned=1.0, quality="B",
final_score=30.0, final_confidence=60.0,
market_regime="trending_up")
def _m21_sim_df(bars, ts="2026-01-05 10:00"):
idx = pd.date_range(ts, periods=len(bars), freq="15min", tz="UTC")
return pd.DataFrame(
{"Open": [b[0] for b in bars], "High": [b[1] for b in bars],
"Low": [b[2] for b in bars], "Close": [b[3] for b in bars],
"Volume": 1.0}, index=idx)
@test("backtester: same-bar SL+TP tie resolves to SL (static path)")
def _():
import gpu_backtest as _gb
t = _m21_sim_trade()
sim = _m21_sim_df([(2400, 2411, 2389, 2400)]) # both barriers inside bar 0
_gb.simulate_trades([t], sim, max_bars=5, spread=0.0, trail_arm_r=0.0)
assert t.exit_reason == "SL", f"tie must be SL-first, got {t.exit_reason}"
assert abs(t.exit_price - 2390.0) < 1e-9
assert abs(t.r_multiple - (-1.0)) < 1e-9
@test("backtester: gap through SL fills at the open, not the stop")
def _():
import gpu_backtest as _gb
t = _m21_sim_trade()
sim = _m21_sim_df([(2385, 2388, 2384, 2386)]) # opens below the 2390 stop
_gb.simulate_trades([t], sim, max_bars=5, spread=0.0, trail_arm_r=0.0)
assert t.exit_reason == "SL"
assert abs(t.exit_price - 2385.0) < 1e-9, "gap must fill at the open"
assert t.r_multiple < -1.0, "gap loss must exceed 1R"
@test("backtester: TIMEOUT closes at the last window bar's close, spread charged")
def _():
import gpu_backtest as _gb
t = _m21_sim_trade()
sim = _m21_sim_df([(2400, 2405, 2395, 2402)] * 3) # never hits either side
_gb.simulate_trades([t], sim, max_bars=3, spread=0.30, trail_arm_r=0.0)
assert t.exit_reason == "TIMEOUT" and t.bars_held == 3
assert abs(t.exit_price - 2402.0) < 1e-9
expected_r = (2402.0 - 2400.0 - 0.30) / 10.0
assert abs(t.r_multiple - expected_r) < 1e-9, \
"R must be (pnl - round-trip spread) / risk"
@test("backtester: trailing stop ratchets at bar close, fills next bar only")
def _():
import gpu_backtest as _gb
t = _m21_sim_trade()
# bar0: runs to +1R (2410) — arms trail (0.6R=6), stop → 2410-7.5=2402.5;
# bar0 low 2398 must NOT trigger the new stop intra-bar.
# bar1: dips to 2402 → trailed stop 2402.5 hit → TRAIL exit.
sim = _m21_sim_df([(2400, 2410, 2398, 2409), (2409, 2409.5, 2402, 2403)])
_gb.simulate_trades([t], sim, max_bars=5, spread=0.0,
trail_arm_r=0.6, trail_dist_r=0.75)
assert t.exit_reason == "TRAIL", f"got {t.exit_reason}"
assert t.bars_held == 2, "new stop may only fill from the NEXT bar"
assert abs(t.exit_price - 2402.5) < 1e-9
assert abs(t.r_multiple - 0.25) < 1e-9
@test("backtester: cooldown pass drops candidates inside the window, in order")
def _():
import gpu_backtest as _gb
cands = [(10, "A"), (12, "B"), (15, "C"), (21, "D"), (24, "E")]
out = _gb._apply_cooldown(cands, cooldown_bars=5)
assert [(p, t) for p, t in out] == [(10, "A"), (15, "C"), (21, "D")], \
f"cooldown must measure from the previously ACCEPTED entry, got {out}"
assert _gb._apply_cooldown([], 5) == []
assert [p for p, _ in _gb._apply_cooldown(cands, 1)] == [10, 12, 15, 21, 24]
@test("weekend calendar is NY-anchored: summer close 21:00 UTC, winter reopen 23:00")
def _():
# 2026-08-04 audit I43, fixed per owner decision: the market week is
# Fri 17:00 -> Sun 18:00 NEW YORK time (measured on 5yr of Oanda
# bars), not a fixed UTC hour.
import providers.base as pbase
T = lambda s: pd.Timestamp(s, tz="UTC")
# US DST 2026: Mar 8 – Nov 1.
assert pbase._is_forex_weekend(T("2026-07-31 21:30")) is True, \
"summer Friday 21:30 UTC = 17:30 ET → market CLOSED (old code said open)"
assert pbase._is_forex_weekend(T("2026-07-31 20:30")) is False
assert pbase._is_forex_weekend(T("2026-01-02 21:30")) is False, \
"winter Friday 21:30 UTC = 16:30 ET → still open"
assert pbase._is_forex_weekend(T("2026-01-02 22:30")) is True
assert pbase._is_forex_weekend(T("2026-01-04 22:30")) is True, \
"winter Sunday 22:30 UTC = 17:30 ET → still closed (old code said open)"
assert pbase._is_forex_weekend(T("2026-01-04 23:30")) is False
assert pbase._is_forex_weekend(T("2026-08-02 21:30")) is True # summer Sun 17:30 ET
assert pbase._is_forex_weekend(T("2026-08-02 22:30")) is False # summer Sun 18:30 ET
assert pbase._is_forex_weekend(T("2026-08-01 03:00")) is True # Saturday
@test("weekend calendar: all four copies agree hourly across DST transitions")
def _():
import providers.base as pbase
import signal_aggregator as sa_mod
import gpu_backtest as _gb
import advisory_outcome as ao
cfg = PlatformConfig()
cfg.xau_strategy.weekend_filter = True
cfg.xau_strategy.allowed_weekdays = [] # isolate the weekend gate
# Sweep spring-forward (2026-03-06..11) and fall-back (2026-10-30..11-04).
for start in ("2026-03-06", "2026-10-30"):
for ts in pd.date_range(start, periods=5 * 24, freq="1h", tz="UTC"):
a = pbase._is_forex_weekend(ts)
b = sa_mod._is_forex_weekend(ts)
c = _gb._is_xau_blocked_by_calendar(ts, cfg) == "WEEKEND_FILTER"
d = ao._is_forex_weekend(ts.to_pydatetime())
assert a == b == c == d, \
f"{ts}: base={a} aggregator={b} backtest={c} advisory={d}"
@test("FB2_EMA_LO: garbage value degrades to tuned default, scan survives")
def _():
os.environ["FB2_EMA_LO"] = "not-a-number"
try:
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), \
"analysis must survive an unparseable FB2_EMA_LO"
finally:
os.environ.pop("FB2_EMA_LO", None)
# ══════════════════════════════════════════════════════════════════════
# MODULE 22: fixed-RR TP research flag (--fixed-rr-tp)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 22: fixed-RR TP research flag ═══")
@test("fixed-RR TP: tp1 = entry ± mult×risk (BUY+SELL), tp2=tp1, trail zeroed")
def _():
import gpu_backtest as _gb
buy = _m21_sim_trade() # 2400 / sl 2390
buy.trail_arm_r, buy.trail_dist_r = 0.8, 0.75 # PB-style stamp
sell = _m21_sim_trade(direction="SELL", entry=2400.0, sl=2410.0, tp1=2390.0)
_gb._apply_fixed_rr_tp([buy, sell], 2.0)
assert abs(buy.tp1 - 2420.0) < 1e-9, f"BUY tp1 must be 2420, got {buy.tp1}"
assert abs(sell.tp1 - 2380.0) < 1e-9, f"SELL tp1 must be 2380, got {sell.tp1}"
for t in (buy, sell):
assert t.tp2 == t.tp1, "tp2 must mirror the hard target"
assert t.trail_arm_r == 0.0 and t.trail_dist_r == 0.0, \
"per-trade trail params must be zeroed"
assert t.rr_planned == 2.0
assert abs(t.reward_pips - 200.0) < 1e-9 # 2 × $10 risk / 0.1 pip
@test("fixed-RR TP: 2R target fills on the static path, spread charged")
def _():
import gpu_backtest as _gb
t = _m21_sim_trade()
_gb._apply_fixed_rr_tp([t], 2.0)
sim = _m21_sim_df([(2400, 2405, 2395, 2404), (2404, 2421, 2402, 2418)])
_gb.simulate_trades([t], sim, max_bars=5, spread=0.30, trail_arm_r=0.0)
assert t.exit_reason == "TP1", f"got {t.exit_reason}"
assert abs(t.exit_price - 2420.0) < 1e-9
assert abs(t.r_multiple - (20.0 - 0.30) / 10.0) < 1e-9, \
"R must be (2R target - round-trip spread) / risk"
@test("fixed-RR TP: same-bar SL+TP tie still resolves SL-first (rule 6)")
def _():
import gpu_backtest as _gb
t = _m21_sim_trade()
_gb._apply_fixed_rr_tp([t], 2.0)
sim = _m21_sim_df([(2400, 2421, 2389, 2400)]) # both barriers inside bar 0
_gb.simulate_trades([t], sim, max_bars=5, spread=0.0, trail_arm_r=0.0)
assert t.exit_reason == "SL", f"tie must stay SL-first, got {t.exit_reason}"
assert abs(t.r_multiple - (-1.0)) < 1e-9
@test("fixed-RR TP: per-trade PB trail params cannot resurrect the trail")
def _():
import gpu_backtest as _gb
t = _m21_sim_trade()
t.trail_arm_r, t.trail_dist_r = 0.8, 0.75 # XAU_PB per-trade override
_gb._apply_fixed_rr_tp([t], 2.0)
# bar0 runs to +1.2R (a live 0.8R-arm trail would ratchet to 2404.5),
# bar1 retraces through that would-be trail stop, bar2 reaches 2R.
sim = _m21_sim_df([(2400, 2412, 2398, 2410),
(2410, 2411, 2402, 2403),
(2403, 2421, 2402, 2420)])
_gb.simulate_trades([t], sim, max_bars=5, spread=0.0, trail_arm_r=0.0)
assert t.exit_reason == "TP1", \
f"trail must be fully disabled under the flag, got {t.exit_reason}"
assert abs(t.exit_price - 2420.0) < 1e-9 and t.bars_held == 3
assert abs(t.r_multiple - 2.0) < 1e-9
@test("fixed-RR TP: CLI parses the flag; trail-override combinations refused")
def _():
import gpu_backtest as _gb
assert _gb._parse_args(
["--pair", "XAUUSD", "--fixed-rr-tp", "2.0"]).fixed_rr_tp == 2.0
assert _gb._parse_args(["--pair", "XAUUSD"]).fixed_rr_tp is None
for bad in (["--trail-arm", "0.5"], ["--trail-dist", "0.8"],
["--set", "signals.trail_arm_r=0.5"],
["--set", "signals.pb_trail_dist_r=0.5"]):
try:
_gb.main(["--pair", "XAUUSD", "--fixed-rr-tp", "2.0"] + bad)
assert False, f"conflict {bad} must raise SystemExit"
except SystemExit as e:
assert "conflicts" in str(e)
try:
_gb.main(["--pair", "XAUUSD", "--fixed-rr-tp", "-1"])
assert False, "non-positive multiple must raise SystemExit"
except SystemExit as e:
assert "must be > 0" in str(e)
print("\n═══ Module 23: LLM agent panel ═══")
@test("app_config: agent_panel block parses; absent block → {}")
def _():
app = load_app_config(_write_json({"agent_panel": {
"enabled": True, "telegram": True, "dashboard": False,
"min_quality": "A", "max_panels_per_day": 10,
"reuse_ttl_seconds": 600, "candles_enabled": False,
"candle_bars_primary": 30, "candle_bars_confirm": 12}}))
assert app.agent_panel_settings["enabled"] is True
assert app.agent_panel_settings["min_quality"] == "A"
assert app.agent_panel_settings["max_panels_per_day"] == 10
assert app.agent_panel_settings["candles_enabled"] is False
assert app.agent_panel_settings["candle_bars_primary"] == 30
assert app.agent_panel_settings["candle_bars_confirm"] == 12
assert load_app_config(_write_json({})).agent_panel_settings == {}
assert AppConfig().agent_panel_settings == {}
@test("app_config: agent_panel rejects unknown keys and bad types")
def _():
for payload in (
{"agent_panel": {"enabld": True}}, # unknown key
{"agent_panel": {"enabled": "yes"}}, # not a bool
{"agent_panel": {"telegram": 1}}, # not a bool
{"agent_panel": {"dashboard": "no"}}, # not a bool
{"agent_panel": {"min_quality": "Z"}}, # invalid grade
{"agent_panel": {"max_panels_per_day": 0}}, # not positive
{"agent_panel": {"max_panels_per_day": True}}, # bool is not an int
{"agent_panel": {"reuse_ttl_seconds": -5}}, # negative
{"agent_panel": {"candles_enabled": "yes"}}, # not a bool
{"agent_panel": {"candle_bars_primary": 0}}, # not positive
{"agent_panel": {"candle_bars_primary": 20.5}}, # not an int
{"agent_panel": {"candle_bars_confirm": True}}, # bool is not an int
{"agent_panel": {"candle_bars_confirm": 500}}, # absurdly large
{"agent_panel": "oops"}, # not an object
):
try:
load_app_config(_write_json(payload))
assert False, f"should have raised for {payload}"
except AppConfigError:
pass
@test("app_config: agent_panel.verdict_source validated")
def _():
assert load_app_config(_write_json(
{"agent_panel": {"verdict_source": "macro"}}
)).agent_panel_settings["verdict_source"] == "macro"
assert load_app_config(_write_json(
{"agent_panel": {"verdict_source": "chair"}}
)).agent_panel_settings["verdict_source"] == "chair"
try:
load_app_config(_write_json(
{"agent_panel": {"verdict_source": "technical"}}))
assert False, "should have raised"
except AppConfigError:
pass
import agent_panel as ap
from signal_aggregator import SignalReport, TradeSetup
class _PanelStub:
"""llm_client-singleton drop-in that routes replies by system prompt."""
available = True
def __init__(self, replies):
self.replies = replies # system prompt -> reply dict | None
self.calls = [] # (system_prompt, payload) in call order
def chat_json(self, system_prompt, user_payload):
self.calls.append((system_prompt, user_payload))
reply = self.replies.get(system_prompt)
return reply(user_payload) if callable(reply) else reply
class _NullDP:
"""Provider stub: candle fetch blows up → _candle_context degrades to
None (and tests stay off the network)."""
def fetch_ohlcv(self, *args, **kwargs):
raise RuntimeError("no network in tests")
class _FakeDP:
"""Provider stub serving synthetic OHLCV per timeframe."""
def __init__(self):
self.calls = []
def fetch_ohlcv(self, symbol, timeframe, lookback_days=None):
self.calls.append((symbol, timeframe))
return make_ohlcv(60, seed=7)
_TAKE_OK = {"stance": "support", "confidence": 70, "note": "fine"}
_SYNTH_OK = {"verdict": "AGREE", "confidence": 75, "rationale": "panel backs it"}
_ALL_OK = {ap._MACRO_SYSTEM: _TAKE_OK, ap._TECH_SYSTEM: _TAKE_OK,
ap._RISK_SYSTEM: _TAKE_OK, ap._CONTRARIAN_SYSTEM: _TAKE_OK,
ap._SYNTH_SYSTEM: _SYNTH_OK}
_PANEL_ON = {"enabled": True, "telegram": True, "dashboard": False,
"min_quality": "B", "max_panels_per_day": 20,
"reuse_ttl_seconds": 3600, "verdict_source": "chair"}
def _panel_report(quality="A", signal=SignalStrength.BUY):
"""Real report via the pipeline, then forced directional/quality."""
config = PlatformConfig()
config.primary_timeframe = Timeframe.H1
config.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
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)
report.final_signal = signal
report.signal_quality = quality
report.trade_setup = None
return report
@test("agent_panel: payload builders compact, filtered, JSON-safe")
def _():
report = _panel_report()
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
directional = [NewsSentiment(
headline=f"h{i}", source="rss", timestamp=now,
currencies_mentioned=["USD"], sentiment_score=0.2, keywords=[])
for i in range(15)]
neutral = NewsSentiment(
headline="neutral", source="rss", timestamp=now,
currencies_mentioned=["XAU"], sentiment_score=0.05, keywords=[])
unrelated = NewsSentiment(
headline="unrelated", source="rss", timestamp=now,
currencies_mentioned=["EUR"], sentiment_score=0.8, keywords=[])
ctx = FundamentalContext(
news_headlines=[neutral, unrelated] + directional,
events_today=[EconomicEvent(
time=now, currency="USD", impact="high", event=f"e{i}",
forecast="1.0", actual="2.0") for i in range(12)],
)
macro = ap._macro_payload(report, ctx)
assert len(macro["headlines"]) == 10, "headline cap"
assert all(h["gold_influence"] == "bearish" for h in macro["headlines"])
assert all(h["headline"] not in ("neutral", "unrelated")
for h in macro["headlines"])
assert "sentiment" not in macro, "neutral aggregate sentiment must be omitted"
assert "news_summary" not in macro, "unfiltered summary must not bypass filtering"
assert len(macro["events"]) == 8, "event cap"
assert macro["events"][0]["released"] is True
assert macro["events"][0]["time"] and macro["events"][0]["surprise"] == "positive"
assert ap._macro_payload(report, None)["headlines"] == []
tech = ap._technical_payload(report)
assert tech["indicators"], "real analysis must yield indicators"
hidden = [n for n in tech["indicators"]
if n.startswith("_")
or report.primary_analysis.indicators[n].weight == 0]
assert not hidden, f"private/weight-0 leaked: {hidden}"
assert tech["brief"]["direction"] == "BUY"
assert tech["brief"]["timeframe"] == report.primary_analysis.timeframe
assert tech["strategy"]["code"] == "NO_SETUP"
assert "setup" not in ap._risk_payload(report), "no setup when None"
report.trade_setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="SELL",
entry_price=2000.0, stop_loss=2010.0, take_profit_1=1985.0,
take_profit_2=1970.0, risk_reward=1.5, risk_pips=100.0,
reward_pips=150.0, pip_value=0.1, strategy="XAU_CP",
setup_pattern="double_top", round_trip_cost=0.30,
trail_arm_r=0.6, trail_dist_r=0.85)
tech = ap._technical_payload(report)
assert tech["strategy"]["code"] == "XAU_CP"
assert tech["strategy"]["setup_pattern"] == "double_top"
risk = ap._risk_payload(report)
assert risk["setup"]["risk_reward_to_advisory_tp1"] == 1.5
assert risk["setup"]["strategy"]["setup_pattern"] == "double_top"
assert risk["setup"]["trailing"] == {
"enabled": True, "arm_r": 0.6, "distance_r": 0.85,
"tp1_tp2_are_advisory": True}
assert risk["setup"]["round_trip_cost"] == 0.3
assert risk["setup"]["atr"] > 0 and risk["setup"]["stop_distance_atr"] > 0
assert "spread" not in risk, "candle range must not be mislabeled as spread"
assert risk["brief"]["direction"] == "SELL", "setup direction wins"
contra = ap._contrarian_payload(report, ctx)
assert len(contra["bullish_reasons"]) <= 3
assert contra["setup"]["strategy"]["code"] == "XAU_CP"
assert contra["market_snapshot"]["basis"] == "latest_closed_candle"
assert contra["market_snapshot"]["bar_timestamp"] == report.timestamp
assert contra["indicators"] == tech["indicators"]
assert len(contra["news"]) == 10
assert "news_summary" not in contra
for payload in (macro, tech, risk, contra):
json.dumps(payload, sort_keys=True, allow_nan=False)
@test("agent_panel: persona reply validation clamps and rejects")
def _():
v = ap._validate_take
assert v("macro", {"stance": "moon", "confidence": 50, "note": "x"}) is None
assert v("macro", {"stance": "support", "confidence": "hot"}) is None
assert v("macro", {"stance": "support"}) is None, "missing confidence"
assert v("macro", None) is None
assert v("macro", "support") is None
take = v("risk", {"stance": "oppose", "confidence": 250, "note": "n" * 300})
assert take.confidence == 100.0 and len(take.note) == 160
assert v("risk", {"stance": "neutral", "confidence": -3, "note": ""}).confidence == 0.0
@test("agent_panel: chair with <2 valid takes → None without spending synthesizer")
def _():
try:
stub = _PanelStub({ap._MACRO_SYSTEM: _TAKE_OK}) # others → None
lc._client = stub
assert ap.run_panel(_panel_report(), verdict_source="chair") is None
assert ap._SYNTH_SYSTEM not in [s for s, _ in stub.calls], \
"synthesizer request must not be spent on a failed panel"
assert len(stub.calls) == 4
finally:
lc._client = None
ap.reset_panel_state()
@test("agent_panel: macro mode yields a verdict from the macro take ALONE")
def _():
# The >=2-take quorum exists to protect the SYNTHESIZER spend. Macro
# mode never calls the synthesizer and needs only its own take, so a
# slow/failed technical+risk+contrarian must NOT discard a good macro
# verdict (the quorum used to run before the macro branch).
try:
stub = _PanelStub({ap._MACRO_SYSTEM: _TAKE_OK}) # others → None
lc._client = stub
v = ap.run_panel(_panel_report(), verdict_source="macro")
assert v is not None, "macro verdict dropped when alone"
assert v.source == "macro" and v.verdict == "AGREE"
assert len(v.takes) == 1 and v.takes[0].persona == "macro"
assert ap._SYNTH_SYSTEM not in [s for s, _ in stub.calls], \
"macro mode must never spend a synthesizer request"
finally:
lc._client = None
ap.reset_panel_state()
@test("agent_panel: zero valid takes → None in both verdict modes")
def _():
try:
for source in ("macro", "chair"):
lc._client = _PanelStub({}) # every persona → None
assert ap.run_panel(_panel_report(), verdict_source=source) is None, source
ap.reset_panel_state()
finally:
lc._client = None
ap.reset_panel_state()
@test("agent_panel: synthesizer failure or garbage verdict → None")
def _():
try:
for synth in (None, {"verdict": "MAYBE", "confidence": 50},
{"verdict": "AGREE", "confidence": "hot"}):
lc._client = _PanelStub({**_ALL_OK, ap._SYNTH_SYSTEM: synth})
assert ap.run_panel(_panel_report(), verdict_source="chair") is None, \
f"synth={synth}"
finally:
lc._client = None
ap.reset_panel_state()
@test("agent_panel: full run yields verdict + 4 ordered takes")
def _():
try:
stub = _PanelStub(dict(_ALL_OK))
stub.replies[ap._SYNTH_SYSTEM] = {
"verdict": "OPPOSE", "confidence": 72.4,
"rationale": "r" * 300}
# The chair's OPPOSE survives the macro-anchored guard only when
# the macro analyst objects (see _apply_oppose_guard).
stub.replies[ap._MACRO_SYSTEM] = {
**stub.replies[ap._MACRO_SYSTEM], "stance": "oppose"}
lc._client = stub
v = ap.run_panel(_panel_report(), verdict_source="chair")
assert v.verdict == "OPPOSE" and v.confidence == 72.4
assert v.source == "chair"
assert v.payload_hash.startswith("sha256:")
assert len(v.rationale) == 240, "rationale truncated"
assert [t.persona for t in v.takes] == \
["macro", "technical", "risk", "contrarian"]
synth_payload = dict(stub.calls)[ap._SYNTH_SYSTEM]
assert synth_payload["direction"] == "BUY"
assert len(synth_payload["takes"]) == 4
finally:
lc._client = None
ap.reset_panel_state()
@test("agent panel: macro-only mode uses the macro take as the verdict and skips the chair")
def _():
try:
stub = _PanelStub(dict(_ALL_OK))
lc._client = stub
v = ap.run_panel(_panel_report())
assert v is not None
assert ap._SYNTH_SYSTEM not in [s for s, _ in stub.calls], \
"macro-only mode must not spend the synthesizer"
assert len(stub.calls) == 4
assert v.verdict == "AGREE", "macro take's stance is support"
assert v.confidence == _TAKE_OK["confidence"]
assert v.rationale == _TAKE_OK["note"]
assert v.source == "macro"
assert [t.persona for t in v.takes] == \
["macro", "technical", "risk", "contrarian"]
finally:
lc._client = None
ap.reset_panel_state()
try:
stub2 = _PanelStub(dict(_ALL_OK))
stub2.replies[ap._MACRO_SYSTEM] = {**_TAKE_OK, "stance": "oppose"}
lc._client = stub2
v2 = ap.run_panel(_panel_report())
assert v2.verdict == "OPPOSE", \
"macro-only verdict follows macro even when others support"
assert ap._SYNTH_SYSTEM not in [s for s, _ in stub2.calls]
finally:
lc._client = None
ap.reset_panel_state()
@test("agent panel: macro take missing falls back to the chair")
def _():
try:
stub = _PanelStub(dict(_ALL_OK))
stub.replies[ap._MACRO_SYSTEM] = None # _validate_take rejects it
lc._client = stub
v = ap.run_panel(_panel_report())
assert v is not None
assert ap._SYNTH_SYSTEM in [s for s, _ in stub.calls], \
"a missing macro take must fall back to the synthesizer"
assert v.source == "chair"
assert v.verdict == _SYNTH_OK["verdict"]
assert [t.persona for t in v.takes] == ["technical", "risk", "contrarian"]
finally:
lc._client = None
ap.reset_panel_state()
@test("agent_panel: NEUTRAL signal → no panel even forced")
def _():
try:
stub = _PanelStub(dict(_ALL_OK))
lc._client = stub
report = _panel_report(signal=SignalStrength.NEUTRAL)
assert ap.run_panel(report) is None
assert ap.attach_panel(report, _PANEL_ON, force=True,
context=FundamentalContext()) is False
assert stub.calls == [], "NEUTRAL must spend nothing"
finally:
lc._client = None
ap.reset_panel_state()
@test("agent_panel: exact + reuse-TTL cache re-attach with zero spend")
def _():
try:
stub = _PanelStub(dict(_ALL_OK))
lc._client = stub
ap._dp = _NullDP()
report = _panel_report()
assert ap.attach_panel(report, _PANEL_ON,
context=FundamentalContext()) is True
assert report.panel_verdict == "AGREE"
assert report.panel_cache_status == "MISS"
assert report.panel_created_at and report.panel_payload_hash.startswith("sha256:")
assert len(stub.calls) == 5
# same bar again → exact-key hit
report2 = _panel_report()
assert ap.attach_panel(report2, _PANEL_ON,
context=FundamentalContext()) is True
assert report2.panel_verdict == "AGREE" and len(stub.calls) == 5
assert report2.panel_cache_status == "EXACT_HIT"
assert report2.panel_payload_hash == report.panel_payload_hash
assert report2.panel_created_at == report.panel_created_at
# newer bar, same symbol/tf/direction → reuse-TTL hit
report3 = _panel_report()
report3.timestamp = "2099-01-01 01:00:00+00:00"
assert ap.attach_panel(report3, _PANEL_ON,
context=FundamentalContext()) is True
assert len(stub.calls) == 5, "persisting signal must not re-spend"
assert report3.panel_cache_status == "TTL_REUSE"
assert report3.panel_payload_hash == report.panel_payload_hash
# Changed evidence must not inherit a direction-only cached verdict.
changed = _panel_report()
changed.timestamp = "2099-01-01 01:05:00+00:00"
changed.current_price += 1.0
assert ap.attach_panel(changed, _PANEL_ON,
context=FundamentalContext()) is True
assert len(stub.calls) == 10
assert changed.panel_cache_status == "MISS"
# TTL 0 → the reuse layer is off; a new bar re-runs the panel
report4 = _panel_report()
report4.timestamp = "2099-01-01 02:00:00+00:00"
assert ap.attach_panel(report4, {**_PANEL_ON, "reuse_ttl_seconds": 0},
context=FundamentalContext()) is True
assert len(stub.calls) == 15
finally:
lc._client = None
ap._dp = None
ap.reset_panel_state()
@test("agent panel: attach_panel honours verdict_source and caches per source")
def _():
try:
stub = _PanelStub(dict(_ALL_OK))
lc._client = stub
ap._dp = _NullDP()
report = _panel_report()
assert ap.attach_panel(report, {**_PANEL_ON, "verdict_source": "macro"},
context=FundamentalContext()) is True
assert report.panel_verdict_source == "macro"
assert report.panel_cache_status == "MISS"
assert len(stub.calls) == 4
report2 = _panel_report()
assert ap.attach_panel(report2, {**_PANEL_ON, "verdict_source": "chair"},
context=FundamentalContext()) is True
assert report2.panel_verdict_source == "chair"
assert report2.panel_cache_status == "MISS", \
"a chair-mode attach must not reuse a macro-mode cached verdict"
assert len(stub.calls) == 9
finally:
lc._client = None
ap._dp = None
ap.reset_panel_state()
@test("agent_panel: daily budget exhausts and re-arms on UTC roll")
def _():
try:
stub = _PanelStub(dict(_ALL_OK))
lc._client = stub
ap._dp = _NullDP()
settings = {**_PANEL_ON, "max_panels_per_day": 1,
"reuse_ttl_seconds": 0}
assert ap.attach_panel(_panel_report(), settings,
context=FundamentalContext()) is True
assert len(stub.calls) == 5
report = _panel_report()
report.timestamp = "2099-01-01 01:00:00+00:00"
assert ap.attach_panel(report, settings,
context=FundamentalContext()) is False
assert len(stub.calls) == 5, "budget-blocked attempt must spend nothing"
ap._panel_day = (_dt_m19.datetime.now(_dt_m19.timezone.utc).date()
- _dt_m19.timedelta(days=1))
assert ap.attach_panel(report, settings,
context=FundamentalContext()) is True
assert len(stub.calls) == 10
assert ap.panel_health()["used_today"] == 1
finally:
lc._client = None
ap._dp = None
ap.reset_panel_state()
@test("agent_panel: identical concurrent attaches are single-flight")
def _():
class _SlowPanelStub(_PanelStub):
def chat_json(self, system_prompt, user_payload):
time.sleep(0.03)
return super().chat_json(system_prompt, user_payload)
try:
stub = _SlowPanelStub(dict(_ALL_OK))
lc._client = stub
ap._dp = _NullDP()
reports = [_panel_report(), _panel_report()]
results = []
threads = [threading.Thread(
target=lambda report=r: results.append(ap.attach_panel(
report, _PANEL_ON, context=FundamentalContext())))
for r in reports]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
assert results == [True, True]
assert len(stub.calls) == 5, "one evidence payload must spend one panel"
assert {r.panel_cache_status for r in reports} == {"MISS", "EXACT_HIT"}
finally:
lc._client = None
ap._dp = None
ap.reset_panel_state()
@test("agent_panel: enabled/surface/min_quality gates; force bypasses")
def _():
try:
stub = _PanelStub(dict(_ALL_OK))
lc._client = stub
ap._dp = _NullDP()
report = _panel_report()
ctx = FundamentalContext()
assert ap.attach_panel(report, {}, context=ctx) is False, \
"absent config block must keep the feature dark"
assert ap.attach_panel(report, {**_PANEL_ON, "enabled": False},
context=ctx) is False
assert ap.attach_panel(report, {**_PANEL_ON, "telegram": False},
context=ctx) is False
assert ap.attach_panel(report, _PANEL_ON, surface="dashboard",
context=ctx) is False, "dashboard defaults off"
assert ap.attach_panel(_panel_report(quality="C"), _PANEL_ON,
context=ctx) is False, "C < min_quality B"
assert ap.attach_panel(_panel_report(quality="F"), _PANEL_ON,
context=ctx) is False
assert stub.calls == [], "every gate must block before any spend"
assert ap.attach_panel(report, {**_PANEL_ON, "dashboard": True},
surface="dashboard", context=ctx) is True
ap.reset_panel_state()
assert ap.attach_panel(_panel_report(quality="C"),
{**_PANEL_ON, "enabled": False},
force=True, context=ctx) is True, \
"force bypasses enabled/surface/quality"
finally:
lc._client = None
ap._dp = None
ap.reset_panel_state()
@test("panel verdict flows to Telegram message; absent verdict silent")
def _():
import telegram_bot as tb
report = _panel_report()
assert "AI Panel" not in tb.format_signal_message(report)
report.panel_verdict = "OPPOSE"
report.panel_confidence = 64.0
report.panel_rationale = "counter-trend against aligned H4"
report.panel_takes = [
{"persona": "macro", "stance": "support", "confidence": 60,
"note": "n"},
{"persona": "technical", "stance": "oppose", "confidence": 80,
"note": "n"},
]
msg = tb.format_signal_message(report)
assert "AI Panel" in msg and "OPPOSE" in msg and "(64%)" in msg
assert "counter-trend against aligned H4" in msg
assert "macro: support" in msg and "technical: oppose" in msg
@test("backtest parity: analyze_from_dataframes never populates panel")
def _():
try:
stub = _PanelStub(dict(_ALL_OK))
lc._client = stub
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)}
for use_news in (True, False):
report = agg.analyze_from_dataframes(
"XAU/USD", dfs, context=FundamentalContext(),
use_news=use_news)
assert report.panel_verdict == ""
assert report.panel_confidence == 0.0
assert report.panel_rationale == ""
assert report.panel_takes == []
assert report.panel_created_at == ""
assert report.panel_cache_status == ""
assert report.panel_payload_hash == ""
assert stub.calls == [], \
"the replayable core must never invoke the panel"
finally:
lc._client = None
ap.reset_panel_state()
@test("agent_panel: attach remains advisory-only")
def _():
try:
lc._client = _PanelStub(dict(_ALL_OK))
ap._dp = _NullDP()
report = _panel_report()
before = (
report.final_signal, report.final_score, report.final_confidence,
report.signal_quality, report.action_recommendation,
report.trade_setup, list(report.warnings),
)
assert ap.attach_panel(report, _PANEL_ON,
context=FundamentalContext()) is True
after = (
report.final_signal, report.final_score, report.final_confidence,
report.signal_quality, report.action_recommendation,
report.trade_setup, list(report.warnings),
)
assert after == before, "panel must mutate only advisory panel fields"
finally:
lc._client = None
ap._dp = None
ap.reset_panel_state()
@test("report_to_dict carries panel keys → dashboard snapshot propagation")
def _():
from main import report_to_dict as _rtd
from dashboard_publisher import report_to_webapp_dict as _rtw
report = _panel_report()
d = _rtd(report)
assert d["panel_verdict"] == "" and d["panel_takes"] == []
report.panel_verdict = "AGREE"
report.panel_confidence = 77.5
report.panel_rationale = "solid confluence"
report.panel_takes = [{"persona": "risk", "stance": "support",
"confidence": 66.0, "note": "rr fine"}]
wd = _rtw(report)
assert wd["panel_verdict"] == "AGREE"
assert wd["panel_confidence"] == 77.5
assert wd["panel_takes"][0]["persona"] == "risk"
json.dumps(wd, allow_nan=False)
@test("agent_panel: attach never raises (LLM explosion, garbage settings)")
def _():
class _Bomb:
available = True
def chat_json(self, system_prompt, user_payload):
raise RuntimeError("boom")
try:
lc._client = _Bomb()
ap._dp = _NullDP()
report = _panel_report()
assert ap.attach_panel(report, _PANEL_ON,
context=FundamentalContext()) is False
assert report.panel_verdict == "" and report.panel_takes == []
logging.disable(logging.CRITICAL) # silence the expected traceback
try:
assert ap.attach_panel(report, 42,
context=FundamentalContext()) is False
finally:
logging.disable(logging.NOTSET)
finally:
lc._client = None
ap._dp = None
ap.reset_panel_state()
@test("agent_panel: candle normalization is ATR-relative to final close")
def _():
import pandas as pd
df = pd.DataFrame({
"Open": [100.0, 110.0], "High": [112.0, 115.0],
"Low": [98.0, 108.0], "Close": [110.0, 112.0],
"Volume": [1, 1]})
out = ap._normalize_candles(df, atr=4.0, bars=2)
assert out["ref_close"] == 112.0 and out["atr"] == 4.0
assert out["bars"][0] == [-3.0, 0.0, -3.5, -0.5]
assert out["bars"][1] == [-0.5, 0.8, -1.0, 0.0]
capped = ap._normalize_candles(df, atr=4.0, bars=50)
assert len(capped["bars"]) == 2, "window capped at available bars"
@test("agent_panel: technical payload carries values + optional candles")
def _():
report = _panel_report()
tech = ap._technical_payload(report)
assert tech["indicators"], "real analysis must yield indicators"
assert "value" in tech["indicators"]["rsi"]
assert "value" in tech["indicators"]["adx"]
assert "candles" not in tech, "candles only when provided"
fake = {"1h": {"ref_close": 2400.0, "atr": 5.0,
"bars": [[0.0, 0.5, -0.5, 0.2]]}}
tech2 = ap._technical_payload(report, candles=fake)
assert tech2["candles"] == fake
json.dumps(tech2, sort_keys=True, allow_nan=False)
@test("agent_panel: _candle_context windows, ATR gating, disable flag")
def _():
try:
ap._dp = _FakeDP()
report = _panel_report()
settings = {**_PANEL_ON, "candle_bars_primary": 20,
"candle_bars_confirm": 10}
ctx = ap._candle_context(report, settings)
tfs = [report.primary_analysis.timeframe] + [
a.timeframe for a in report.confirmation_analyses]
assert set(ctx) == set(tfs), f"one block per analysis TF: {set(ctx)}"
assert len(ctx[tfs[0]]["bars"]) == 20, "primary window"
assert len(ctx[tfs[1]]["bars"]) == 10, "confirm window"
assert all(len(b) == 4 for b in ctx[tfs[0]]["bars"])
json.dumps(ctx, allow_nan=False)
# disabled flag → omitted entirely, zero fetches
ap._dp = _FakeDP()
assert ap._candle_context(
report, {**settings, "candles_enabled": False}) is None
assert ap._dp.calls == [], "disabled must not fetch"
# degenerate ATR → that TF skipped, the rest survive
ap._dp = _FakeDP()
report.primary_analysis.indicators["atr"].value = 0.0
ctx2 = ap._candle_context(report, settings)
assert tfs[0] not in ctx2 and tfs[1] in ctx2
# provider explosion → None, never raises
ap._dp = _NullDP()
assert ap._candle_context(report, settings) is None
finally:
ap._dp = None
@test("agent_panel: attach injects candles into the technical payload only")
def _():
try:
ap._dp = _FakeDP()
stub = _PanelStub(dict(_ALL_OK))
lc._client = stub
report = _panel_report()
assert ap.attach_panel(report, _PANEL_ON,
context=FundamentalContext()) is True
by_prompt = dict(stub.calls)
tech_payload = by_prompt[ap._TECH_SYSTEM]
assert "candles" in tech_payload
primary_tf = report.primary_analysis.timeframe
assert len(tech_payload["candles"][primary_tf]["bars"]) == \
ap.DEFAULT_CANDLE_BARS_PRIMARY
assert "value" in tech_payload["indicators"]["rsi"]
for sys_p in (ap._MACRO_SYSTEM, ap._RISK_SYSTEM,
ap._CONTRARIAN_SYSTEM):
assert "candles" not in by_prompt[sys_p], \
"candles are technical-persona evidence only"
contra = by_prompt[ap._CONTRARIAN_SYSTEM]
assert contra["setup"] is None
assert contra["market_snapshot"]["basis"] == "latest_closed_candle"
assert contra["indicators"]
assert "news" in contra
finally:
lc._client = None
ap._dp = None
ap.reset_panel_state()
@test("agent_panel: prompts forbid outside knowledge and cap output words")
def _():
assert "OFF-LIMITS" in ap._TAKE_CONTRACT
assert "max 15 words" in ap._TAKE_CONTRACT
assert "max 30 words" in ap._SYNTH_SYSTEM
assert "ATR units" in ap._TECH_SYSTEM
assert "setup_pattern" in ap._TECH_SYSTEM
assert "tp1_tp2_are_advisory" not in ap._RISK_SYSTEM # prose, not wire key
assert "reference levels" in ap._RISK_SYSTEM
assert "gold_influence" in ap._MACRO_SYSTEM
assert "market_snapshot" in ap._CONTRARIAN_SYSTEM
for prompt in (ap._MACRO_SYSTEM, ap._TECH_SYSTEM, ap._RISK_SYSTEM,
ap._CONTRARIAN_SYSTEM):
assert ap._TAKE_CONTRACT in prompt, \
"every persona must carry the anti-hallucination contract"
@test("agent panel: OPPOSE guard is macro-anchored (advisory backstop)")
def _():
import agent_panel as ap
T = ap.PersonaTake
def takes(macro, technical, risk="oppose", contrarian="oppose"):
return [T("macro", macro, 70.0, "m"), T("technical", technical, 65.0, "t"),
T("risk", risk, 70.0, "r"), T("contrarian", contrarian, 60.0, "c")]
g = ap._apply_oppose_guard
# non-OPPOSE verdicts pass through untouched
assert g("AGREE", 80.0, "x", takes("oppose", "oppose")) == ("AGREE", 80.0, "x")
assert g("NEUTRAL", 40.0, "x", takes("support", "support")) == ("NEUTRAL", 40.0, "x")
# macro opposes → OPPOSE stands
assert g("OPPOSE", 75.0, "x", takes("oppose", "support"))[0] == "OPPOSE"
# technical opposes and macro is neutral → stands
assert g("OPPOSE", 75.0, "x", takes("neutral", "oppose"))[0] == "OPPOSE"
# macro supports → never OPPOSE, even when everyone else objects
v, c, r = g("OPPOSE", 80.0, "too fast", takes("support", "oppose"))
assert v == "NEUTRAL" and c <= 49.0 and r.endswith("too fast")
# only the defensive roles object → NEUTRAL
v, c, _ = g("OPPOSE", 80.0, "x", takes("neutral", "support"))
assert v == "NEUTRAL" and c <= 49.0
# missing macro take (timed out) with technical opposing → stands
assert g("OPPOSE", 70.0, "x", [T("technical", "oppose", 60.0, "t"),
T("risk", "oppose", 60.0, "r")])[0] == "OPPOSE"
@test("agent panel: run_panel applies the OPPOSE guard to the chair's reply")
def _():
import agent_panel as ap
import llm_client as lc
class _GuardStub:
available = True
_timeout = 5.0
def __init__(self):
self.calls = []
def chat_json(self, system_prompt, payload):
self.calls.append(system_prompt)
if system_prompt is ap._SYNTH_SYSTEM:
return {"verdict": "OPPOSE", "confidence": 80,
"rationale": "Price climbed too fast."}
persona = {ap._MACRO_SYSTEM: ("macro", "support"),
ap._TECH_SYSTEM: ("technical", "oppose"),
ap._RISK_SYSTEM: ("risk", "oppose"),
ap._CONTRARIAN_SYSTEM: ("contrarian", "oppose")}[system_prompt]
return {"stance": persona[1], "confidence": 70,
"note": f"{persona[0]} says so."}
stub = _GuardStub()
lc._client = stub
try:
ap.reset_panel_state_for_tests() if hasattr(ap, "reset_panel_state_for_tests") else None
verdict = ap.run_panel(_panel_report(), FundamentalContext(),
verdict_source="chair")
assert verdict is not None
assert verdict.verdict == "NEUTRAL", verdict
assert verdict.confidence <= 49.0
assert verdict.rationale.endswith("Price climbed too fast.")
assert {t.persona: t.stance for t in verdict.takes}["macro"] == "support"
assert len(stub.calls) == 5
finally:
lc._client = None
print("\n═══ Module 24: signal_log.py (SQLite signal history) ═══")
def _make_log_report():
"""SignalReport with a full trade setup, MTF data, reasons + warnings."""
report = SignalReport(
symbol="XAU/USD",
display_name="XAU/USD",
timestamp="2026-08-01T12:00:00+00:00",
final_signal=SignalStrength.BUY,
final_score=0.72,
final_confidence=0.85,
mtf_agreement=True,
mtf_signals={Timeframe.M5: SignalStrength.STRONG_BUY,
Timeframe.M15: SignalStrength.BUY},
mtf_scores={Timeframe.M5: 0.6, Timeframe.M15: 0.7},
event_risk="caution",
event_description="NFP in 20m",
news_sentiment=-0.4,
news_description="Dovish Fed headlines",
news_summary="Gold supported by soft dollar.",
action_recommendation="BUY",
signal_quality="A",
market_regime="trending_up",
volatility_regime="normal",
exhaustion_count=0,
)
report.current_price = 2450.50
report.spread_estimate = 0.30
report.bullish_reasons = ["EMA trend up", "MTF agree"]
report.bearish_reasons = []
report.warnings = ["STALE DATA on H1 — signal may be unreliable"]
report.trade_setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=2450.50, stop_loss=2445.00, take_profit_1=2458.00,
take_profit_2=2468.00, risk_reward=1.6, risk_pips=5.5,
reward_pips=8.8, pip_value=1.0, strategy="XAU_PB",
setup_pattern=None, round_trip_cost=0.30,
trail_arm_r=0.70, trail_dist_r=0.85,
)
report.primary_analysis = TechnicalAnalysis(
symbol="XAU/USD", timeframe=Timeframe.M5.value,
timestamp=report.timestamp,
indicators={
"rsi": IndicatorSignal(
"RSI", 62.5, SignalStrength.BUY, 70.0,
"Momentum is bullish", 1.2),
"atr": IndicatorSignal(
"ATR", 5.25, SignalStrength.NEUTRAL, 0.0,
"Volatility context", 0.0),
},
composite_signal=SignalStrength.BUY,
composite_score=42.5,
confidence=76.0,
market_regime="trending_up",
volatility_regime="normal",
key_levels={"support": 2440.0, "atr": 5.25},
pattern_signals=["bullish_engulfing"],
)
return report
def _make_log_context():
now = datetime.now(timezone.utc)
return FundamentalContext(
events_today=[EconomicEvent(
time=now + timedelta(minutes=20), currency="USD",
impact="high", event="Non-Farm Employment",
forecast="228K", previous="200K", actual=None)],
events_upcoming_1h=[],
high_impact_warning=True,
warning_message="High-impact event within 1h",
news_sentiment={"USD": -0.4},
news_headlines=[NewsSentiment(
headline="Fed signals rate hike", source="test",
timestamp=now, currencies_mentioned=["USD", "XAU"],
sentiment_score=1.0, keywords=["rate hike"],
sentiment_by_currency={"USD": 1.0, "XAU": -1.0},
link="https://example.test/fed", description="Policy update",
publisher="Example News")],
calendar_available=True,
llm_summary="Gold supported by soft dollar.",
)
@test("signal_log: schema creation + full roundtrip")
def _():
import sqlite3
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
report = _make_log_report()
context = _make_log_context()
candles = {
Timeframe.M5: make_ohlcv(20, seed=91),
Timeframe.H1: make_ohlcv(10, seed=92),
}
lg.log(report, context=context, source="test", timeframe="M5",
dataframes=candles)
lg.close()
conn = sqlite3.connect(path)
rows = conn.execute("SELECT * FROM signals").fetchall()
cur = conn.execute("SELECT * FROM signals")
cols = [c[0] for c in cur.description]
conn.close()
assert len(rows) == 1, "exactly one row written"
row = dict(zip(cols, rows[0]))
assert row["symbol"] == "XAU/USD"
assert row["timeframe"] == "M5"
assert row["source"] == "test"
assert row["signal"] == "BUY"
assert row["quality"] == "A"
assert row["action"] == "BUY"
assert row["price"] == 2450.50
assert row["spread"] == 0.30
assert row["bar_range_median"] == 0.30, "spread is the median bar range"
assert row["scan_kind"] == "scan" and row["actionable"] == 1
assert row["gate_reason"] is None, "a row WITH a setup has no gate reason"
assert row["setup_key"] == "M5|BUY|2450.50|2445.00|XAU_PB"
assert row["advisory_event_id"] == row["id"]
# Seeded at log time (not only panel rows). The fixture's candle
# snapshot is synthetic hourly data dated "now" for an August
# signal bar, so the resolver immediately declares it
# unresolvable — what matters here is that a seed exists.
assert row["advisory_outcome_status"] in ("PENDING", "UNRESOLVABLE"), \
"every setup row is seeded, not only panel rows"
assert json.loads(row["advisory_outcome_json"])["model"] == \
"stored-closed-mid-next-open-trail-v1"
assert row["market_regime"] == "trending_up"
assert row["event_risk"] == "caution"
assert row["news_sentiment"] == -0.4
mtf = json.loads(row["mtf_json"])
assert mtf["agreement"] is True
assert mtf["signals"][Timeframe.M15.value] == "BUY"
assert mtf["scores"][Timeframe.M5.value] == 0.6
setup = json.loads(row["setup_json"])
assert setup["strategy"] == "XAU_PB"
assert setup["setup_pattern"] is None
assert setup["round_trip_cost"] == 0.30
assert setup["entry"] == 2450.50 and setup["trail_arm_r"] == 0.70
reasons = json.loads(row["reasons_json"])
assert "MTF agree" in reasons["bullish"]
assert "STALE" in json.loads(row["warnings_json"])[0]
# News blobs are interned (2026-09): inline column NULL, resolved
# through the hash → news_snapshots (read_news / the view).
assert row["headlines_json"] is None and row["headlines_hash"]
assert row["events_json"] is None and row["events_hash"]
conn2 = sqlite3.connect(path)
_ev, _hl = slog.read_news(conn2, row["id"])
via_view = conn2.execute(
"SELECT headlines_resolved, events_resolved FROM"
" signals_with_news WHERE id=?", (row["id"],)).fetchone()
conn2.close()
assert via_view[0] == _hl and via_view[1] == _ev
events = json.loads(_ev)
assert events["high_impact_warning"] is True
assert events["events_today"][0]["event"] == "Non-Farm Employment"
assert events["events_today"][0]["surprise"] is None
headlines = json.loads(_hl)
assert headlines[0]["headline"] == "Fed signals rate hike"
assert headlines[0]["score"] == 1.0
assert headlines[0]["score_by_currency"]["XAU"] == -1.0
assert headlines[0]["currencies"] == ["USD", "XAU"]
assert headlines[0]["description"] == "Policy update"
indicators = json.loads(row["indicators_json"])
primary = indicators[Timeframe.M5.value]
assert primary["composite_score"] == 42.5
assert primary["indicators"]["rsi"]["signal"] == "BUY"
assert primary["indicators"]["atr"]["value"] == 5.25
candle_snapshot = json.loads(row["candles_json"])
assert len(candle_snapshot[Timeframe.M5.value]) == 14
assert len(candle_snapshot[Timeframe.H1.value]) == 10
bar = candle_snapshot[Timeframe.M5.value][0]
assert set(bar) == {"timestamp", "open", "high", "low", "close",
"volume"}
assert bar["timestamp"].endswith("+00:00")
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("signal_log: panel verdict updates the scan row post-hoc")
def _():
import sqlite3
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
report = _make_log_report()
rowid = lg.log(report, context=None, source="test", timeframe="M5")
assert rowid >= 1, "log() must return the rowid"
report.panel_verdict = "OPPOSE"
report.panel_confidence = 64.0
report.panel_rationale = "risk too thin"
report.panel_created_at = "2026-08-01T12:00:03+00:00"
report.panel_cache_status = "MISS"
report.panel_payload_hash = "sha256:" + "a" * 64
report.panel_takes = [{"persona": "risk", "stance": "oppose",
"confidence": 70.0, "note": "n"}]
lg.update_panel(rowid, report.panel_verdict, 64.0,
report.panel_rationale,
slog._dumps(report.panel_takes),
created_at=report.panel_created_at,
cache_status=report.panel_cache_status,
payload_hash=report.panel_payload_hash,
verdict_source="macro")
lg.close()
conn = sqlite3.connect(path)
row = conn.execute(
"SELECT panel_verdict, panel_confidence, panel_rationale,"
" panel_takes_json, panel_created_at, panel_cache_status,"
" panel_payload_hash, advisory_outcome_status,"
" panel_verdict_source"
" FROM signals WHERE id=?",
(rowid,)).fetchone()
conn.close()
assert row[0] == "OPPOSE" and row[1] == 64.0
assert row[2] == "risk too thin"
assert json.loads(row[3])[0]["persona"] == "risk"
assert row[4] == report.panel_created_at and row[5] == "MISS"
assert row[6] == report.panel_payload_hash
assert row[7] == "PENDING"
assert row[8] == "macro"
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("signal_log: log_panel_update rowid hand-off; no-ops stay silent")
def _():
import sqlite3
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
os.remove(path) # want a fresh file from SignalLogger
try:
os.environ["SIGNAL_LOG_PATH"] = path
os.environ.pop("SIGNAL_LOG_ENABLED", None)
# no rowid (backtest / unlogged report) → silent no-op
slog.log_panel_update(_make_log_report())
assert not os.path.exists(path), "no-op must not create the DB"
report = _make_log_report()
slog.log_signal(report, source="test", timeframe="M5")
assert getattr(report, "_signal_log_rowid", None) is not None, \
"log_signal must stash the rowid on the report"
# empty verdict → no-op (row keeps its empty insert values)
slog.log_panel_update(report)
conn = sqlite3.connect(path)
row = conn.execute(
"SELECT panel_verdict, panel_takes_json FROM signals").fetchone()
conn.close()
assert row[0] in (None, "") and row[1] is None
report.panel_verdict = "AGREE"
report.panel_confidence = 81.0
report.panel_rationale = "all aligned"
report.panel_takes = []
report.panel_created_at = "2026-08-01T12:00:05+00:00"
report.panel_cache_status = "EXACT_HIT"
report.panel_payload_hash = "sha256:" + "b" * 64
slog.log_panel_update(report)
conn = sqlite3.connect(path)
row = conn.execute(
"SELECT panel_verdict, panel_confidence, panel_rationale,"
" panel_takes_json, panel_created_at, panel_cache_status,"
" panel_payload_hash, advisory_outcome_status FROM signals").fetchone()
conn.close()
assert row[0] == "AGREE" and row[1] == 81.0
assert row[2] == "all aligned"
assert json.loads(row[3]) == []
assert row[4] == report.panel_created_at and row[5] == "EXACT_HIT"
assert row[6] == report.panel_payload_hash and row[7] == "PENDING"
# disabled → no-op, never raises
os.environ["SIGNAL_LOG_ENABLED"] = "false"
slog.log_panel_update(report)
finally:
os.environ.pop("SIGNAL_LOG_PATH", None)
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.reset_loggers_for_tests()
if os.path.exists(path):
os.remove(path)
@test("signal_log: null context → news JSON NULL, row still written")
def _():
import sqlite3
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
lg.log(_make_log_report(), context=None, source="test", timeframe="H1")
lg.close()
conn = sqlite3.connect(path)
row = conn.execute(
"SELECT events_json, headlines_json, news_summary, signal "
"FROM signals").fetchone()
conn.close()
assert row[0] is None and row[1] is None
assert row[2] == "Gold supported by soft dollar."
assert row[3] == "BUY"
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("advisory outcome: next-open trailing ignores TP1 and ratchets at close")
def _():
from advisory_outcome import advance_outcome, initial_outcome
setup = {
"direction": "BUY", "entry": 100.0, "stop_loss": 90.0,
"take_profit_1": 105.0, "strategy": "GENERIC",
"setup_pattern": None, "trail_arm_r": 0.6,
"trail_dist_r": 0.75,
}
indicators = {"5min": {"timestamp": "2026-08-03T10:00:00+00:00"}}
status, state = initial_outcome(setup, indicators, "M5")
assert status == "PENDING"
state = advance_outcome(state, [{
"timestamp": "2026-08-03T10:00:00+00:00",
"open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0,
}, {
"timestamp": "2026-08-03T10:05:00+00:00",
"open": 101.0, "high": 112.0, "low": 100.0, "close": 110.0,
}])
assert state["status"] == "PENDING", "advisory TP1 is not a hard exit"
assert state["progress"]["entry_price"] == 101.0
assert state["progress"]["initial_stop"] == 91.0
assert state["progress"]["current_stop"] == 104.5
state = advance_outcome(state, [{
"timestamp": "2026-08-03T10:05:00+00:00",
"open": 101.0, "high": 112.0, "low": 100.0, "close": 110.0,
}, {
"timestamp": "2026-08-03T10:10:00+00:00",
"open": 108.0, "high": 109.0, "low": 104.0, "close": 105.0,
}])
assert state["status"] == "RESOLVED"
result = state["result"]
assert result["exit_reason"] == "TRAIL" and result["bars_held"] == 2
assert abs(result["net_r_065"] - 0.285) < 1e-12
_, broken = initial_outcome(setup, indicators, "M5")
broken = advance_outcome(broken, [{
"timestamp": "2026-08-03T10:10:00+00:00",
"open": 108.0, "high": 109.0, "low": 104.0, "close": 105.0,
}])
assert broken["status"] == "UNRESOLVABLE"
assert "continuity" in broken["reason"]
_, gapped = initial_outcome(setup, indicators, "M5")
gapped = advance_outcome(gapped, [{
"timestamp": "2026-08-03T10:00:00+00:00",
"open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0,
}, {
"timestamp": "2026-08-03T10:10:00+00:00",
"open": 101.0, "high": 102.0, "low": 100.0, "close": 101.0,
}])
assert gapped["status"] == "UNRESOLVABLE"
assert "internal gap" in gapped["reason"]
weekend_indicators = {
"5min": {"timestamp": "2026-08-07T20:55:00+00:00"}}
_, weekend = initial_outcome(setup, weekend_indicators, "M5")
weekend = advance_outcome(weekend, [{
"timestamp": "2026-08-07T20:55:00+00:00",
"open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0,
}, {
"timestamp": "2026-08-09T22:00:00+00:00",
"open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0,
}])
assert weekend["status"] == "PENDING", \
"the scheduled FX weekend closure is not a data gap"
maintenance_indicators = {
"5min": {"timestamp": "2026-08-03T20:55:00+00:00"}}
_, maintenance = initial_outcome(setup, maintenance_indicators, "M5")
maintenance = advance_outcome(maintenance, [{
"timestamp": "2026-08-03T20:55:00+00:00",
"open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0,
}, {
"timestamp": "2026-08-03T22:00:00+00:00",
"open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0,
}])
assert maintenance["status"] == "PENDING", \
"Oanda's scheduled daily XAU closure is not a data gap"
for timeframe, normalized, signal_at, next_at in (
("M30", "30min", "2026-08-03T10:00:00+00:00",
"2026-08-03T10:30:00+00:00"),
("D1", "1day", "2026-08-03T00:00:00+00:00",
"2026-08-04T00:00:00+00:00"),
):
stored_key = "1d" if timeframe == "D1" else normalized
alias_indicators = {stored_key: {"timestamp": signal_at}}
_, alias_state = initial_outcome(setup, alias_indicators, timeframe)
alias_state = advance_outcome(alias_state, [{
"timestamp": signal_at,
"open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0,
}, {
"timestamp": next_at,
"open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0,
}])
assert alias_state["status"] == "PENDING", timeframe
@test("signal_log: pending advisory outcome resolves from later snapshots")
def _():
import sqlite3
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
report = _make_log_report()
rowid = lg.log(report, source="test", timeframe="M5")
lg.update_panel(
rowid, "OPPOSE", 70.0, "test", "[]",
created_at="2026-08-01T12:00:03+00:00",
cache_status="MISS", payload_hash="sha256:" + "c" * 64,
)
bars = slog._dumps({"5min": [{
"timestamp": "2026-08-01T12:00:00+00:00",
"open": 2450.0, "high": 2451.0, "low": 2449.0,
"close": 2450.5, "volume": 1.0,
}, {
"timestamp": "2026-08-01T12:05:00+00:00",
"open": 2450.5, "high": 2451.0, "low": 2444.0,
"close": 2445.0, "volume": 1.0,
}]})
assert lg.resolve_pending("XAU/USD", bars) == 1
row = lg._conn.execute(
"SELECT advisory_outcome_status, advisory_r_multiple,"
" advisory_outcome_json FROM signals WHERE id=?", (rowid,)
).fetchone()
assert row[0] == "RESOLVED" and row[1] < -1.0
outcome = json.loads(row[2])
assert outcome["result"]["exit_reason"] == "SL"
assert outcome["model"] == "stored-closed-mid-next-open-trail-v1"
# Overlapping snapshots are idempotent after resolution.
assert lg.resolve_pending("XAU/USD", bars) == 0
d1_report = _make_log_report()
d1_report.timestamp = "2026-08-03T00:00:00+00:00"
d1_report.primary_analysis.timeframe = Timeframe.D1.value
d1_report.primary_analysis.timestamp = d1_report.timestamp
d1_rowid = lg.log(d1_report, source="test", timeframe="D1")
lg.update_panel(
d1_rowid, "AGREE", 70.0, "test", "[]",
created_at="2026-08-03T00:00:03+00:00",
cache_status="MISS", payload_hash="sha256:" + "d" * 64,
)
d1_bars = slog._dumps({"1d": [{
"timestamp": "2026-08-03T00:00:00+00:00",
"open": 2450.0, "high": 2451.0, "low": 2449.0,
"close": 2450.5, "volume": 1.0,
}, {
"timestamp": "2026-08-04T00:00:00+00:00",
"open": 2450.5, "high": 2451.0, "low": 2449.0,
"close": 2450.0, "volume": 1.0,
}]})
assert lg.resolve_pending("XAU/USD", d1_bars) == 1
d1_state = json.loads(lg._conn.execute(
"SELECT advisory_outcome_json FROM signals WHERE id=?",
(d1_rowid,)).fetchone()[0])
assert d1_state["status"] == "PENDING"
assert d1_state["progress"]["entry_at"] == \
"2026-08-04T00:00:00+00:00"
lg.close()
finally:
slog.reset_loggers_for_tests()
if os.path.exists(path):
os.remove(path)
@test("signal_log: module entry honors SIGNAL_LOG_ENABLED/PATH env")
def _():
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
os.remove(path) # want a fresh file from SignalLogger
try:
os.environ["SIGNAL_LOG_PATH"] = path
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.log_signal(_make_log_report(), source="cli", timeframe="H4")
assert os.path.exists(path), "enabled default must write the DB"
slog.reset_loggers_for_tests()
os.remove(path)
os.environ["SIGNAL_LOG_ENABLED"] = "false"
slog.log_signal(_make_log_report(), source="cli", timeframe="H4")
assert not os.path.exists(path), "disabled must not create the DB"
finally:
os.environ.pop("SIGNAL_LOG_PATH", None)
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.reset_loggers_for_tests()
if os.path.exists(path):
os.remove(path)
@test("signal_log: unwritable path degrades to warning, never raises")
def _():
import signal_log as slog
# Route the DB through a plain file so makedirs fails
# (NotADirectoryError). log_signal must swallow it.
fd, blocker = tempfile.mkstemp()
os.close(fd)
try:
os.environ["SIGNAL_LOG_PATH"] = os.path.join(blocker, "sub", "x.db")
os.environ.pop("SIGNAL_LOG_ENABLED", None)
logging.disable(logging.CRITICAL) # silence the expected traceback
try:
slog.log_signal(_make_log_report(), source="test", timeframe="H1")
finally:
logging.disable(logging.NOTSET)
finally:
os.environ.pop("SIGNAL_LOG_PATH", None)
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.reset_loggers_for_tests()
os.remove(blocker)
@test("signal_log: analyze_pair weekend early-return is logged")
def _():
import signal_log as slog
from signal_aggregator import _is_forex_weekend
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
os.remove(path)
try:
os.environ["SIGNAL_LOG_PATH"] = path
os.environ.pop("SIGNAL_LOG_ENABLED", None)
agg = SignalAggregator(PlatformConfig())
agg._get_news_engine = lambda: _NoNews()
with patch("signal_aggregator._is_forex_weekend", return_value=True):
report = agg.analyze_pair(symbol="XAU/USD", log_source="test")
assert report.final_signal == SignalStrength.NEUTRAL
assert report.signal_quality == "F"
import sqlite3
conn = sqlite3.connect(path)
row = conn.execute(
"SELECT source, signal, quality, timeframe FROM signals").fetchone()
conn.close()
assert row is not None, "weekend report must be persisted"
assert row[0] == "test"
assert row[1] == "NEUTRAL"
assert row[2] == "F"
assert row[3] == Timeframe.H1.value
conn = sqlite3.connect(path)
kind = conn.execute(
"SELECT scan_kind, headlines_hash, candles_json, gate_reason"
" FROM signals").fetchone()
conn.close()
assert kind[0] == "blocked" and kind[1] is None and kind[2] is None
assert kind[3].startswith("WEEKEND FILTER")
# log_blocked_scans=false → the early return writes nothing
slog.reset_loggers_for_tests()
os.remove(path)
os.environ["SIGNAL_LOG_BLOCKED_SCANS"] = "false"
try:
with patch("signal_aggregator._is_forex_weekend", return_value=True):
agg.analyze_pair(symbol="XAU/USD", log_source="test")
assert not os.path.exists(path), \
"blocked scans must be skipped when log_blocked_scans is off"
finally:
os.environ.pop("SIGNAL_LOG_BLOCKED_SCANS", None)
finally:
os.environ.pop("SIGNAL_LOG_PATH", None)
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.reset_loggers_for_tests()
if os.path.exists(path):
os.remove(path)
@test("signal_log parity: analyze_from_dataframes never logs")
def _():
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
os.remove(path)
try:
os.environ["SIGNAL_LOG_PATH"] = path
os.environ.pop("SIGNAL_LOG_ENABLED", None)
agg = SignalAggregator(PlatformConfig())
agg.primary_timeframe = Timeframe.H1 # unused; keeps intent explicit
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=FundamentalContext(), use_news=False)
assert report.final_signal in (SignalStrength.NEUTRAL,
SignalStrength.BUY,
SignalStrength.SELL)
assert not os.path.exists(path), \
"the replayable core must never write the signal-history DB"
finally:
os.environ.pop("SIGNAL_LOG_PATH", None)
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.reset_loggers_for_tests()
if os.path.exists(path):
os.remove(path)
@test("signal_log: _log_signal tolerates a failing news engine")
def _():
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
os.remove(path)
try:
os.environ["SIGNAL_LOG_PATH"] = path
os.environ.pop("SIGNAL_LOG_ENABLED", None)
agg = SignalAggregator(PlatformConfig())
agg._get_news_engine = lambda: _BoomNews()
with patch("signal_aggregator._is_forex_weekend", return_value=True):
report = agg.analyze_pair(symbol="XAU/USD", log_source="test")
assert report.final_signal == SignalStrength.NEUTRAL
import sqlite3
conn = sqlite3.connect(path)
row = conn.execute(
"SELECT events_json, headlines_json FROM signals").fetchone()
conn.close()
assert row == (None, None), \
"row still written with NULL news when context fetch fails"
finally:
os.environ.pop("SIGNAL_LOG_PATH", None)
os.environ.pop("SIGNAL_LOG_ENABLED", None)
slog.reset_loggers_for_tests()
if os.path.exists(path):
os.remove(path)
class _NoNews:
def get_fundamental_context(self):
return FundamentalContext()
class _BoomNews:
def get_fundamental_context(self):
raise RuntimeError("news engine down")
# ══════════════════════════════════════════════════════════════════════
# MODULE 25: analyze_signal_log.py (read-only evaluator) + surface parity
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 25: analyze_signal_log.py + surface parity ═══")
import analyze_signal_log as asl
def _asl_row(id_, logged_at, tf="15min", source="telegram", signal="BUY",
quality="B", entry=3400.0, sl=3392.0, strategy="GENERIC",
bar_ts=None, action="BUY — Decent setup", fp="cfg1",
setup=True, price=None, news_sentiment=None):
setup_d = None
if setup:
setup_d = {"direction": "BUY" if "BUY" in signal else "SELL",
"entry": entry, "stop_loss": sl,
"take_profit_1": entry + 8, "take_profit_2": entry + 16,
"risk_reward": 1.0, "risk_pips": 80.0,
"reward_pips": 80.0, "strategy": strategy,
"trail_arm_r": 0.7, "trail_dist_r": 0.75}
return asl.LogRow(
id=id_, logged_at=logged_at, source=source, timeframe=tf,
signal=signal, score=30.0, quality=quality,
price=price if price is not None else entry,
market_regime="trending_up", setup=setup_d, panel_verdict="",
panel_confidence=None, action=action, bar_ts=bar_ts,
config_fingerprint=fp, news_sentiment=news_sentiment)
@test("analyze_signal_log: entry_bar_open floors the log time to the TF boundary")
def _():
ts = datetime(2026, 8, 19, 12, 47, 10, tzinfo=timezone.utc)
assert asl.entry_bar_open(ts, Timeframe.M15) == pd.Timestamp(
"2026-08-19 12:45:00+00:00")
assert asl.entry_bar_open(ts, Timeframe.M5) == pd.Timestamp(
"2026-08-19 12:45:00+00:00")
assert asl.entry_bar_open(ts, Timeframe.H1) == pd.Timestamp(
"2026-08-19 12:00:00+00:00")
@test("analyze_signal_log: clustering merges cross-source + re-fire rows, splits after the gap")
def _():
t0 = datetime(2026, 8, 19, 12, 45, 10, tzinfo=timezone.utc)
rows = [
_asl_row(1, t0),
_asl_row(2, t0 + timedelta(seconds=3), source="dashboard"),
_asl_row(3, t0 + timedelta(minutes=15)), # re-fire
_asl_row(4, t0 + timedelta(minutes=15), sl=3390.0), # different SL
_asl_row(5, t0 + timedelta(minutes=90)), # > gap → new event
]
events = asl.cluster_setup_rows(rows)
assert [e.representative.id for e in events] == [1, 4, 5]
assert [r.id for r in events[0].rows] == [1, 2, 3]
# identity is shared with the logger (signal_log.setup_key)
from signal_log import setup_key
assert asl._setup_key(rows[0])[1] == setup_key(rows[0].setup, "15min")
@test("analyze_signal_log: build_trade preserves SL/TP distances at the next-open fill")
def _():
idx = pd.date_range("2026-08-19 12:30", periods=4, freq="15min", tz="UTC")
raw = pd.DataFrame({"Open": [3399.0, 3401.5, 3402.0, 3403.0],
"High": [3400.0, 3403.0, 3404.0, 3405.0],
"Low": [3398.0, 3400.0, 3401.0, 3402.0],
"Close": [3399.5, 3402.0, 3403.0, 3404.0],
"Volume": [1.0] * 4}, index=idx)
row = _asl_row(1, datetime(2026, 8, 19, 12, 45, 10, tzinfo=timezone.utc))
trade, risk = asl.build_trade(row, raw)
assert trade is not None and risk == 8.0
assert trade.entry_time == pd.Timestamp("2026-08-19 12:45:00+00:00")
assert trade.entry_price == 3401.5
assert abs((trade.entry_price - trade.sl) - 8.0) < 1e-9
assert abs((trade.tp1 - trade.entry_price) - 8.0) < 1e-9
late = _asl_row(2, datetime(2026, 8, 19, 14, 0, 10, tzinfo=timezone.utc))
assert asl.build_trade(late, raw) == (None, 0.0)
@test("analyze_signal_log: stored-candle replay resolves SL / trail / open and dedups the book")
def _():
t0 = datetime(2026, 8, 19, 12, 0, 10, tzinfo=timezone.utc) # Wednesday
bars = []
price = 3400.0
for i in range(40):
ts = (pd.Timestamp("2026-08-19 12:00", tz="UTC")
+ pd.Timedelta(minutes=15 * i))
o = price
c = price + (2.0 if i < 20 else -2.0)
bars.append({"timestamp": ts.isoformat(), "open": o,
"high": max(o, c) + 0.5, "low": min(o, c) - 0.5,
"close": c, "volume": 1.0})
price = c
streams = {"15min": bars}
winner = _asl_row(1, t0, bar_ts="2026-08-19T12:00:00+00:00")
loser = _asl_row(2, t0 + timedelta(hours=7), signal="BUY",
entry=3440.0, sl=3436.0,
bar_ts="2026-08-19T19:00:00+00:00")
events = asl.cluster_setup_rows([winner, loser])
asl.replay_stored(events, streams)
w, l = events
assert w.exit_reason == "TRAIL" and w.r_headline is not None
assert w.r_headline > 0, "long into the rally must trail out positive"
assert w.exit_time == pd.Timestamp("2026-08-19 17:30", tz="UTC"), w.exit_time
assert l.exit_reason == "SL" and l.r_headline < -0.99
assert l.entry_fill == bars[29]["open"], "next-open reanchoring"
opp = asl.opportunity_view(events)
assert len(opp) == 2
one = asl.one_position_view(events)
assert [e.representative.id for e in one] == [1, 2], \
"loser logged after exit + 5-bar cooldown (18:45) is taken"
early = _asl_row(3, t0 + timedelta(hours=6), signal="BUY",
entry=3440.0, sl=3437.0,
bar_ts="2026-08-19T18:00:00+00:00")
events3 = asl.cluster_setup_rows([winner, early, loser])
asl.replay_stored(events3, streams)
assert [e.representative.id for e in asl.one_position_view(events3)] \
== [1, 2], "a signal inside the cooldown window is skipped"
@test("analyze_signal_log: parity report flags a divergent bar and rows-per-bar")
def _():
t0 = datetime(2026, 8, 19, 12, 45, 10, tzinfo=timezone.utc)
rows = [
_asl_row(1, t0, bar_ts="2026-08-19T12:30:00+00:00", source="telegram"),
_asl_row(2, t0, bar_ts="2026-08-19T12:30:00+00:00", source="dashboard"),
_asl_row(3, t0, bar_ts="2026-08-19T12:45:00+00:00", source="telegram",
quality="A"),
_asl_row(4, t0, bar_ts="2026-08-19T12:45:00+00:00", source="dashboard",
quality="F", setup=False,
action="NO TRADE — Outside tradeable session", fp="cfg2"),
_asl_row(5, t0 + timedelta(minutes=5),
bar_ts="2026-08-19T12:45:00+00:00", source="telegram",
quality="A"), # same bar scanned twice by telegram
]
rep = asl.parity_report(rows)
assert rep["bars_with_2_sources"] == 2
assert len(rep["divergences"]) == 1
d = rep["divergences"][0]
assert d["bar"] == "2026-08-19T12:45:00+00:00"
assert d["sources"] == ("dashboard", "telegram")
assert "quality F/A" in d["diffs"] and "setup presence" in d["diffs"]
assert "config fingerprint" in d["diffs"] and "action" in d["diffs"]
assert rep["rows_per_bar"]["telegram/15min"] == 1.5
assert rep["rows_per_bar"]["dashboard/15min"] == 1.0
@test("analyze_signal_log: load_rows sees WAL-resident rows and never writes")
def _():
import sqlite3
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path) # WAL mode
lg.log(_make_log_report(), source="telegram", timeframe="5min")
# keep the writer open so the row stays in the -wal file
sizes = (os.path.getsize(path),
os.path.getsize(path + "-wal") if os.path.exists(path + "-wal") else 0)
rows = asl.load_rows(path)
assert len(rows) == 1 and rows[0].source == "telegram"
assert rows[0].bar_ts == "2026-08-01T12:00:00+00:00"
assert rows[0].advisory_status == "PENDING"
assert rows[0].actionable == 1
immutable = sqlite3.connect(f"file:{path}?immutable=1", uri=True)
try:
seen_immutable = immutable.execute(
"SELECT COUNT(*) FROM signals").fetchone()[0]
except sqlite3.OperationalError:
seen_immutable = 0 # not even the schema is checkpointed yet
immutable.close()
assert seen_immutable == 0, \
"immutable=1 cannot see WAL rows — that was the bug"
after = (os.path.getsize(path),
os.path.getsize(path + "-wal") if os.path.exists(path + "-wal") else 0)
assert after == sizes, "read-only loader must not write"
lg.close()
finally:
slog.reset_loggers_for_tests()
for suffix in ("", "-wal", "-shm"):
if os.path.exists(path + suffix):
os.remove(path + suffix)
@test("analyze_signal_log: directional hit-rate signs by direction and dedups 5-min buckets")
def _():
idx = pd.date_range("2026-08-19 12:00", periods=60, freq="5min", tz="UTC")
m5 = pd.DataFrame({"Close": [3400.0 + i for i in range(60)]}, index=idx)
t0 = datetime(2026, 8, 19, 12, 0, 10, tzinfo=timezone.utc)
rows = [
_asl_row(1, t0, tf="5min", signal="BUY", quality="D", setup=False,
price=3400.0),
_asl_row(2, t0 + timedelta(seconds=2), tf="5min", signal="BUY",
quality="D", setup=False, source="dashboard", price=3400.0),
_asl_row(3, t0, tf="5min", signal="SELL", quality="D", setup=False,
price=3400.0),
]
hit = asl.directional_hit_rate(rows, m5, horizons_h=(1,))
assert len(hit["rows"]) == 2, "cross-source duplicate collapses"
agg = hit["aggregate"][0]
assert agg["n"] == 2 and agg["hit_rate"] == 0.5
by_sig = {r["signal"]: r["fwd_1h"] for r in hit["rows"]}
assert by_sig["BUY"] > 0 and by_sig["SELL"] < 0
@test("analyze_signal_log: news_lead_lag separates trailing sentiment from a lead")
def _():
rng = np.random.default_rng(20260904)
n = 200
start = pd.Timestamp("2026-08-19 00:00", tz="UTC") # Wednesday
opens = 3400.0 + np.cumsum(rng.normal(0, 2.0, n + 1))
bars, rows = [], []
for i in range(n):
o, c = float(opens[i]), float(opens[i + 1])
ts = start + pd.Timedelta(hours=i)
bars.append({"timestamp": ts.isoformat(), "open": o,
"high": max(o, c) + 1.0, "low": min(o, c) - 1.0,
"close": c, "volume": 100})
# The logged score IS a trailing 24h return, by construction.
sent = 0.0 if i < 24 else float(np.sign(opens[i] - opens[i - 24]))
rows.append(_asl_row(i + 1, ts.to_pydatetime() + timedelta(seconds=10),
tf="1h", setup=False, price=o,
news_sentiment=sent))
ll = asl.news_lead_lag(rows, {"1h": bars})
assert ll["n_hours"] == n
assert set(ll["corr_past"]) == set(asl.NEWS_LAGS_H)
assert set(ll["corr_fwd"]) == set(asl.NEWS_LAGS_H)
assert set(ll["corr_fwd_split"]) == set(asl.NEWS_LAGS_H)
assert ll["corr_past"][24] > 0.5, ll["corr_past"]
assert abs(ll["corr_fwd"][24]) < 0.3, ll["corr_fwd"]
assert set(ll["daily"]) == {"same", "prev", "next"}
@test("analyze_signal_log: news_lead_lag needs 8 joined hours")
def _():
start = pd.Timestamp("2026-08-19 00:00", tz="UTC")
bars = [{"timestamp": (start + pd.Timedelta(hours=i)).isoformat(),
"open": 3400.0, "high": 3401.0, "low": 3399.0,
"close": 3400.0, "volume": 1} for i in range(4)]
rows = [_asl_row(i + 1, (start + pd.Timedelta(hours=i)).to_pydatetime(),
tf="1h", setup=False, price=3400.0, news_sentiment=0.2)
for i in range(4)]
assert asl.news_lead_lag(rows, {"1h": bars}) == {"n_hours": 4}
assert asl.news_lead_lag(rows, {}) == {"n_hours": 0}
@test("analyze_signal_log: news_extremes times the first turn call after the high/low")
def _():
n = 100
start = pd.Timestamp("2026-08-19 00:00", tz="UTC")
bars, rows = [], []
for i in range(n):
ts = start + pd.Timedelta(hours=i)
bars.append({"timestamp": ts.isoformat(), "open": 3400.0,
"high": 3450.0 if i == 30 else 3405.0,
"low": 3350.0 if i == 80 else 3395.0,
"close": 3400.0, "volume": 100})
sent = 0.3 if (i < 36 or i >= 86) else -0.3
rows.append(_asl_row(i + 1, ts.to_pydatetime() + timedelta(seconds=10),
tf="1h", setup=False, price=3400.0,
news_sentiment=sent))
ex = asl.news_extremes(rows, {"1h": bars})
assert ex["high"]["price"] == 3450.0 and ex["low"]["price"] == 3350.0
assert ex["high"]["ts"].startswith("2026-08-20T06:00") # hour 30
assert ex["low"]["ts"].startswith("2026-08-22T08:00") # hour 80
assert abs(ex["high"]["sentiment"] - 0.3) < 1e-9
assert abs(ex["low"]["sentiment"] + 0.3) < 1e-9
assert ex["bear_after_high"]["lag_h"] == 6.0
assert ex["bull_after_low"]["lag_h"] == 6.0
assert ex["bear_after_high"]["price_move"] == -50.0
assert ex["bull_after_low"]["price_move"] == 50.0
assert asl.news_extremes(rows, {}) == {}
@test("analyze_signal_log: news_outcome_buckets splits R by news bucket x macro stance")
def _():
import sqlite3
t0 = datetime(2026, 8, 19, 12, 0, 10, tzinfo=timezone.utc)
rows = [
_asl_row(1, t0, signal="BUY", entry=3400.0, sl=3392.0,
news_sentiment=0.3),
_asl_row(2, t0 + timedelta(hours=3), signal="BUY", entry=3410.0,
sl=3402.0, news_sentiment=-0.3),
_asl_row(3, t0 + timedelta(hours=6), signal="SELL", entry=3420.0,
sl=3428.0, news_sentiment=0.0),
]
events = asl.cluster_setup_rows(rows)
assert len(events) == 3
for ev, r in zip(events, (2.0, -1.0, 0.5)):
ev.r_headline = r
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
con = sqlite3.connect(path)
con.execute("CREATE TABLE signals(id INTEGER PRIMARY KEY,"
" panel_takes_json TEXT)")
con.execute("INSERT INTO signals VALUES (?, ?)",
(1, json.dumps([{"persona": "technical",
"stance": "oppose"},
{"persona": "macro",
"stance": "support"}])))
con.commit()
con.close()
buckets = asl.news_outcome_buckets(rows, events, path)
finally:
os.remove(path)
assert sum(b["n"] for b in buckets) == 3
by_key = {(b["direction"], b["bucket"], b["macro"]): b for b in buckets}
assert set(by_key) == {("BUY", "bull", "support"),
("BUY", "bear", "none"),
("SELL", "neutral", "none")}
assert by_key[("BUY", "bull", "support")]["sum_r"] == 2.0
assert by_key[("BUY", "bull", "support")]["win_rate"] == 1.0
assert by_key[("BUY", "bear", "none")]["sum_r"] == -1.0
assert by_key[("SELL", "neutral", "none")]["sum_r"] == 0.5
# print_news must survive every shape it can be handed
import contextlib
import io as _io
buf = _io.StringIO()
with contextlib.redirect_stdout(buf):
asl.print_news(asl.news_lead_lag(rows, {}), {}, buckets)
asl.print_news({"n_hours": 0}, {}, [])
out = buf.getvalue()
assert "need 8+" in out and "BUY bull support" in out
assert "no resolved events to bucket yet" in out
@test("analyze_signal_log: load_rows round-trips news_sentiment and event_risk")
def _():
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
lg.log(_make_log_report(), source="telegram", timeframe="5min")
lg.close()
rows = asl.load_rows(path)
assert len(rows) == 1
assert abs(rows[0].news_sentiment - (-0.4)) < 1e-9, rows[0].news_sentiment
assert rows[0].event_risk == "caution", rows[0].event_risk
finally:
slog.reset_loggers_for_tests()
for suffix in ("", "-wal", "-shm"):
if os.path.exists(path + suffix):
os.remove(path + suffix)
# ══════════════════════════════════════════════════════════════════════
# MODULE 26: market_outlook.py (advisory hourly market brief)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 26: AI market outlook ═══")
import market_outlook as mo
class _OutlookStub:
available = True
def __init__(self, reply=None):
self.reply = reply or {
"bias": "bullish", "setup_forming": "buy", "confidence": 68,
"watch": "Look for the price to hold above its recent low.",
"note": "The short-term chart is improving while the bigger trend remains supportive.",
}
self.calls = []
def chat_json(self, system_prompt, user_payload):
self.calls.append((system_prompt, user_payload))
if isinstance(self.reply, Exception):
raise self.reply
return self.reply
class _OutlookNews:
def __init__(self, config):
pass
def get_fundamental_context(self):
now = datetime.now(timezone.utc)
return FundamentalContext(
news_headlines=[NewsSentiment(
headline="Gold firms as the dollar weakens", source="test",
timestamp=now, currencies_mentioned=["XAU", "USD"],
sentiment_score=0.3, keywords=[])],
events_today=[EconomicEvent(
time=now - timedelta(minutes=5), currency="USD", impact="high",
event="Inflation", actual="0.2%", forecast="0.3%")],
)
def get_pair_sentiment(self, symbol, context):
return 0.25, "slightly bullish"
def get_week_events(self):
return []
_OUTLOOK_ON = {
"enabled": True,
"telegram": True,
"max_per_day": 12,
"candles_bars": 20,
}
@test("app_config: market_outlook defaults, env override, and validation")
def _():
defaults = load_app_config(_write_json({})).market_outlook_settings
assert defaults == {
"enabled": False, "telegram": False,
"max_per_day": 12, "candles_bars": 20,
}
app = load_app_config(_write_json({"market_outlook": _OUTLOOK_ON}))
assert app.market_outlook_settings == _OUTLOOK_ON
old = os.environ.get("MARKET_OUTLOOK_ENABLED")
try:
os.environ["MARKET_OUTLOOK_ENABLED"] = "false"
assert app_config.market_outlook_enabled(app) is False
os.environ["MARKET_OUTLOOK_ENABLED"] = "true"
assert app_config.market_outlook_enabled(app) is True
finally:
if old is None:
os.environ.pop("MARKET_OUTLOOK_ENABLED", None)
else:
os.environ["MARKET_OUTLOOK_ENABLED"] = old
for bad in (
{"market_outlook": {"enabld": True}},
{"market_outlook": {"enabled": "yes"}},
{"market_outlook": {"telegram": 1}},
{"market_outlook": {"max_per_day": 0}},
{"market_outlook": {"max_per_day": True}},
{"market_outlook": {"candles_bars": 2.5}},
):
try:
load_app_config(_write_json(bad))
assert False, f"should have rejected {bad}"
except AppConfigError:
pass
@test("signal_log: market outlook table roundtrip and old-db migration")
def _():
import sqlite3
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
con = sqlite3.connect(path)
# Legacy pre-panel schema: it has a signals table but no outlooks
# table. Keep the index columns from the original schema present.
con.execute(
"CREATE TABLE signals (id INTEGER PRIMARY KEY, logged_at TEXT,"
" symbol TEXT, timeframe TEXT)")
con.execute(
"CREATE TABLE outlooks (id INTEGER PRIMARY KEY, logged_at TEXT,"
" surface TEXT, bias TEXT, setup_forming TEXT, confidence REAL,"
" watch TEXT, note TEXT)")
con.commit()
con.close()
barrier = threading.Barrier(4)
loggers = []
errors = []
def open_logger():
try:
barrier.wait()
loggers.append(slog.SignalLogger(path))
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=open_logger) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
assert not errors, f"concurrent additive migration failed: {errors}"
assert len(loggers) == 4
logger = loggers[0]
columns = {
row[1] for row in logger._conn.execute("PRAGMA table_info(signals)")
}
assert {
"indicators_json", "candles_json", "panel_created_at",
"panel_cache_status", "panel_payload_hash",
"advisory_outcome_status", "advisory_r_multiple",
"advisory_outcome_json",
} <= columns
outlook_columns = {
row[1] for row in logger._conn.execute("PRAGMA table_info(outlooks)")
}
assert {"news_json", "indicators_json", "candles_json"} <= \
outlook_columns
result = mo.OutlookResult(
bias="bearish", setup_forming="sell", confidence=71.5,
watch="Wait for price to fail at resistance.",
note="Momentum is fading after a sharp rise.", created_ts=time.time())
payload = {
"news": {"headlines": ["Gold firms"]},
"indicators": {"1h": {"rsi": {"value": 55.0}}},
"market_regime": {"1h": "trending_up"},
"volatility_regime": {"1h": "normal"},
"key_levels": {"1h": {"support": 4400.0}},
"candles": {"1h": {"bars": [[-0.2, 0.1, -0.3, 0.0]]}},
}
rowid = logger.log_outlook(result, surface="test", payload=payload)
for open_instance in loggers:
open_instance.close()
con = sqlite3.connect(path)
row = con.execute(
"SELECT surface, bias, setup_forming, confidence, watch, note, "
"news_json, indicators_json, candles_json "
"FROM outlooks WHERE id=?", (rowid,)).fetchone()
con.close()
assert row[:6] == ("test", "bearish", "sell", 71.5,
"Wait for price to fail at resistance.",
"Momentum is fading after a sharp rise.")
assert json.loads(row[6])["headlines"] == ["Gold firms"]
assert json.loads(row[7])["market_regime"]["1h"] == "trending_up"
assert len(json.loads(row[8])["1h"]["bars"]) == 1
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("market_outlook: payload is finite, normalized, and news-safe")
def _():
try:
mo._dp = _FakeDP()
with patch("news_engine.NewsEngine", _OutlookNews):
payload = mo._collect_payload(_OUTLOOK_ON)
assert mo._normalize_candles is ap._normalize_candles
assert set(payload["candles"]) == {"15min", "1h", "4h"}
assert all(len(block["bars"]) == 20
for block in payload["candles"].values())
assert payload["indicators"]["1h"]
assert payload["news"]["headlines"] == ["Gold firms as the dollar weakens"]
json.dumps(payload, sort_keys=True, allow_nan=False)
with patch("news_engine.NewsEngine", side_effect=RuntimeError("down")):
no_news = mo._collect_payload(_OUTLOOK_ON)["news"]
assert no_news == mo._empty_news_payload()
finally:
mo._dp = None
@test("market_outlook: reply validation clamps and rejects malformed JSON")
def _():
validate = mo._validate_outlook
assert validate(None) is None
assert validate({"bias": "up", "setup_forming": "buy", "confidence": 50}) is None
assert validate({"bias": "bullish", "setup_forming": "wait", "confidence": 50}) is None
assert validate({"bias": "bullish", "setup_forming": "buy"}) is None
assert validate({"bias": "bullish", "setup_forming": "buy", "confidence": "hot"}) is None
result = validate({
"bias": "neutral", "setup_forming": "none", "confidence": 150,
"watch": "word " * 30, "note": "note " * 30,
})
assert result.confidence == 100.0
assert len(result.watch.split()) == 25 and len(result.note.split()) == 25
@test("market_outlook: disabled default spends no LLM request")
def _():
original_app = app_config.get_app_config()
old = os.environ.pop("MARKET_OUTLOOK_ENABLED", None)
try:
app_config.set_app_config(AppConfig())
stub = _OutlookStub()
lc._client = stub
mo.reset_outlook_state()
assert mo.maybe_run_outlook("telegram") is None
assert stub.calls == []
finally:
if old is not None:
os.environ["MARKET_OUTLOOK_ENABLED"] = old
lc._client = None
mo.reset_outlook_state()
app_config.set_app_config(original_app)
@test("market_outlook: hourly dedup prevents a second request")
def _():
original_app = app_config.get_app_config()
old = os.environ.pop("MARKET_OUTLOOK_ENABLED", None)
try:
app_config.set_app_config(AppConfig(market_outlook_settings=dict(_OUTLOOK_ON)))
stub = _OutlookStub()
lc._client = stub
mo.reset_outlook_state()
with patch("providers.base._is_forex_weekend", return_value=False), \
patch.object(mo, "_collect_payload", return_value={}), \
patch("signal_log.log_outlook"):
assert mo.maybe_run_outlook("telegram") is not None
assert mo.maybe_run_outlook("telegram") is None
assert len(stub.calls) == 1
finally:
if old is not None:
os.environ["MARKET_OUTLOOK_ENABLED"] = old
lc._client = None
mo.reset_outlook_state()
app_config.set_app_config(original_app)
@test("market_outlook: daily budget blocks force calls after the cap")
def _():
original_app = app_config.get_app_config()
try:
app_config.set_app_config(AppConfig(market_outlook_settings={
**_OUTLOOK_ON, "max_per_day": 1}))
stub = _OutlookStub()
lc._client = stub
mo.reset_outlook_state()
with patch("providers.base._is_forex_weekend", return_value=False), \
patch.object(mo, "_collect_payload", return_value={}), \
patch("signal_log.log_outlook"):
assert mo.maybe_run_outlook("cli", force=True) is not None
assert mo.maybe_run_outlook("cli", force=True) is None
assert len(stub.calls) == 1
finally:
lc._client = None
mo.reset_outlook_state()
app_config.set_app_config(original_app)
@test("market_outlook: LLM and persistence failures remain fail-safe")
def _():
original_app = app_config.get_app_config()
try:
app_config.set_app_config(AppConfig(market_outlook_settings=dict(_OUTLOOK_ON)))
lc._client = _OutlookStub(RuntimeError("LLM down"))
mo.reset_outlook_state()
with patch("providers.base._is_forex_weekend", return_value=False), \
patch.object(mo, "_collect_payload", return_value={}):
assert mo.maybe_run_outlook("telegram") is None
lc._client = _OutlookStub()
mo.reset_outlook_state()
with patch("providers.base._is_forex_weekend", return_value=False), \
patch.object(mo, "_collect_payload", return_value={}), \
patch("signal_log.log_outlook", side_effect=RuntimeError("db locked")):
assert mo.maybe_run_outlook("telegram") is not None
finally:
lc._client = None
mo.reset_outlook_state()
app_config.set_app_config(original_app)
@test("market_outlook: weekend gate spends nothing")
def _():
original_app = app_config.get_app_config()
try:
app_config.set_app_config(AppConfig(market_outlook_settings=dict(_OUTLOOK_ON)))
stub = _OutlookStub()
lc._client = stub
mo.reset_outlook_state()
with patch("providers.base._is_forex_weekend", return_value=True):
assert mo.maybe_run_outlook("telegram") is None
assert stub.calls == []
finally:
lc._client = None
mo.reset_outlook_state()
app_config.set_app_config(original_app)
@test("market_outlook: hour-bucket dedup runs consecutive hours 3,590 s apart")
def _():
original_app = app_config.get_app_config()
old = os.environ.pop("MARKET_OUTLOOK_ENABLED", None)
try:
app_config.set_app_config(AppConfig(market_outlook_settings=dict(_OUTLOOK_ON)))
stub = _OutlookStub()
lc._client = stub
mo.reset_outlook_state()
base = 1_800_000_000 - (1_800_000_000 % 3600) # an exact hour
clock = {"t": base + 25.0} # 10:00:25
with patch("providers.base._is_forex_weekend", return_value=False), \
patch.object(mo, "_collect_payload", return_value={}), \
patch("signal_log.log_outlook"), \
patch.object(mo.time, "time", lambda: clock["t"]):
assert mo.maybe_run_outlook("telegram") is not None
clock["t"] = base + 1800.0 # 10:30 same bucket
assert mo.maybe_run_outlook("telegram") is None
clock["t"] = base + 3600.0 + 15.0 # 11:00:15 — 3,590 s later
assert mo.maybe_run_outlook("telegram") is not None, \
"the old `now - last < 3600` rule dropped every other hour"
assert len(stub.calls) == 2
finally:
if old is not None:
os.environ["MARKET_OUTLOOK_ENABLED"] = old
lc._client = None
mo.reset_outlook_state()
app_config.set_app_config(original_app)
@test("market_outlook: every silent early return leaves a heartbeat log line")
def _():
original_app = app_config.get_app_config()
old = os.environ.pop("MARKET_OUTLOOK_ENABLED", None)
records = []
class _Grab(logging.Handler):
def emit(self, record):
records.append(record.getMessage())
handler = _Grab()
mo.logger.addHandler(handler)
prev_level = mo.logger.level
mo.logger.setLevel(logging.INFO)
try:
app_config.set_app_config(AppConfig(market_outlook_settings={
**_OUTLOOK_ON, "enabled": False}))
mo.reset_outlook_state()
assert mo.maybe_run_outlook("telegram") is None
assert any("skipped (disabled)" in m for m in records), records
records.clear()
app_config.set_app_config(AppConfig(market_outlook_settings=dict(_OUTLOOK_ON)))
with patch("providers.base._is_forex_weekend", return_value=True):
assert mo.maybe_run_outlook("telegram") is None
assert any("skipped (weekend)" in m for m in records), records
records.clear()
# LLM returns nothing → warning, nothing persisted
stub = _OutlookStub(reply=None)
stub.reply = None
lc._client = stub
persisted = []
with patch("providers.base._is_forex_weekend", return_value=False), \
patch.object(mo, "_collect_payload", return_value={}), \
patch("signal_log.log_outlook", persisted.append):
assert mo.maybe_run_outlook("telegram") is None
assert any("no outlook produced" in m for m in records), records
assert persisted == []
finally:
mo.logger.removeHandler(handler)
mo.logger.setLevel(prev_level)
if old is not None:
os.environ["MARKET_OUTLOOK_ENABLED"] = old
lc._client = None
mo.reset_outlook_state()
app_config.set_app_config(original_app)
@test("market_outlook isolation: signal path never imports the advisory module")
def _():
root = os.path.dirname(__file__)
for name in ("engine.py", "signal_aggregator.py"):
with open(os.path.join(root, name), encoding="utf-8") as fh:
assert "market_outlook" not in fh.read(), name
# ══════════════════════════════════════════════════════════════════════
# MODULE 27: signal-log observability (2026-09 audit)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 27: signal-log observability ═══")
def _m27_report(entry=2450.5, sl=2445.0, quality="A", direction="BUY",
ts="2026-08-19T12:00:00+00:00", warnings=None, setup=True,
tf=Timeframe.M5):
"""Weekday (Wed) signal bar so continuity gaps are NOT scheduled."""
r = _make_log_report()
r.timestamp = ts
r.signal_quality = quality
r.final_signal = (SignalStrength.BUY if direction == "BUY"
else SignalStrength.SELL)
r.warnings = list(warnings or [])
r.primary_analysis.timeframe = tf.value
r.primary_analysis.timestamp = ts
if setup:
r.trade_setup.direction = direction
r.trade_setup.entry_price = entry
r.trade_setup.stop_loss = sl
r.trade_setup.take_profit_1 = entry + (8 if direction == "BUY" else -8)
r.trade_setup.strategy = "GENERIC"
else:
r.trade_setup = None
return r
def _m27_bars(start, n, step_min=5, up=1.0, base=2450.0):
out = []
price = base
t = pd.Timestamp(start)
for i in range(n):
o = price
c = price + up
out.append({"timestamp": (t + pd.Timedelta(minutes=step_min * i)).isoformat(),
"open": o, "high": max(o, c) + 0.3, "low": min(o, c) - 0.3,
"close": c, "volume": 1.0})
price = c
return out
def _m27_df(bars):
df = pd.DataFrame([{"Open": b["open"], "High": b["high"], "Low": b["low"],
"Close": b["close"], "Volume": b["volume"]} for b in bars],
index=pd.to_datetime([b["timestamp"] for b in bars], utc=True))
return df
@test("signal_log: every setup row seeds PENDING; same setup within 60 min → DUPLICATE (cross-source)")
def _():
import sqlite3
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
r1 = lg.log(_m27_report(), source="telegram", timeframe="5min")
r2 = lg.log(_m27_report(), source="dashboard", timeframe="5min")
r3 = lg.log(_m27_report(sl=2444.0), source="telegram", timeframe="5min")
rows = {r[0]: r[1:] for r in lg._conn.execute(
"SELECT id, setup_key, advisory_event_id, advisory_outcome_status"
" FROM signals")}
assert rows[r1][2] == "PENDING" and rows[r1][1] == r1
assert rows[r2][2] == "DUPLICATE" and rows[r2][1] == r1, rows[r2]
assert rows[r2][0] == rows[r1][0]
assert rows[r3][2] == "PENDING" and rows[r3][1] == r3, \
"a different stop is a different event"
dup = json.loads(lg._conn.execute(
"SELECT advisory_outcome_json FROM signals WHERE id=?",
(r2,)).fetchone()[0])
assert dup == {"model": "advisory-event-dedup-v1",
"status": "DUPLICATE", "event_id": r1}
# panel update on the duplicate must not re-seed it
lg.update_panel(r2, "OPPOSE", 70.0, "t", "[]")
st = lg._conn.execute(
"SELECT advisory_outcome_status FROM signals WHERE id=?",
(r2,)).fetchone()[0]
assert st == "DUPLICATE"
# a re-log after the gap is a new event
lg._conn.execute(
"UPDATE signals SET logged_at=? WHERE id=?",
((datetime.now(timezone.utc) - timedelta(hours=2)).isoformat(), r1))
lg._conn.commit()
r4 = lg.log(_m27_report(), source="telegram", timeframe="5min")
st4 = lg._conn.execute(
"SELECT advisory_outcome_status, advisory_event_id FROM signals"
" WHERE id=?", (r4,)).fetchone()
# r2 (the duplicate, logged just now) is the most recent same-key
# row → still within the gap → duplicate of event r1.
assert st4[0] == "DUPLICATE" and st4[1] == r1
lg.close()
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("signal_log: logger dedup == analyze_signal_log.cluster_setup_rows on the same sequence")
def _():
import analyze_signal_log as asl
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
ids = [lg.log(_m27_report(), source="telegram", timeframe="5min"),
lg.log(_m27_report(), source="dashboard", timeframe="5min"),
lg.log(_m27_report(entry=2460.0, sl=2455.0), source="telegram",
timeframe="5min"),
lg.log(_m27_report(), source="telegram", timeframe="5min")]
lg.close()
rows = asl.load_rows(path)
events = asl.cluster_setup_rows(rows)
tool_map = {r.id: ev.representative.id for ev in events for r in ev.rows}
db_map = {r.id: r.advisory_event_id for r in rows}
assert tool_map == db_map, (tool_map, db_map)
assert tool_map[ids[1]] == ids[0] and tool_map[ids[3]] == ids[0]
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("signal_log: seeding failure never blocks the insert")
def _():
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
logging.disable(logging.CRITICAL)
try:
with patch("signal_log.initial_outcome",
side_effect=RuntimeError("seed boom")):
rowid = lg.log(_m27_report(), source="telegram",
timeframe="5min")
finally:
logging.disable(logging.NOTSET)
row = lg._conn.execute(
"SELECT id, advisory_outcome_status FROM signals").fetchone()
assert row == (rowid, None)
lg.close()
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("signal_log: resolver rebuilds the bar stream across a >14-bar logging gap")
def _():
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
rowid = lg.log(_m27_report(), source="telegram", timeframe="5min")
bars = _m27_bars("2026-08-19T12:00:00+00:00", 60, up=0.2) # Wednesday
# snapshot 1: bars 0..13 (own snapshot overlaps the signal bar)
lg.log(_m27_report(setup=False, quality="D",
ts=bars[13]["timestamp"]),
source="telegram", timeframe="5min",
dataframes={Timeframe.M5: _m27_df(bars[0:14])})
st = json.loads(lg._conn.execute(
"SELECT advisory_outcome_json FROM signals WHERE id=?",
(rowid,)).fetchone()[0])
assert st["status"] == "PENDING" and st["progress"]["bars_held"] == 13
# a DIFFERENT surface logged the middle bars (14..41) meanwhile,
# in two 14-bar snapshots that never overlap this outcome's
# last_processed_bar → each insert takes the union fallback
for lo, hi in ((14, 28), (28, 42)):
lg.log(_m27_report(setup=False, quality="D",
ts=bars[hi - 1]["timestamp"]),
source="dashboard", timeframe="15min",
dataframes={Timeframe.M5: _m27_df(bars[lo:hi])})
st = json.loads(lg._conn.execute(
"SELECT advisory_outcome_json FROM signals WHERE id=?",
(rowid,)).fetchone()[0])
assert st["status"] == "PENDING", st.get("reason")
assert st["progress"]["bars_held"] == 41, st["progress"]
# this surface's next snapshot starts 28 bars after its last one
lg.log(_m27_report(setup=False, quality="D",
ts=bars[55]["timestamp"]),
source="telegram", timeframe="5min",
dataframes={Timeframe.M5: _m27_df(bars[42:56])})
st = json.loads(lg._conn.execute(
"SELECT advisory_outcome_json FROM signals WHERE id=?",
(rowid,)).fetchone()[0])
assert st["status"] == "PENDING", st.get("reason")
assert st["progress"]["bars_held"] == 55, st["progress"]
# a REAL hole (bars 56, 57 never logged anywhere) is still terminal
lg.log(_m27_report(setup=False, quality="D",
ts=bars[59]["timestamp"]),
source="telegram", timeframe="5min",
dataframes={Timeframe.M5: _m27_df(bars[58:60])})
st = json.loads(lg._conn.execute(
"SELECT advisory_outcome_status, advisory_outcome_json"
" FROM signals WHERE id=?", (rowid,)).fetchone()[1])
assert st["status"] == "UNRESOLVABLE" and "gap" in st["reason"]
# bounded re-seed → PENDING again, count stamped, second call no-op
assert lg.reseed_unresolvable() == [rowid]
st = json.loads(lg._conn.execute(
"SELECT advisory_outcome_json FROM signals WHERE id=?",
(rowid,)).fetchone()[0])
assert st["status"] == "PENDING" and st["reseed"]["count"] == 1
lg._conn.execute(
"UPDATE signals SET advisory_outcome_status='UNRESOLVABLE',"
" advisory_outcome_json=json_set(advisory_outcome_json,"
" '$.status', 'UNRESOLVABLE', '$.reason', 'internal gap')"
" WHERE id=?", (rowid,))
lg._conn.commit()
assert lg.reseed_unresolvable() == [], "max_reseeds=1 is a hard bound"
lg.close()
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("signal_log: backfill_seeds keys legacy rows, dedups, seeds, keeps panel outcomes")
def _():
import signal_log as slog
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
a = lg.log(_m27_report(), source="telegram", timeframe="5min")
b = lg.log(_m27_report(), source="dashboard", timeframe="5min")
c = lg.log(_m27_report(entry=2460.0, sl=2455.0), source="telegram",
timeframe="5min")
d = lg.log(_m27_report(), source="telegram", timeframe="5min")
# simulate legacy rows: strip the new columns / statuses, keep one
# panel-seeded outcome on `c` and a panel-seeded RESOLVED re-log `d`
lg._conn.execute(
"UPDATE signals SET setup_key=NULL, advisory_event_id=NULL,"
" advisory_outcome_status=NULL, advisory_outcome_json=NULL"
" WHERE id IN (?, ?)", (a, b))
lg._conn.execute(
"UPDATE signals SET setup_key=NULL, advisory_event_id=NULL,"
" advisory_outcome_status='RESOLVED', advisory_r_multiple=0.5"
" WHERE id IN (?, ?)", (c, d))
lg._conn.commit()
stats = lg.backfill_seeds()
assert stats == {"keyed": 2, "duplicates": 2, "seeded": 1}, stats
rows = {r[0]: r[1:] for r in lg._conn.execute(
"SELECT id, advisory_event_id, advisory_outcome_status,"
" advisory_r_multiple FROM signals")}
assert rows[a] == (a, "PENDING", None)
assert rows[b] == (a, "DUPLICATE", None)
assert rows[c] == (c, "RESOLVED", 0.5), "existing outcome untouched"
assert rows[d] == (a, "DUPLICATE", None), \
"a RESOLVED re-log of the same trade is still a duplicate"
assert json.loads(lg._conn.execute(
"SELECT advisory_outcome_json FROM signals WHERE id=?",
(d,)).fetchone()[0])["superseded_status"] == "RESOLVED"
assert lg.backfill_seeds() == {"keyed": 2, "duplicates": 0,
"seeded": 0}, "idempotent"
lg.close()
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("signal_log: news blobs are interned once; view and read_news resolve both eras")
def _():
import sqlite3
import signal_log as slog
from scripts.migrate_signal_log_news import migrate, prune_orphans
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
ctx = _make_log_context()
r1 = lg.log(_m27_report(), context=ctx, source="telegram",
timeframe="5min")
r2 = lg.log(_m27_report(setup=False, quality="D"), context=ctx,
source="dashboard", timeframe="5min")
n = lg._conn.execute("SELECT COUNT(*) FROM news_snapshots").fetchone()[0]
assert n == 2, "one headlines blob + one events blob, shared by both rows"
h1, h2 = [r[0] for r in lg._conn.execute(
"SELECT headlines_hash FROM signals ORDER BY id")]
assert h1 == h2
# legacy row: inline blob, no hash
lg._conn.execute(
"INSERT INTO signals (logged_at, source, symbol, timeframe, signal,"
" headlines_json, events_json) VALUES (?, 'cli', 'XAU/USD', '1h',"
" 'NEUTRAL', ?, ?)",
(datetime.now(timezone.utc).isoformat(),
'[{"headline": "legacy"}]', '{"events_today": []}'))
lg._conn.commit()
legacy_id = lg._conn.execute("SELECT MAX(id) FROM signals").fetchone()[0]
ev, hl = slog.read_news(lg._conn, legacy_id)
assert json.loads(hl)[0]["headline"] == "legacy"
ev, hl = lg.news_for(r1)
assert json.loads(hl)[0]["headline"] == "Fed signals rate hike"
view = dict(lg._conn.execute(
"SELECT id, headlines_resolved FROM signals_with_news").fetchall())
assert json.loads(view[legacy_id])[0]["headline"] == "legacy"
assert json.loads(view[r2])[0]["headline"] == "Fed signals rate hike"
# migration moves the legacy blob and is idempotent
stats = migrate(lg._conn)
assert stats["rows"] == 1 and stats["headlines_interned"] == 1
assert migrate(lg._conn)["rows"] == 0
row = lg._conn.execute(
"SELECT headlines_json, headlines_hash FROM signals WHERE id=?",
(legacy_id,)).fetchone()
assert row[0] is None and row[1]
assert json.loads(slog.read_news(lg._conn, legacy_id)[1])[0][
"headline"] == "legacy"
lg._conn.execute("DELETE FROM signals WHERE id=?", (legacy_id,))
lg._conn.commit()
assert prune_orphans(lg._conn) == 2, "legacy headline + event blobs"
lg.close()
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("signal_log: gate_reason names the suppressing gate; actionable ignores stale action text")
def _():
import signal_log as slog
gr = slog.gate_reason
assert gr(["TREND EXHAUSTION — x"], [], "A", True) is None
assert gr(["TREND EXHAUSTION — x",
"SELL FILTER: quality C below minimum for SELL"], [], "D",
False).startswith("SELL FILTER")
assert gr(["M5 FILTER: |score| 20.0 < 25.0 minimum",
"SESSION FILTER: 13:00 UTC blocked"], [], "F",
False).startswith("SESSION FILTER"), "gate order, not list order"
assert gr([], ["MTF OPPOSITION: majority"], "B", False).startswith(
"MTF OPPOSITION"), "cleaned (CP) warnings count too"
assert gr(["NO PULLBACK — x"], [], "B", False).startswith(
"SETUP GENERATOR")
assert gr([], [], "D", False) == "GRADE D: below setup threshold"
assert gr([], [], "", False) is None
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
r = _m27_report(setup=False, quality="D",
warnings=["SELL FILTER: quality C below minimum for SELL"])
r.action_recommendation = "STRONG SELL — High-quality setup, consider entry"
r.cleaned_warnings = ["SPREAD GATE: target $3.00 < $9.00 minimum — x"]
rowid = lg.log(r, source="telegram", timeframe="5min")
row = lg._conn.execute(
"SELECT action, actionable, gate_reason, cleaned_warnings_json,"
" advisory_outcome_status FROM signals WHERE id=?",
(rowid,)).fetchone()
assert row[0].startswith("STRONG SELL") and row[1] == 0, \
"action text predates the gate chain; actionable is the truth"
assert row[2].startswith("SELL FILTER")
assert json.loads(row[3])[0].startswith("SPREAD GATE")
assert row[4] is None, "no setup → no outcome seed"
lg.close()
finally:
slog.reset_loggers_for_tests()
os.remove(path)
@test("signal_aggregator: XAU_CP warning cleanup keeps the dropped warnings for the log")
def _():
from signal_aggregator import SignalAggregator, SignalReport
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
r.warnings = [
"SPREAD GATE: target $3.00 < $9.00 minimum — x",
"TREND EXHAUSTION — price extended >3.5 ATR past swing low",
"MTF OPPOSITION: majority of timeframes point against signal — no trade",
"NO PULLBACK — price 2.8 ATR from EMA21",
"REGIME FILTER: 'trending' blocked for XAU",
"Low volatility — potential for breakout",
]
SignalAggregator._clean_accepted_cp_warnings(r)
assert r.warnings == [
"TREND EXHAUSTION — price extended >3.5 ATR past swing low",
"Low volatility — potential for breakout",
], r.warnings
assert sorted(r.cleaned_warnings) == sorted([
"SPREAD GATE: target $3.00 < $9.00 minimum — x",
"MTF OPPOSITION: majority of timeframes point against signal — no trade",
"REGIME FILTER: 'trending' blocked for XAU",
"NO PULLBACK — price 2.8 ATR from EMA21",
]), r.cleaned_warnings
@test("signal_aggregator: gate config fingerprint changes with the gate config")
def _():
from pair_signals.xauusd import make_config
from signal_aggregator import SignalAggregator
a = SignalAggregator(make_config(Timeframe.M5))
b = SignalAggregator(make_config(Timeframe.M5))
assert a.gate_config_fingerprint() == b.gate_config_fingerprint()
assert len(a.gate_config_fingerprint()) == 12
b.config.xau_strategy.blocked_hours_utc = list(
b.config.xau_strategy.blocked_hours_utc) + [12]
assert a.gate_config_fingerprint() != b.gate_config_fingerprint()
m15 = SignalAggregator(make_config(Timeframe.M15))
assert m15.gate_config_fingerprint() != a.gate_config_fingerprint()
@test("app_config: signal_log.log_blocked_scans is validated and env-overridable")
def _():
app = load_app_config(_write_json({"signal_log": {"log_blocked_scans": False}}))
assert app_config.signal_log_blocked_scans(app) is False
assert app_config.signal_log_blocked_scans(load_app_config(_write_json({}))) is True
old = os.environ.get("SIGNAL_LOG_BLOCKED_SCANS")
try:
os.environ["SIGNAL_LOG_BLOCKED_SCANS"] = "true"
assert app_config.signal_log_blocked_scans(app) is True
finally:
if old is None:
os.environ.pop("SIGNAL_LOG_BLOCKED_SCANS", None)
else:
os.environ["SIGNAL_LOG_BLOCKED_SCANS"] = old
try:
load_app_config(_write_json({"signal_log": {"log_blocked_scans": "no"}}))
assert False, "must reject a non-bool"
except AppConfigError:
pass
print("\n═══ Module 28: 2026-09-04 audit fixes ═══")
@test("signal_log: inverted logged_at (write-lock race) still marks DUPLICATE")
def _():
# logged_at is stamped BEFORE the row wins the SQLite write lock
# (busy_timeout 5 s), so two processes logging the same setup at one
# candle boundary can land with timestamp order inverted against id
# order. That must not be read as a second, independent trade.
import signal_log as slog
real_dt = slog.datetime
stamp = {"v": "2026-08-19T12:00:30+00:00"}
class _FakeDT:
@staticmethod
def now(tz=None):
return real_dt.fromisoformat(stamp["v"])
@staticmethod
def fromisoformat(s):
return real_dt.fromisoformat(s)
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
lg = slog.SignalLogger(path)
slog.datetime = _FakeDT
r1 = lg.log(_m27_report(), source="telegram", timeframe="5min")
stamp["v"] = "2026-08-19T12:00:00+00:00" # EARLIER, but higher id
r2 = lg.log(_m27_report(), source="dashboard", timeframe="5min")
slog.datetime = real_dt
rows = {r[0]: r[1:] for r in lg._conn.execute(
"SELECT id, advisory_event_id, advisory_outcome_status FROM signals")}
assert rows[r2][1] == "DUPLICATE", (
f"negative gap is a lock-order inversion, not a new trade: {rows}")
assert rows[r2][0] == r1
lg.close()
finally:
slog.datetime = real_dt
os.unlink(path)
@test("analyze_signal_log: forward return is None until the horizon elapsed")
def _():
import analyze_signal_log as asl
idx = pd.date_range("2026-08-19T00:00:00Z", periods=24, freq="1h")
df = pd.DataFrame({"Close": [2400.0 + i for i in range(24)]}, index=idx)
ts = idx[-3].to_pydatetime()
assert asl._forward_return(df, ts, 2400.0, 1) is not None, \
"an elapsed horizon must still measure"
assert asl._forward_return(df, ts, 2400.0, 24) is None, \
"an unreached horizon must be None, not the latest close"
assert asl._forward_return(df, idx[-1].to_pydatetime(), 2400.0, 1) is None
@test("analyze_signal_log: persona scorecard counts each event once, not per row")
def _():
import sqlite3
import types as _types
import analyze_signal_log as asl
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
con = sqlite3.connect(path)
con.execute("CREATE TABLE signals (id INTEGER PRIMARY KEY,"
" panel_takes_json TEXT)")
takes = json.dumps([{"persona": "macro", "stance": "support",
"confidence": 70, "note": "x"}])
con.executemany(
"INSERT INTO signals (id, panel_takes_json) VALUES (?,?)",
[(1, takes), (2, takes)])
con.commit()
con.close()
# ONE event whose two rows both carry panel takes (same scan
# re-logged inside the cluster window).
ev = _types.SimpleNamespace(
rows=[_types.SimpleNamespace(id=1), _types.SimpleNamespace(id=2)],
r_headline=2.0)
out = asl.persona_scorecard([], [ev], path)
assert len(out) == 1, out
assert out[0]["n"] == 1, f"two rows of ONE event must count once: {out}"
assert out[0]["mean_r"] == 2.0
finally:
os.unlink(path)
@test("llm_client: failure warnings re-arm after a recovered call")
def _():
import requests as _req
import llm_client as _lc
class _Resp:
status_code = 200
def __init__(self, payload):
self._p = payload
def json(self):
return self._p
class _Sess:
def __init__(self, seq):
self.seq = list(seq)
def post(self, *a, **k):
item = self.seq.pop(0)
if isinstance(item, Exception):
raise item
return item
c = _lc.DeepSeekClient("key")
c._session = _Sess([
_req.RequestException("boom"),
_Resp({"choices": [{"message": {"content": '{"ok": 1}'}}]}),
])
assert c.chat_json("sys", {}) is None
assert "network" in c._warned, "first failure must arm the warn-once flag"
assert c.chat_json("sys", {}) == {"ok": 1}
assert "network" not in c._warned, \
"a recovered call must re-arm so the NEXT outage logs at WARNING"
@test("advisory_outcome: evaluator errors are logged, not silently UNRESOLVABLE")
def _():
import advisory_outcome as ao
records = []
class _H(logging.Handler):
def emit(self, record):
records.append(record)
handler = _H()
ao.logger.addHandler(handler)
try:
# progress={} makes the evaluator raise KeyError internally.
out = ao.advance_outcome(
{"status": "PENDING", "progress": {}, "setup": {}, "policy": {},
"signal_bar_at": "2026-08-19T12:00:00+00:00", "timeframe": "1h"},
[])
finally:
ao.logger.removeHandler(handler)
assert out["status"] == "UNRESOLVABLE", "must stay fail-safe"
assert out["result"] is None
assert records and max(r.levelno for r in records) >= logging.ERROR, \
"a real evaluator fault must be visible in the log"
@test("migrate: prune_news_older_than prunes the cutoff calendar day")
def _():
import sqlite3
import importlib.util
spec = importlib.util.spec_from_file_location(
"_mig_test", os.path.join(os.path.dirname(os.path.abspath(__file__)),
"scripts", "migrate_signal_log_news.py"))
mig = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mig)
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
con = sqlite3.connect(path)
con.execute("CREATE TABLE signals (id INTEGER PRIMARY KEY,"
" logged_at TEXT, headlines_hash TEXT, events_hash TEXT)")
con.execute("CREATE TABLE news_snapshots (hash TEXT PRIMARY KEY,"
" body TEXT)")
now = datetime.now(timezone.utc)
# Genuinely older than the 30-day cutoff, but on the SAME calendar
# day as it — the case the raw string compare put on the wrong side
# ('T' sorts after ' ').
boundary = (now - timedelta(days=30)).replace(
hour=0, minute=0, second=1, microsecond=0).isoformat()
con.executemany(
"INSERT INTO signals VALUES (?,?,?,?)",
[(1, boundary, "h1", "e1"), (2, now.isoformat(), "h2", "e2")])
con.executemany("INSERT INTO news_snapshots VALUES (?,?)",
[("h1", "{}"), ("e1", "{}"), ("h2", "{}"), ("e2", "{}")])
con.commit()
n = mig.prune_news_older_than(con, 30)
assert n == 1, f"the cutoff-day row must prune, got {n}"
kept = con.execute(
"SELECT headlines_hash FROM signals ORDER BY id").fetchall()
assert kept == [(None,), ("h2",)], kept
con.close()
finally:
os.unlink(path)
# ══════════════════════════════════════════════════════════════════════
# MODULE 29: stake-multiplier sizing — features + record-keeping
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 29: stake-multiplier sizing ═══")
import position_sizing as _ps
#: Every tag name that _compute_final_signal can append to `penalties`.
_M29_KNOWN_TAGS = {
"mtf_disagreement", "news_conflict", "event_caution", "counter_trend",
"extreme_vol", "ema200_near", "ema200_far", "exhaustion",
"no_pullback", "weak_pullback", "rsi_extended", "bb_overextended",
}
def _m29_ramp(n=300, start=2400.0, step=1.2, noise=0.25, seed=1, freq="5min"):
"""Steep, low-noise uptrend — rides the upper Bollinger band, so the
bb_overextended / no_pullback / rsi_extended penalties fire."""
rng = np.random.default_rng(seed)
idx = pd.date_range(end=pd.Timestamp("2026-08-19 12:00", tz="UTC"),
periods=n, freq=freq)
close = start + np.arange(n) * step + rng.normal(0, noise, n)
high = close + np.abs(rng.normal(0, noise, n)) + 0.2
low = close - np.abs(rng.normal(0, noise, n)) - 0.2
open_ = close - rng.normal(0, noise, n)
df = pd.DataFrame({"Open": open_, "High": high, "Low": low,
"Close": close, "Volume": np.full(n, 1000.0)},
index=idx)
df.index.name = "Datetime"
return df
_M29_CACHE = {}
def _m29_live(n=1500):
"""One REAL analyze_from_dataframes run (M5 primary) + a synthetic
BUY setup on its close. Cached — the replay is the slow part."""
key = ("live", n)
if key not in _M29_CACHE:
cfg = PlatformConfig()
cfg.primary_timeframe = Timeframe.M5
cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H1]
dfs = {
Timeframe.M5: _m29_ramp(n=n, seed=5),
Timeframe.M15: _m29_ramp(n=400, seed=55, freq="15min"),
Timeframe.H1: _m29_ramp(n=200, seed=95, freq="1h"),
}
report = SignalAggregator(cfg).analyze_from_dataframes(
"XAU/USD", dfs, use_news=False)
px = report.current_price
setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=px, stop_loss=px - 6.0, take_profit_1=px + 12.0,
take_profit_2=px + 20.0, risk_reward=2.0, risk_pips=60.0,
reward_pips=120.0, pip_value=0.10, strategy="XAU_ATR")
_M29_CACHE[key] = (cfg, dfs, report, setup)
return _M29_CACHE[key]
def _m29_fixture(direction="BUY", score=42.0):
"""Hand-built report/setup/frames — no engine run, full control over
key_levels, indicators and MTF scores. Levels are pinned RELATIVE to
the frame's last close, which is the price build_features reads."""
dfs = {
Timeframe.M5: _m29_ramp(n=300, seed=7),
Timeframe.M15: _m29_ramp(n=120, seed=8, freq="15min"),
Timeframe.H1: _m29_ramp(n=120, seed=9, freq="1h"),
}
px = float(dfs[Timeframe.M5]["Close"].iloc[-1])
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
timestamp="2026-08-19T12:00:00+00:00")
report.final_score = score
report.final_confidence = 61.0
report.signal_quality = "A"
report.mtf_agreement = True
report.mtf_scores = {Timeframe.M5.value: 30.0, Timeframe.M15.value: 22.0,
Timeframe.H1.value: 14.0}
report.current_price = px
report.exhaustion_count = 2
report.penalty_tags = {"no_pullback": 8.0, "rsi_extended": 5.0}
report.penalty_total = 10.5
report.momentum_fading = True
report.primary_analysis = TechnicalAnalysis(
symbol="XAU/USD", timeframe=Timeframe.M5.value,
timestamp=report.timestamp,
indicators={
"rsi": IndicatorSignal("RSI", 68.0, SignalStrength.BUY, 70.0, "", 1.5),
"bollinger": IndicatorSignal("BB", 0.9, SignalStrength.SELL, 60.0, "", 1.5),
"adx": IndicatorSignal("ADX", 31.0, SignalStrength.BUY, 60.0, "", 0.5),
"keltner": IndicatorSignal("KC", 4.0, SignalStrength.BUY, 60.0, "", 1.5),
"roc": IndicatorSignal("ROC", 0.8, SignalStrength.BUY, 60.0, "", 1.5),
"macd": IndicatorSignal("MACD", 1.25, SignalStrength.BUY, 60.0, "", 1.5),
"atr": IndicatorSignal("ATR", 5.0, SignalStrength.NEUTRAL, 50.0, "", 0.0),
},
composite_signal=SignalStrength.BUY, composite_score=score,
confidence=61.0, market_regime="trending_up", volatility_regime="normal",
key_levels={
"atr": 5.0, "atr_pct": 0.2,
"ema_medium": px - 10.0, "ema_trend": px - 50.0,
"swing_low_1": px - 30.0, "swing_low_2": px - 40.0,
"swing_low_3": px - 50.0,
"swing_high_1": px + 10.0, "swing_high_2": px + 15.0,
"swing_high_3": px + 20.0,
},
)
setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction=direction,
entry_price=px, stop_loss=px - 6.0 if direction == "BUY" else px + 6.0,
take_profit_1=px + 12.0 if direction == "BUY" else px - 12.0,
take_profit_2=px + 20.0 if direction == "BUY" else px - 20.0,
risk_reward=2.0, risk_pips=60.0, reward_pips=120.0, pip_value=0.10,
strategy="XAU_PB")
return report, setup, dfs
@test("sizing: SignalReport records penalty tags incl. bb_overextended")
def _():
_cfg, _dfs, report, _setup = _m29_live()
assert report.penalty_tags, "penalty tags must be recorded"
assert "bb_overextended" in report.penalty_tags, \
f"bb_overextended must be recorded, got {report.penalty_tags}"
assert report.penalty_tags["bb_overextended"] == 8.0, "raw pre-decay amount"
# The tag is invisible in `warnings` — that is exactly why the
# record-keeping dict exists.
assert not any("bb_overextended" in w for w in report.warnings), \
f"bb_overextended must not surface as a warning: {report.warnings}"
unknown = set(report.penalty_tags) - _M29_KNOWN_TAGS
assert not unknown, f"unknown penalty tags recorded: {unknown}"
@test("sizing: penalty_total equals the decayed sum subtracted from confidence")
def _():
_cfg, _dfs, report, _setup = _m29_live()
assert len(report.penalty_tags) >= 2, \
f"need >=2 penalties to exercise the decay, got {report.penalty_tags}"
max_penalty, total, decay = 30.0, 0.0, 1.0
for _name, amount in sorted(report.penalty_tags.items(),
key=lambda kv: kv[1], reverse=True):
applied = amount * decay
if total + applied > max_penalty:
applied = max_penalty - total
total += applied
decay *= 0.5
if total >= max_penalty:
break
assert abs(total - report.penalty_total) < 1e-9, \
f"recomputed {total} != recorded {report.penalty_total}"
@test("sizing: build_features returns every FEATURE_ORDER key, all finite")
def _():
cfg, dfs, report, setup = _m29_live()
feats = _ps.build_features(report, setup, dfs, Timeframe.M5,
cfg.confirmation_timeframes)
assert list(feats.keys()) == list(_ps.FEATURE_ORDER), \
"keys must be exactly FEATURE_ORDER, in order"
assert len(_ps.FEATURE_ORDER) == len(set(_ps.FEATURE_ORDER)), \
"FEATURE_ORDER must not contain duplicates"
for name, value in feats.items():
assert type(value) is float, f"{name} is {type(value)}, not float"
assert np.isfinite(value), f"{name} is not finite: {value}"
assert all(n.startswith("f_") for n in _ps.FEATURE_ORDER)
@test("sizing: exhaustion features are signed by TRADE direction, not score sign")
def _():
report, setup, dfs = _m29_fixture(direction="SELL", score=42.0)
assert report.final_score > 0 and setup.direction == "SELL"
feats = _ps.build_features(report, setup, dfs, Timeframe.M5,
[Timeframe.M15, Timeframe.H1])
# SELL must measure to the HIGHEST tracked swing high (px + 20), not
# the lowest swing low (px - 50), and the EMA21 distance (px - 10)
# must carry the SELL sign. ATR = 5.
assert abs(feats["f_swing_dist_atr"] - 20.0 / 5.0) < 1e-9, \
f"expected swing-high distance 4.0, got {feats['f_swing_dist_atr']}"
assert abs(feats["f_ema21_dist_atr"] - (-10.0 / 5.0)) < 1e-9, \
f"expected SELL-signed EMA21 distance -2.0, got {feats['f_ema21_dist_atr']}"
assert feats["f_score_dir"] < 0, "a SELL on a positive score scores negative"
assert feats["f_is_buy"] == 0.0
@test("sizing: direction-signed features flip between a BUY and its mirrored SELL")
def _():
report, buy, dfs = _m29_fixture(direction="BUY")
_r2, sell, _d2 = _m29_fixture(direction="SELL")
a = _ps.build_features(report, buy, dfs, Timeframe.M5,
[Timeframe.M15, Timeframe.H1])
b = _ps.build_features(report, sell, dfs, Timeframe.M5,
[Timeframe.M15, Timeframe.H1])
for name in ("f_ema21_dist_atr", "f_ret_12_atr", "f_rsi_dir",
"f_score_dir", "f_mtf_primary"):
assert a[name] != 0.0, f"{name} must be non-zero for the flip test"
assert abs(a[name] + b[name]) < 1e-9, \
f"{name} must flip sign: {a[name]} vs {b[name]}"
for name in ("f_atr", "f_hour_utc", "f_atr_pct", "f_weekday",
"f_penalty_total"):
assert a[name] == b[name], f"{name} must be direction-independent"
@test("sizing: decision time is last closed label + candle duration")
def _():
from providers.base import BaseDataProvider as _BDP
for tf in _ps.CANDLE_SECONDS:
assert _ps.CANDLE_SECONDS[tf] == _BDP.CANDLE_SECONDS[tf], \
f"CANDLE_SECONDS copy diverged on {tf}"
for tf, freq in ((Timeframe.M5, "5min"), (Timeframe.M15, "15min"),
(Timeframe.H1, "1h"), (Timeframe.H4, "4h")):
df = _m29_ramp(n=30, freq=freq)
expected = df.index[-1] + pd.Timedelta(seconds=_ps.CANDLE_SECONDS[tf])
assert _ps.decision_time(df, tf) == expected, tf
# The decision clock, not the bar label, drives the time features.
df = _m29_ramp(n=30, freq="1h")
report, setup, _dfs = _m29_fixture()
feats = _ps.build_features(report, setup, {Timeframe.H1: df},
Timeframe.H1, [])
assert feats["f_hour_utc"] == float(
(df.index[-1] + pd.Timedelta(hours=1)).hour)
assert _ps.session_label(4) == "asian" and _ps.session_label(9) == "london"
assert _ps.session_label(14) == "overlap" and _ps.session_label(20) == "ny"
assert _ps.session_label(1) == "off" and _ps.session_label(23) == "off"
@test("sizing: features identical on a full frame and on its live-depth tail")
def _():
cfg, dfs, report, setup = _m29_live()
assert len(dfs[Timeframe.M5]) > _ps.MAX_LOOKBACK_BARS, "need a long frame"
full = _ps.build_features(report, setup, dfs, Timeframe.M5,
cfg.confirmation_timeframes)
tail = _ps.build_features(
report, setup,
{Timeframe.M5: dfs[Timeframe.M5].iloc[-_ps.MAX_LOOKBACK_BARS:],
Timeframe.M15: dfs[Timeframe.M15].iloc[-_ps.CONFIRM_LOOKBACK_BARS:],
Timeframe.H1: dfs[Timeframe.H1].iloc[-_ps.CONFIRM_LOOKBACK_BARS:]},
Timeframe.M5, cfg.confirmation_timeframes)
assert full == tail, {
k: (full[k], tail[k]) for k in full if full[k] != tail[k]}
@test("sizing: stake_multiplier returns 1.0 with no artifact dir, missing file, or stale feature_version")
def _():
cfg, dfs, report, setup = _m29_live()
args = (report, setup, dfs, Timeframe.M5, cfg.confirmation_timeframes)
assert _ps.stake_multiplier(*args) == 1.0, "default (no dir) must be neutral"
assert _ps.stake_multiplier(*args, artifact_dir=None) == 1.0
with tempfile.TemporaryDirectory() as tmp:
assert _ps.stake_multiplier(*args, artifact_dir=tmp) == 1.0, \
"missing artifact file must be neutral"
path = _ps.artifact_path_for(Timeframe.M5, tmp)
assert path.name == "xauusd_5min.json", path.name
path.write_text(json.dumps({"feature_version": 999, "weights": {}}))
assert _ps.load_artifact(path)["feature_version"] == 999
assert _ps.stake_multiplier(*args, artifact_dir=tmp) == 1.0, \
"stale feature_version must be neutral"
path.write_text("{not json")
# mtime granularity: force a cache miss so the invalid body is read
os.utime(path, (0, 0))
assert _ps.load_artifact(path) is None, "invalid JSON -> None"
assert _ps.stake_multiplier(*args, artifact_dir=tmp) == 1.0
assert _ps.load_artifact(os.path.join(tmp, "nope.json")) is None
@test("sizing: build_features never raises on degenerate input")
def _():
report, setup, _dfs = _m29_fixture()
tiny = _m29_ramp(n=3, seed=3)
def _check(feats):
assert list(feats.keys()) == list(_ps.FEATURE_ORDER)
for name, value in feats.items():
assert type(value) is float and np.isfinite(value), (name, value)
return feats
# atr = 0 -> every ATR-normalised feature collapses to 0.0
report.primary_analysis.key_levels = {"atr": 0.0}
f = _check(_ps.build_features(report, setup, {Timeframe.M5: tiny},
Timeframe.M5, [Timeframe.M15]))
for name in ("f_sl_atr", "f_tp1_atr", "f_swing_dist_atr",
"f_ema21_dist_atr", "f_bar_range_atr", "f_ret_6_atr"):
assert f[name] == 0.0, f"{name} must be 0.0 when ATR is unusable"
assert f["f_bars_since_burst"] == 200.0, "sentinel when no burst in window"
assert f["f_rsi"] == 68.0 and f["f_bb_pos"] == 0.9, \
"un-normalised indicator readings survive an unusable ATR"
# empty key_levels + no indicators + no confirmation frames
report.primary_analysis.key_levels = {}
report.primary_analysis.indicators = {}
f = _check(_ps.build_features(report, setup, {Timeframe.M5: tiny},
Timeframe.M5, []))
assert f["f_rsi"] == 50.0 and f["f_bb_pos"] == 0.5, \
"missing indicators fall back to their neutral readings"
assert f["f_ind_agree_frac"] == 0.0 and f["f_ind_oppose_frac"] == 0.0
# no primary analysis at all
report.primary_analysis = None
_check(_ps.build_features(report, setup, {Timeframe.M5: tiny},
Timeframe.M5, [Timeframe.M15, Timeframe.H1]))
# no frames, no setup
_check(_ps.build_features(report, None, {}, Timeframe.M5, []))
_check(_ps.build_features(None, None, {Timeframe.M5: tiny.iloc[:0]},
Timeframe.M5, None))
@test("sizing: TradeSetup.stake_multiplier defaults to None (no sizing overlay ships)")
def _():
# 2026-09-06 verdict: the stake-multiplier model FAILED the pre-registered
# rule on M5 and M15, so no SizingConfig / artifact exists. The field is
# reserved (None = unsized) and the backtester reads it as 1.0.
ts = TradeSetup(pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=2450.0, stop_loss=2444.0, take_profit_1=2462.0,
take_profit_2=2470.0, risk_reward=2.0, risk_pips=60.0,
reward_pips=120.0, pip_value=0.10)
assert ts.stake_multiplier is None
assert not hasattr(PlatformConfig(), "sizing"), "rule 8: no unread config section"
_M29_PINNED_COLS = [
"pair", "strategy", "quality", "regime", "vol_regime", "direction",
"entry_time", "entry_weekday", "entry", "sl", "tp1", "rr_planned", "score",
"confidence", "indicators_agree", "indicators_oppose", "indicators_neutral",
"indicators_total", "entry_reasons", "warnings", "exit_time", "exit",
"exit_reason", "bars_held", "r_multiple", "mfe_r", "mae_r",
]
_M29_IND_NAMES = ["ema_trend", "rsi", "bollinger", "adx", "macd", "roc",
"keltner", "candlestick", "divergence"]
def _m29_trade(r_multiple=1.0, stake=1.0, features=None):
"""A resolved backtest Trade with a full 9-indicator breakdown."""
from gpu_backtest import Trade
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", volatility_regime="normal",
strategy_tag="GENERIC",
entry_reasons=["r1"], entry_warnings=["w1"])
t.indicator_breakdown = {
name: {"signal": "BUY", "value": 1.0 + i, "confidence": 60.0,
"agrees": True}
for i, name in enumerate(_M29_IND_NAMES)
}
t.indicators_agreeing, t.indicators_total = 9, 9
t.exit_time = pd.Timestamp("2026-01-05 12:00", tz="UTC")
t.exit_price = 3312.0
t.exit_reason = "TP1"
t.bars_held = 24
t.r_multiple = r_multiple
t.mfe_r, t.mae_r = 1.8, 0.3
if features is not None:
t.features = features
t.stake_multiplier = stake
return t
@test("sizing: Trade defaults keep features empty and stake_multiplier 1.0")
def _():
from gpu_backtest import Trade
bare = 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")
assert bare.features == {}, "an unsized trade carries no features"
assert bare.stake_multiplier == 1.0, "neutral stake by default"
# Two Trades must not share one features dict (mutable default trap).
bare.features["f_hour_utc"] = 9.0
assert 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").features == {}
@test("sizing: trades_to_dataframe default export keeps the pinned column set")
def _():
from gpu_backtest import trades_to_dataframe
feats_a = {name: float(i) for i, name in enumerate(_ps.FEATURE_ORDER)}
trades = [
_m29_trade(r_multiple=1.5, stake=1.25, features=feats_a),
_m29_trade(r_multiple=-1.0, stake=0.75, features=dict(feats_a)),
]
off = trades_to_dataframe(trades)
assert len(off.columns) == 63, \
f"pinned books are 63 columns, got {len(off.columns)}"
assert list(off.columns[:27]) == _M29_PINNED_COLS, list(off.columns[:27])
assert not any(c.startswith("f_") or c in ("stake_multiplier", "r_sized")
for c in off.columns), "feature columns must stay off by default"
on = trades_to_dataframe(trades, include_features=True)
expected = (_M29_PINNED_COLS + list(off.columns[27:])
+ ["stake_multiplier", "r_sized"] + list(_ps.FEATURE_ORDER))
assert len(on.columns) == 63 + 2 + len(_ps.FEATURE_ORDER), \
f"expected {63 + 2 + len(_ps.FEATURE_ORDER)} columns, got {len(on.columns)}"
assert list(on.columns) == expected, \
[c for c in zip(list(on.columns), expected) if c[0] != c[1]][:5]
# The default export must be a strict prefix — pinned books stay diffable.
pd.testing.assert_frame_equal(off, on[off.columns])
for i in range(len(trades)):
assert on["r_sized"].iloc[i] == (
on["r_multiple"].iloc[i] * on["stake_multiplier"].iloc[i])
assert on["f_hour_utc"].iloc[i] == feats_a["f_hour_utc"]
# A trade with no features (flag on, but nothing computed) → NaN, not 0.
partial = trades_to_dataframe([_m29_trade()], include_features=True)
assert np.isnan(partial["f_atr"].iloc[0]), "missing features export as NaN"
assert partial["stake_multiplier"].iloc[0] == 1.0
# 2026-09-05 regression: when the FIRST trade lacks an indicator that a
# later trade carries, pandas appends that indicator's columns at first
# appearance. The extras must still land after every base column so the
# default export stays a strict prefix (seen on the 3yr M5 book, whose
# first trade had no candlestick pattern).
first, second = _m29_trade(features=feats_a), _m29_trade(features=dict(feats_a))
for suffix in ("candlestick",):
first.indicator_breakdown.pop(suffix)
off2 = trades_to_dataframe([first, second])
on2 = trades_to_dataframe([first, second], include_features=True)
assert list(on2.columns[:len(off2.columns)]) == list(off2.columns), \
"extras must follow ALL base columns even when a later trade adds one"
assert list(on2.columns[len(off2.columns):]) == (
["stake_multiplier", "r_sized"] + list(_ps.FEATURE_ORDER))
pd.testing.assert_frame_equal(off2, on2[off2.columns])
@test("sizing: --export-features parses and defaults off")
def _():
from gpu_backtest import _parse_args
assert _parse_args(["--pair", "XAUUSD"]).export_features is False
args = _parse_args(["--pair", "XAUUSD", "--export-features",
"--export-csv", "/tmp/x.csv"])
assert args.export_features is True
assert args.export_csv == "/tmp/x.csv"
# ══════════════════════════════════════════════════════════════════════
# 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)