2e332545a9
Delivery-side fixes from the 2026-09-08 three-day signal-log + telegram-log review. Nothing in the signal core changes: the 3-year H1 and M15 books at $0.65 were regenerated before/after and are identical on every trade column (H1 253 / +70.02R / PF 1.98, M15 1092 / +151.90R / PF 1.38); only the exported `warnings` text differs, on 50/253 H1 and 358/1092 M15 trades. - signal_aggregator: step 7 moves into `_attach_core_setup`; a final pass after the PB/CP archetypes (`_drop_stale_spread_gate_vetoes`) moves a core generator's "SPREAD GATE: ... costs would eat the edge" veto to `cleaned_warnings` whenever a later generator shipped a setup. The 2026-09-08 07:45 UTC A-grade alert carried that line beside a $26.49 TP1; a fifth of H1 and a third of M15 A-grade alerts had been doing the same. The prefix is now the `_SPREAD_GATE_PREFIX` constant shared by both emitters and both cleanups. - telegram_bot: alerts require a trade setup (`_is_alert_candidate`, shared by the scan and breach paths; no-setup candidates log `alert skipped: ... (gate reason)`). The 07:50 UTC B-grade SELL with no setup had gone out under the same warning. - telegram_bot: every delivered alert / breach alert / violent-move heads-up logs an `... sent:` INFO line with the signal-log row id (only failures were logged before, so the review had to reconstruct sends from panel rows). Send fan-out is one `_broadcast` helper for both paths. - telegram_bot: `telegram.log` rotates daily x 30 instead of 3-hourly x 240 (old `_HH` files on the live box need one manual delete). - signal_log / CLAUDE.md: `cleaned_warnings_json` is no longer a CP-only marker; docs say so. - test_all: 468 tests (+2): stale-veto cleanup incl. the PB-shaped case and the both-vetoed case, and the alert-candidate rule with its skip line; the rotation test asserts MIDNIGHT / interval 86400 / 30 backups. Claude-Session: https://claude.ai/code/session_018xY4NPPrx6NJoG6QWHzwpd
10760 lines
442 KiB
Python
10760 lines
442 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
ForexBot2 — Comprehensive QA Test Suite
|
||
=========================================
|
||
Tests every function in the codebase without requiring live API connections.
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
import time
|
||
import threading
|
||
import tempfile
|
||
import traceback
|
||
from datetime import datetime, timedelta, timezone
|
||
from unittest.mock import patch
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
sys.path.insert(0, os.path.dirname(__file__))
|
||
|
||
# ── Hermetic app-config: the suite must not depend on the developer's
|
||
# local config.json or TELEGRAM_*/TWELVE_DATA_API_KEY env vars. The
|
||
# dummy TD key mirrors the pre-config.json behavior where a key was
|
||
# always present (TDClient built, no network at construction).
|
||
import json
|
||
import app_config
|
||
for _k in ("TELEGRAM_TOKEN", "TELEGRAM_CHAT_ID", "TELEGRAM_CHANNEL_ID",
|
||
"TWELVE_DATA_API_KEY", "DEEPSEEK_API_KEY"):
|
||
os.environ.pop(_k, None)
|
||
_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 for XAU pair")
|
||
def _():
|
||
base, quote = NewsEngine._parse_pair("XAU/USD")
|
||
assert base == "XAU"
|
||
assert quote == "USD"
|
||
|
||
|
||
@test("NewsEngine._analyze_headline_sentiment bullish")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
score = ne._analyze_headline_sentiment("Fed hawkish rate hike boosts dollar rally")
|
||
assert score > 0, f"Expected positive sentiment, got {score}"
|
||
|
||
|
||
@test("NewsEngine._analyze_headline_sentiment bearish")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
score = ne._analyze_headline_sentiment("Recession fears drive dovish rate cut expectations")
|
||
assert score < 0, f"Expected negative sentiment, got {score}"
|
||
|
||
|
||
@test("NewsEngine._analyze_headline_sentiment neutral")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
score = ne._analyze_headline_sentiment("Markets remain quiet ahead of data release")
|
||
assert score == 0.0
|
||
|
||
|
||
@test("NewsEngine._extract_currencies USD")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
currencies = ne._extract_currencies("Federal Reserve raises interest rates, dollar soars")
|
||
assert "USD" in currencies
|
||
|
||
|
||
@test("NewsEngine._extract_currencies EUR")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
currencies = ne._extract_currencies("ECB meeting pushes euro higher")
|
||
assert "EUR" in currencies
|
||
|
||
|
||
@test("NewsEngine._extract_currencies GBP")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
currencies = ne._extract_currencies("Bank of England holds rates, pound steady")
|
||
assert "GBP" in currencies
|
||
|
||
|
||
@test("NewsEngine._extract_currencies XAU")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
currencies = ne._extract_currencies("Gold prices surge as safe haven demand rises")
|
||
assert "XAU" in currencies
|
||
|
||
|
||
@test("NewsEngine._extract_currencies multiple")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
currencies = ne._extract_currencies("Gold drops as Fed hawkish, dollar surges")
|
||
assert "USD" in currencies
|
||
assert "XAU" in currencies
|
||
|
||
|
||
@test("NewsEngine._extract_keywords")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
kws = ne._extract_keywords("Hawkish Fed rate hike surprises with positive surprise")
|
||
assert "rate hike" in kws
|
||
assert "hawkish" in kws
|
||
assert "positive surprise" in kws
|
||
|
||
|
||
@test("NewsEngine._parse_rss_date RFC822 format")
|
||
def _():
|
||
dt = NewsEngine._parse_rss_date("Mon, 12 May 2025 14:30:00 +0000")
|
||
assert dt is not None
|
||
assert dt.year == 2025
|
||
assert dt.month == 5
|
||
assert dt.day == 12
|
||
assert dt.tzinfo is not None
|
||
|
||
|
||
@test("NewsEngine._parse_rss_date named zones carry their real offset")
|
||
def _():
|
||
# EST is UTC−5: 12:00 EST == 17:00 UTC. The old "%Z" path accepted the
|
||
# abbreviation but stamped UTC — a silent 5-hour shift.
|
||
dt = NewsEngine._parse_rss_date("Mon, 20 Jul 2026 12:00:00 EST")
|
||
assert dt is not None and dt.tzinfo is not None
|
||
assert dt.astimezone(timezone.utc).hour == 17
|
||
# GMT stays a no-op; without-seconds (MyFXBook) form still parses.
|
||
dt2 = NewsEngine._parse_rss_date("Mon, 20 Jul 2026 12:00 GMT")
|
||
assert dt2 is not None and dt2.astimezone(timezone.utc).hour == 12
|
||
|
||
|
||
@test("NewsEngine._parse_rss_date ISO format")
|
||
def _():
|
||
dt = NewsEngine._parse_rss_date("2025-05-12T14:30:00+00:00")
|
||
assert dt is not None
|
||
assert dt.year == 2025
|
||
|
||
|
||
@test("NewsEngine._parse_rss_date naive format assumes UTC")
|
||
def _():
|
||
dt = NewsEngine._parse_rss_date("2025-05-12 14:30:00")
|
||
assert dt is not None
|
||
assert dt.tzinfo == timezone.utc
|
||
|
||
|
||
@test("NewsEngine._parse_rss_date None input")
|
||
def _():
|
||
assert NewsEngine._parse_rss_date(None) is None
|
||
|
||
|
||
@test("NewsEngine._parse_rss_date garbage input")
|
||
def _():
|
||
assert NewsEngine._parse_rss_date("not-a-date") is None
|
||
|
||
|
||
@test("EconomicEvent properties")
|
||
def _():
|
||
future_time = datetime.now(timezone.utc) + timedelta(minutes=30)
|
||
event = EconomicEvent(
|
||
time=future_time, currency="USD",
|
||
impact="high", event="NFP",
|
||
)
|
||
assert event.is_upcoming is True
|
||
assert 25 < event.minutes_until < 35
|
||
assert event.is_high_impact is True
|
||
|
||
past_time = datetime.now(timezone.utc) - timedelta(minutes=10)
|
||
past_event = EconomicEvent(
|
||
time=past_time, currency="EUR",
|
||
impact="low", event="CPI",
|
||
)
|
||
assert past_event.is_upcoming is False
|
||
assert past_event.is_high_impact is False
|
||
|
||
|
||
@test("NewsEngine.get_event_risk_level danger zone")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
context.events_upcoming_1h = [
|
||
EconomicEvent(
|
||
time=datetime.now(timezone.utc) + timedelta(minutes=15),
|
||
currency="USD", impact="high", event="NFP",
|
||
)
|
||
]
|
||
risk, desc = ne.get_event_risk_level("XAU/USD", context)
|
||
assert risk == "danger"
|
||
assert "NFP" in desc
|
||
|
||
|
||
@test("NewsEngine.get_event_risk_level caution zone")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
context.events_upcoming_1h = [
|
||
EconomicEvent(
|
||
time=datetime.now(timezone.utc) + timedelta(minutes=45),
|
||
currency="USD", impact="high", event="GDP",
|
||
)
|
||
]
|
||
risk, desc = ne.get_event_risk_level("XAU/USD", context)
|
||
assert risk == "caution"
|
||
|
||
|
||
@test("NewsEngine.get_event_risk_level grades from the soonest event")
|
||
def _():
|
||
# Far event listed FIRST: an unsorted feed must still yield danger
|
||
# for the nearer event, not caution for the first match.
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
context.events_upcoming_1h = [
|
||
EconomicEvent(
|
||
time=datetime.now(timezone.utc) + timedelta(minutes=55),
|
||
currency="USD", impact="high", event="GDP",
|
||
),
|
||
EconomicEvent(
|
||
time=datetime.now(timezone.utc) + timedelta(minutes=10),
|
||
currency="USD", impact="high", event="NFP",
|
||
),
|
||
]
|
||
risk, desc = ne.get_event_risk_level("XAU/USD", context)
|
||
assert risk == "danger"
|
||
assert "NFP" in desc
|
||
|
||
|
||
@test("NewsEngine.get_event_risk_level clear when no events")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
risk, desc = ne.get_event_risk_level("XAU/USD", context)
|
||
assert risk == "clear"
|
||
|
||
|
||
@test("NewsEngine.get_event_risk_level ignores unrelated currency events")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
context.events_upcoming_1h = [
|
||
EconomicEvent(
|
||
time=datetime.now(timezone.utc) + timedelta(minutes=15),
|
||
currency="AUD", impact="high", event="RBA Rate",
|
||
)
|
||
]
|
||
risk, _ = ne.get_event_risk_level("XAU/USD", context)
|
||
assert risk == "clear"
|
||
|
||
|
||
@test("NewsEngine.get_pair_sentiment with positive base sentiment")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
context.news_sentiment = {"XAU": 0.5, "USD": -0.1}
|
||
score, desc = ne.get_pair_sentiment("XAU/USD", context)
|
||
assert score > 0, "XAU bullish + USD bearish should be net bullish for XAU/USD"
|
||
assert "bullish" in desc.lower()
|
||
|
||
|
||
@test("NewsEngine.get_pair_sentiment with negative pair sentiment")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
context.news_sentiment = {"XAU": -0.5, "USD": 0.3}
|
||
score, desc = ne.get_pair_sentiment("XAU/USD", context)
|
||
assert score < 0
|
||
|
||
|
||
@test("NewsEngine.get_pair_sentiment neutral")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
context = FundamentalContext()
|
||
context.news_sentiment = {"XAU": 0.0, "USD": 0.0}
|
||
score, desc = ne.get_pair_sentiment("XAU/USD", context)
|
||
assert score == 0.0
|
||
assert "neutral" in desc.lower()
|
||
|
||
|
||
@test("NewsEngine._extract_currencies no USD false positive from 'versus'")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
currencies = ne._extract_currencies(
|
||
"Canada building permits for May -1.7% versus 2.4% estimate"
|
||
)
|
||
assert "USD" not in currencies, f"'versus' must not tag USD, got {currencies}"
|
||
assert "CAD" in currencies
|
||
|
||
|
||
@test("NewsEngine._extract_currencies no AUD false positive from 'audit'")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
currencies = ne._extract_currencies("Treasury audit finds accounting errors")
|
||
assert "AUD" not in currencies
|
||
|
||
|
||
@test("EconomicEvent.surprise from actual vs forecast")
|
||
def _():
|
||
now = datetime.now(timezone.utc)
|
||
beat = EconomicEvent(time=now, currency="USD", impact="high",
|
||
event="NFP", forecast="900K", actual="1.1M")
|
||
assert beat.surprise == "positive"
|
||
|
||
miss = EconomicEvent(time=now, currency="USD", impact="high",
|
||
event="CPI y/y", forecast="0.3%", actual="0.2%")
|
||
assert miss.surprise == "negative"
|
||
|
||
inline = EconomicEvent(time=now, currency="USD", impact="high",
|
||
event="CPI y/y", forecast="0.3%", actual="0.3%")
|
||
assert inline.surprise == "inline"
|
||
|
||
pending = EconomicEvent(time=now, currency="USD", impact="high",
|
||
event="CPI y/y", forecast="0.3%")
|
||
assert pending.surprise is None
|
||
|
||
negative = EconomicEvent(time=now, currency="USD", impact="high",
|
||
event="Permits m/m", forecast="2.4%", actual="-1.7%")
|
||
assert negative.surprise == "negative"
|
||
|
||
|
||
@test("NewsEngine.get_event_risk_level surprise widens post-event window")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
released_at = datetime.now(timezone.utc) - timedelta(minutes=30)
|
||
|
||
# Surprise print 30 min ago: outside the 15-min inline buffer but
|
||
# inside the 45-min surprise buffer → still caution.
|
||
context = FundamentalContext()
|
||
context.events_today = [EconomicEvent(
|
||
time=released_at, currency="USD", impact="high",
|
||
event="CPI m/m", forecast="0.3%", actual="0.5%",
|
||
)]
|
||
risk, desc = ne.get_event_risk_level("XAU/USD", context)
|
||
assert risk == "caution", f"Expected caution after surprise, got {risk}"
|
||
assert "surprise" in desc
|
||
|
||
# Same timing but inline print → clear.
|
||
context.events_today = [EconomicEvent(
|
||
time=released_at, currency="USD", impact="high",
|
||
event="CPI m/m", forecast="0.3%", actual="0.3%",
|
||
)]
|
||
risk, _ = ne.get_event_risk_level("XAU/USD", context)
|
||
assert risk == "clear", f"Inline release 30min ago should be clear, got {risk}"
|
||
|
||
|
||
@test("NewsEngine gold-inverted sentiment for policy keywords")
|
||
def _():
|
||
ne = NewsEngine(NewsConfig())
|
||
headline = "Fed signals rate hike as hawkish stance hardens"
|
||
# Currency-bullish for the dollar...
|
||
assert ne._analyze_headline_sentiment(headline) > 0
|
||
assert ne._analyze_headline_sentiment(headline, "USD") > 0
|
||
# ...but bearish for gold.
|
||
assert ne._analyze_headline_sentiment(headline, "XAU") < 0
|
||
|
||
dovish = "Fed turns dovish, rate cut expected"
|
||
assert ne._analyze_headline_sentiment(dovish) < 0
|
||
assert ne._analyze_headline_sentiment(dovish, "XAU") > 0
|
||
|
||
# Non-policy keywords keep their direction for gold.
|
||
data = "Gold surges to record high"
|
||
assert ne._analyze_headline_sentiment(data, "XAU") > 0
|
||
|
||
|
||
@test("NewsEngine calendar cache avoids refetch within TTL")
|
||
def _():
|
||
NewsEngine._calendar_cache = (0.0, [])
|
||
try:
|
||
ne = NewsEngine(NewsConfig())
|
||
calls = {"n": 0}
|
||
|
||
def fake_fetch():
|
||
calls["n"] += 1
|
||
return [EconomicEvent(
|
||
time=datetime.now(timezone.utc), currency="USD",
|
||
impact="low", event="Test Event",
|
||
)]
|
||
|
||
ne._try_fetch_calendar_api = fake_fetch
|
||
ne._try_fetch_myfxbook_rss = lambda: []
|
||
|
||
first = ne._fetch_economic_calendar(["USD", "XAU"])
|
||
second = ne._fetch_economic_calendar(["USD", "XAU"])
|
||
assert calls["n"] == 1, f"Expected 1 fetch, got {calls['n']}"
|
||
assert len(first) == 1 and len(second) == 1
|
||
|
||
# A second engine instance (new scan) must also hit the cache.
|
||
ne2 = NewsEngine(NewsConfig())
|
||
ne2._try_fetch_calendar_api = fake_fetch
|
||
ne2._try_fetch_myfxbook_rss = lambda: []
|
||
third = ne2._fetch_economic_calendar(["USD", "XAU"])
|
||
assert calls["n"] == 1, "New engine instance should reuse class-level cache"
|
||
assert len(third) == 1
|
||
finally:
|
||
NewsEngine._calendar_cache = (0.0, [])
|
||
|
||
|
||
@test("NewsEngine.get_week_events filters, sorts, and rides the cache")
|
||
def _():
|
||
NewsEngine._calendar_cache = (0.0, [])
|
||
try:
|
||
ne = NewsEngine(NewsConfig())
|
||
calls = {"n": 0}
|
||
now = datetime.now(timezone.utc)
|
||
|
||
def fake_fetch():
|
||
calls["n"] += 1
|
||
return [
|
||
EconomicEvent(time=now + timedelta(hours=5), currency="USD",
|
||
impact="high", event="NFP"),
|
||
EconomicEvent(time=now - timedelta(hours=2), currency="USD",
|
||
impact="low", event="Past Event"),
|
||
EconomicEvent(time=now + timedelta(hours=1), currency="EUR",
|
||
impact="high", event="ECB Rate"),
|
||
]
|
||
|
||
ne._try_fetch_calendar_api = fake_fetch
|
||
ne._try_fetch_myfxbook_rss = lambda: []
|
||
|
||
events = ne.get_week_events() # defaults to tracked ["USD", "XAU"]
|
||
assert [e.event for e in events] == ["Past Event", "NFP"], \
|
||
f"Expected USD-only sorted by time, got {[e.event for e in events]}"
|
||
|
||
# Second call (and explicit currency widening) must not refetch.
|
||
wide = ne.get_week_events(currencies=["USD", "EUR"])
|
||
assert calls["n"] == 1, f"Expected 1 fetch, got {calls['n']}"
|
||
assert [e.event for e in wide] == ["Past Event", "ECB Rate", "NFP"]
|
||
finally:
|
||
NewsEngine._calendar_cache = (0.0, [])
|
||
|
||
|
||
@test("FundamentalContext defaults")
|
||
def _():
|
||
ctx = FundamentalContext()
|
||
assert ctx.events_today == []
|
||
assert ctx.events_upcoming_1h == []
|
||
assert ctx.high_impact_warning is False
|
||
assert ctx.calendar_available is False
|
||
assert ctx.news_sentiment == {}
|
||
assert ctx.news_headlines == []
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 5: signal_aggregator.py
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 5: signal_aggregator.py ═══")
|
||
|
||
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup
|
||
|
||
|
||
@test("SignalReport defaults")
|
||
def _():
|
||
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
assert r.final_signal == SignalStrength.NEUTRAL
|
||
assert r.final_score == 0.0
|
||
assert r.primary_signal == SignalStrength.NEUTRAL
|
||
assert r.trade_setup is None
|
||
assert r.mtf_agreement is False
|
||
assert r.signal_quality == ""
|
||
assert r.bullish_reasons == []
|
||
assert r.bearish_reasons == []
|
||
assert r.warnings == []
|
||
|
||
|
||
@test("TradeSetup dataclass creation")
|
||
def _():
|
||
ts = TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||
entry_price=1.1000, stop_loss=1.0950, take_profit_1=1.1100,
|
||
take_profit_2=1.1200, risk_reward=2.0, risk_pips=50, reward_pips=100,
|
||
pip_value=0.0001,
|
||
)
|
||
assert ts.direction == "BUY"
|
||
assert ts.risk_reward == 2.0
|
||
assert ts.entry_price > ts.stop_loss
|
||
assert ts.trail_arm_r is None and ts.trail_dist_r is None # unstamped default
|
||
assert ts.setup_pattern is None and ts.round_trip_cost is None
|
||
|
||
|
||
@test("trade setups carry trail params from the producing config")
|
||
def _():
|
||
config = PlatformConfig()
|
||
config.signals.trail_arm_r = 0.70
|
||
config.signals.trail_dist_r = 0.85
|
||
agg = SignalAggregator(config)
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.final_signal = SignalStrength.BUY
|
||
report.final_score = 40.0
|
||
report.signal_quality = "A+"
|
||
report.current_price = 3300.0
|
||
ts = agg._generate_xau_atr_trade_setup(report, 10.0)
|
||
assert ts is not None, "A+ setup with ATR 10 should generate"
|
||
assert ts.strategy == "XAU_ATR"
|
||
assert ts.round_trip_cost == config.signals.round_trip_cost
|
||
assert ts.trail_arm_r == 0.70, "setup must carry the producing config's arm"
|
||
assert ts.trail_dist_r == 0.85, "setup must carry the producing config's dist"
|
||
|
||
|
||
@test("SignalAggregator._check_mtf_agreement with 2 bullish")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.mtf_signals = {
|
||
"15min": SignalStrength.BUY,
|
||
"1h": SignalStrength.STRONG_BUY,
|
||
"4h": SignalStrength.NEUTRAL,
|
||
}
|
||
assert agg._check_mtf_agreement(report) is True
|
||
|
||
|
||
@test("SignalAggregator._check_mtf_agreement with mixed signals")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.mtf_signals = {
|
||
"15min": SignalStrength.BUY,
|
||
"1h": SignalStrength.SELL,
|
||
"4h": SignalStrength.NEUTRAL,
|
||
}
|
||
assert agg._check_mtf_agreement(report) is False
|
||
|
||
|
||
@test("SignalAggregator._check_mtf_agreement with single signal")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.mtf_signals = {"1h": SignalStrength.BUY}
|
||
assert agg._check_mtf_agreement(report) is False
|
||
|
||
|
||
@test("SignalAggregator._latest_adx computes valid value")
|
||
def _():
|
||
df = make_ohlcv(300)
|
||
adx = SignalAggregator._latest_adx(df)
|
||
assert adx is not None
|
||
assert adx >= 0
|
||
|
||
|
||
@test("SignalAggregator._latest_adx with insufficient data")
|
||
def _():
|
||
df = make_ohlcv(10)
|
||
adx = SignalAggregator._latest_adx(df)
|
||
assert adx is None
|
||
|
||
|
||
@test("SignalAggregator.analyze_from_dataframes produces valid report")
|
||
def _():
|
||
config = PlatformConfig()
|
||
config.primary_timeframe = Timeframe.H1
|
||
config.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
|
||
agg = SignalAggregator(config)
|
||
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert isinstance(report, SignalReport)
|
||
assert report.symbol == "XAU/USD"
|
||
assert report.display_name == "XAU/USD"
|
||
assert report.final_signal in list(SignalStrength)
|
||
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
|
||
assert report.current_price > 0
|
||
|
||
|
||
@test("SignalAggregator._compute_final_signal quality grades")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
|
||
assert report.action_recommendation != ""
|
||
|
||
|
||
@test("Event-risk signal gating obeys news.event_risk_caps_signals")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
|
||
def recompute(risk, caps):
|
||
config.news.event_risk_caps_signals = caps
|
||
report.event_risk = risk
|
||
report.event_description = "USD Test Event in 10min"
|
||
report.warnings = []
|
||
agg._compute_final_signal(report)
|
||
return report.final_confidence
|
||
|
||
baseline = recompute("clear", True)
|
||
# default XAU behavior (caps off): danger warns but does not gut
|
||
conf_off = recompute("danger", False)
|
||
assert conf_off == baseline, "flag off must not change confidence"
|
||
assert any("EVENT RISK" in w for w in report.warnings), "warning must persist"
|
||
# caps on: danger hard-caps confidence at 30
|
||
conf_on = recompute("danger", True)
|
||
assert conf_on <= 30, f"danger with caps on must cap conf at 30, got {conf_on}"
|
||
# caution with caps off is also warning-only
|
||
conf_caut_off = recompute("caution", False)
|
||
assert conf_caut_off == baseline
|
||
assert any("Upcoming event" in w for w in report.warnings)
|
||
# PlatformConfig default is off (XAU-tuned)
|
||
assert PlatformConfig().news.event_risk_caps_signals is False
|
||
|
||
|
||
@test("Sentiment signal gating obeys news.sentiment_affects_signals")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
|
||
def recompute(sentiment, affects):
|
||
config.news.sentiment_affects_signals = affects
|
||
report.news_sentiment = sentiment
|
||
report.warnings = []
|
||
agg._compute_final_signal(report)
|
||
return report.final_confidence
|
||
|
||
score_sign = 1 if report.primary_score >= 0 else -1
|
||
aligned, conflicting = 0.5 * score_sign, -0.5 * score_sign
|
||
|
||
baseline = recompute(0.0, False)
|
||
# default: neither aligned sentiment nor conflict moves confidence
|
||
assert recompute(aligned, False) == baseline, "aligned sentiment must not add confidence"
|
||
assert recompute(conflicting, False) == baseline, "conflicting sentiment must not penalize"
|
||
assert any("CONFLICTS" in w for w in report.warnings), "conflict warning must persist"
|
||
# flag on: aligned sentiment adds confidence again
|
||
assert recompute(aligned, True) > baseline, "flag on must restore the alignment bonus"
|
||
# PlatformConfig default is off (news is informational only)
|
||
assert PlatformConfig().news.sentiment_affects_signals is False
|
||
|
||
|
||
@test("SignalAggregator._generate_trade_setup BUY direction")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, trend=0.003, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, trend=0.003, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, trend=0.003, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
if report.trade_setup:
|
||
ts = report.trade_setup
|
||
if ts.direction == "BUY":
|
||
assert ts.stop_loss < ts.entry_price, "BUY SL should be below entry"
|
||
assert ts.take_profit_1 > ts.entry_price, "BUY TP should be above entry"
|
||
assert ts.risk_reward >= config.signals.min_rr_ratio
|
||
|
||
|
||
@test("SignalAggregator._collect_reasons populates reasons")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
total_reasons = len(report.bullish_reasons) + len(report.bearish_reasons)
|
||
assert total_reasons > 0, "Should have at least one reason"
|
||
|
||
|
||
@test("SignalAggregator session filter blocks outside hours")
|
||
def _():
|
||
config = PlatformConfig()
|
||
config.backtest.session_filter = True
|
||
config.xau_strategy.blocked_hours_utc = list(range(24))
|
||
config.xau_strategy.enabled = False
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_xau_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_xau_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_xau_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert report.signal_quality == "F"
|
||
|
||
|
||
@test("SignalAggregator with missing primary timeframe")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert report.signal_quality == "F"
|
||
assert any("unavailable" in w.lower() for w in report.warnings)
|
||
|
||
|
||
@test("SignalAggregator XAU strategy check (conditions not met)")
|
||
def _():
|
||
config = PlatformConfig()
|
||
config.xau_strategy.enabled = True
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_xau_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_xau_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_xau_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert isinstance(report, SignalReport)
|
||
|
||
|
||
@test("SignalAggregator event risk affects confidence")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
context = FundamentalContext()
|
||
context.events_upcoming_1h = [
|
||
EconomicEvent(
|
||
time=datetime.now(timezone.utc) + timedelta(minutes=10),
|
||
currency="USD", impact="high", event="NFP",
|
||
)
|
||
]
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, context=context, use_news=True)
|
||
assert any("EVENT RISK" in w or "event" in w.lower() for w in report.warnings)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 6: main.py
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 6: main.py ═══")
|
||
|
||
from main import (
|
||
signal_color, quality_color, _compact_reason,
|
||
report_to_dict, build_config,
|
||
)
|
||
|
||
|
||
@test("signal_color maps all SignalStrength values")
|
||
def _():
|
||
for sig in SignalStrength:
|
||
color = signal_color(sig)
|
||
assert isinstance(color, str)
|
||
|
||
|
||
@test("quality_color maps all quality grades")
|
||
def _():
|
||
for q in ("A+", "A", "B", "C", "D", "F"):
|
||
color = quality_color(q)
|
||
assert isinstance(color, str)
|
||
|
||
|
||
@test("_compact_reason strips STRONG prefix")
|
||
def _():
|
||
result = _compact_reason("STRONG: EMA Trend: Full bullish alignment")
|
||
assert not result.startswith("STRONG: ")
|
||
|
||
|
||
@test("_compact_reason strips indicator prefix")
|
||
def _():
|
||
result = _compact_reason("RSI: RSI 45.3 — Neutral")
|
||
assert not result.startswith("RSI: ")
|
||
|
||
|
||
@test("_compact_reason strips detail after pipe")
|
||
def _():
|
||
result = _compact_reason("EMA alignment: 3/4 | Golden cross (fast/medium)")
|
||
assert "|" not in result
|
||
|
||
|
||
@test("_compact_reason handles plain text")
|
||
def _():
|
||
result = _compact_reason("Simple reason text")
|
||
assert result == "Simple reason text"
|
||
|
||
|
||
@test("report_to_dict basic conversion")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
d = report_to_dict(report)
|
||
assert "symbol" in d
|
||
assert "signal" in d
|
||
assert "score" in d
|
||
assert "confidence" in d
|
||
assert "quality" in d
|
||
assert "action" in d
|
||
assert "market_regime" in d
|
||
assert "volatility_regime" in d
|
||
assert "mtf_agreement" in d
|
||
assert "mtf_signals" in d
|
||
assert d["symbol"] == "XAU/USD"
|
||
assert d["display_name"] == "XAU/USD"
|
||
|
||
|
||
@test("report_to_dict includes trade_setup when present")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, trend=0.003, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, trend=0.003, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, trend=0.003, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
d = report_to_dict(report)
|
||
if report.trade_setup:
|
||
assert "trade_setup" in d
|
||
ts = d["trade_setup"]
|
||
assert "direction" in ts
|
||
assert "entry" in ts
|
||
assert "stop_loss" in ts
|
||
assert "take_profit_1" in ts
|
||
assert "risk_reward" in ts
|
||
|
||
|
||
@test("report_to_dict includes indicators")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
d = report_to_dict(report)
|
||
assert "indicators" in d
|
||
assert "key_levels" in d
|
||
for name, ind_dict in d["indicators"].items():
|
||
assert "signal" in ind_dict
|
||
assert "confidence" in ind_dict
|
||
assert "description" in ind_dict
|
||
|
||
|
||
@test("build_config sets correct timeframe and confirmations")
|
||
def _():
|
||
class FakeArgs:
|
||
pair = "XAUUSD"
|
||
timeframe = "H4"
|
||
config = build_config(FakeArgs())
|
||
assert config.primary_timeframe == Timeframe.H4
|
||
assert Timeframe.H1 in config.confirmation_timeframes
|
||
assert Timeframe.D1 in config.confirmation_timeframes
|
||
|
||
|
||
@test("build_config: every CLI timeframe uses the pair factory's confirmation map")
|
||
def _():
|
||
from pair_signals import xauusd as _xau
|
||
for tf_str, tf in (("M5", Timeframe.M5), ("M15", Timeframe.M15),
|
||
("H1", Timeframe.H1), ("H4", Timeframe.H4)):
|
||
class FakeArgs:
|
||
pair = "XAUUSD"
|
||
timeframe = tf_str
|
||
config = build_config(FakeArgs())
|
||
assert config.primary_timeframe == tf
|
||
# The CLI must not carry its own confirmation table: it once gave
|
||
# M5 an unvalidated [M1, M15] pairing that no other surface used.
|
||
assert config.confirmation_timeframes == _xau._CONFIRMATIONS[tf], tf_str
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 7: telegram_bot.py
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 7: telegram_bot.py ═══")
|
||
|
||
from telegram_bot import (
|
||
_escape_md, _signal_label, _mtf_line, _indicator_summary,
|
||
format_signal_message, _news_text,
|
||
_seconds_until_boundary, _check_level_breach,
|
||
SCAN_BUTTONS, SCAN_LOOKUP,
|
||
_update_level_cache, _last_prices, _level_cache, _level_cache_ts,
|
||
)
|
||
|
||
|
||
@test("_escape_md escapes markdown characters")
|
||
def _():
|
||
assert _escape_md("hello *world*") == "hello \\*world\\*"
|
||
assert _escape_md("test_value") == "test\\_value"
|
||
assert _escape_md("`code`") == "\\`code\\`"
|
||
assert _escape_md("[link]") == "\\[link\\]"
|
||
|
||
|
||
@test("_signal_label formatting")
|
||
def _():
|
||
assert _signal_label(SignalStrength.STRONG_BUY) == "STRONG BUY"
|
||
assert _signal_label(SignalStrength.STRONG_SELL) == "STRONG SELL"
|
||
assert _signal_label(SignalStrength.NEUTRAL) == "NEUTRAL"
|
||
assert _signal_label(SignalStrength.BUY) == "BUY"
|
||
assert _signal_label(SignalStrength.SELL) == "SELL"
|
||
|
||
|
||
@test("_mtf_line formatting with signals")
|
||
def _():
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.mtf_signals = {
|
||
"15min": SignalStrength.BUY,
|
||
"1h": SignalStrength.NEUTRAL,
|
||
}
|
||
report.mtf_scores = {"15min": 25.0, "1h": 5.0}
|
||
line = _mtf_line(report)
|
||
assert "15min" in line
|
||
assert "1h" in line
|
||
assert "BUY" in line
|
||
|
||
|
||
@test("_mtf_line empty when no signals")
|
||
def _():
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
assert _mtf_line(report) == ""
|
||
|
||
|
||
@test("format_signal_message produces valid text")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
msg = format_signal_message(report)
|
||
assert isinstance(msg, str)
|
||
assert len(msg) > 0
|
||
assert "XAU/USD" in msg
|
||
assert "Score" in msg
|
||
assert "Regime" in msg
|
||
assert "Not financial advice" in msg
|
||
|
||
|
||
@test("format_signal_message makes chart pattern actions Markdown-safe")
|
||
def _():
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.action_recommendation = "BUY — double_bottom pattern completion"
|
||
msg = format_signal_message(report)
|
||
assert "_BUY — double bottom pattern completion_" in msg
|
||
assert "double_bottom" not in msg
|
||
|
||
|
||
@test("format_signal_message renders stamped per-TF trail plan")
|
||
def _():
|
||
def _setup(**extra):
|
||
return TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||
entry_price=3300.0, stop_loss=3290.0, take_profit_1=3315.0,
|
||
take_profit_2=3325.0, risk_reward=1.5, risk_pips=100,
|
||
reward_pips=150, pip_value=0.10, **extra,
|
||
)
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.final_signal = SignalStrength.BUY
|
||
report.signal_quality = "A"
|
||
# Stamped setup (M15-style overrides) must render the stamped values…
|
||
report.trade_setup = _setup(trail_arm_r=0.70, trail_dist_r=0.85)
|
||
msg = format_signal_message(report)
|
||
assert "+0.7R" in msg, "must render the stamped arm, not the 0.6 default"
|
||
assert "$8.50" in msg, "must render the stamped dist × risk, not the default"
|
||
# …while a legacy unstamped setup falls back to platform defaults.
|
||
report.trade_setup = _setup()
|
||
msg = format_signal_message(report)
|
||
assert "+0.6R" in msg
|
||
assert "$7.50" in msg
|
||
|
||
|
||
@test("format_signal_message renders 100-pip informational TP")
|
||
def _():
|
||
def _setup(direction="BUY", **extra):
|
||
return TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction=direction,
|
||
entry_price=3300.0, stop_loss=3290.0, take_profit_1=3315.0,
|
||
take_profit_2=3325.0, risk_reward=1.5, risk_pips=100,
|
||
reward_pips=150, pip_value=0.10, **extra,
|
||
)
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
|
||
report.final_signal = SignalStrength.BUY
|
||
report.signal_quality = "A"
|
||
# BUY: 100 pips × $0.10 = $10 above entry; risk $10 → exactly 1.0R.
|
||
report.trade_setup = _setup()
|
||
msg = format_signal_message(report)
|
||
assert "TP 100p" in msg
|
||
assert "3310.00" in msg, "BUY 100p TP must be entry + $10.00"
|
||
assert "≈1.0R" in msg
|
||
# SELL mirror: $10 below entry.
|
||
report.final_signal = SignalStrength.SELL
|
||
report.trade_setup = _setup(direction="SELL")
|
||
msg = format_signal_message(report)
|
||
assert "TP 100p" in msg
|
||
assert "3290.00" in msg, "SELL 100p TP must be entry - $10.00"
|
||
# No setup → no informational TP line.
|
||
report.trade_setup = None
|
||
msg = format_signal_message(report)
|
||
assert "TP 100p" not in msg
|
||
|
||
|
||
@test("_indicator_summary condenses indicators to one arrow line")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
summary = _indicator_summary(report)
|
||
assert "EMA" in summary
|
||
assert "RSI" in summary
|
||
assert summary.count("\n") == 0, "summary must be a single line"
|
||
msg = format_signal_message(report)
|
||
assert summary in msg
|
||
assert "[Bullish]" not in msg and "[Bearish]" not in msg and "[Neutral]" not in msg
|
||
|
||
|
||
@test("_seconds_until_boundary returns positive value")
|
||
def _():
|
||
result = _seconds_until_boundary(15, buffer=10)
|
||
assert result > 0
|
||
assert result <= 15 * 60 + 10 + 1
|
||
|
||
|
||
@test("_seconds_until_boundary for M5 interval")
|
||
def _():
|
||
result = _seconds_until_boundary(5, buffer=10)
|
||
assert result > 0
|
||
assert result <= 5 * 60 + 10 + 1
|
||
|
||
|
||
@test("_seconds_until_boundary for H1 interval")
|
||
def _():
|
||
result = _seconds_until_boundary(60, buffer=10)
|
||
assert result > 0
|
||
assert result <= 60 * 60 + 10 + 1
|
||
|
||
|
||
@test("_seconds_until_boundary for H4 interval")
|
||
def _():
|
||
result = _seconds_until_boundary(240, buffer=10)
|
||
assert result > 0
|
||
assert result <= 240 * 60 + 10 + 1
|
||
|
||
|
||
# NOTE 2026-08-04 (audit H34): the level cache is keyed symbol → TF →
|
||
# levels and _check_level_breach returns "<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("_update_level_cache populates cache from reports")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
_level_cache.clear()
|
||
_level_cache_ts.clear()
|
||
_update_level_cache([report])
|
||
if report.primary_analysis and report.primary_analysis.key_levels:
|
||
assert "XAU/USD" in _level_cache or len(report.primary_analysis.key_levels) == 0
|
||
_level_cache.clear()
|
||
_level_cache_ts.clear()
|
||
|
||
|
||
@test("streaming symbol mapping covers every scanned pair")
|
||
def _():
|
||
# iter_ticks maps the provider's WS symbols (display names) back to
|
||
# canonical pair symbols — the mapping must be invertible for every
|
||
# scanned pair or ticks would be silently dropped.
|
||
from telegram_bot import SCAN_PAIRS
|
||
ws_to_pair = {PAIR_INFO[p]["display"]: p for p in SCAN_PAIRS}
|
||
for p in SCAN_PAIRS:
|
||
assert ws_to_pair[PAIR_INFO[p]["display"]] == p
|
||
assert ws_to_pair["XAU/USD"] == XAUUSD
|
||
|
||
|
||
@test("_news_text bullish score")
|
||
def _():
|
||
ctx = FundamentalContext(news_sentiment={"XAU": 0.5, "USD": -0.1})
|
||
text = _news_text(ctx, NewsEngine(PlatformConfig().news))
|
||
assert "+0.60" in text
|
||
assert "Bullish" in text
|
||
assert "no scored headlines" not in text
|
||
|
||
|
||
@test("_news_text bearish score")
|
||
def _():
|
||
ctx = FundamentalContext(news_sentiment={"XAU": -0.4, "USD": 0.1})
|
||
text = _news_text(ctx, NewsEngine(PlatformConfig().news))
|
||
assert "-0.50" in text
|
||
assert "Bearish" in text
|
||
|
||
|
||
@test("_news_text never renders negative zero")
|
||
def _():
|
||
# Balanced feed: XAU and USD means nearly cancel → net -0.0035,
|
||
# which "+.2f" would render as "-0.00" without normalization.
|
||
ctx = FundamentalContext(news_sentiment={"XAU": 0.0238, "USD": 0.0273})
|
||
text = _news_text(ctx, NewsEngine(PlatformConfig().news))
|
||
assert "-0.00" not in text
|
||
assert "+0.00" in text
|
||
assert "Neutral" in text
|
||
|
||
|
||
@test("_news_text degrades on empty context")
|
||
def _():
|
||
text = _news_text(FundamentalContext(), NewsEngine(PlatformConfig().news))
|
||
assert "+0.00" in text
|
||
assert "Neutral" in text
|
||
assert "no scored headlines" in text
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 8: Integration / Cross-module tests
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 8: Integration Tests ═══")
|
||
|
||
|
||
@test("Full pipeline: config → engine → aggregator → report → dict")
|
||
def _():
|
||
config = PlatformConfig()
|
||
engine = TechnicalEngine(config.technical)
|
||
df = make_ohlcv(300, seed=99)
|
||
analysis = engine.analyze(df, "XAU/USD", "1h")
|
||
assert isinstance(analysis, TechnicalAnalysis)
|
||
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=99),
|
||
Timeframe.M15: make_ohlcv(300, seed=100),
|
||
Timeframe.H4: make_ohlcv(300, seed=101),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
d = report_to_dict(report)
|
||
assert isinstance(d, dict)
|
||
assert d["symbol"] == "XAU/USD"
|
||
|
||
|
||
@test("Full pipeline: XAU/USD analysis")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_xau_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_xau_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_xau_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert report.display_name == "XAU/USD"
|
||
assert report.current_price > 1000 # XAU should be in the thousands
|
||
|
||
|
||
@test("Full pipeline: format Telegram message from report")
|
||
def _():
|
||
config = PlatformConfig()
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
msg = format_signal_message(report)
|
||
assert isinstance(msg, str)
|
||
assert len(msg) > 100
|
||
|
||
|
||
@test("XAU/USD analysis with different seeds")
|
||
def _():
|
||
config = PlatformConfig()
|
||
for seed_base in [10, 20, 30]:
|
||
agg = SignalAggregator(config)
|
||
dfs = {
|
||
Timeframe.H1: make_xau_ohlcv(300, seed=seed_base),
|
||
Timeframe.M15: make_xau_ohlcv(300, seed=seed_base+1),
|
||
Timeframe.H4: make_xau_ohlcv(300, seed=seed_base+2),
|
||
}
|
||
report = agg.analyze_from_dataframes(XAUUSD, dfs, use_news=False)
|
||
assert report.symbol == XAUUSD
|
||
assert report.current_price > 0
|
||
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
|
||
|
||
|
||
@test("Edge case: all EMAs at same price (flat market)")
|
||
def _():
|
||
engine = TechnicalEngine(TechnicalConfig())
|
||
dates = pd.date_range(end=pd.Timestamp.now(tz="UTC").floor("h"), periods=300, freq="h")
|
||
df = pd.DataFrame({
|
||
"Open": 1.1, "High": 1.1001, "Low": 1.0999, "Close": 1.1, "Volume": 1000.0,
|
||
}, index=dates)
|
||
df.index.name = "Datetime"
|
||
result = engine.analyze(df, "XAU/USD", "1h")
|
||
assert result.composite_signal == SignalStrength.NEUTRAL
|
||
|
||
|
||
@test("Edge case: extreme volatility data")
|
||
def _():
|
||
engine = TechnicalEngine(TechnicalConfig())
|
||
np.random.seed(77)
|
||
dates = pd.date_range(end=pd.Timestamp.now(tz="UTC").floor("h"), periods=300, freq="h")
|
||
close = 1.1 + np.cumsum(np.random.randn(300) * 0.01)
|
||
high = close + np.abs(np.random.randn(300) * 0.005)
|
||
low = close - np.abs(np.random.randn(300) * 0.005)
|
||
df = pd.DataFrame({
|
||
"Open": close + np.random.randn(300) * 0.003,
|
||
"High": high, "Low": low, "Close": close,
|
||
"Volume": np.random.randint(100, 50000, 300).astype(float),
|
||
}, index=dates)
|
||
df.index.name = "Datetime"
|
||
result = engine.analyze(df, "XAU/USD", "1h")
|
||
assert isinstance(result, TechnicalAnalysis)
|
||
assert result.volatility_regime in ("low", "normal", "high", "extreme")
|
||
|
||
|
||
@test("Determinism: same data produces same results")
|
||
def _():
|
||
engine = TechnicalEngine(TechnicalConfig())
|
||
df = make_ohlcv(300, seed=42)
|
||
r1 = engine.analyze(df.copy(), "XAU/USD", "1h")
|
||
r2 = engine.analyze(df.copy(), "XAU/USD", "1h")
|
||
assert r1.composite_score == r2.composite_score
|
||
assert r1.composite_signal == r2.composite_signal
|
||
assert r1.market_regime == r2.market_regime
|
||
|
||
|
||
print("\n═══ Module 9: pair_signals registry ═══")
|
||
|
||
@test("pair_signals registry includes XAU/USD only")
|
||
def _():
|
||
import pair_signals
|
||
pairs = list(pair_signals.PAIR_PRODUCERS)
|
||
assert XAUUSD in pairs, f"XAU missing from registry: {pairs}"
|
||
assert pairs == [XAUUSD], f"registry must be XAU-only: {pairs}"
|
||
|
||
|
||
@test("xauusd.make_config equals inline PlatformConfig construction")
|
||
def _():
|
||
import dataclasses
|
||
from pair_signals import PAIR_PRODUCERS
|
||
for tf, ctfs in [
|
||
(Timeframe.M5, [Timeframe.M15, Timeframe.H1]),
|
||
(Timeframe.M15, [Timeframe.M5, Timeframe.H1]),
|
||
(Timeframe.H1, [Timeframe.M15, Timeframe.H4]),
|
||
]:
|
||
factory_cfg = PAIR_PRODUCERS[XAUUSD](tf, ctfs)
|
||
inline_cfg = PlatformConfig(
|
||
symbol=XAUUSD,
|
||
display_name="XAU/USD",
|
||
primary_timeframe=tf,
|
||
confirmation_timeframes=ctfs,
|
||
)
|
||
app_config.apply_pair_overrides(inline_cfg)
|
||
if tf == Timeframe.M15:
|
||
# Documented per-TF overrides (see xauusd.py docstring):
|
||
# 1R minimum targets + mixed-"trending"-regime block +
|
||
# SELL hour block (15/17 UTC), all validated for M15 only.
|
||
inline_cfg.signals.tp_floor_rr = 1.0
|
||
inline_cfg.xau_strategy.blocked_regimes = ["trending"]
|
||
inline_cfg.xau_strategy.sell_blocked_hours_utc = [15, 17]
|
||
inline_cfg.signals.trail_arm_r = 0.70
|
||
inline_cfg.signals.trail_dist_r = 0.75
|
||
if tf == Timeframe.M5:
|
||
# Documented per-TF overrides (see xauusd.py docstring):
|
||
# hour 9 UTC blocked, hour 21 UNBLOCKED (2026-07-30
|
||
# relaxation campaign) + BUY-only chart patterns on M5 only.
|
||
inline_cfg.xau_strategy.blocked_hours_utc = [
|
||
h for h in inline_cfg.xau_strategy.blocked_hours_utc if h != 21
|
||
] + [9]
|
||
inline_cfg.xau_strategy.cp_enabled = True
|
||
inline_cfg.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"]
|
||
inline_cfg.xau_strategy.m5_pullback_enabled = False
|
||
inline_cfg.technical.ema_partial_neutral = True
|
||
inline_cfg.signals.trail_arm_r = 0.60
|
||
inline_cfg.signals.trail_dist_r = 0.85
|
||
# Block SELL entries while regime == "transitioning" on M5
|
||
# only (2026-09-03 signal-log campaign) — see docstring.
|
||
inline_cfg.xau_strategy.sell_blocked_regimes = ["transitioning"]
|
||
if tf == Timeframe.H1:
|
||
# Block every H1 SELL (2026-09-03 signal-log campaign) —
|
||
# see docstring; H1 shorts were a structural loser.
|
||
inline_cfg.xau_strategy.sell_min_quality = []
|
||
assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \
|
||
f"XAU factory config differs from inline construction for {tf}"
|
||
|
||
|
||
@test("M5 factory disables XAU_PB; other TFs keep the global default")
|
||
def _():
|
||
# 2026-08-04 campaign (Oanda bars, $0.65 real spread): pullback
|
||
# entries are the largest wrong-direction loss source on M5 and
|
||
# fail at real cost; disabled in the M5 factory branch only.
|
||
from pair_signals import PAIR_PRODUCERS
|
||
m5 = PAIR_PRODUCERS[XAUUSD](Timeframe.M5, [Timeframe.M15, Timeframe.H1])
|
||
assert m5.xau_strategy.m5_pullback_enabled is False
|
||
assert m5.technical.ema_partial_neutral is True # 2026-08-04 override 5
|
||
m15 = PAIR_PRODUCERS[XAUUSD](Timeframe.M15, [Timeframe.M5, Timeframe.H1])
|
||
assert m15.xau_strategy.m5_pullback_enabled is True, \
|
||
"PB flag must stay at the global default off-M5 (inert there)"
|
||
assert m15.technical.ema_partial_neutral is False, \
|
||
"partial-alignment squash is M5-validated only"
|
||
|
||
|
||
@test("produce_signal raises for unregistered pair")
|
||
def _():
|
||
from pair_signals import produce_signal
|
||
try:
|
||
produce_signal("GBP/JPY", Timeframe.H1, None)
|
||
assert False, "Expected ValueError for unregistered pair"
|
||
except ValueError as e:
|
||
assert "GBP/JPY" in str(e)
|
||
|
||
|
||
print("\n═══ Module 9b: Engine Bug Fix Regressions ═══")
|
||
|
||
# ── 9a. Swing point NaN fix ──────────────────────────────────────────
|
||
|
||
@test("Swing points: all-NaN neighbors produces no false positives")
|
||
def _():
|
||
series = pd.Series([np.nan, np.nan, np.nan, 5.0, np.nan, np.nan, np.nan])
|
||
lows = TechnicalEngine._find_swing_points(series, mode="low", window=3)
|
||
highs = TechnicalEngine._find_swing_points(series, mode="high", window=3)
|
||
assert lows == [], f"Expected no lows with all-NaN neighbors, got {lows}"
|
||
assert highs == [], f"Expected no highs with all-NaN neighbors, got {highs}"
|
||
|
||
|
||
@test("Swing points: partial NaN neighbors still detect valid swings")
|
||
def _():
|
||
series = pd.Series([10.0, 8.0, np.nan, 4.0, np.nan, 8.0, 10.0])
|
||
lows = TechnicalEngine._find_swing_points(series, mode="low", window=2)
|
||
low_vals = [v for _, v in lows]
|
||
assert 4.0 in low_vals, f"Expected 4.0 as swing low, got {low_vals}"
|
||
|
||
|
||
@test("Swing points: empty series returns empty list")
|
||
def _():
|
||
series = pd.Series([], dtype=float)
|
||
assert TechnicalEngine._find_swing_points(series, mode="low") == []
|
||
assert TechnicalEngine._find_swing_points(series, mode="high") == []
|
||
|
||
|
||
@test("Swing points: monotonic increasing has no interior lows")
|
||
def _():
|
||
series = pd.Series([float(i) for i in range(20)])
|
||
lows = TechnicalEngine._find_swing_points(series, mode="low", window=2)
|
||
assert len(lows) == 0, f"Monotonic increase should have no interior lows, got {lows}"
|
||
|
||
|
||
@test("Swing points: V-shape detects low at bottom")
|
||
def _():
|
||
series = pd.Series([10.0, 8.0, 6.0, 4.0, 6.0, 8.0, 10.0])
|
||
lows = TechnicalEngine._find_swing_points(series, mode="low", window=2)
|
||
low_vals = [v for _, v in lows]
|
||
assert 4.0 in low_vals, f"V-shape should detect 4.0 as low, got {low_vals}"
|
||
|
||
|
||
@test("Swing points: inverted V-shape detects high at top")
|
||
def _():
|
||
series = pd.Series([4.0, 6.0, 8.0, 10.0, 8.0, 6.0, 4.0])
|
||
highs = TechnicalEngine._find_swing_points(series, mode="high", window=2)
|
||
high_vals = [v for _, v in highs]
|
||
assert 10.0 in high_vals, f"Inverted V should detect 10.0 as high, got {high_vals}"
|
||
|
||
|
||
# ── 9b. ROC div-by-zero fix ──────────────────────────────────────────
|
||
|
||
@test("ROC: zero close at lookback position does not produce inf")
|
||
def _():
|
||
df = make_ohlcv(n=100, seed=42)
|
||
df.iloc[-11, df.columns.get_loc("Close")] = 0.0
|
||
engine = TechnicalEngine(PlatformConfig().technical)
|
||
result = engine.analyze(df, "XAU/USD", Timeframe.H1)
|
||
if "roc" in result.indicators:
|
||
assert np.isfinite(result.indicators["roc"].value), "ROC should not be inf"
|
||
|
||
|
||
# ── 9c. ADX epsilon fix ──────────────────────────────────────────────
|
||
|
||
@test("ADX: flat data produces finite ADX value")
|
||
def _():
|
||
n = 100
|
||
dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h")
|
||
df = pd.DataFrame({
|
||
"Open": [100.0]*n, "High": [100.01]*n, "Low": [99.99]*n,
|
||
"Close": [100.0]*n, "Volume": [1000.0]*n,
|
||
}, index=dates)
|
||
engine = TechnicalEngine(PlatformConfig().technical)
|
||
result = engine.analyze(df, "XAU/USD", Timeframe.H1)
|
||
if "adx" in result.indicators:
|
||
assert np.isfinite(result.indicators["adx"].value), "ADX should be finite on flat data"
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 11: 2026-07-16 audit-fix regressions
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 11: 2026-07-16 audit fixes ═══")
|
||
|
||
_BT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backtest")
|
||
if _BT_DIR not in sys.path:
|
||
sys.path.insert(0, _BT_DIR)
|
||
|
||
|
||
def make_xau_bars(n=400, end="2026-07-08 16:55", freq="5min", drift=-0.8, seed=7):
|
||
"""Seeded XAU-priced bars ending at a FIXED timestamp (deterministic)."""
|
||
rng = np.random.default_rng(seed)
|
||
idx = pd.date_range(end=pd.Timestamp(end, tz="UTC"), periods=n, freq=freq)
|
||
close = 3400 + np.cumsum(drift + rng.normal(0, 0.5, n))
|
||
open_ = np.roll(close, 1)
|
||
open_[0] = close[0]
|
||
high = np.maximum(open_, close) + rng.uniform(0.5, 2.0, n)
|
||
low = np.minimum(open_, close) - rng.uniform(0.5, 2.0, n)
|
||
df = pd.DataFrame({
|
||
"Open": open_, "High": high, "Low": low,
|
||
"Close": close, "Volume": rng.uniform(1e3, 5e3, n),
|
||
}, index=idx)
|
||
df.index.name = "Datetime"
|
||
return df
|
||
|
||
|
||
@test("Gate 6e: session-blocked sell-direction report stays F (no F→D upgrade)")
|
||
def _():
|
||
# Bar LABELED in a blocked hour (16:xx UTC, Wednesday) with a bearish
|
||
# score: gate 6b sets F; before the fix, 6e re-graded it D, which
|
||
# re-opened the M5 pullback path (entry check: quality != "F").
|
||
from pair_signals.xauusd import make_config as xau_make_config
|
||
primary = make_xau_bars(end="2026-07-08 16:55", freq="5min")
|
||
m15 = make_xau_bars(end="2026-07-08 16:45", freq="15min")
|
||
h1 = make_xau_bars(end="2026-07-08 16:00", freq="60min")
|
||
agg = SignalAggregator(xau_make_config(Timeframe.M5, [Timeframe.M15, Timeframe.H1]))
|
||
report = agg.analyze_from_dataframes(
|
||
XAUUSD, {Timeframe.M5: primary, Timeframe.M15: m15, Timeframe.H1: h1},
|
||
use_news=False,
|
||
)
|
||
assert any("SESSION FILTER" in w for w in report.warnings), \
|
||
f"session filter did not fire: {report.warnings}"
|
||
assert report.primary_score < 0, "test setup must produce a sell-direction score"
|
||
assert report.signal_quality == "F", \
|
||
f"blocked bar must stay F, got {report.signal_quality}"
|
||
assert report.trade_setup is None
|
||
assert not any("SELL FILTER: quality" in w for w in report.warnings), \
|
||
"6e must not fire on non-actionable grades"
|
||
|
||
|
||
@test("GPU engine parity: composite + ema_trend identical to live (incl. ribbon)")
|
||
def _():
|
||
from gpu_backtest import precompute_indicators, GPUTechnicalEngine
|
||
from engine import TechnicalEngine
|
||
cfg = PlatformConfig()
|
||
# Two-phase series: flat chop (EMA ribbon compresses) then a strong
|
||
# trend (ribbon expands) — guarantees the sweep crosses both ribbon
|
||
# states, the exact logic the GPU engine was missing.
|
||
rng = np.random.default_rng(11)
|
||
n = 650
|
||
drift = np.where(np.arange(n) < 450, 0.0, 1.2)
|
||
idx = pd.date_range(end=pd.Timestamp("2026-06-30 12:00", tz="UTC"),
|
||
periods=n, freq="60min")
|
||
close = 3400 + np.cumsum(drift + rng.normal(0, 0.5, n))
|
||
open_ = np.roll(close, 1)
|
||
open_[0] = close[0]
|
||
high = np.maximum(open_, close) + rng.uniform(0.5, 2.0, n)
|
||
low = np.minimum(open_, close) - rng.uniform(0.5, 2.0, n)
|
||
df = pd.DataFrame({"Open": open_, "High": high, "Low": low, "Close": close,
|
||
"Volume": rng.uniform(1e3, 5e3, n)}, index=idx)
|
||
df.index.name = "Datetime"
|
||
cache = precompute_indicators(df, cfg.technical)
|
||
gpu = GPUTechnicalEngine(cfg.technical, {"1h": cache})
|
||
live = TechnicalEngine(cfg.technical)
|
||
seen_compressed = seen_expanding = False
|
||
for i in range(250, n + 1, 3):
|
||
s = df.iloc[:i]
|
||
r_live = live.analyze(s, "XAU/USD", "1h")
|
||
r_gpu = gpu.analyze(s, "XAU/USD", "1h")
|
||
assert r_live.composite_signal == r_gpu.composite_signal, f"signal flip @ {i}"
|
||
assert abs(r_live.composite_score - r_gpu.composite_score) < 1e-9, f"score @ {i}"
|
||
assert abs(r_live.confidence - r_gpu.confidence) < 1e-9, f"confidence @ {i}"
|
||
el = r_live.indicators["ema_trend"]
|
||
eg = r_gpu.indicators["ema_trend"]
|
||
assert el.confidence == eg.confidence, f"ema_trend conf @ {i}"
|
||
assert el.description == eg.description, f"ema_trend desc @ {i}"
|
||
bl = r_live.indicators["bollinger"]
|
||
bg = r_gpu.indicators["bollinger"]
|
||
assert bl.signal == bg.signal and bl.description == bg.description, f"bollinger @ {i}"
|
||
al = r_live.indicators["atr"]
|
||
ag = r_gpu.indicators["atr"]
|
||
assert abs(al.value - ag.value) < 1e-9, f"atr value @ {i}"
|
||
assert al.description == ag.description, f"atr description @ {i}"
|
||
assert r_live.volatility_regime == r_gpu.volatility_regime, f"vol regime @ {i}"
|
||
assert abs(r_live.key_levels["atr"] - r_gpu.key_levels["atr"]) < 1e-9, f"key_levels atr @ {i}"
|
||
assert abs(r_live.key_levels["atr_pct"] - r_gpu.key_levels["atr_pct"]) < 1e-9, f"key_levels atr_pct @ {i}"
|
||
if "Ribbon COMPRESSED" in el.description:
|
||
seen_compressed = True
|
||
if "Ribbon EXPANDING" in el.description:
|
||
seen_expanding = True
|
||
assert seen_compressed, "sweep never saw a compressed ribbon — no teeth"
|
||
assert seen_expanding, "sweep never saw a ribbon expansion (+10 conf path) — no teeth"
|
||
|
||
|
||
@test("Regime filter: live gate semantics + backtest calls the same method")
|
||
def _():
|
||
from signal_aggregator import SignalAggregator
|
||
from pair_signals import PAIR_PRODUCERS
|
||
cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.M15, [Timeframe.M5, Timeframe.H1])
|
||
cfg.backtest.regime_filter = True
|
||
agg = SignalAggregator(cfg)
|
||
|
||
df = make_ohlcv(n=300, start_price=3400.0, trend=0.05, seed=3)
|
||
flat = make_ohlcv(n=300, start_price=3400.0, trend=0.0, seed=7)
|
||
adx = agg._latest_adx(flat, cfg.technical.adx_period)
|
||
assert adx is not None and adx < cfg.backtest.regime_filter_min_adx, \
|
||
f"fixture must be choppy (ADX {adx}) or the test has no teeth"
|
||
|
||
# Low higher-TF ADX → signal downgraded to NEUTRAL/F, setup killed.
|
||
report = agg.analyze_from_dataframes(XAUUSD, {Timeframe.M15: df},
|
||
context=None, use_news=False)
|
||
agg._apply_regime_filter_live(report, {Timeframe.H4: flat})
|
||
assert report.final_signal == SignalStrength.NEUTRAL
|
||
assert report.signal_quality == "F"
|
||
assert report.trade_setup is None
|
||
assert any("REGIME FILTER" in w for w in report.warnings)
|
||
|
||
# Regime TF absent from dfs → warning only, report untouched.
|
||
report2 = agg.analyze_from_dataframes(XAUUSD, {Timeframe.M15: df},
|
||
context=None, use_news=False)
|
||
quality_before = report2.signal_quality
|
||
agg._apply_regime_filter_live(report2, {})
|
||
assert report2.signal_quality == quality_before
|
||
assert any("data unavailable" in w for w in report2.warnings)
|
||
|
||
# Backtest wiring: the replay applies the SAME method under the SAME
|
||
# guard as live analyze_pair (function-identity parity).
|
||
import gpu_backtest as _gb
|
||
src = open(_gb.__file__, encoding="utf-8").read()
|
||
assert "_apply_regime_filter_live(report, slice_dfs)" in src
|
||
assert "backtest.regime_filter" in src
|
||
# …and loads the regime-filter TF like live analyze_pair does, or the
|
||
# method silently no-ops for M5/M15 primaries (confirm maps lack H4).
|
||
assert "regime_filter_timeframe not in timeframes" in src, \
|
||
"replay must fetch/slice the regime-filter TF when the gate is on"
|
||
|
||
|
||
@test("disabled_indicators=['macd'] removes the pillar but keeps the histogram")
|
||
def _():
|
||
from engine import TechnicalEngine
|
||
from config import TechnicalConfig
|
||
df = make_xau_ohlcv(n=300, seed=5)
|
||
result = TechnicalEngine(TechnicalConfig(disabled_indicators=["macd"])).analyze(
|
||
df, "XAU/USD", "1h")
|
||
assert "macd" not in result.indicators, "disabled MACD still scored"
|
||
assert getattr(result, "_macd_histogram", None) is not None, \
|
||
"histogram must survive for divergence"
|
||
baseline = TechnicalEngine(TechnicalConfig()).analyze(df, "XAU/USD", "1h")
|
||
assert "macd" in baseline.indicators
|
||
|
||
|
||
@test("Outlier filter drops the spike bar but keeps the snap-back bar")
|
||
def _():
|
||
provider = TwelveDataProvider(PlatformConfig())
|
||
df = make_ohlcv(n=300, seed=3)
|
||
k = 150
|
||
spike_ts, after_ts = df.index[k], df.index[k + 1]
|
||
spike_val = df["Close"].iloc[k - 1] * 1.05 # ~+5% on a ~0.1%-σ series
|
||
df.loc[spike_ts, ["Open", "High", "Low", "Close"]] = spike_val
|
||
cleaned = provider._validate_and_clean(df, "XAU/USD", Timeframe.H1)
|
||
assert spike_ts not in cleaned.index, "spike bar must be removed"
|
||
assert after_ts in cleaned.index, \
|
||
"snap-back bar after the spike must be kept (two-bar hole regression)"
|
||
|
||
|
||
@test("Outlier filter keeps a >10σ bar whose move PERSISTS (real news bar)")
|
||
def _():
|
||
# 2026-09-07 audit: the 12:30 UTC NFP bar (-1.7%, ~23σ) was deleted
|
||
# from the signal-side M5/M15 data, hiding the day's most violent bar
|
||
# from ATR/exhaustion math. Three years of Oanda XAU bars: 155 such
|
||
# M5 bars, 7 snapped back. A level shift that holds is real price.
|
||
provider = TwelveDataProvider(PlatformConfig())
|
||
df = make_ohlcv(n=300, seed=3)
|
||
k = 150
|
||
jump_ts = df.index[k]
|
||
shift = df["Close"].iloc[k - 1] * 0.05 # ~+5% step on a ~0.1%-σ series
|
||
df.loc[df.index[k]:, ["Open", "High", "Low", "Close"]] += shift
|
||
cleaned = provider._validate_and_clean(df, "XAU/USD", Timeframe.H1)
|
||
assert jump_ts in cleaned.index, \
|
||
"a 10σ move that the next close confirms must be KEPT"
|
||
assert len(cleaned) == len(df), "no bar may be dropped for a real move"
|
||
|
||
|
||
@test("Outlier filter keeps the NEWEST >10σ bar (no successor to judge it)")
|
||
def _():
|
||
# Live at 12:35 on a release day the crash bar IS the last closed
|
||
# bar. Dropping it would make the scan run on pre-release data (the
|
||
# 2026-09-04 12:35 scan was a byte-identical repeat of 12:30). With
|
||
# no next close available the bar is treated as real; a true bad
|
||
# print on the newest bar is caught on the following scan.
|
||
provider = TwelveDataProvider(PlatformConfig())
|
||
df = make_ohlcv(n=300, seed=3)
|
||
last_ts = df.index[-1]
|
||
df.loc[last_ts, ["Open", "High", "Low", "Close"]] = df["Close"].iloc[-2] * 1.05
|
||
cleaned = provider._validate_and_clean(df, "XAU/USD", Timeframe.H1)
|
||
assert last_ts in cleaned.index, "newest 10σ bar must be kept"
|
||
|
||
|
||
@test("Outlier filter: partial snap-back (below the revert fraction) is a real move")
|
||
def _():
|
||
provider = TwelveDataProvider(PlatformConfig())
|
||
df = make_ohlcv(n=300, seed=3)
|
||
k = 150
|
||
base = df["Close"].iloc[k - 1]
|
||
spike_ts = df.index[k]
|
||
# +5% spike, next close gives back only half — a violent real move,
|
||
# not a bad print (revert fraction 0.5 < OUTLIER_REVERT_FRACTION 0.8).
|
||
df.loc[spike_ts, ["Open", "High", "Low", "Close"]] = base * 1.05
|
||
df.loc[df.index[k + 1]:, ["Open", "High", "Low", "Close"]] += base * 0.025
|
||
cleaned = provider._validate_and_clean(df, "XAU/USD", Timeframe.H1)
|
||
assert spike_ts in cleaned.index, "half-retraced spike is real price"
|
||
assert provider.OUTLIER_REVERT_FRACTION == 0.8
|
||
|
||
|
||
@test("Cache: pre-boundary snapshot refetched; boundary-complete snapshot served")
|
||
def _():
|
||
def bars_ending(last):
|
||
idx = pd.date_range(end=last, periods=300, freq="15min", tz="UTC")
|
||
v = np.linspace(3000.0, 3100.0, 300)
|
||
return pd.DataFrame({"Open": v, "High": v + 1, "Low": v - 1,
|
||
"Close": v, "Volume": 1000.0}, index=idx)
|
||
|
||
for _attempt in range(2):
|
||
provider = TwelveDataProvider(PlatformConfig())
|
||
started = pd.Timestamp.now(tz="UTC").floor("15min")
|
||
last_closed = started - pd.Timedelta(minutes=15)
|
||
stale = bars_ending(last_closed - pd.Timedelta(minutes=15))
|
||
fresh = bars_ending(last_closed)
|
||
key = "XAU/USD_15min"
|
||
with patch("providers.base._is_forex_weekend", return_value=False):
|
||
# (a) snapshot cached mid-candle 5 min ago, missing the
|
||
# just-closed bar: TTL says fresh, boundary check must refetch.
|
||
provider._cache[key] = stale
|
||
provider._cache_timestamps[key] = time.time() - 300
|
||
with patch.object(provider, "_fetch_twelve_data",
|
||
return_value=fresh.copy()) as mock_fetch:
|
||
served = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
|
||
# (b) cache now boundary-complete: served without any fetch.
|
||
with patch.object(provider, "_fetch_twelve_data",
|
||
side_effect=AssertionError("must not refetch")):
|
||
served2 = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
|
||
if pd.Timestamp.now(tz="UTC").floor("15min") != started:
|
||
continue # boundary crossed mid-test — retry once
|
||
assert mock_fetch.called, "stale pre-boundary snapshot was served from cache"
|
||
assert served.index[-1] == last_closed
|
||
assert served2.index[-1] == last_closed
|
||
break
|
||
|
||
|
||
@test("Fresh fetch missing the just-closed bar retries once, then warns")
|
||
def _():
|
||
def bars_ending(last):
|
||
idx = pd.date_range(end=last, periods=300, freq="15min", tz="UTC")
|
||
v = np.linspace(3000.0, 3100.0, 300)
|
||
return pd.DataFrame({"Open": v, "High": v + 1, "Low": v - 1,
|
||
"Close": v, "Volume": 1000.0}, index=idx)
|
||
|
||
for _attempt in range(2):
|
||
provider = TwelveDataProvider(PlatformConfig())
|
||
started = pd.Timestamp.now(tz="UTC").floor("15min")
|
||
last_closed = started - pd.Timedelta(minutes=15)
|
||
lagging = bars_ending(last_closed - pd.Timedelta(minutes=15))
|
||
complete = bars_ending(last_closed)
|
||
with patch("providers.base._is_forex_weekend", return_value=False), \
|
||
patch.object(TwelveDataProvider, "FRESH_FETCH_RETRY_SECONDS", 0.0):
|
||
# (a) lagging first response → one retry returns the closed bar.
|
||
with patch.object(provider, "_fetch_twelve_data",
|
||
side_effect=[lagging.copy(), complete.copy()]) as mf:
|
||
served = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
|
||
# (b) complete first response → no retry at all.
|
||
provider._cache.clear()
|
||
provider._cache_timestamps.clear()
|
||
with patch.object(provider, "_fetch_twelve_data",
|
||
return_value=complete.copy()) as mf2:
|
||
provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
|
||
if pd.Timestamp.now(tz="UTC").floor("15min") != started:
|
||
continue # boundary crossed mid-test — retry once
|
||
assert mf.call_count == 2, "lagging response must trigger exactly one retry"
|
||
assert served.index[-1] == last_closed, "retry result must be served"
|
||
assert mf2.call_count == 1, "boundary-complete response must not retry"
|
||
break
|
||
|
||
|
||
@test("Alert dedup: drift dedups, materially-moved setup re-alerts, no early commit")
|
||
def _():
|
||
import telegram_bot as tb
|
||
from signal_aggregator import SignalReport, TradeSetup
|
||
|
||
def rpt(entry, sl):
|
||
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
|
||
r.final_signal = SignalStrength.BUY
|
||
r.signal_quality = "B"
|
||
r.trade_setup = TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||
entry_price=entry, stop_loss=sl, take_profit_1=entry + 12,
|
||
take_profit_2=entry + 20, risk_reward=1.5,
|
||
risk_pips=80.0, reward_pips=120.0, pip_value=0.10,
|
||
)
|
||
return r
|
||
|
||
key = "TEST_ALERT_FP"
|
||
tb._last_alert_fp.pop(key, None)
|
||
tb._last_alert_ts.pop(key, None)
|
||
tb._last_alert_setup.pop(key, None)
|
||
r1 = rpt(3400.0, 3392.0) # risk $8 → re-alert threshold $4
|
||
assert tb._should_alert(key, r1)
|
||
assert tb._should_alert(key, r1), "_should_alert must not commit state itself"
|
||
tb._commit_alert(key, r1)
|
||
assert not tb._should_alert(key, r1), "identical signal must dedup"
|
||
assert not tb._should_alert(key, rpt(3400.5, 3392.5)), \
|
||
"sub-threshold drift must dedup"
|
||
assert not tb._should_alert(key, rpt(3401.0, 3392.6)), \
|
||
"SL-distance drift (ATR/anchor wobble) must not re-fingerprint"
|
||
assert tb._should_alert(key, rpt(3412.0, 3404.0)), \
|
||
"setup moved 1.5x risk — must re-alert"
|
||
assert tb._should_alert(key, rpt(3395.5, 3387.5)), \
|
||
"setup moved half risk AGAINST the entry — must re-alert too"
|
||
tb._last_alert_fp.pop(key, None)
|
||
tb._last_alert_ts.pop(key, None)
|
||
tb._last_alert_setup.pop(key, None)
|
||
tb._last_alert_quality.pop(key, None)
|
||
|
||
|
||
@test("Alert dedup: grade flicker does not re-alert, an upgrade does")
|
||
def _():
|
||
import telegram_bot as tb
|
||
from signal_aggregator import SignalReport, TradeSetup
|
||
|
||
def rpt(q):
|
||
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
|
||
r.final_signal = SignalStrength.SELL
|
||
r.signal_quality = q
|
||
r.trade_setup = TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction="SELL",
|
||
entry_price=3400.0, stop_loss=3408.0, take_profit_1=3388.0,
|
||
take_profit_2=3380.0, risk_reward=1.5, risk_pips=80.0,
|
||
reward_pips=120.0, pip_value=0.10)
|
||
return r
|
||
|
||
key = "TEST_ALERT_GRADE"
|
||
for d in (tb._last_alert_fp, tb._last_alert_ts, tb._last_alert_setup,
|
||
tb._last_alert_quality):
|
||
d.pop(key, None)
|
||
try:
|
||
assert tb._should_alert(key, rpt("B"))
|
||
tb._commit_alert(key, rpt("B"))
|
||
assert not tb._should_alert(key, rpt("B")), "identical → dedup"
|
||
assert tb._should_alert(key, rpt("A")), "B→A upgrade must re-alert"
|
||
tb._commit_alert(key, rpt("A"))
|
||
assert not tb._should_alert(key, rpt("B")), \
|
||
"A→B downgrade (flicker) must NOT re-alert"
|
||
assert not tb._should_alert(key, rpt("A")), "A again → dedup"
|
||
assert tb._alert_fingerprint(rpt("A")) == tb._alert_fingerprint(rpt("B")), \
|
||
"grade is not part of the alert identity"
|
||
finally:
|
||
for d in (tb._last_alert_fp, tb._last_alert_ts, tb._last_alert_setup,
|
||
tb._last_alert_quality):
|
||
d.pop(key, None)
|
||
|
||
|
||
@test("Alert candidates: a quality-passing signal without a trade setup is skipped and logged")
|
||
def _():
|
||
import logging
|
||
import telegram_bot as tb
|
||
from signal_aggregator import SignalReport, TradeSetup
|
||
|
||
def rpt(setup: bool, signal=SignalStrength.SELL, q="B"):
|
||
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
|
||
r.final_signal = signal
|
||
r.signal_quality = q
|
||
r.warnings = ["SPREAD GATE: target $8.31 < $9.00 minimum — "
|
||
"costs would eat the edge"]
|
||
if setup:
|
||
r.trade_setup = TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction="SELL",
|
||
entry_price=4388.0, stop_loss=4410.0, take_profit_1=4377.0,
|
||
take_profit_2=4372.0, risk_reward=0.5, risk_pips=220.0,
|
||
reward_pips=110.0, pip_value=0.10)
|
||
return r
|
||
|
||
records = []
|
||
handler = logging.Handler()
|
||
handler.emit = records.append
|
||
tb.logger.addHandler(handler)
|
||
saved_threshold = tb._alert_min_quality
|
||
tb._alert_min_quality = "C"
|
||
try:
|
||
assert tb._is_alert_candidate(rpt(True), "5min")
|
||
assert not tb._is_alert_candidate(rpt(False), "5min"), \
|
||
"no trade setup → no alert (the 2026-09-08 07:50 UTC no-setup SELL)"
|
||
assert not tb._is_alert_candidate(rpt(True, SignalStrength.NEUTRAL), "5min")
|
||
assert not tb._is_alert_candidate(rpt(True, q="F"), "5min")
|
||
finally:
|
||
tb._alert_min_quality = saved_threshold
|
||
tb.logger.removeHandler(handler)
|
||
skips = [r.getMessage() for r in records
|
||
if r.getMessage().startswith("alert skipped:")]
|
||
assert len(skips) == 1, skips
|
||
assert "5min SELL grade B" in skips[0] and "SPREAD GATE" in skips[0], skips[0]
|
||
|
||
line = tb._alert_sent_line(rpt(True), "5min", 2)
|
||
assert line.startswith("alert sent: XAU/USD 5min SELL grade B"), line
|
||
assert "SELL entry 4388.00 sl 4410.00 tp1 4377.00" in line and "2 chat(s)" in line, line
|
||
r = rpt(True)
|
||
r._signal_log_rowid = 917
|
||
breach = tb._alert_sent_line(r, "15min", 1, kind="breach alert")
|
||
assert breach.startswith("breach alert sent:") and "row #917" in breach, breach
|
||
|
||
|
||
@test("Backtester --confirm-tfs defaults to None (factory's live map)")
|
||
def _():
|
||
from gpu_backtest import _parse_args
|
||
args = _parse_args(["--pair", "XAUUSD"])
|
||
assert args.confirm_tfs is None
|
||
|
||
|
||
@test("Backtest entries re-anchor to the first RAW bar after the signal bar")
|
||
def _():
|
||
from gpu_backtest import Trade, _reanchor_entries_to_raw
|
||
idx = pd.date_range("2026-01-05 10:00", periods=6, freq="5min", tz="UTC")
|
||
opens = np.array([100.0, 101.0, 110.0, 102.0, 103.0, 104.0])
|
||
sim = pd.DataFrame({"Open": opens, "High": opens + 1,
|
||
"Low": opens - 1, "Close": opens}, index=idx)
|
||
|
||
def mk_trade(entry_time, entry_price):
|
||
return Trade(pair="XAU/USD", entry_time=entry_time, entry_price=entry_price,
|
||
sl=entry_price - 8, tp1=entry_price + 12, tp2=entry_price + 20,
|
||
direction="BUY", pip_value=0.10, risk_pips=80.0,
|
||
reward_pips=120.0, rr_planned=1.5, quality="B",
|
||
final_score=30.0, final_confidence=60.0,
|
||
market_regime="trending_up")
|
||
|
||
# Cleaned frame lacked the spike at idx[2], so the replay entered at idx[3].
|
||
t = mk_trade(idx[3], 102.0)
|
||
_reanchor_entries_to_raw([t], [idx[1]], sim)
|
||
assert t.entry_time == idx[2] and t.entry_price == 110.0
|
||
assert t.sl == 102.0 and t.tp1 == 122.0 and t.tp2 == 130.0, \
|
||
"SL/TP distances must be preserved relative to the corrected entry"
|
||
|
||
# Entry already the first raw bar after the signal → untouched.
|
||
t2 = mk_trade(idx[2], 110.0)
|
||
_reanchor_entries_to_raw([t2], [idx[1]], sim)
|
||
assert t2.entry_time == idx[2] and t2.entry_price == 110.0 and t2.sl == 102.0
|
||
|
||
|
||
@test("Max drawdown counts the decline from flat starting equity")
|
||
def _():
|
||
from gpu_backtest import Trade, compute_metrics
|
||
|
||
def mk(r):
|
||
t = Trade(pair="XAU/USD",
|
||
entry_time=pd.Timestamp("2026-01-05 10:00", tz="UTC"),
|
||
entry_price=3300.0, sl=3292.0, tp1=3312.0, tp2=3320.0,
|
||
direction="BUY", pip_value=0.10, risk_pips=80.0,
|
||
reward_pips=120.0, rr_planned=1.5, quality="B",
|
||
final_score=30.0, final_confidence=60.0,
|
||
market_regime="trending_up")
|
||
t.exit_reason = "SL" if r < 0 else "TP1"
|
||
t.r_multiple = r
|
||
return t
|
||
|
||
# Opening losing streak: equity 0 → −3R with no prior peak. The old
|
||
# high-water started at the first trade's cum (−1) and reported 2R.
|
||
m = compute_metrics([mk(-1.0), mk(-1.0), mk(-1.0)])
|
||
assert m.max_drawdown_r == 3.0, f"expected 3.0, got {m.max_drawdown_r}"
|
||
# Unchanged when a peak precedes the streak.
|
||
m2 = compute_metrics([mk(2.0), mk(-1.0), mk(-1.0), mk(-1.0)])
|
||
assert m2.max_drawdown_r == 3.0, f"expected 3.0, got {m2.max_drawdown_r}"
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 12: Chart-pattern entry (XAU_CP)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 12: chart patterns (XAU_CP) ═══")
|
||
|
||
from chart_patterns import detect_chart_pattern
|
||
from config import XauStrategyConfig
|
||
|
||
|
||
def make_cp_df(closes, end="2026-07-08 14:00", freq="1h", wick=1.5):
|
||
"""Deterministic OHLCV from an explicit close path (fixed end time:
|
||
Wed, label hour 14, close hour 15 — clear of every session block)."""
|
||
idx = pd.date_range(end=end, periods=len(closes), freq=freq, tz="UTC")
|
||
c = np.asarray(closes, dtype=float)
|
||
o = np.concatenate([[c[0]], c[:-1]])
|
||
return pd.DataFrame({"Open": o, "High": c + wick, "Low": c - wick,
|
||
"Close": c, "Volume": 1000.0}, index=idx)
|
||
|
||
|
||
def mirror_cp_df(df, pivot=6000.0):
|
||
"""Price-mirror a fixture: bullish pattern → exact bearish twin."""
|
||
return pd.DataFrame({
|
||
"Open": pivot - df["Open"], "High": pivot - df["Low"],
|
||
"Low": pivot - df["High"], "Close": pivot - df["Close"],
|
||
"Volume": df["Volume"]}, index=df.index)
|
||
|
||
|
||
def double_bottom_closes():
|
||
"""L1@30 (2984) and L2@65 (2984.5), neckline peak 3000 @45,
|
||
below-neckline drift, breakout close 3004 on the last bar."""
|
||
return (list(np.linspace(2988, 3013, 15))
|
||
+ list(np.linspace(3011, 2984, 16))
|
||
+ list(np.linspace(2986, 3000, 15))
|
||
+ list(np.linspace(2999, 2984.5, 20))
|
||
+ list(np.linspace(2986, 2998, 13))
|
||
+ [3004.0])
|
||
|
||
|
||
def hs_top_closes():
|
||
"""S1@20 (3000), head@38 (3010), S2@62 (2999), neckline ~2989.5,
|
||
breakdown close 2988 on the last bar."""
|
||
return (list(np.linspace(2982, 3000, 21))
|
||
+ list(np.linspace(2998.5, 2990, 7))
|
||
+ list(np.linspace(2992, 3010, 11))
|
||
+ list(np.linspace(3008, 2991, 8))
|
||
+ list(np.linspace(2992, 2999, 16))
|
||
+ list(np.linspace(2998.5, 2991, 16))
|
||
+ [2988.0])
|
||
|
||
|
||
def bull_flag_closes():
|
||
"""Pole 60→69 (3000→3025), flag drift 70→78, breakout close 3028.5."""
|
||
return (list(np.linspace(3000, 2996, 60))
|
||
+ list(np.linspace(2999, 3025, 10))
|
||
+ list(np.linspace(3023, 3018, 9))
|
||
+ [3028.5])
|
||
|
||
|
||
_CP_CFG = XauStrategyConfig()
|
||
|
||
|
||
@test("CP: double bottom detected on breakout bar with correct geometry")
|
||
def _():
|
||
df = make_cp_df(double_bottom_closes())
|
||
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
|
||
assert hit is not None, "double bottom not detected"
|
||
assert hit["pattern"] == "double_bottom" and hit["direction"] == "BUY"
|
||
assert abs(hit["anchor"] - 2982.5) < 1e-9, f"anchor {hit['anchor']}"
|
||
assert abs(hit["trigger"] - 3001.5) < 1e-9, f"trigger {hit['trigger']}"
|
||
assert abs(hit["height"] - 19.0) < 1e-9, f"height {hit['height']}"
|
||
|
||
|
||
@test("CP: double top detected on mirrored fixture")
|
||
def _():
|
||
df = mirror_cp_df(make_cp_df(double_bottom_closes()))
|
||
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
|
||
assert hit is not None and hit["pattern"] == "double_top"
|
||
assert hit["direction"] == "SELL"
|
||
assert abs(hit["anchor"] - (6000 - 2982.5)) < 1e-9
|
||
assert abs(hit["trigger"] - (6000 - 3001.5)) < 1e-9
|
||
|
||
|
||
@test("CP: trigger discipline — no signal before breakout close")
|
||
def _():
|
||
df = make_cp_df(double_bottom_closes()).iloc[:-1]
|
||
assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None
|
||
|
||
|
||
@test("CP: trigger discipline — no re-fire on the bar after breakout")
|
||
def _():
|
||
df = make_cp_df(double_bottom_closes() + [3006.0])
|
||
assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None
|
||
|
||
|
||
@test("CP: double bottom rejected when lows differ beyond tolerance")
|
||
def _():
|
||
closes = double_bottom_closes()
|
||
closes[46:66] = list(np.linspace(2999, 2979, 20)) # L2 5.5 below L1
|
||
df = make_cp_df(closes)
|
||
assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None
|
||
|
||
|
||
@test("CP: double bottom invalidated by a lower low after L2")
|
||
def _():
|
||
closes = double_bottom_closes()
|
||
closes[77] = 2978.0 # undercuts both bottoms before the breakout
|
||
df = make_cp_df(closes)
|
||
assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None
|
||
|
||
|
||
@test("CP: head & shoulders top detected (SELL, right-shoulder anchor)")
|
||
def _():
|
||
df = make_cp_df(hs_top_closes())
|
||
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
|
||
assert hit is not None, "H&S top not detected"
|
||
assert hit["pattern"] == "hs_top" and hit["direction"] == "SELL"
|
||
assert abs(hit["anchor"] - 3000.5) < 1e-9, f"anchor {hit['anchor']}"
|
||
assert abs(hit["trigger"] - 2989.5) < 1e-9, f"trigger {hit['trigger']}"
|
||
|
||
|
||
@test("CP: inverse head & shoulders detected on mirrored fixture")
|
||
def _():
|
||
df = mirror_cp_df(make_cp_df(hs_top_closes()))
|
||
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
|
||
assert hit is not None and hit["pattern"] == "inv_hs"
|
||
assert hit["direction"] == "BUY"
|
||
|
||
|
||
@test("CP: bull flag detected (pole, shallow retrace, new-high close)")
|
||
def _():
|
||
df = make_cp_df(bull_flag_closes())
|
||
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
|
||
assert hit is not None, "bull flag not detected"
|
||
assert hit["pattern"] == "bull_flag" and hit["direction"] == "BUY"
|
||
assert abs(hit["trigger"] - 3026.5) < 1e-9
|
||
assert hit["height"] > 15.0 # pole ≥ cp_flag_pole_atr × atr
|
||
|
||
|
||
@test("CP: bear flag detected on mirrored fixture")
|
||
def _():
|
||
df = mirror_cp_df(make_cp_df(bull_flag_closes()))
|
||
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
|
||
assert hit is not None and hit["pattern"] == "bear_flag"
|
||
assert hit["direction"] == "SELL"
|
||
|
||
|
||
@test("CP: cp_patterns subset filter excludes disabled detectors")
|
||
def _():
|
||
df = make_cp_df(double_bottom_closes())
|
||
cfg = XauStrategyConfig(cp_patterns=["bull_flag"])
|
||
assert detect_chart_pattern(df, atr=5.0, cfg=cfg) is None
|
||
|
||
|
||
def _cp_report(df, config, vol_regime="normal", mtf_signals=None):
|
||
"""SignalReport shell around a real engine analysis of `df`."""
|
||
from engine import TechnicalEngine
|
||
eng = TechnicalEngine(config.technical)
|
||
pa = eng.analyze(df, "XAU/USD", config.primary_timeframe.value)
|
||
rep = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
|
||
timestamp=str(df.index[-1]))
|
||
rep.primary_analysis = pa
|
||
rep.current_price = float(df["Close"].iloc[-1])
|
||
rep.volatility_regime = vol_regime
|
||
rep.mtf_signals = mtf_signals or {}
|
||
rep.signal_quality = "D"
|
||
return rep
|
||
|
||
|
||
def _cp_config(**xau_overrides):
|
||
cfg = PlatformConfig()
|
||
cfg.primary_timeframe = Timeframe.H1
|
||
cfg.xau_strategy.cp_enabled = True
|
||
for k, v in xau_overrides.items():
|
||
setattr(cfg.xau_strategy, k, v)
|
||
return cfg
|
||
|
||
|
||
@test("CP gate: BUY pattern builds an XAU_CP setup with measured-move TP")
|
||
def _():
|
||
cfg = _cp_config()
|
||
df = make_cp_df(double_bottom_closes())
|
||
rep = _cp_report(df, cfg)
|
||
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
|
||
ts = rep.trade_setup
|
||
assert ts is not None, "pattern setup not generated"
|
||
assert ts.strategy == "XAU_CP" and ts.direction == "BUY"
|
||
assert ts.setup_pattern == "double_bottom"
|
||
assert ts.stop_loss < ts.entry_price
|
||
assert abs((ts.take_profit_1 - ts.entry_price) - 19.0) < 1e-6, \
|
||
"TP1 must be the measured move"
|
||
assert rep.signal_quality == "B"
|
||
assert rep.bullish_reasons[0].startswith("XAU CHART PATTERN"), \
|
||
"tag marker must sit at reasons[0]"
|
||
|
||
|
||
@test("CP gate: accepted setup removes stale vetoes and deduplicates extension context")
|
||
def _():
|
||
cfg = _cp_config()
|
||
df = make_cp_df(double_bottom_closes())
|
||
rep = _cp_report(df, cfg)
|
||
rep.warnings = [
|
||
"SPREAD GATE: old core target failed",
|
||
"EXHAUSTED TREND FILTER: C-grade blocked in exhausted trend",
|
||
"M5 FILTER: no MTF agreement — M5 signals require confirmation",
|
||
"FAR FROM EMA200 (4.0%) — over-extended",
|
||
"TREND EXHAUSTION — price extended >3.5 ATR past swing low",
|
||
"NO PULLBACK — price 3.0 ATR from EMA21",
|
||
"Extended — RSI 78 in uptrend",
|
||
"EVENT RISK: CPI in 20m",
|
||
"NEAR EMA200 (0.3%) — chop risk",
|
||
]
|
||
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
|
||
assert rep.trade_setup is not None
|
||
assert rep.warnings == [
|
||
"TREND EXHAUSTION — price extended >3.5 ATR past swing low",
|
||
"EVENT RISK: CPI in 20m",
|
||
"NEAR EMA200 (0.3%) — chop risk",
|
||
], rep.warnings
|
||
|
||
|
||
@test("Core setup: a stale ATR spread-gate veto is cleaned when the swing generator ships a setup")
|
||
def _():
|
||
from types import SimpleNamespace
|
||
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup
|
||
|
||
agg = SignalAggregator(PlatformConfig())
|
||
price = 4400.0
|
||
|
||
def rpt(levels):
|
||
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
|
||
r.final_signal = SignalStrength.STRONG_BUY
|
||
r.final_score = 40.0
|
||
r.signal_quality = "A"
|
||
r.current_price = price
|
||
r.primary_analysis = SimpleNamespace(key_levels=dict(levels))
|
||
return r
|
||
|
||
# ATR $2 → the A-grade ATR target is 1.5 × $2 = $3, under the $9 cost
|
||
# floor (veto + warning). The swing generator's stop sits under the
|
||
# $20-deep swing low, so its RR-floor target is $11 and a GENERIC
|
||
# setup ships — the ATR veto is then stale (alert #915, 2026-09-08).
|
||
r = rpt({"atr": 2.0, "swing_low_1": price - 20.0})
|
||
agg._attach_core_setup(r, 0.10)
|
||
assert r.trade_setup is not None and r.trade_setup.strategy == "GENERIC", r.trade_setup
|
||
assert sum(w.startswith("SPREAD GATE:") for w in r.warnings) == 1, \
|
||
"the generators themselves leave the veto in place"
|
||
agg._drop_stale_spread_gate_vetoes(r)
|
||
assert not any(w.startswith("SPREAD GATE:") for w in r.warnings), r.warnings
|
||
assert sum(w.startswith("SPREAD GATE:") for w in r.cleaned_warnings) == 1, \
|
||
"the veto must stay auditable in cleaned_warnings"
|
||
|
||
# No swing structure: the swing generator's fallback target is
|
||
# max(1.5 × ATR, RR floor × the $8 minimum stop) = $4 — also gated —
|
||
# so no setup ships and BOTH vetoes stay visible.
|
||
r2 = rpt({"atr": 2.0})
|
||
agg._attach_core_setup(r2, 0.10)
|
||
agg._drop_stale_spread_gate_vetoes(r2)
|
||
assert r2.trade_setup is None
|
||
assert sum(w.startswith("SPREAD GATE:") for w in r2.warnings) == 2, r2.warnings
|
||
assert not r2.cleaned_warnings
|
||
|
||
# The pass runs after the PB/CP archetypes too: a setup shipped by a
|
||
# later generator (XAU_PB shape here) supersedes BOTH core vetoes,
|
||
# and unrelated warnings keep their order.
|
||
r3 = rpt({"atr": 2.0})
|
||
agg._attach_core_setup(r3, 0.10)
|
||
r3.warnings.insert(0, "NEAR EMA200 (0.2%) — chop risk")
|
||
r3.warnings.append("TREND EXHAUSTION — price extended >3.5 ATR past swing low")
|
||
r3.trade_setup = TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||
entry_price=price, stop_loss=price - 8.0, take_profit_1=price + 12.0,
|
||
take_profit_2=price + 18.0, risk_reward=1.5, risk_pips=80.0,
|
||
reward_pips=120.0, pip_value=0.10, strategy="XAU_PB")
|
||
agg._drop_stale_spread_gate_vetoes(r3)
|
||
assert r3.warnings == ["NEAR EMA200 (0.2%) — chop risk",
|
||
"TREND EXHAUSTION — price extended >3.5 ATR past swing low"], r3.warnings
|
||
assert sum(w.startswith("SPREAD GATE:") for w in r3.cleaned_warnings) == 2
|
||
|
||
|
||
@test("CP gate: MTF opposition blocks the pattern trade")
|
||
def _():
|
||
cfg = _cp_config()
|
||
df = make_cp_df(double_bottom_closes())
|
||
rep = _cp_report(df, cfg, mtf_signals={
|
||
"15min": SignalStrength.SELL, "4h": SignalStrength.STRONG_SELL})
|
||
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
|
||
assert rep.trade_setup is None, "BUY pattern must not fire against consensus"
|
||
|
||
|
||
@test("CP gate: SELL pattern blocked in quiet volatility, allowed in high")
|
||
def _():
|
||
cfg = _cp_config()
|
||
df = mirror_cp_df(make_cp_df(double_bottom_closes()))
|
||
rep = _cp_report(df, cfg, vol_regime="normal")
|
||
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
|
||
assert rep.trade_setup is None, "quiet-vol SELL must be blocked"
|
||
rep2 = _cp_report(df, cfg, vol_regime="high")
|
||
SignalAggregator(cfg)._try_chart_pattern(rep2, df, 0.10)
|
||
assert rep2.trade_setup is not None and rep2.trade_setup.direction == "SELL"
|
||
|
||
|
||
@test("CP gate: SELL blocked-hour (bar close) stops pattern shorts only")
|
||
def _():
|
||
cfg = _cp_config(sell_blocked_hours_utc=[15]) # fixture closes 15:00
|
||
df = mirror_cp_df(make_cp_df(double_bottom_closes()))
|
||
rep = _cp_report(df, cfg, vol_regime="high")
|
||
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
|
||
assert rep.trade_setup is None, "SELL at blocked close hour must not fire"
|
||
# BUY side unaffected at the same hour.
|
||
df_buy = make_cp_df(double_bottom_closes())
|
||
rep_buy = _cp_report(df_buy, cfg)
|
||
SignalAggregator(cfg)._try_chart_pattern(rep_buy, df_buy, 0.10)
|
||
assert rep_buy.trade_setup is not None
|
||
|
||
|
||
@test("CP gate: M5 chop-transition volatility blocks both directions")
|
||
def _():
|
||
cfg = _cp_config()
|
||
cfg.primary_timeframe = Timeframe.M5
|
||
df = make_cp_df(double_bottom_closes())
|
||
rep = _cp_report(df, cfg, vol_regime="high") # 'high' blocked on M5
|
||
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
|
||
assert rep.trade_setup is None
|
||
|
||
|
||
@test("CP gate: spread gate rejects patterns smaller than TP minimum")
|
||
def _():
|
||
cfg = _cp_config()
|
||
cfg.signals.min_tp_cost_multiple = 1000.0 # $300 minimum target
|
||
df = make_cp_df(double_bottom_closes())
|
||
rep = _cp_report(df, cfg)
|
||
rep.warnings = ["SPREAD GATE: prior warning", "NO PULLBACK — context"]
|
||
before = list(rep.warnings)
|
||
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
|
||
assert rep.trade_setup is None
|
||
assert rep.warnings == before, "rejected CP must not rewrite diagnostics"
|
||
|
||
|
||
@test("CP pipeline: fires end-to-end when core generators pass")
|
||
def _():
|
||
cfg = _cp_config()
|
||
cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
|
||
df = make_cp_df(double_bottom_closes())
|
||
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
|
||
agg = SignalAggregator(cfg)
|
||
with patch.object(SignalAggregator, "_generate_trade_setup",
|
||
return_value=None), \
|
||
patch.object(SignalAggregator, "_generate_xau_atr_trade_setup",
|
||
return_value=None):
|
||
rep = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert rep.trade_setup is not None, \
|
||
f"pipeline did not produce a setup (warnings: {rep.warnings})"
|
||
assert rep.trade_setup.strategy == "XAU_CP"
|
||
|
||
|
||
@test("CP pipeline: disabled by default except M5 BUY-only")
|
||
def _():
|
||
assert XauStrategyConfig().cp_enabled is False
|
||
assert PlatformConfig().xau_strategy.cp_enabled is False
|
||
from pair_signals import PAIR_PRODUCERS
|
||
for tf in (Timeframe.M15, Timeframe.H1, Timeframe.H4):
|
||
assert PAIR_PRODUCERS[XAUUSD](tf, None).xau_strategy.cp_enabled is False
|
||
m5_cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.M5, None)
|
||
assert m5_cfg.xau_strategy.cp_enabled is True
|
||
assert m5_cfg.xau_strategy.cp_patterns == ["inv_hs", "double_bottom", "bull_flag"]
|
||
cfg = PlatformConfig()
|
||
cfg.primary_timeframe = Timeframe.H1
|
||
cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
|
||
df = make_cp_df(double_bottom_closes())
|
||
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
|
||
with patch.object(SignalAggregator, "_generate_trade_setup",
|
||
return_value=None), \
|
||
patch.object(SignalAggregator, "_generate_xau_atr_trade_setup",
|
||
return_value=None):
|
||
rep = SignalAggregator(cfg).analyze_from_dataframes(
|
||
"XAU/USD", dfs, use_news=False)
|
||
assert rep.trade_setup is None
|
||
|
||
|
||
@test("CP pipeline: session-blocked bar never hosts a pattern trade")
|
||
def _():
|
||
cfg = _cp_config()
|
||
cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
|
||
# Label hour 13 is in blocked_hours_utc → 6b sets F + SESSION FILTER.
|
||
df = make_cp_df(double_bottom_closes(), end="2026-07-08 13:00")
|
||
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
|
||
with patch.object(SignalAggregator, "_generate_trade_setup",
|
||
return_value=None), \
|
||
patch.object(SignalAggregator, "_generate_xau_atr_trade_setup",
|
||
return_value=None):
|
||
rep = SignalAggregator(cfg).analyze_from_dataframes(
|
||
"XAU/USD", dfs, use_news=False)
|
||
assert rep.trade_setup is None
|
||
assert rep.signal_quality == "F"
|
||
|
||
|
||
@test("CP backtester: --cp flag enables patterns, tag filter knows XAU_CP")
|
||
def _():
|
||
from gpu_backtest import _parse_args
|
||
args = _parse_args(["--pair", "XAUUSD", "--cp",
|
||
"--cp-patterns", "double_bottom,bull_flag"])
|
||
assert args.cp is True
|
||
assert args.cp_patterns == "double_bottom,bull_flag"
|
||
args2 = _parse_args(["--pair", "XAUUSD"])
|
||
assert args2.cp is False
|
||
|
||
|
||
@test("Backtester: --trail-arm/--trail-dist research knobs parse, default None")
|
||
def _():
|
||
from gpu_backtest import _parse_args
|
||
args = _parse_args(["--pair", "XAUUSD",
|
||
"--trail-arm", "0.45", "--trail-dist", "0.75"])
|
||
assert args.trail_arm == 0.45 and args.trail_dist == 0.75
|
||
args2 = _parse_args(["--pair", "XAUUSD"])
|
||
assert args2.trail_arm is None and args2.trail_dist is None
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 13: app_config.py (config.json overlay)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
print("\n═══ Module 13: app_config.py ═══")
|
||
|
||
import tempfile
|
||
from app_config import (
|
||
AppConfig, AppConfigError, load_app_config, set_app_config,
|
||
telegram_token, telegram_chat_id, telegram_channel_id,
|
||
provider_api_key, apply_pair_overrides,
|
||
)
|
||
|
||
|
||
def _write_json(payload) -> str:
|
||
fh = tempfile.NamedTemporaryFile(
|
||
"w", suffix=".json", delete=False, encoding="utf-8")
|
||
if isinstance(payload, str):
|
||
fh.write(payload)
|
||
else:
|
||
json.dump(payload, fh)
|
||
fh.close()
|
||
return fh.name
|
||
|
||
|
||
_FULL_CFG = {
|
||
"telegram": {"token": "t0k", "chat_id": "111", "channel_id": "-100222",
|
||
"alert_min_quality": "B"},
|
||
"data_providers": {"active": "twelve_data",
|
||
"twelve_data": {"api_key": "json-key"}},
|
||
"scanning": {"pairs": ["XAU/USD"], "timeframes": ["M15", "H1"]},
|
||
"pairs": {"XAU/USD": {"blocked_hours_utc": [3, 4]}},
|
||
"log_level": "WARNING",
|
||
}
|
||
|
||
|
||
@test("app_config: valid config.json maps every field")
|
||
def _():
|
||
app = load_app_config(_write_json(_FULL_CFG))
|
||
assert app.telegram_token == "t0k"
|
||
assert app.telegram_chat_id == "111"
|
||
assert app.telegram_channel_id == "-100222"
|
||
assert app.alert_min_quality == "B"
|
||
assert app.active_provider == "twelve_data"
|
||
assert app.provider_settings["twelve_data"]["api_key"] == "json-key"
|
||
assert app.scan_pairs == [XAUUSD]
|
||
assert app.scan_timeframes == [Timeframe.M15, Timeframe.H1]
|
||
assert app.pair_overrides[XAUUSD]["blocked_hours_utc"] == [3, 4]
|
||
assert app.log_level == "WARNING"
|
||
|
||
|
||
@test("app_config: missing file returns pure defaults")
|
||
def _():
|
||
app = load_app_config("/nonexistent/config.json")
|
||
assert app == AppConfig(), "absent config.json must yield pure defaults"
|
||
|
||
|
||
@test("app_config: malformed JSON raises AppConfigError")
|
||
def _():
|
||
path = _write_json("{not json")
|
||
try:
|
||
load_app_config(path)
|
||
assert False, "should have raised"
|
||
except AppConfigError as e:
|
||
assert "not valid JSON" in str(e)
|
||
|
||
|
||
@test("app_config: unknown keys raise AppConfigError")
|
||
def _():
|
||
for payload in (
|
||
{"telgram": {}}, # top-level typo
|
||
{"telegram": {"tokn": "x"}}, # section typo
|
||
{"pairs": {"XAU/USD": {"blocked_hours": [1]}}}, # pair-block typo
|
||
{"scanning": {"pair": ["XAU/USD"]}}, # scanning typo
|
||
):
|
||
try:
|
||
load_app_config(_write_json(payload))
|
||
assert False, f"should have raised for {payload}"
|
||
except AppConfigError as e:
|
||
assert "unknown key" in str(e).lower()
|
||
|
||
|
||
@test("app_config: invalid values raise AppConfigError")
|
||
def _():
|
||
bad_payloads = [
|
||
{"pairs": {"XAU/USD": {"blocked_hours_utc": [24]}}}, # hour range
|
||
{"pairs": {"XAU/USD": {"blocked_hours_utc": "3"}}}, # not a list
|
||
{"pairs": {"EUR/USD": {"blocked_hours_utc": [3]}}}, # unknown pair
|
||
{"scanning": {"pairs": ["EUR/USD"]}}, # unknown pair
|
||
{"scanning": {"timeframes": ["M7"]}}, # bad TF name
|
||
{"scanning": {"timeframes": ["W1"]}}, # unscannable TF
|
||
{"scanning": {"pairs": 5}}, # pairs not a list
|
||
{"scanning": {"timeframes": 5}}, # timeframes not a list
|
||
{"data_providers": {"active": "polygon",
|
||
"twelve_data": {"api_key": "k"}}}, # no block
|
||
{"telegram": {"alert_min_quality": "Z"}}, # bad quality
|
||
{"log_level": "CHATTY"}, # bad level
|
||
]
|
||
for payload in bad_payloads:
|
||
try:
|
||
load_app_config(_write_json(payload))
|
||
assert False, f"should have raised for {payload}"
|
||
except AppConfigError:
|
||
pass
|
||
|
||
|
||
@test("app_config: env precedence env → config.json → default")
|
||
def _():
|
||
app = load_app_config(_write_json(_FULL_CFG))
|
||
with patch.dict(os.environ, {}, clear=True):
|
||
assert telegram_token(app) == "t0k"
|
||
assert telegram_chat_id(app) == "111"
|
||
assert telegram_channel_id(app) == "-100222"
|
||
assert provider_api_key(app) == "json-key"
|
||
with patch.dict(os.environ, {"TELEGRAM_TOKEN": "env-tok",
|
||
"TELEGRAM_CHAT_ID": "999",
|
||
"TWELVE_DATA_API_KEY": "env-key"}):
|
||
assert telegram_token(app) == "env-tok"
|
||
assert telegram_chat_id(app) == "999"
|
||
assert provider_api_key(app) == "env-key"
|
||
# channel: env set-but-empty means "disable feed" and must beat JSON
|
||
with patch.dict(os.environ, {"TELEGRAM_CHANNEL_ID": ""}):
|
||
assert telegram_channel_id(app) == ""
|
||
|
||
|
||
@test("app_config: non-object sections raise AppConfigError")
|
||
def _():
|
||
for payload in (
|
||
{"telegram": None},
|
||
{"telegram": "oops"},
|
||
{"scanning": 5},
|
||
{"pairs": ["oops"]},
|
||
{"pairs": {"XAU/USD": ["blocked_hours_utc"]}},
|
||
):
|
||
try:
|
||
load_app_config(_write_json(payload))
|
||
assert False, f"should have raised for {payload}"
|
||
except AppConfigError:
|
||
pass
|
||
|
||
|
||
@test("app_config: apply_pair_overrides stamps both blocked-hours mirrors")
|
||
def _():
|
||
app = AppConfig(pair_overrides={XAUUSD: {"blocked_hours_utc": [3, 4]}},
|
||
scan_pairs=[XAUUSD],
|
||
scan_timeframes=[Timeframe.M15])
|
||
cfg = apply_pair_overrides(PlatformConfig(), app)
|
||
assert cfg.xau_strategy.blocked_hours_utc == [3, 4]
|
||
assert cfg.backtest.blocked_hours_utc == [3, 4]
|
||
assert cfg.scan_timeframes == [Timeframe.M15]
|
||
# the two mirrors must be independent lists, not aliases
|
||
cfg.xau_strategy.blocked_hours_utc.append(5)
|
||
assert cfg.backtest.blocked_hours_utc == [3, 4]
|
||
|
||
|
||
@test("app_config: M5 factory appends hour 9 on top of a JSON base")
|
||
def _():
|
||
from pair_signals import PAIR_PRODUCERS
|
||
app = AppConfig(pair_overrides={XAUUSD: {"blocked_hours_utc": [3, 4]}},
|
||
provider_settings={"twelve_data": {"api_key": "test-key"}})
|
||
set_app_config(app)
|
||
try:
|
||
cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.M5, None)
|
||
finally:
|
||
set_app_config(_TEST_APP)
|
||
assert cfg.xau_strategy.blocked_hours_utc == [3, 4, 9], \
|
||
"validated M5 hour-9 delta must land on top of the JSON base"
|
||
assert cfg.backtest.blocked_hours_utc == [3, 4], \
|
||
"mirror carries the base only (per-TF delta is XAU-strategy-side)"
|
||
|
||
|
||
@test("app_config: config.json.example is valid and behavior-neutral")
|
||
def _():
|
||
import dataclasses
|
||
from pair_signals import PAIR_PRODUCERS
|
||
example = load_app_config(
|
||
os.path.join(os.path.dirname(__file__), "config.json.example"))
|
||
for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4):
|
||
baseline = PAIR_PRODUCERS[XAUUSD](tf, None) # under _TEST_APP
|
||
set_app_config(example)
|
||
try:
|
||
with_example = PAIR_PRODUCERS[XAUUSD](tf, None)
|
||
finally:
|
||
set_app_config(_TEST_APP)
|
||
assert dataclasses.asdict(baseline) == dataclasses.asdict(with_example), \
|
||
f"example config.json must be byte-identical to defaults on {tf}"
|
||
|
||
|
||
@test("providers: api key resolves env → config.json (no code default)")
|
||
def _():
|
||
from providers import TwelveDataProvider
|
||
app = AppConfig(provider_settings={"twelve_data": {"api_key": "json-key"}})
|
||
set_app_config(app)
|
||
try:
|
||
with patch.dict(os.environ, {}, clear=True):
|
||
dp = TwelveDataProvider(PlatformConfig())
|
||
assert dp._api_key == "json-key"
|
||
with patch.dict(os.environ, {"TWELVE_DATA_API_KEY": "env-key"}):
|
||
dp = TwelveDataProvider(PlatformConfig())
|
||
assert dp._api_key == "env-key"
|
||
set_app_config(AppConfig()) # no key anywhere
|
||
with patch.dict(os.environ, {}, clear=True):
|
||
dp = TwelveDataProvider(PlatformConfig())
|
||
assert dp._api_key is None and dp._td is None
|
||
finally:
|
||
set_app_config(_TEST_APP)
|
||
|
||
|
||
@test("providers: make_data_provider dispatches on active provider")
|
||
def _():
|
||
from providers import TwelveDataProvider, make_data_provider
|
||
assert isinstance(make_data_provider(PlatformConfig()), TwelveDataProvider)
|
||
set_app_config(AppConfig(active_provider="polygon",
|
||
provider_settings={"polygon": {"api_key": "k"}}))
|
||
try:
|
||
make_data_provider(PlatformConfig())
|
||
assert False, "unregistered provider must raise"
|
||
except AppConfigError as e:
|
||
assert "polygon" in str(e)
|
||
finally:
|
||
set_app_config(_TEST_APP)
|
||
|
||
|
||
@test("providers: make_data_provider explicit name overrides active")
|
||
def _():
|
||
from providers import (TiingoDataProvider as _Tii,
|
||
TwelveDataProvider, make_data_provider)
|
||
# active is twelve_data under _TEST_APP; explicit name wins
|
||
with patch.dict(os.environ, {"TIINGO_API_KEY": "k"}):
|
||
assert isinstance(make_data_provider(PlatformConfig(), "tiingo"), _Tii)
|
||
assert isinstance(make_data_provider(PlatformConfig()), TwelveDataProvider)
|
||
try:
|
||
make_data_provider(PlatformConfig(), "polygon")
|
||
assert False, "unregistered explicit name must raise"
|
||
except AppConfigError as e:
|
||
assert "polygon" in str(e)
|
||
|
||
|
||
@test("app_config: data_providers.streaming scalar selects the tick stream")
|
||
def _():
|
||
app = load_app_config(_write_json({
|
||
"data_providers": {"active": "oanda", "streaming": "tiingo",
|
||
"oanda": {}, "tiingo": {"api_key": "k"}}}))
|
||
assert app.streaming_provider == "tiingo"
|
||
assert set(app.provider_settings) == {"oanda", "tiingo"}, \
|
||
"reserved scalars must not become provider blocks"
|
||
app = load_app_config(_write_json(
|
||
{"data_providers": {"active": "oanda", "oanda": {}}}))
|
||
assert app.streaming_provider is None
|
||
|
||
|
||
@test("app_config: streaming provider must be a string with a settings block")
|
||
def _():
|
||
for payload in (
|
||
{"data_providers": {"active": "oanda", "oanda": {},
|
||
"streaming": "polygon"}}, # no block
|
||
{"data_providers": {"active": "oanda", "oanda": {},
|
||
"streaming": 5}}, # not a string
|
||
):
|
||
try:
|
||
load_app_config(_write_json(payload))
|
||
assert False, f"should have raised for {payload}"
|
||
except AppConfigError:
|
||
pass
|
||
|
||
|
||
@test("app_config: deepseek block parses, validates, defaults empty")
|
||
def _():
|
||
app = load_app_config(_write_json({"deepseek": {
|
||
"api_key": "sk-x", "model": "deepseek-v4-flash", "enabled": False,
|
||
"timeout_seconds": 5, "daily_request_cap": 100}}))
|
||
assert app.deepseek_settings["api_key"] == "sk-x"
|
||
assert app.deepseek_settings["enabled"] is False
|
||
assert load_app_config(_write_json({})).deepseek_settings == {}
|
||
for payload in (
|
||
{"deepseek": {"api_keyy": "typo"}}, # unknown key
|
||
{"deepseek": {"enabled": "yes"}}, # not a bool
|
||
{"deepseek": {"timeout_seconds": 0}}, # not positive
|
||
{"deepseek": {"timeout_seconds": True}}, # bool is not a number
|
||
{"deepseek": {"daily_request_cap": 2.5}}, # not an int
|
||
{"deepseek": {"model": ""}}, # empty model
|
||
{"deepseek": "oops"}, # not an object
|
||
):
|
||
try:
|
||
load_app_config(_write_json(payload))
|
||
assert False, f"should have raised for {payload}"
|
||
except AppConfigError:
|
||
pass
|
||
|
||
|
||
@test("app_config: deepseek_api_key resolves env → config.json → empty")
|
||
def _():
|
||
from app_config import deepseek_api_key
|
||
app = load_app_config(_write_json({"deepseek": {"api_key": "json-ds"}}))
|
||
with patch.dict(os.environ, {}, clear=True):
|
||
assert deepseek_api_key(app) == "json-ds"
|
||
with patch.dict(os.environ, {"DEEPSEEK_API_KEY": "env-ds"}):
|
||
assert deepseek_api_key(app) == "env-ds"
|
||
with patch.dict(os.environ, {}, clear=True):
|
||
assert deepseek_api_key(AppConfig()) == ""
|
||
|
||
|
||
@test("config.py: removed PlatformConfig fields stay gone (twelve_data_api_key, pip_value)")
|
||
def _():
|
||
assert not hasattr(PlatformConfig(), "twelve_data_api_key"), \
|
||
"credentials must not live in committed source"
|
||
# pip value comes from PAIR_INFO; the config field was never read (2026-09-06).
|
||
assert not hasattr(PlatformConfig(), "pip_value")
|
||
|
||
|
||
@test("telegram_bot: credentials, quality, pairs, loops from app_config")
|
||
def _():
|
||
import importlib
|
||
import telegram_bot
|
||
app = AppConfig(telegram_token="json-tok", telegram_chat_id="42",
|
||
telegram_channel_id="", alert_min_quality="B",
|
||
scan_timeframes=[Timeframe.M15, Timeframe.H1],
|
||
provider_settings={"twelve_data": {"api_key": "k"}})
|
||
set_app_config(app)
|
||
try:
|
||
with patch.dict(os.environ, {}, clear=True):
|
||
tb = importlib.reload(telegram_bot)
|
||
assert tb.TOKEN == "json-tok"
|
||
assert tb.OWNER_ID == 42
|
||
assert tb.CHANNEL_ID is None, '"" channel id must disable the feed'
|
||
assert tb._alert_min_quality == "B"
|
||
assert [tf for tf, _ in tb._AUTOSCAN_LOOPS] == \
|
||
[Timeframe.M15, Timeframe.H1]
|
||
assert tb._AUTOSCAN_LOOPS == [(Timeframe.M15, 15),
|
||
(Timeframe.H1, 60)]
|
||
finally:
|
||
set_app_config(_TEST_APP)
|
||
importlib.reload(telegram_bot)
|
||
|
||
|
||
@test("telegram_bot: main() refuses to start without credentials")
|
||
def _():
|
||
import telegram_bot
|
||
if telegram_bot.TOKEN: # only meaningful in the hermetic no-cred env
|
||
return
|
||
try:
|
||
telegram_bot.main()
|
||
assert False, "main() must refuse to start without token/chat_id"
|
||
except SystemExit as e:
|
||
assert "credentials" in str(e)
|
||
|
||
|
||
@test("app_config: absolute parity — default overlay leaves tuned values untouched")
|
||
def _():
|
||
from pair_signals import PAIR_PRODUCERS
|
||
cfg = PAIR_PRODUCERS[XAUUSD](Timeframe.H1, None)
|
||
assert cfg.xau_strategy.blocked_hours_utc == [0, 1, 2, 10, 11, 13, 16, 21]
|
||
assert cfg.backtest.blocked_hours_utc == [0, 1, 2, 10, 11, 13, 16, 21]
|
||
assert cfg.scan_pairs == [XAUUSD]
|
||
assert cfg.scan_timeframes == [Timeframe.M5, Timeframe.M15,
|
||
Timeframe.H1, Timeframe.H4]
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 14: .env loader + telegram_bot resilience
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
print("\n═══ Module 14: .env loader + telegram_bot resilience ═══")
|
||
|
||
from app_config import load_dotenv
|
||
|
||
|
||
@test("app_config: load_dotenv parses lines, never overrides real env")
|
||
def _():
|
||
fh = tempfile.NamedTemporaryFile(
|
||
"w", suffix=".env", delete=False, encoding="utf-8")
|
||
fh.write("# comment\n\nPLAIN=abc\nexport EXPORTED=def\n"
|
||
'QUOTED="with spaces"\nSINGLE=\'sq\'\nEXISTING=from-dotenv\n'
|
||
"NOT A KV LINE\n"
|
||
"COMMENTED=abc123 # prod key\n"
|
||
'QUOTED_HASH="keep #this"\n'
|
||
"HASH_IN_VALUE=abc#123\n")
|
||
fh.close()
|
||
with patch.dict(os.environ, {"EXISTING": "from-shell"}, clear=True):
|
||
load_dotenv(fh.name)
|
||
assert os.environ["PLAIN"] == "abc"
|
||
assert os.environ["EXPORTED"] == "def"
|
||
assert os.environ["QUOTED"] == "with spaces"
|
||
assert os.environ["SINGLE"] == "sq"
|
||
assert os.environ["EXISTING"] == "from-shell", \
|
||
"a real shell export must beat .env"
|
||
assert os.environ["COMMENTED"] == "abc123", \
|
||
"inline comment must be stripped, not absorbed into the value"
|
||
assert os.environ["QUOTED_HASH"] == "keep #this", \
|
||
"quoted values keep their hashes"
|
||
assert os.environ["HASH_IN_VALUE"] == "abc#123", \
|
||
"a hash with no preceding whitespace is part of the value"
|
||
load_dotenv("/nonexistent/.env") # missing file is a silent no-op
|
||
|
||
|
||
@test("telegram_bot: log rotation is daily with 30-day retention")
|
||
def _():
|
||
import logging.handlers
|
||
import telegram_bot as tb
|
||
h = tb._file_handler
|
||
assert isinstance(h, logging.handlers.TimedRotatingFileHandler)
|
||
assert h.when.upper() == "MIDNIGHT", \
|
||
"one file per day (2026-09-08: was 3-hourly × 240)"
|
||
assert h.interval == 86400, "plain daily — an interval=N would stretch retention"
|
||
assert h.backupCount == 30, "30 daily files = 30-day retention"
|
||
|
||
|
||
@test("telegram_bot: candle loop survives a failing scan cycle")
|
||
def _():
|
||
import asyncio
|
||
import telegram_bot as tb
|
||
calls = {"n": 0}
|
||
|
||
async def boom(bot, tf, confirm):
|
||
calls["n"] += 1
|
||
if calls["n"] == 1:
|
||
raise RuntimeError("scan blew up")
|
||
tb._autoscan_chats.clear() # let the loop exit after call 2
|
||
|
||
tb._autoscan_chats.add(1)
|
||
try:
|
||
with patch.object(tb, "_scan_timeframe", boom), \
|
||
patch.object(tb, "_seconds_until_boundary",
|
||
lambda interval, buffer=10: 0.0):
|
||
asyncio.run(tb._candle_loop(None, Timeframe.M15, 15))
|
||
assert calls["n"] == 2, \
|
||
"loop must keep firing after an exception, not die on it"
|
||
finally:
|
||
tb._autoscan_chats.clear()
|
||
|
||
|
||
@test("telegram_bot: _get_streaming_dp resolves streaming name, else shared")
|
||
def _():
|
||
import telegram_bot as tb
|
||
from providers import TiingoDataProvider as _Tii
|
||
set_app_config(AppConfig(
|
||
active_provider="twelve_data",
|
||
streaming_provider="tiingo",
|
||
provider_settings={"twelve_data": {"api_key": "k"},
|
||
"tiingo": {"api_key": "k"}}))
|
||
tb._streaming_dp = None
|
||
tb._shared_dp = None
|
||
try:
|
||
dp = tb._get_streaming_dp()
|
||
assert isinstance(dp, _Tii)
|
||
assert dp is not tb._get_shared_dp(), \
|
||
"streaming provider must be a separate instance"
|
||
# unset → falls back to the shared active provider
|
||
set_app_config(_TEST_APP)
|
||
tb._streaming_dp = None
|
||
tb._shared_dp = None
|
||
assert tb._get_streaming_dp() is tb._get_shared_dp()
|
||
finally:
|
||
set_app_config(_TEST_APP)
|
||
tb._streaming_dp = None
|
||
tb._shared_dp = None
|
||
|
||
|
||
@test("telegram_bot: ws monitor auto-disables on non-streaming or keyless")
|
||
def _():
|
||
import asyncio
|
||
import telegram_bot as tb
|
||
|
||
class NoStream:
|
||
name = "stub"
|
||
supports_streaming = False
|
||
|
||
class KeylessStream:
|
||
name = "stub"
|
||
supports_streaming = True
|
||
_api_key = None
|
||
|
||
try:
|
||
for stub in (NoStream(), KeylessStream()):
|
||
tb._streaming_dp = stub
|
||
asyncio.run(tb._ws_monitor(None)) # must return, not connect
|
||
finally:
|
||
tb._streaming_dp = None
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 15: Oanda provider
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 15: Oanda provider ═══")
|
||
|
||
import providers.oanda
|
||
|
||
|
||
@test("providers.oanda: OandaDataProvider parses Oanda candle JSON into correct DataFrame")
|
||
def _():
|
||
sample_candles = [
|
||
{
|
||
"complete": True,
|
||
"time": "2026-07-20T10:00:00.000000000Z",
|
||
"mid": {"o": "2400.00", "h": "2405.00", "l": "2398.00", "c": "2403.00"},
|
||
"bid": {"o": "2399.85", "h": "2404.85", "l": "2397.85", "c": "2402.85"},
|
||
"ask": {"o": "2400.15", "h": "2405.15", "l": "2398.15", "c": "2403.15"},
|
||
"volume": 1234,
|
||
},
|
||
{
|
||
"complete": True,
|
||
"time": "2026-07-20T11:00:00.000000000Z",
|
||
"mid": {"o": "2403.00", "h": "2408.00", "l": "2401.00", "c": "2406.00"},
|
||
"bid": {"o": "2402.85", "h": "2407.85", "l": "2400.85", "c": "2405.85"},
|
||
"ask": {"o": "2403.15", "h": "2408.15", "l": "2401.15", "c": "2406.15"},
|
||
"volume": 5678,
|
||
},
|
||
{
|
||
"complete": False,
|
||
"time": "2026-07-20T12:00:00.000000000Z",
|
||
"mid": {"o": "2406.00", "h": "2406.50", "l": "2405.50", "c": "2406.20"},
|
||
"bid": {"o": "2405.85", "h": "2406.35", "l": "2405.35", "c": "2406.05"},
|
||
"ask": {"o": "2406.15", "h": "2406.65", "l": "2405.65", "c": "2406.35"},
|
||
"volume": 100,
|
||
},
|
||
]
|
||
|
||
mock_resp = type("MockResp", (), {
|
||
"status_code": 200,
|
||
"json": lambda self: {"candles": sample_candles, "instrument": "XAU_USD"},
|
||
"raise_for_status": lambda self: None,
|
||
})()
|
||
|
||
provider2 = providers.oanda.OandaDataProvider.__new__(
|
||
providers.oanda.OandaDataProvider)
|
||
provider2.config = PlatformConfig()
|
||
provider2._base_url = "https://test"
|
||
provider2._cache = {}
|
||
provider2._cache_timestamps = {}
|
||
provider2._cache_outputsize = {}
|
||
provider2._cache_lock = threading.Lock()
|
||
provider2._inflight = {}
|
||
provider2._spread_cache = {}
|
||
provider2._session = type("S", (), {
|
||
"get": lambda self, *a, **k: mock_resp,
|
||
"headers": {},
|
||
})()
|
||
|
||
df = provider2._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
|
||
|
||
assert len(df) == 2, f"Expected 2 complete bars, got {len(df)}"
|
||
assert list(df.columns) == ["Open", "High", "Low", "Close", "Volume"], \
|
||
f"Wrong columns: {list(df.columns)}"
|
||
assert df.index.name == "Datetime"
|
||
assert df.index.tz is not None, "Index must be tz-aware"
|
||
assert df.iloc[0]["Open"] == 2400.0
|
||
assert df.iloc[1]["Close"] == 2406.0
|
||
assert df.iloc[0]["Volume"] == 1234
|
||
|
||
spread = provider2._spread_cache.get("XAU/USD")
|
||
assert spread is not None, "Spread should be cached"
|
||
assert abs(spread - 0.30) < 0.01, f"Expected spread ~0.30, got {spread}"
|
||
|
||
|
||
def _make_oanda_provider(responses):
|
||
"""Bare OandaDataProvider whose session pops canned responses in order."""
|
||
provider = providers.oanda.OandaDataProvider.__new__(
|
||
providers.oanda.OandaDataProvider)
|
||
provider.config = PlatformConfig()
|
||
provider._base_url = "https://test"
|
||
provider._cache = {}
|
||
provider._cache_timestamps = {}
|
||
provider._cache_outputsize = {}
|
||
provider._cache_lock = threading.Lock()
|
||
provider._inflight = {}
|
||
provider._spread_cache = {}
|
||
queue = list(responses)
|
||
provider._session = type("S", (), {
|
||
"get": lambda self, *a, **k: queue.pop(0),
|
||
"headers": {},
|
||
})()
|
||
return provider
|
||
|
||
|
||
def _oanda_resp(status_code, candles=None, text=""):
|
||
return type("MockResp", (), {
|
||
"status_code": status_code,
|
||
"text": text,
|
||
"json": lambda self: {"candles": candles or [], "instrument": "XAU_USD"},
|
||
})()
|
||
|
||
|
||
_OANDA_OK_CANDLE = {
|
||
"complete": True,
|
||
"time": "2026-07-20T10:00:00.000000000Z",
|
||
"mid": {"o": "2400.00", "h": "2405.00", "l": "2398.00", "c": "2403.00"},
|
||
"bid": {"o": "2399.85", "h": "2404.85", "l": "2397.85", "c": "2402.85"},
|
||
"ask": {"o": "2400.15", "h": "2405.15", "l": "2398.15", "c": "2403.15"},
|
||
"volume": 1234,
|
||
}
|
||
|
||
|
||
@test("providers.oanda: transient 401 is retried once and recovers")
|
||
def _():
|
||
provider = _make_oanda_provider([
|
||
_oanda_resp(401, text='{"errorMessage":"Insufficient authorization"}'),
|
||
_oanda_resp(200, candles=[_OANDA_OK_CANDLE]),
|
||
])
|
||
orig_sleep = providers.oanda.time.sleep
|
||
slept = []
|
||
providers.oanda.time.sleep = lambda s: slept.append(s)
|
||
try:
|
||
df = provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
|
||
finally:
|
||
providers.oanda.time.sleep = orig_sleep
|
||
assert len(df) == 1, f"Expected recovered fetch with 1 bar, got {len(df)}"
|
||
assert slept == [providers.oanda.OandaDataProvider.RETRY_DELAY_S], \
|
||
f"Expected one retry sleep, got {slept}"
|
||
|
||
|
||
@test("providers.oanda: persistent 401 walks the whole retry ladder then raises; 4xx never retried")
|
||
def _():
|
||
orig_sleep = providers.oanda.time.sleep
|
||
slept = []
|
||
providers.oanda.time.sleep = lambda s: slept.append(s)
|
||
try:
|
||
ladder = providers.oanda.OandaDataProvider.RETRY_DELAYS_S
|
||
provider = _make_oanda_provider([
|
||
_oanda_resp(401, text='{"errorMessage":"Insufficient authorization"}')
|
||
for _ in range(1 + len(ladder))
|
||
])
|
||
try:
|
||
provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
|
||
assert False, "Persistent 401 should raise OandaDataError"
|
||
except providers.oanda.OandaDataError as e:
|
||
assert "401" in str(e)
|
||
assert slept == list(ladder), \
|
||
f"every ladder delay must be slept before giving up, got {slept}"
|
||
assert ladder == (2.0, 5.0, 10.0), \
|
||
"ladder is sized to outlast a 10-15 s top-of-hour provider blip"
|
||
|
||
# Non-auth 4xx must fail immediately — only one response queued,
|
||
# so a retry attempt would IndexError on the empty queue.
|
||
provider = _make_oanda_provider([_oanda_resp(400, text="bad request")])
|
||
try:
|
||
provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
|
||
assert False, "HTTP 400 should raise OandaDataError"
|
||
except providers.oanda.OandaDataError as e:
|
||
assert "400" in str(e)
|
||
finally:
|
||
providers.oanda.time.sleep = orig_sleep
|
||
|
||
|
||
@test("providers.oanda: a 5xx blip that clears on the third attempt recovers")
|
||
def _():
|
||
# The 2026-09-07 signal-log audit: 11 boundary scans lost their
|
||
# primary frame because one 2 s retry could not outlast a ~12 s
|
||
# provider blip. Two failures then success must recover, having
|
||
# slept exactly the first two ladder steps.
|
||
orig_sleep = providers.oanda.time.sleep
|
||
slept = []
|
||
providers.oanda.time.sleep = lambda s: slept.append(s)
|
||
try:
|
||
provider = _make_oanda_provider([
|
||
_oanda_resp(502, text="bad gateway"),
|
||
_oanda_resp(503, text="unavailable"),
|
||
_oanda_resp(200, candles=[_OANDA_OK_CANDLE]),
|
||
])
|
||
df = provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
|
||
finally:
|
||
providers.oanda.time.sleep = orig_sleep
|
||
assert len(df) == 1, "third attempt must return the frame"
|
||
assert slept == list(
|
||
providers.oanda.OandaDataProvider.RETRY_DELAYS_S[:2]), slept
|
||
|
||
|
||
@test("providers.oanda: retry budget stops the ladder before a delay would overrun it")
|
||
def _():
|
||
# A scan fetches three frames; the whole ladder must stay bounded so
|
||
# an M5 scan finishes inside its bar. With the clock jumped past the
|
||
# budget after the first failure, no further attempt (and no sleep)
|
||
# may happen — the queue holds only ONE response, so a second
|
||
# attempt would IndexError.
|
||
orig_sleep = providers.oanda.time.sleep
|
||
orig_mono = providers.oanda.time.monotonic
|
||
slept = []
|
||
ticks = iter([0.0, 10_000.0, 10_000.0, 10_000.0])
|
||
providers.oanda.time.sleep = lambda s: slept.append(s)
|
||
providers.oanda.time.monotonic = lambda: next(ticks)
|
||
try:
|
||
provider = _make_oanda_provider([_oanda_resp(503, text="unavailable")])
|
||
try:
|
||
provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
|
||
assert False, "budget exhaustion must raise the last error"
|
||
except providers.oanda.OandaDataError as e:
|
||
assert "503" in str(e)
|
||
finally:
|
||
providers.oanda.time.sleep = orig_sleep
|
||
providers.oanda.time.monotonic = orig_mono
|
||
assert slept == [], "no sleep once the budget cannot fit the next delay"
|
||
|
||
|
||
@test("providers.oanda: instrument name mapping")
|
||
def _():
|
||
provider = providers.oanda.OandaDataProvider.__new__(
|
||
providers.oanda.OandaDataProvider)
|
||
assert provider._to_oanda_instrument("XAU/USD") == "XAU_USD"
|
||
try:
|
||
provider._to_oanda_instrument("EUR/USD")
|
||
assert False, "unregistered symbol must raise, not be guessed"
|
||
except DataIntegrityError:
|
||
pass
|
||
|
||
|
||
@test("providers.oanda: granularity mapping covers all scan timeframes")
|
||
def _():
|
||
for tf in [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]:
|
||
assert tf in providers.oanda.OandaDataProvider.OANDA_GRANULARITY, \
|
||
f"{tf} missing from OANDA_GRANULARITY"
|
||
|
||
|
||
@test("providers.oanda: get_spread returns default when no cache")
|
||
def _():
|
||
provider = providers.oanda.OandaDataProvider.__new__(
|
||
providers.oanda.OandaDataProvider)
|
||
provider._spread_cache = {}
|
||
spread = provider.get_spread("XAU/USD")
|
||
assert spread == 0.30, f"Expected default spread 0.30, got {spread}"
|
||
|
||
|
||
@test("app_config: unknown oanda key raises AppConfigError")
|
||
def _():
|
||
import tempfile
|
||
bad = {"oanda": {"unknown_key": 1}}
|
||
with tempfile.NamedTemporaryFile(
|
||
mode="w", suffix=".json", delete=False) as f:
|
||
json.dump(bad, f)
|
||
tmp_path = f.name
|
||
try:
|
||
app_config.load_app_config(tmp_path)
|
||
assert False, f"Should have raised for {bad}"
|
||
except app_config.AppConfigError:
|
||
pass
|
||
finally:
|
||
os.unlink(tmp_path)
|
||
|
||
|
||
@test("app_config: oanda in _KNOWN_TOP (no unknown-key error)")
|
||
def _():
|
||
assert "oanda" in app_config._KNOWN_TOP, \
|
||
"'oanda' must be in _KNOWN_TOP to avoid config.json validation errors"
|
||
|
||
|
||
@test("app_config: oanda resolver helpers return defaults without config")
|
||
def _():
|
||
url = app_config.oanda_base_url()
|
||
assert "oanda.com" in url, f"Expected Oanda URL, got {url}"
|
||
|
||
|
||
@test("providers.oanda: HTTP error surfaces status code and body")
|
||
def _():
|
||
mock_resp = type("MockResp", (), {
|
||
"status_code": 401,
|
||
"text": '{"errorMessage":"Insufficient authorization to perform request"}',
|
||
})()
|
||
|
||
provider = providers.oanda.OandaDataProvider.__new__(
|
||
providers.oanda.OandaDataProvider)
|
||
provider._base_url = "https://test"
|
||
provider._spread_cache = {}
|
||
provider._session = type("S", (), {
|
||
"get": lambda self, *a, **k: mock_resp,
|
||
"headers": {},
|
||
})()
|
||
|
||
try:
|
||
provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
|
||
assert False, "Should have raised OandaDataError"
|
||
except providers.oanda.OandaDataError as e:
|
||
msg = str(e)
|
||
assert "401" in msg, f"Status code missing from error: {msg}"
|
||
assert "Insufficient authorization" in msg, \
|
||
f"Body detail missing from error: {msg}"
|
||
|
||
|
||
@test("providers.oanda: boundary-retry failure keeps original data")
|
||
def _():
|
||
provider = providers.oanda.OandaDataProvider.__new__(
|
||
providers.oanda.OandaDataProvider)
|
||
provider.config = PlatformConfig()
|
||
provider._cache = {}
|
||
provider._cache_timestamps = {}
|
||
provider._cache_outputsize = {}
|
||
provider._cache_lock = threading.Lock()
|
||
provider._inflight = {}
|
||
provider._spread_cache = {}
|
||
provider.FRESH_FETCH_RETRY_SECONDS = 0 # instance override — no sleep
|
||
|
||
call_count = {"n": 0}
|
||
idx = pd.date_range("2026-07-01", periods=250, freq="5min", tz="UTC")
|
||
original_df = pd.DataFrame({
|
||
"Open": 2400.0, "High": 2401.0, "Low": 2399.0,
|
||
"Close": 2400.5, "Volume": 100.0,
|
||
}, index=idx)
|
||
|
||
def _fetch_oanda_candles(symbol, timeframe, count):
|
||
call_count["n"] += 1
|
||
if call_count["n"] == 1:
|
||
return original_df
|
||
raise providers.oanda.OandaDataError("boom")
|
||
|
||
def _validate_and_clean(df, symbol, timeframe):
|
||
return df
|
||
|
||
def _has_latest_closed_bar(df, timeframe):
|
||
return False # forces the retry path every call
|
||
|
||
def _is_data_fresh(df, symbol, timeframe):
|
||
return True
|
||
|
||
# Plain functions assigned to the instance bypass the descriptor
|
||
# protocol — they run unbound, so they take exactly the params
|
||
# fetch_ohlcv passes (no self).
|
||
provider._fetch_oanda_candles = _fetch_oanda_candles
|
||
provider._validate_and_clean = _validate_and_clean
|
||
provider._has_latest_closed_bar = _has_latest_closed_bar
|
||
provider._is_data_fresh = _is_data_fresh
|
||
|
||
result = provider.fetch_ohlcv("XAU/USD", Timeframe.M5)
|
||
|
||
assert call_count["n"] == 2, \
|
||
f"Expected original fetch + one retry attempt, got {call_count['n']} calls"
|
||
assert len(result) == 250, \
|
||
f"Retry failure must keep the original 250-bar df, got {len(result)}"
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 16: Provider abstraction (providers/ package)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 16: Provider abstraction ═══")
|
||
|
||
from providers import (
|
||
BaseDataProvider, PROVIDER_REGISTRY, TwelveDataProvider,
|
||
make_data_provider,
|
||
)
|
||
from providers.oanda import OandaDataProvider as _POanda
|
||
from providers.tiingo import TiingoDataError, TiingoDataProvider
|
||
|
||
|
||
@test("providers: registry holds all providers, active default builds TD")
|
||
def _():
|
||
assert PROVIDER_REGISTRY["twelve_data"] is TwelveDataProvider
|
||
assert PROVIDER_REGISTRY["oanda"] is _POanda
|
||
assert PROVIDER_REGISTRY["tiingo"] is TiingoDataProvider
|
||
dp = make_data_provider(PlatformConfig())
|
||
assert isinstance(dp, TwelveDataProvider)
|
||
assert isinstance(dp, BaseDataProvider)
|
||
|
||
|
||
@test("providers: single knob — data_providers.active selects the class")
|
||
def _():
|
||
from app_config import AppConfig
|
||
for name, cls in PROVIDER_REGISTRY.items():
|
||
fake = AppConfig()
|
||
fake.active_provider = name
|
||
fake.provider_settings = {name: {"api_key": ""}}
|
||
with patch("providers.get_app_config", return_value=fake):
|
||
dp = make_data_provider(PlatformConfig())
|
||
assert type(dp) is cls, f"active={name} built {type(dp).__name__}"
|
||
# An unknown name must fail loudly, not fall back silently.
|
||
from app_config import AppConfigError
|
||
fake = AppConfig()
|
||
fake.active_provider = "nonsense"
|
||
with patch("providers.get_app_config", return_value=fake):
|
||
try:
|
||
make_data_provider(PlatformConfig())
|
||
assert False, "unknown provider must raise"
|
||
except AppConfigError as e:
|
||
assert "nonsense" in str(e)
|
||
|
||
|
||
@test("providers: shared behavior is defined ONCE on the base class")
|
||
def _():
|
||
# The pre-refactor bug this guards against: Oanda's provider was a
|
||
# copy-paste of the TD one, so shared-logic fixes had to be made
|
||
# twice. Every shared method must now BE the base implementation.
|
||
for meth in ("fetch_ohlcv", "fetch_range", "_validate_and_clean",
|
||
"_is_data_fresh", "_has_latest_closed_bar",
|
||
"_drop_forming_candle", "clear_cache", "get_pair_info"):
|
||
for cls in PROVIDER_REGISTRY.values():
|
||
assert getattr(cls, meth) is getattr(BaseDataProvider, meth), \
|
||
f"{cls.__name__} overrides shared method {meth}"
|
||
assert TwelveDataProvider.CANDLE_SECONDS is BaseDataProvider.CANDLE_SECONDS
|
||
assert _POanda.BARS_PER_DAY is BaseDataProvider.BARS_PER_DAY
|
||
assert TiingoDataProvider.CANDLE_SECONDS is BaseDataProvider.CANDLE_SECONDS
|
||
|
||
|
||
@test("providers: 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><a href=\"https://x\">Gold &amp; dollar "
|
||
b"diverge as the Fed turns dovish.</a></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;) 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("&lt;b&gt;x&lt;/b&gt; & y") == "x & y"
|
||
long = _clean_html_text("a" * 600)
|
||
assert len(long) == 501 and long.endswith("…")
|
||
assert _safe_link(" https://a.example/x ") == "https://a.example/x"
|
||
assert _safe_link("ftp://a.example/x") is None
|
||
assert _safe_link("javascript:alert(1)") is None
|
||
assert _safe_link("https://" + "x" * 2100) is None
|
||
assert _safe_link(None) is None
|
||
|
||
|
||
@test("/status renders news/calendar/AI health with ⚠️ degradations")
|
||
def _():
|
||
import telegram_bot as tb
|
||
saved = NewsEngine._source_health
|
||
t0 = time.time()
|
||
NewsEngine._source_health = {
|
||
"calendar": {"ok": t0, "err": None, "detail": "",
|
||
"extra": "42 events via MyFXBook"},
|
||
"calendar:forexfactory": {"ok": None, "err": t0,
|
||
"detail": "HTTP 503", "extra": ""},
|
||
"headlines": {"ok": t0, "err": None, "detail": "",
|
||
"extra": "38 headlines from 3/4 feeds"},
|
||
"rss:fxstreet.com": {"ok": None, "err": t0,
|
||
"detail": "timeout", "extra": ""},
|
||
}
|
||
stub = _StubLLM(None)
|
||
stub.last_ok = 0.0
|
||
stub.last_error = (t0, "http_429")
|
||
stub._count = 87
|
||
stub._daily_cap = 500
|
||
stub._count_day = _dt_m19.datetime.now(_dt_m19.timezone.utc).date()
|
||
lc._client = stub
|
||
try:
|
||
text = tb._status_text()
|
||
assert "⚠️ Calendar: 42 events via MyFXBook — ForexFactory" in text
|
||
assert "HTTP 503" in text
|
||
assert "38 headlines from 3/4 feeds" in text
|
||
assert "⚠️ fxstreet.com: timeout" in text
|
||
assert "⚠️ DeepSeek: last call failed (http_429" in text
|
||
# healthy path: FF serving, DeepSeek clean → no warnings
|
||
NewsEngine._source_health = {
|
||
"calendar": {"ok": t0, "err": None, "detail": "",
|
||
"extra": "42 events via ForexFactory"},
|
||
"headlines": {"ok": t0, "err": None, "detail": "",
|
||
"extra": "38 headlines from 4/4 feeds"},
|
||
}
|
||
stub.last_ok = t0
|
||
text = tb._status_text()
|
||
assert "✅ Calendar: 42 events via ForexFactory" in text
|
||
assert "✅ DeepSeek: 87/500 calls today" in text
|
||
assert "⚠️" not in text.split("*News & AI:*")[1].split("*Alert")[0]
|
||
# empty state (pre-first-scan) renders the neutral line
|
||
NewsEngine._source_health = {}
|
||
assert "no news cycle yet" in tb._status_text()
|
||
# yesterday's exhausted counter must NOT show as cap-exhausted
|
||
# after the UTC date roll (counter resets lazily on next call)
|
||
stub._count = 500
|
||
stub._count_day = stub._count_day - _dt_m19.timedelta(days=1)
|
||
text = tb._status_text()
|
||
assert "cap" not in text and "0/500" in text, text
|
||
finally:
|
||
NewsEngine._source_health = saved
|
||
lc._client = None
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 20: dashboard_publisher.py (static web dashboard)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 20: dashboard_publisher.py ═══")
|
||
|
||
import tempfile
|
||
import dashboard_publisher as dpub
|
||
from engine import TechnicalAnalysis, IndicatorSignal
|
||
|
||
|
||
def make_fake_dashboard_report():
|
||
"""SignalReport with every serialization hazard: NaN value, inf key
|
||
level, weight-0 ATR, a poisoned '_secret' indicator, a dynamic
|
||
pandas attr, and a full TradeSetup."""
|
||
analysis = TechnicalAnalysis(
|
||
symbol="XAU/USD", timeframe="1h",
|
||
timestamp="2026-08-03 10:00:00+00:00")
|
||
names = ["ema_trend", "rsi", "macd", "bollinger", "adx",
|
||
"roc", "keltner", "divergence", "candlestick"]
|
||
for i, name in enumerate(names):
|
||
analysis.indicators[name] = IndicatorSignal(
|
||
name=name, value=float(i),
|
||
signal=SignalStrength.BUY if i % 2 == 0 else SignalStrength.SELL,
|
||
confidence=60.0, description=f"{name} desc", weight=1.0)
|
||
analysis.indicators["rsi"].value = float("nan")
|
||
analysis.indicators["atr"] = IndicatorSignal(
|
||
name="atr", value=12.5, signal=SignalStrength.NEUTRAL,
|
||
confidence=0.0, description="ATR context", weight=0.0)
|
||
analysis.indicators["_secret"] = IndicatorSignal(
|
||
name="_secret", value=1.0, signal=SignalStrength.NEUTRAL,
|
||
confidence=0.0, description="must not serialize", weight=1.0)
|
||
analysis._rsi_series = pd.Series([1.0, 2.0]) # dynamic attr, must not leak
|
||
analysis.key_levels = {"atr": 12.5, "support": 3300.0,
|
||
"broken": float("inf")}
|
||
analysis.composite_signal = SignalStrength.BUY
|
||
analysis.composite_score = 42.0
|
||
analysis.confidence = 61.5
|
||
analysis.pattern_signals = ["BULLISH_ENGULFING"]
|
||
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
|
||
timestamp="2026-08-03 10:00:00+00:00")
|
||
report.final_signal = SignalStrength.BUY
|
||
report.final_score = 55.0
|
||
report.final_confidence = 70.0
|
||
report.signal_quality = "B"
|
||
report.primary_signal = SignalStrength.BUY
|
||
report.primary_score = 50.0
|
||
report.primary_analysis = analysis
|
||
report.confirmation_analyses = [analysis]
|
||
report.mtf_signals = {"1h": SignalStrength.BUY,
|
||
"4h": SignalStrength.NEUTRAL}
|
||
report.mtf_scores = {"1h": 50.0, "4h": 10.0}
|
||
report.exhaustion_count = 1
|
||
report.current_price = 3350.0
|
||
report.trade_setup = TradeSetup(
|
||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||
entry_price=3350.0, stop_loss=3340.0, take_profit_1=3360.0,
|
||
take_profit_2=3370.0, risk_reward=2.0, risk_pips=100.0,
|
||
reward_pips=200.0, pip_value=0.1, strategy="XAU_ATR",
|
||
trail_arm_r=1.0, trail_dist_r=0.85)
|
||
return report
|
||
|
||
|
||
@test("dashboard: report_to_webapp_dict round-trips allow_nan=False")
|
||
def _():
|
||
d = dpub.report_to_webapp_dict(make_fake_dashboard_report())
|
||
json.dumps(d, allow_nan=False) # raises if NaN/inf leaked
|
||
assert d["signal"] == "BUY" and d["primary_signal"] == "BUY"
|
||
assert d["mtf_scores"] == {"1h": 50.0, "4h": 10.0}
|
||
assert d["exhaustion_count"] == 1
|
||
assert d["trade_setup"]["strategy"] == "XAU_ATR"
|
||
pa = d["primary_analysis"]
|
||
assert pa["composite_signal"] == "BUY"
|
||
assert pa["indicators"]["rsi"]["value"] is None # NaN → None
|
||
assert pa["key_levels"]["broken"] is None # inf → None
|
||
assert len(d["confirmation_analyses"]) == 1
|
||
|
||
|
||
@test("dashboard: ATR is context not grid; _-keys excluded")
|
||
def _():
|
||
pa = dpub.report_to_webapp_dict(make_fake_dashboard_report())["primary_analysis"]
|
||
assert "atr" not in pa["indicators"]
|
||
assert "_secret" not in pa["indicators"]
|
||
assert "_rsi_series" not in pa
|
||
assert pa["atr_context"]["value"] == 12.5
|
||
assert len(pa["indicators"]) == 9
|
||
|
||
|
||
@test("dashboard: missing divergence and weekend report tolerated")
|
||
def _():
|
||
report = make_fake_dashboard_report()
|
||
del report.primary_analysis.indicators["divergence"]
|
||
pa = dpub.report_to_webapp_dict(report)["primary_analysis"]
|
||
assert len(pa["indicators"]) == 8 and "divergence" not in pa["indicators"]
|
||
|
||
weekend = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
|
||
timestamp="now") # primary_analysis=None
|
||
d = dpub.report_to_webapp_dict(weekend)
|
||
json.dumps(d, allow_nan=False)
|
||
assert d["primary_analysis"] is None
|
||
assert d["confirmation_analyses"] == []
|
||
|
||
|
||
@test("dashboard: due_timeframes uses RAW wake time")
|
||
def _():
|
||
def due(h, m):
|
||
wake = datetime(2026, 8, 3, h, m, 10, tzinfo=timezone.utc)
|
||
return [tf.value for tf in dpub.due_timeframes(wake)]
|
||
# The v1 regression: 12:00:10 must classify as the 12:00 boundary
|
||
# (all four due), not 11:55.
|
||
assert due(12, 0) == ["5min", "15min", "1h", "4h"]
|
||
assert due(13, 0) == ["5min", "15min", "1h"]
|
||
assert due(12, 15) == ["5min", "15min"]
|
||
assert due(12, 5) == ["5min"]
|
||
assert due(0, 0) == ["5min", "15min", "1h", "4h"]
|
||
|
||
|
||
@test("dashboard: build_snapshot_payload warming/error/next_close")
|
||
def _():
|
||
now = datetime(2026, 8, 3, 12, 2, 30, tzinfo=timezone.utc) # Monday
|
||
with patch("providers.base._is_forex_weekend", return_value=False):
|
||
payload = dpub.build_snapshot_payload({}, now)
|
||
assert payload["symbol"] == "XAU/USD"
|
||
assert payload["market_closed"] is False
|
||
assert set(payload["timeframes"]) == {"5min", "15min", "1h", "4h"}
|
||
for entry in payload["timeframes"].values():
|
||
assert entry["status"] == "warming" and entry["report"] is None
|
||
assert entry["next_close_epoch"] > payload["server_time_epoch"]
|
||
assert entry["next_scan_epoch"] == entry["next_close_epoch"] + 10
|
||
# M5 closes at 12:05 → 150 s away from 12:02:30.
|
||
assert abs(payload["timeframes"]["5min"]["next_close_epoch"]
|
||
- (now.timestamp() + 150)) < 1e-6
|
||
|
||
# An errored TF keeps its stale report + error badge.
|
||
stale = {"status": "error", "scanned_at": "earlier",
|
||
"error": "ValueError: boom", "error_at": "now",
|
||
"report": {"signal": "BUY"}}
|
||
with patch("providers.base._is_forex_weekend", return_value=True):
|
||
payload = dpub.build_snapshot_payload({"5min": stale}, now)
|
||
assert payload["market_closed"] is True
|
||
m5 = payload["timeframes"]["5min"]
|
||
assert m5["status"] == "error" and m5["report"] == {"signal": "BUY"}
|
||
assert m5["error"] == "ValueError: boom"
|
||
json.dumps(payload, allow_nan=False)
|
||
|
||
|
||
@test("dashboard: event_to_dict includes computed properties")
|
||
def _():
|
||
ev = EconomicEvent(
|
||
time=datetime(2026, 8, 7, 12, 30, tzinfo=timezone.utc),
|
||
currency="USD", impact="high", event="Non-Farm Payrolls",
|
||
forecast="180K", previous="147K", actual="228K")
|
||
d = dpub.event_to_dict(ev)
|
||
assert d["event"] == "Non-Farm Payrolls"
|
||
assert d["actual"] == "228K" # strings stay strings
|
||
assert d["surprise"] == "positive" # property, added explicitly
|
||
assert "minutes_until" not in d # frozen-at-publish value, dropped
|
||
assert d["time"] == "2026-08-07T12:30:00+00:00"
|
||
json.dumps(d, allow_nan=False)
|
||
|
||
|
||
@test("dashboard: build_news_payload sorts headlines, survives NaN")
|
||
def _():
|
||
now = datetime(2026, 8, 3, 12, 0, tzinfo=timezone.utc)
|
||
old = NewsSentiment(
|
||
headline="Old story", source="http://feed/a",
|
||
timestamp=now - timedelta(hours=5),
|
||
currencies_mentioned=["USD"], sentiment_score=float("nan"),
|
||
keywords=[], sentiment_by_currency={})
|
||
new = NewsSentiment(
|
||
headline="Gold surges", source="http://feed/b",
|
||
timestamp=now - timedelta(minutes=10),
|
||
currencies_mentioned=["XAU"], sentiment_score=0.6,
|
||
keywords=["surge"], sentiment_by_currency={"XAU": 0.6})
|
||
ctx = FundamentalContext(
|
||
news_headlines=[old, new],
|
||
news_sentiment={"XAU": 0.6, "USD": -0.1},
|
||
llm_summary="Macro blurb.", calendar_available=True)
|
||
events = [EconomicEvent(time=now + timedelta(hours=3),
|
||
currency="USD", impact="medium", event="ISM")]
|
||
payload = dpub.build_news_payload(ctx, events, now)
|
||
json.dumps(payload, allow_nan=False)
|
||
assert payload["llm_summary"] == "Macro blurb."
|
||
assert [h["headline"] for h in payload["headlines"]] == \
|
||
["Gold surges", "Old story"] # newest first
|
||
assert payload["headlines"][1]["score"] is None # NaN → None
|
||
assert payload["events"][0]["event"] == "ISM"
|
||
assert payload["sentiment"] == {"XAU": 0.6, "USD": -0.1}
|
||
# Every headline carries the 40-hex id the PHP endpoint whitelists on.
|
||
import re as _re
|
||
assert all(_re.fullmatch(r"[0-9a-f]{40}", h["id"])
|
||
for h in payload["headlines"])
|
||
|
||
|
||
@test("dashboard: headline_to_dict id matches LLM cache scheme + enrichment")
|
||
def _():
|
||
import hashlib
|
||
h = NewsSentiment(
|
||
headline="Gold surges", source="http://feed/b",
|
||
timestamp=datetime(2026, 8, 7, 10, 0, tzinfo=timezone.utc),
|
||
currencies_mentioned=["XAU"], sentiment_score=0.6,
|
||
keywords=["surge"], sentiment_by_currency={"XAU": 0.6},
|
||
link="https://x.example/a", description="Body.", publisher="Reuters")
|
||
d = dpub.headline_to_dict(h)
|
||
# Same sha1(headline) scheme as NewsEngine._llm_sent_cache — the id is
|
||
# the join key news_summary.php validates against.
|
||
assert d["id"] == hashlib.sha1(b"Gold surges").hexdigest()
|
||
assert d["link"] == "https://x.example/a"
|
||
assert d["description"] == "Body."
|
||
assert d["publisher"] == "Reuters"
|
||
# Pre-enrichment NewsSentiment (defaults) still serializes cleanly.
|
||
bare = NewsSentiment(
|
||
headline="Old", source="s", timestamp=None,
|
||
currencies_mentioned=[], sentiment_score=0.0, keywords=[])
|
||
d2 = dpub.headline_to_dict(bare)
|
||
assert d2["link"] is None and d2["description"] is None \
|
||
and d2["publisher"] is None
|
||
json.dumps([d, d2], allow_nan=False)
|
||
|
||
|
||
@test("dashboard: install_static installs page + PHP endpoint atomically")
|
||
def _():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
pub = dpub.DashboardPublisher(out_dir=tmp)
|
||
pub.install_static()
|
||
assert sorted(os.listdir(tmp)) == \
|
||
["dashboard.css", "dashboard.html", "dashboard.js", "news_summary.php"] # all installed, no .tmp
|
||
|
||
|
||
@test("dashboard: static UI 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 (parity, providers, backtester)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
print("\n═══ Module 21: 2026-08-04 audit fixes — parity, providers, backtester ═══")
|
||
|
||
|
||
@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
|
||
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: a scan whose PRIMARY fetch fails is logged as scan_kind 'failed'")
|
||
def _():
|
||
# 2026-09-07 audit: 11 rows in two days carried price 0 / score 0 /
|
||
# NEUTRAL F because the top-of-hour Oanda fetch failed; logged as
|
||
# ordinary scans they polluted NEUTRAL statistics. They are liveness
|
||
# evidence and readers skip them like ``blocked``.
|
||
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)
|
||
cfg = PlatformConfig()
|
||
cfg.xau_strategy.weekend_filter = False # deterministic on any day
|
||
cfg.xau_strategy.allowed_weekdays = []
|
||
agg = SignalAggregator(cfg)
|
||
agg._get_news_engine = lambda: _NoNews()
|
||
|
||
def _boom(*a, **k):
|
||
raise RuntimeError("HTTP 503 at the boundary")
|
||
agg.data_provider.fetch_ohlcv = _boom
|
||
report = agg.analyze_pair(symbol="XAU/USD", log_source="test")
|
||
assert "Primary timeframe data unavailable" in report.warnings
|
||
import sqlite3
|
||
conn = sqlite3.connect(path)
|
||
row = conn.execute(
|
||
"SELECT scan_kind, gate_reason, signal, quality FROM signals"
|
||
).fetchone()
|
||
conn.close()
|
||
assert row is not None, "the failed scan must still leave a liveness row"
|
||
assert row[0] == "failed", f"scan_kind must be 'failed', got {row[0]!r}"
|
||
assert row[1] == "Primary timeframe data unavailable"
|
||
assert row[2] == "NEUTRAL" and row[3] == "F"
|
||
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
|
||
# A ``failed`` row (primary fetch lost, price 0) is liveness evidence,
|
||
# not a scan: it must not count as a source on its bar.
|
||
import dataclasses
|
||
failed = dataclasses.replace(
|
||
_asl_row(6, t0, bar_ts="2026-08-19T12:30:00+00:00", source="cli",
|
||
quality="F", setup=False, price=0.0,
|
||
action="NO TRADE — No clear signal"),
|
||
scan_kind="failed")
|
||
rep2 = asl.parity_report(rows + [failed])
|
||
assert rep2["bars_with_2_sources"] == 2
|
||
assert "cli/15min" not in rep2["rows_per_bar"]
|
||
assert len(rep2["divergences"]) == 1
|
||
|
||
|
||
@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(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 = slog.read_news(lg._conn, 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"
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 30: violent-move watch (display-only)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 30: violent-move watch ═══")
|
||
|
||
import violent_watch as _vw
|
||
from violent_watch import ViolentWatch, assess, attach_watch, should_send_headsup
|
||
from news_engine import EconomicEvent
|
||
|
||
# Tuesday 08:30 UTC — deliberately OUTSIDE every hot clock slot, so a
|
||
# test that wants only indicator readings gets only indicator readings.
|
||
_M30_QUIET = datetime(2026, 9, 8, 8, 30, tzinfo=timezone.utc)
|
||
# Tuesday 12:30 UTC — inside the US-data-hours slot.
|
||
_M30_CLOCK = datetime(2026, 9, 8, 12, 30, tzinfo=timezone.utc)
|
||
|
||
|
||
def _m30_event(minutes_from_now, name="Non-Farm Payrolls",
|
||
currency="USD", impact="high", now=_M30_QUIET):
|
||
return EconomicEvent(time=now + timedelta(minutes=minutes_from_now),
|
||
currency=currency, impact=impact, event=name)
|
||
|
||
|
||
def _m30_analysis(indicators=None, regime="ranging", vol="normal",
|
||
key_levels=None, adx_slope=None):
|
||
a = TechnicalAnalysis(
|
||
symbol="XAU/USD", timeframe=Timeframe.M5.value,
|
||
timestamp="2026-09-08T08:30:00+00:00",
|
||
indicators=dict(indicators or {}),
|
||
market_regime=regime, volatility_regime=vol,
|
||
key_levels=dict(key_levels or {}),
|
||
)
|
||
if adx_slope is not None:
|
||
a._adx_slope = adx_slope
|
||
return a
|
||
|
||
|
||
def _m30_ind(name, value, signal=SignalStrength.NEUTRAL, desc=""):
|
||
return IndicatorSignal(name, value, signal, 60.0, desc, 1.5)
|
||
|
||
|
||
def _m30_report(analysis=None):
|
||
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
|
||
timestamp="2026-09-08T08:30:00+00:00")
|
||
r.primary_analysis = analysis
|
||
return r
|
||
|
||
|
||
# Keltner band geometry used below: mid 2400, upper 2410, lower 2390, so
|
||
# the indicator's stored value (close - mid) is outside the channel when
|
||
# it is above +10 or below -10.
|
||
_M30_KC_LEVELS = {"kc_upper": 2410.0, "kc_lower": 2390.0, "kc_mid": 2400.0}
|
||
|
||
|
||
@test("violent watch: high-impact USD release in 12 min is HOT")
|
||
def _():
|
||
w = assess(_m30_report(), _M30_QUIET, [_m30_event(12)], Timeframe.M5)
|
||
assert w.level == "hot", w.level
|
||
assert "calendar" in w.kinds and w.kinds == ["calendar"]
|
||
assert "Non-Farm Payrolls" in w.triggers[0], w.triggers
|
||
assert "in 12 min" in w.triggers[0], w.triggers
|
||
assert "HOT" in w.line() and "3–5× normal" in w.line()
|
||
|
||
|
||
@test("violent watch: release 10 minutes ago is still HOT")
|
||
def _():
|
||
w = assess(_m30_report(), _M30_QUIET, [_m30_event(-10, name="US CPI")],
|
||
Timeframe.M5)
|
||
assert w.level == "hot"
|
||
assert w.kinds == ["calendar"]
|
||
assert "just out" in w.triggers[0] and "US CPI" in w.triggers[0]
|
||
assert "10 min ago" in w.triggers[0], w.triggers
|
||
|
||
|
||
@test("violent watch: calendar ignores non-USD, low impact and far events")
|
||
def _():
|
||
for ev in (_m30_event(12, currency="EUR"),
|
||
_m30_event(12, impact="medium"),
|
||
_m30_event(90), # outside the +30 min window
|
||
_m30_event(-90)): # outside the -30 min window
|
||
w = assess(_m30_report(), _M30_QUIET, [ev], Timeframe.M5)
|
||
assert w.level == "none", (ev.currency, ev.impact, w.level)
|
||
|
||
|
||
@test("violent watch: hot clock slot alone is WATCH")
|
||
def _():
|
||
w = assess(_m30_report(), _M30_CLOCK, [], Timeframe.M5)
|
||
assert w.level == "watch", w.level
|
||
assert w.kinds == ["clock"] and w.readings == 0
|
||
assert "US data hours" in w.triggers[0]
|
||
assert "about 2× normal" in w.line() and "watch" in w.line()
|
||
# The other two slots resolve too.
|
||
assert _vw._clock_trigger(
|
||
datetime(2026, 9, 8, 1, 5, tzinfo=timezone.utc)) == \
|
||
"Asian open hour (01:00 UTC)"
|
||
assert _vw._clock_trigger(
|
||
datetime(2026, 9, 13, 22, 5, tzinfo=timezone.utc)) == "Sunday reopen"
|
||
# Sunday 01:00 is NOT the Asian-open slot (weekday rule).
|
||
assert _vw._clock_trigger(
|
||
datetime(2026, 9, 13, 1, 5, tzinfo=timezone.utc)) is None
|
||
|
||
|
||
@test("violent watch: clock slot + ONE price extreme is HOT")
|
||
def _():
|
||
a = _m30_analysis(indicators={
|
||
"rsi": _m30_ind("RSI", 24.0),
|
||
"bollinger": _m30_ind("BB", 0.5),
|
||
})
|
||
w = assess(_m30_report(a), _M30_CLOCK, [], Timeframe.M5)
|
||
assert w.level == "hot", w.level
|
||
assert w.readings == 1 and sorted(w.kinds) == ["clock", "extreme"]
|
||
assert "RSI 24, oversold extreme" in w.triggers
|
||
|
||
|
||
@test("violent watch: context readings alone never raise the level")
|
||
def _():
|
||
a = _m30_analysis(
|
||
indicators={"macd": _m30_ind("MACD", -1.2, SignalStrength.STRONG_SELL)},
|
||
regime="transitioning", vol="extreme", adx_slope=5.0)
|
||
w = assess(_m30_report(a), _M30_QUIET, [], Timeframe.M5)
|
||
assert w.level == "none", w.level
|
||
assert w.readings == 0 and w.kinds == []
|
||
assert w.line() == ""
|
||
# ...and they ARE printed once a real level exists.
|
||
w2 = assess(_m30_report(a), _M30_CLOCK, [], Timeframe.M5)
|
||
assert w2.level == "watch"
|
||
for phrase in ("trend strength rising", "volatility extreme",
|
||
"regime transitioning", "downside momentum accelerating"):
|
||
assert phrase in w2.triggers, (phrase, w2.triggers)
|
||
|
||
|
||
@test("violent watch: two bearish price extremes are WATCH, direction down")
|
||
def _():
|
||
a = _m30_analysis(
|
||
indicators={
|
||
"bollinger": _m30_ind("BB", -0.2),
|
||
"keltner": _m30_ind("KC", -12.0),
|
||
"macd": _m30_ind("MACD", -1.2, SignalStrength.STRONG_SELL),
|
||
},
|
||
key_levels=_M30_KC_LEVELS)
|
||
w = assess(_m30_report(a), _M30_QUIET, [], Timeframe.M5)
|
||
assert w.level == "watch", w.level
|
||
assert w.readings == 2 and w.kinds == ["extreme"]
|
||
assert w.likely_direction == "down"
|
||
assert "price below the Bollinger bands" in w.triggers
|
||
assert "price outside the Keltner channel" in w.triggers
|
||
assert "DOWN" in w.line()
|
||
|
||
|
||
@test("violent watch: keltner side read from stored channel levels")
|
||
def _():
|
||
above = _m30_analysis(indicators={"keltner": _m30_ind("KC", 12.0)},
|
||
key_levels=_M30_KC_LEVELS)
|
||
inside = _m30_analysis(indicators={"keltner": _m30_ind("KC", 4.0)},
|
||
key_levels=_M30_KC_LEVELS)
|
||
below = _m30_analysis(indicators={"keltner": _m30_ind("KC", -12.0)},
|
||
key_levels=_M30_KC_LEVELS)
|
||
assert _vw._keltner_side(above) == "above"
|
||
assert _vw._keltner_side(inside) is None
|
||
assert _vw._keltner_side(below) == "below"
|
||
# A squeeze rewrites the description but keeps the levels — still read.
|
||
squeezed = _m30_analysis(
|
||
indicators={"keltner": _m30_ind(
|
||
"KC", 12.0, SignalStrength.STRONG_BUY,
|
||
"Keltner SQUEEZE RELEASED — bullish breakout")},
|
||
key_levels=_M30_KC_LEVELS)
|
||
assert _vw._keltner_side(squeezed) == "above"
|
||
# No key_levels → fall back to the description text.
|
||
desc_only = _m30_analysis(indicators={"keltner": _m30_ind(
|
||
"KC", 12.0, SignalStrength.BUY,
|
||
"Price above Keltner upper — strong uptrend continuation")})
|
||
assert _vw._keltner_side(desc_only) == "above"
|
||
assert _vw._keltner_side(_m30_analysis(indicators={
|
||
"keltner": _m30_ind("KC", 1.0, SignalStrength.NEUTRAL,
|
||
"Price within Keltner Channels")})) is None
|
||
|
||
|
||
@test("violent watch: bullish direction is called on M5 only")
|
||
def _():
|
||
a = _m30_analysis(indicators={
|
||
"bollinger": _m30_ind("BB", 1.2),
|
||
"rsi": _m30_ind("RSI", 78.0),
|
||
})
|
||
m15 = assess(_m30_report(a), _M30_QUIET, [], Timeframe.M15)
|
||
assert m15.level == "watch" and m15.readings == 2
|
||
assert m15.likely_direction == "", m15.likely_direction
|
||
assert "UP" not in m15.line()
|
||
m5 = assess(_m30_report(a), _M30_QUIET, [], Timeframe.M5)
|
||
assert m5.likely_direction == "up"
|
||
assert "UP" in m5.line()
|
||
# A string timeframe works the same as the enum.
|
||
assert assess(_m30_report(a), _M30_QUIET, [],
|
||
Timeframe.M5.value).likely_direction == "up"
|
||
|
||
|
||
@test("violent watch: nothing firing yields level none and an empty line")
|
||
def _():
|
||
a = _m30_analysis(indicators={
|
||
"rsi": _m30_ind("RSI", 51.0),
|
||
"bollinger": _m30_ind("BB", 0.5),
|
||
"keltner": _m30_ind("KC", 1.0),
|
||
}, key_levels=_M30_KC_LEVELS)
|
||
w = assess(_m30_report(a), _M30_QUIET, None, Timeframe.M5)
|
||
assert w.level == "none" and w.triggers == [] and w.kinds == []
|
||
assert w.readings == 0 and w.likely_direction == ""
|
||
assert w.line() == "" and ViolentWatch().line() == ""
|
||
|
||
|
||
@test("violent watch: degenerate inputs never raise")
|
||
def _():
|
||
class _Broken:
|
||
@property
|
||
def primary_analysis(self):
|
||
raise RuntimeError("boom")
|
||
|
||
bad_ind = _m30_analysis(indicators={
|
||
"rsi": _m30_ind("RSI", float("nan")),
|
||
"bollinger": IndicatorSignal("BB", None, SignalStrength.NEUTRAL,
|
||
0.0, "", 1.5),
|
||
"keltner": _m30_ind("KC", 5.0),
|
||
}, adx_slope="not-a-number")
|
||
cases = [
|
||
_m30_report(None),
|
||
_m30_report(bad_ind),
|
||
_m30_report(_m30_analysis(indicators={})),
|
||
object(),
|
||
None,
|
||
]
|
||
for report in cases:
|
||
w = assess(report, _M30_QUIET, None, Timeframe.M5)
|
||
assert w.level == "none", (report, w.level)
|
||
assert w.line() == ""
|
||
# Bad event objects and a naive `now` also degrade quietly.
|
||
assert assess(_m30_report(), _M30_QUIET, [object(), None],
|
||
Timeframe.M5).level == "none"
|
||
assert assess(_m30_report(), datetime(2026, 9, 8, 8, 30),
|
||
None, Timeframe.M5).level == "none"
|
||
|
||
|
||
@test("violent watch: heads-up budget dedups by hour+triggers and by day")
|
||
def _():
|
||
state = {}
|
||
t0 = datetime(2026, 9, 8, 12, 5, tzinfo=timezone.utc)
|
||
ok, why = should_send_headsup(state, t0, ["clock", "extreme"], 12)
|
||
assert ok and why == "ok"
|
||
# Same hour, same trigger set → suppressed.
|
||
ok, why = should_send_headsup(state, t0.replace(minute=40),
|
||
["extreme", "clock"], 12)
|
||
assert not ok and why == "already sent this hour for these triggers"
|
||
# Same hour, DIFFERENT trigger set → allowed.
|
||
assert should_send_headsup(state, t0, ["calendar"], 12)[0] is True
|
||
# New hour → allowed again.
|
||
assert should_send_headsup(state, t0.replace(hour=13),
|
||
["clock", "extreme"], 12)[0] is True
|
||
assert state["count"] == 3
|
||
# Daily cap.
|
||
capped = {}
|
||
assert should_send_headsup(capped, t0, ["clock"], 1)[0] is True
|
||
ok, why = should_send_headsup(capped, t0.replace(hour=14), ["clock"], 1)
|
||
assert not ok and why == "daily cap"
|
||
assert should_send_headsup({}, t0, ["clock"], 0) == (False, "daily cap")
|
||
# UTC day rollover resets count and the per-hour memory.
|
||
ok, why = should_send_headsup(capped, t0 + timedelta(days=1),
|
||
["clock"], 1)
|
||
assert ok and capped["count"] == 1
|
||
|
||
|
||
@test("app_config: violent_watch defaults dark, env override, validation")
|
||
def _():
|
||
defaults = load_app_config(_write_json({})).violent_watch_settings
|
||
assert defaults == {
|
||
"enabled": False, "alert_line": True, "headsup": True,
|
||
"max_headsup_per_day": 12, "headsup_min_level": "hot",
|
||
}
|
||
on = {"enabled": True, "alert_line": False, "headsup": False,
|
||
"max_headsup_per_day": 3, "headsup_min_level": "watch"}
|
||
app = load_app_config(_write_json({"violent_watch": on}))
|
||
assert app.violent_watch_settings == on
|
||
# A partial block keeps the remaining defaults.
|
||
part = load_app_config(
|
||
_write_json({"violent_watch": {"enabled": True}}))
|
||
assert part.violent_watch_settings["enabled"] is True
|
||
assert part.violent_watch_settings["headsup_min_level"] == "hot"
|
||
old = os.environ.get("VIOLENT_WATCH_ENABLED")
|
||
try:
|
||
os.environ.pop("VIOLENT_WATCH_ENABLED", None)
|
||
assert app_config.violent_watch_enabled(
|
||
load_app_config(_write_json({}))) is False
|
||
os.environ["VIOLENT_WATCH_ENABLED"] = "false"
|
||
assert app_config.violent_watch_enabled(app) is False
|
||
os.environ["VIOLENT_WATCH_ENABLED"] = "1"
|
||
assert app_config.violent_watch_enabled(
|
||
load_app_config(_write_json({}))) is True
|
||
finally:
|
||
if old is None:
|
||
os.environ.pop("VIOLENT_WATCH_ENABLED", None)
|
||
else:
|
||
os.environ["VIOLENT_WATCH_ENABLED"] = old
|
||
for bad in (
|
||
{"violent_watch": {"enabld": True}},
|
||
{"violent_watch": {"enabled": "yes"}},
|
||
{"violent_watch": {"alert_line": 1}},
|
||
{"violent_watch": {"headsup": "no"}},
|
||
{"violent_watch": {"max_headsup_per_day": -1}},
|
||
{"violent_watch": {"max_headsup_per_day": True}},
|
||
{"violent_watch": {"max_headsup_per_day": 2.5}},
|
||
{"violent_watch": {"headsup_min_level": "none"}},
|
||
):
|
||
try:
|
||
load_app_config(_write_json(bad))
|
||
assert False, f"should have raised for {bad}"
|
||
except AppConfigError:
|
||
pass
|
||
|
||
|
||
@test("violent watch: attach_watch honours enabled/alert_line, never raises")
|
||
def _():
|
||
report = _m30_report(_m30_analysis(indicators={
|
||
"bollinger": _m30_ind("BB", -0.2), "rsi": _m30_ind("RSI", 22.0)}))
|
||
with patch("news_engine.NewsEngine.get_week_events", return_value=[]):
|
||
# Dark by default: the report is untouched.
|
||
w = attach_watch(report, Timeframe.M5, {"enabled": False},
|
||
_M30_QUIET)
|
||
assert w.level == "none" and report.watch_line == ""
|
||
assert attach_watch(report, Timeframe.M5, {}, _M30_QUIET).level == "none"
|
||
assert report.watch_line == ""
|
||
# Enabled: the line is stamped.
|
||
w = attach_watch(report, Timeframe.M5, {"enabled": True}, _M30_QUIET)
|
||
assert w.level == "watch" and w.likely_direction == "down"
|
||
assert report.watch_line.startswith("🌪 Violent-move watch (watch): ")
|
||
# alert_line False assesses but does not stamp.
|
||
report.watch_line = ""
|
||
w = attach_watch(report, Timeframe.M5,
|
||
{"enabled": True, "alert_line": False}, _M30_QUIET)
|
||
assert w.level == "watch" and report.watch_line == ""
|
||
# A calendar blow-up degrades to a technical-only assessment.
|
||
report.watch_line = ""
|
||
with patch("news_engine.NewsEngine.get_week_events",
|
||
side_effect=RuntimeError("calendar down")):
|
||
w = attach_watch(report, Timeframe.M5, {"enabled": True}, _M30_QUIET)
|
||
assert w.level == "watch" and report.watch_line != ""
|
||
# A broken report can never propagate out of attach_watch.
|
||
with patch.object(_vw.logger, "exception") as logged:
|
||
assert attach_watch(object(), Timeframe.M5, {"enabled": True},
|
||
_M30_QUIET).level == "none"
|
||
assert logged.called, "a swallowed failure must still be logged"
|
||
|
||
|
||
@test("violent watch: telegram alert renders the line only when stamped")
|
||
def _():
|
||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
|
||
timestamp="now")
|
||
assert report.watch_line == ""
|
||
assert "Violent-move watch" not in format_signal_message(report)
|
||
report.watch_line = ("🌪 Violent-move watch (HOT): US data hours "
|
||
"(12:00–15:00 UTC) · RSI 24, oversold extreme "
|
||
"— odds of a violent bar about 3–5× normal")
|
||
msg = format_signal_message(report)
|
||
assert "Violent-move watch (HOT)" in msg
|
||
assert "RSI 24, oversold extreme" in msg
|
||
|
||
|
||
@test("violent watch: heads-up text names triggers and defends the stop")
|
||
def _():
|
||
from telegram_bot import (_format_headsup, _HEADSUP_TF,
|
||
_TF_INTERVAL_MINUTES, _APP as _tb_app)
|
||
w = ViolentWatch(level="hot",
|
||
triggers=["US data hours (12:00–15:00 UTC)",
|
||
"RSI 24, oversold extreme"],
|
||
kinds=["clock", "extreme"], readings=1,
|
||
likely_direction="down")
|
||
text = _format_headsup(_m30_report(), w)
|
||
assert "Heads-up" in text and "XAU/USD" in text
|
||
assert "US data hours (12:00–15:00 UTC)" in text
|
||
assert "RSI 24, oversold extreme" in text
|
||
assert "DOWN" in text
|
||
assert "Your stop is the protection" in text
|
||
assert "loses money" in text
|
||
# The heads-up rides the fastest scanned loop only.
|
||
assert _HEADSUP_TF == min(_tb_app.scan_timeframes,
|
||
key=lambda tf: _TF_INTERVAL_MINUTES[tf])
|
||
|
||
|
||
@test("violent watch: report_to_dict carries watch_line")
|
||
def _():
|
||
from main import report_to_dict
|
||
report = _m30_report()
|
||
assert report_to_dict(report)["watch_line"] == ""
|
||
report.watch_line = "🌪 Violent-move watch (HOT): US release just out"
|
||
assert report_to_dict(report)["watch_line"] == report.watch_line
|
||
|
||
|
||
@test("violent watch: signal path is free of it (import + runtime lock)")
|
||
def _():
|
||
root = os.path.dirname(os.path.abspath(__file__))
|
||
for rel in ("signal_aggregator.py", "engine.py",
|
||
os.path.join("backtest", "gpu_backtest.py")):
|
||
with open(os.path.join(root, rel), encoding="utf-8") as fh:
|
||
src = fh.read()
|
||
for banned in ("import violent_watch", "from violent_watch",
|
||
"attach_watch(", "should_send_headsup("):
|
||
assert banned not in src, \
|
||
f"{rel} must not use {banned} (backtest parity)"
|
||
# A real replay leaves watch_line empty.
|
||
agg = SignalAggregator(PlatformConfig())
|
||
dfs = {
|
||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||
}
|
||
rep = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||
assert rep.watch_line == "", "analyze_from_dataframes must not stamp it"
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# MODULE 31: signal-log data points (indicator-weight research)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
print("\n═══ Module 31: signal-log data points ═══")
|
||
|
||
from engine import _DEFAULT_REGIME_WEIGHTS, regime_weight_table
|
||
|
||
|
||
def _m31_report(ts, tf=Timeframe.M5, price=2450.0, quality="B",
|
||
signal=SignalStrength.BUY, atr=5.0, setup=False,
|
||
analysis=None):
|
||
"""Logged scan with the record-keeping fields populated."""
|
||
r = _make_log_report()
|
||
r.timestamp = ts
|
||
r.final_signal = signal
|
||
r.signal_quality = quality
|
||
r.current_price = price
|
||
r.trade_setup = r.trade_setup if setup else None
|
||
r.exhaustion_count = 2
|
||
r.penalty_tags = {"rsi_overbought": 8.0, "extended_from_ema": 4.0}
|
||
r.penalty_total = 6.5
|
||
r.momentum_fading = True
|
||
if analysis is not None:
|
||
r.primary_analysis = analysis
|
||
r.primary_analysis.timeframe = tf.value
|
||
r.primary_analysis.timestamp = ts
|
||
r.primary_analysis.key_levels = {"support": 2440.0, "atr": atr}
|
||
return r
|
||
|
||
|
||
def _m31_bars(start, n, step_min=5, up=0.1, base=2450.0, burst_at=None):
|
||
"""Deterministic ramp: bar i opens base+i*up and closes one `up`
|
||
higher, with 0.3 wicks — every forward return is exact arithmetic."""
|
||
out = []
|
||
t = pd.Timestamp(start)
|
||
for i in range(n):
|
||
o = base + i * up
|
||
c = o + up
|
||
high = max(o, c) + (20.0 if i == burst_at else 0.3)
|
||
out.append({
|
||
"timestamp": (t + pd.Timedelta(minutes=step_min * i)).isoformat(),
|
||
"open": o, "high": high, "low": min(o, c) - 0.3,
|
||
"close": c, "volume": 1.0})
|
||
return out
|
||
|
||
|
||
def _m31_log_stream(lg, bars, chunk=14):
|
||
"""Log the bar stream the way live scans store it (14 bars per row)."""
|
||
for i in range(0, len(bars), chunk):
|
||
window = bars[i:i + chunk]
|
||
lg.log(_m31_report(str(pd.Timestamp(window[-1]["timestamp"])),
|
||
quality="D", signal=SignalStrength.NEUTRAL),
|
||
source="dashboard", timeframe="5min",
|
||
dataframes={Timeframe.M5: _m27_df(window)})
|
||
|
||
|
||
@test("engine: effective_weight = weight x regime multiplier, composite unchanged")
|
||
def _():
|
||
config = TechnicalConfig()
|
||
result = TechnicalEngine(config).analyze(
|
||
make_xau_ohlcv(300, seed=11), "XAU/USD", "1h")
|
||
table = regime_weight_table(config).get(result.market_regime, {})
|
||
total = weights = 0.0
|
||
scored = 0
|
||
for name, ind in result.indicators.items():
|
||
if name.startswith("_") or ind.weight == 0:
|
||
assert ind.effective_weight is None, name
|
||
assert ind.weighted_score is None, name
|
||
continue
|
||
scored += 1
|
||
assert abs(ind.effective_weight
|
||
- ind.weight * table.get(name, 1.0)) < 1e-12, name
|
||
assert abs(ind.weighted_score - ind.signal.value
|
||
* ind.effective_weight * ind.confidence / 100.0) < 1e-12
|
||
total += ind.weighted_score
|
||
weights += ind.effective_weight
|
||
assert scored >= 5, scored
|
||
recomputed = max(-100.0, min(100.0, (total / weights) * 50))
|
||
assert abs(recomputed - result.composite_score) < 1e-9, (
|
||
recomputed, result.composite_score)
|
||
|
||
|
||
@test("engine: regime_weight_table honours regime_weights_override")
|
||
def _():
|
||
df = make_xau_ohlcv(300, seed=12)
|
||
base = TechnicalEngine(TechnicalConfig()).analyze(df, "XAU/USD", "1h")
|
||
assert regime_weight_table(TechnicalConfig()) == _DEFAULT_REGIME_WEIGHTS
|
||
# The table is a COPY: mutating it cannot reach the shipped weights.
|
||
regime_weight_table(TechnicalConfig())["ranging"]["rsi"] = 99.0
|
||
again = TechnicalEngine(TechnicalConfig()).analyze(df, "XAU/USD", "1h")
|
||
assert again.composite_score == base.composite_score
|
||
|
||
regime = base.market_regime
|
||
override = {r: dict(w) for r, w in _DEFAULT_REGIME_WEIGHTS.items()}
|
||
override[regime]["rsi"] = override[regime].get("rsi", 1.0) * 4 + 1
|
||
config = TechnicalConfig(regime_weights_override=override)
|
||
assert regime_weight_table(config) == override
|
||
tuned = TechnicalEngine(config).analyze(df, "XAU/USD", "1h")
|
||
assert tuned.market_regime == regime
|
||
assert tuned.composite_score != base.composite_score, "override ignored"
|
||
total = weights = 0.0
|
||
for name, ind in tuned.indicators.items():
|
||
if ind.effective_weight is None:
|
||
continue
|
||
assert abs(ind.effective_weight
|
||
- ind.weight * override[regime].get(name, 1.0)) < 1e-12
|
||
total += ind.weighted_score
|
||
weights += ind.effective_weight
|
||
assert abs(max(-100.0, min(100.0, (total / weights) * 50))
|
||
- tuned.composite_score) < 1e-9
|
||
|
||
|
||
@test("aggregator: engine_config_fingerprint tracks scoring config only")
|
||
def _():
|
||
from pair_signals.xauusd import make_config
|
||
m5 = SignalAggregator(make_config(Timeframe.M5))
|
||
m15 = SignalAggregator(make_config(Timeframe.M15))
|
||
fingerprint = m5.engine_config_fingerprint()
|
||
assert len(fingerprint) == 12 and fingerprint.isalnum()
|
||
assert fingerprint == m5.engine_config_fingerprint(), "not stable"
|
||
assert fingerprint == SignalAggregator(
|
||
make_config(Timeframe.M5)).engine_config_fingerprint()
|
||
assert fingerprint != m15.engine_config_fingerprint(), "timeframe blind"
|
||
|
||
flipped = make_config(Timeframe.M5)
|
||
flipped.technical.ema_partial_neutral = (
|
||
not flipped.technical.ema_partial_neutral)
|
||
flipped_agg = SignalAggregator(flipped)
|
||
assert flipped_agg.engine_config_fingerprint() != fingerprint
|
||
assert flipped_agg.gate_config_fingerprint() == m5.gate_config_fingerprint()
|
||
|
||
weighted = make_config(Timeframe.M5)
|
||
weighted.technical.regime_weights_override = {"ranging": {"rsi": 9.0}}
|
||
weighted_agg = SignalAggregator(weighted)
|
||
assert weighted_agg.engine_config_fingerprint() != fingerprint
|
||
assert weighted_agg.gate_config_fingerprint() == m5.gate_config_fingerprint()
|
||
|
||
|
||
@test("signal_log: migration adds the research columns, scan_outcomes and indexes")
|
||
def _():
|
||
import sqlite3
|
||
import signal_log as slog
|
||
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,"
|
||
" symbol TEXT, timeframe TEXT)")
|
||
con.commit()
|
||
con.close()
|
||
lg = slog.SignalLogger(path)
|
||
columns = {r[1] for r in lg._conn.execute("PRAGMA table_info(signals)")}
|
||
assert {"bar_ts", "engine_fingerprint", "code_version",
|
||
"penalty_tags_json", "penalty_total", "momentum_fading",
|
||
"exhaustion_count", "watch_level", "watch_kinds",
|
||
"watch_line"} <= columns, sorted(columns)
|
||
outcome_cols = [r[1] for r in
|
||
lg._conn.execute("PRAGMA table_info(scan_outcomes)")]
|
||
assert outcome_cols[0] == "signal_id"
|
||
assert {"signal_id", "timeframe", "bar_ts", "atr", "close0",
|
||
"fwd_1h", "fwd_4h", "fwd_24h", "fwd_1h_atr", "fwd_4h_atr",
|
||
"fwd_24h_atr", "mfe_24h_atr", "mae_24h_atr", "burst_1h",
|
||
"bars_used", "complete", "labeled_at"} == set(outcome_cols), \
|
||
outcome_cols
|
||
names = {r[0] for r in lg._conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type='index'")}
|
||
assert "idx_signals_tf_bar" in names
|
||
assert "idx_scan_outcomes_tf_bar" in names
|
||
slog.SignalLogger(path).close() # idempotent re-migration
|
||
lg.close()
|
||
finally:
|
||
slog.reset_loggers_for_tests()
|
||
os.remove(path)
|
||
|
||
|
||
@test("signal_log: a row records bar_ts, fingerprints, penalties and eff weights")
|
||
def _():
|
||
import signal_log as slog
|
||
fd, path = tempfile.mkstemp(suffix=".db")
|
||
os.close(fd)
|
||
try:
|
||
analysis = TechnicalEngine(TechnicalConfig()).analyze(
|
||
make_xau_ohlcv(300, seed=13), "XAU/USD", Timeframe.M5.value)
|
||
bar = "2026-08-19 12:00:00+00:00"
|
||
report = _m31_report(bar, analysis=analysis)
|
||
lg = slog.SignalLogger(path)
|
||
rowid = lg.log(report, source="telegram", timeframe="5min",
|
||
extra={"scan_kind": "scan",
|
||
"config_fingerprint": "gate12345678",
|
||
"engine_fingerprint": "engn12345678"})
|
||
row = lg._conn.execute(
|
||
"SELECT bar_ts, engine_fingerprint, config_fingerprint,"
|
||
" code_version, penalty_tags_json, penalty_total,"
|
||
" momentum_fading, exhaustion_count, indicators_json"
|
||
" FROM signals WHERE id=?", (rowid,)).fetchone()
|
||
assert row[0] == bar, row[0]
|
||
assert row[1] == "engn12345678" and row[2] == "gate12345678"
|
||
assert row[3] == slog._code_version()
|
||
assert json.loads(row[4]) == {"rsi_overbought": 8.0,
|
||
"extended_from_ema": 4.0}
|
||
assert row[5] == 6.5 and row[6] == 1 and row[7] == 2
|
||
stored = json.loads(row[8])["5min"]["indicators"]
|
||
scored = [n for n, e in stored.items() if e["eff_weight"] is not None]
|
||
assert scored, stored
|
||
for name in scored:
|
||
entry = stored[name]
|
||
live = analysis.indicators[name]
|
||
assert entry["eff_weight"] == live.effective_weight
|
||
assert entry["wscore"] == live.weighted_score
|
||
assert entry["weight"] == live.weight # base weight untouched
|
||
assert stored["atr"]["eff_weight"] is None, "context indicator scored"
|
||
lg.close()
|
||
# The aggregator is what hands the engine fingerprint over.
|
||
captured = {}
|
||
with patch("signal_log.log_signal",
|
||
lambda *a, **k: captured.update(k.get("extra") or {})):
|
||
agg = SignalAggregator(PlatformConfig())
|
||
agg._log_signal(_make_log_report(), "test", blocked=True)
|
||
assert captured["engine_fingerprint"] == agg.engine_config_fingerprint()
|
||
assert captured["config_fingerprint"] == agg.gate_config_fingerprint()
|
||
finally:
|
||
slog.reset_loggers_for_tests()
|
||
os.remove(path)
|
||
|
||
|
||
@test("signal_log: attach_watch fills watch_level/kinds/line post-hoc")
|
||
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["SIGNAL_LOG_ENABLED"] = "1"
|
||
analysis = _m30_analysis(indicators={
|
||
"bollinger": _m30_ind("BB", -0.2), "rsi": _m30_ind("RSI", 22.0)})
|
||
report = _m31_report("2026-08-19 12:00:00+00:00", analysis=analysis)
|
||
slog.log_signal(report, source="telegram", timeframe="5min")
|
||
rowid = report._signal_log_rowid
|
||
with patch("news_engine.NewsEngine.get_week_events", return_value=[]):
|
||
# alert_line off: the assessment is still recorded.
|
||
watch = attach_watch(report, Timeframe.M5,
|
||
{"enabled": True, "alert_line": False},
|
||
_M30_QUIET)
|
||
assert watch.level == "watch" and report.watch_line == ""
|
||
lg = slog._loggers[path]
|
||
assert lg._conn.execute(
|
||
"SELECT watch_level, watch_kinds, watch_line FROM signals"
|
||
" WHERE id=?", (rowid,)).fetchone() == (
|
||
"watch", ",".join(sorted(watch.kinds)), "")
|
||
# With the line on, the displayed text is stored too.
|
||
watch = attach_watch(report, Timeframe.M5, {"enabled": True},
|
||
_M30_QUIET)
|
||
assert lg._conn.execute(
|
||
"SELECT watch_level, watch_line FROM signals WHERE id=?",
|
||
(rowid,)).fetchone() == (watch.level, report.watch_line)
|
||
# A report that was never logged is a silent no-op.
|
||
orphan = _m31_report("2026-08-19 12:05:00+00:00",
|
||
analysis=analysis)
|
||
attach_watch(orphan, Timeframe.M5, {"enabled": True}, _M30_QUIET)
|
||
assert lg._conn.execute(
|
||
"SELECT COUNT(*) FROM signals").fetchone()[0] == 1
|
||
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: label_scan_outcomes computes exact forward moves")
|
||
def _():
|
||
import signal_log as slog
|
||
fd, path = tempfile.mkstemp(suffix=".db")
|
||
os.close(fd)
|
||
try:
|
||
bars = _m31_bars("2026-08-19T12:00:00+00:00", 320, up=0.1,
|
||
burst_at=6) # bar 6 spans 20.3 = 4x the ATR
|
||
anchor = str(pd.Timestamp(bars[0]["timestamp"]))
|
||
lg = slog.SignalLogger(path)
|
||
rowid = lg.log(_m31_report(anchor), source="telegram",
|
||
timeframe="5min",
|
||
dataframes={Timeframe.M5: _m27_df(bars[0:1])})
|
||
# log() labels opportunistically: with no forward bars stored yet
|
||
# the row exists but nothing is known.
|
||
assert lg._conn.execute(
|
||
"SELECT complete, fwd_1h FROM scan_outcomes WHERE signal_id=?",
|
||
(rowid,)).fetchone() == (0, None)
|
||
# Only 6 h of stream: the 24 h window is not judgeable yet.
|
||
_m31_log_stream(lg, bars[:73])
|
||
lg.label_scan_outcomes(budget=500, time_budget=30.0)
|
||
cols = ("fwd_1h", "fwd_4h", "fwd_24h", "complete", "bars_used")
|
||
partial = lg._conn.execute(
|
||
"SELECT " + ", ".join(cols) + " FROM scan_outcomes"
|
||
" WHERE signal_id=?", (rowid,)).fetchone()
|
||
assert partial[3] == 0, partial
|
||
assert abs(partial[0] - 1.2) < 1e-9 and abs(partial[1] - 4.8) < 1e-9
|
||
assert partial[2] is None, "unfinished horizon must stay NULL"
|
||
|
||
_m31_log_stream(lg, bars[73:]) # the rest arrives later
|
||
lg.label_scan_outcomes(budget=500, time_budget=30.0)
|
||
row = lg._conn.execute(
|
||
"SELECT timeframe, bar_ts, atr, close0, fwd_1h, fwd_4h, fwd_24h,"
|
||
" fwd_1h_atr, fwd_4h_atr, fwd_24h_atr, mfe_24h_atr, mae_24h_atr,"
|
||
" burst_1h, bars_used, complete FROM scan_outcomes"
|
||
" WHERE signal_id=?", (rowid,)).fetchone()
|
||
assert row[0] == "5min" and row[1] == anchor
|
||
assert row[2] == 5.0 and abs(row[3] - 2450.1) < 1e-9
|
||
for value, expected in zip(row[4:10],
|
||
(1.2, 4.8, 28.8, 0.24, 0.96, 5.76)):
|
||
assert abs(value - expected) < 1e-9, (row, expected)
|
||
assert abs(row[10] - 29.1 / 5.0) < 1e-9 # MFE = last high
|
||
assert abs(row[11] - 0.3 / 5.0) < 1e-9 # MAE = first wick
|
||
assert row[12] == 1, "the 4x ATR bar in the first hour is a burst"
|
||
assert row[13] == 288 and row[14] == 1 # 24 h of 5-min bars
|
||
|
||
# Idempotent: a settled database writes nothing.
|
||
assert lg.label_scan_outcomes(budget=500, time_budget=30.0) == 0
|
||
# Budget caps the rows examined per pass.
|
||
lg._conn.execute("DELETE FROM scan_outcomes")
|
||
lg._conn.commit()
|
||
assert lg.label_scan_outcomes(budget=1, time_budget=30.0) == 1
|
||
assert lg._conn.execute(
|
||
"SELECT COUNT(*) FROM scan_outcomes").fetchone()[0] == 1
|
||
lg.close()
|
||
finally:
|
||
slog.reset_loggers_for_tests()
|
||
os.remove(path)
|
||
|
||
|
||
@test("signal_log: a failing scan-outcome labeller never breaks 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.object(slog.SignalLogger, "label_scan_outcomes",
|
||
side_effect=RuntimeError("label boom")):
|
||
rowid = lg.log(_m31_report("2026-08-19 12:00:00+00:00"),
|
||
source="telegram", timeframe="5min")
|
||
finally:
|
||
logging.disable(logging.NOTSET)
|
||
assert lg._conn.execute(
|
||
"SELECT id FROM signals").fetchone()[0] == rowid
|
||
lg.close()
|
||
finally:
|
||
slog.reset_loggers_for_tests()
|
||
os.remove(path)
|
||
|
||
|
||
@test("backtest parity: the replay path knows nothing about the new writers")
|
||
def _():
|
||
root = os.path.dirname(os.path.abspath(__file__))
|
||
with open(os.path.join(root, "backtest", "gpu_backtest.py"),
|
||
encoding="utf-8") as fh:
|
||
src = fh.read()
|
||
for banned in ("scan_outcomes", "label_scan_outcomes", "update_watch",
|
||
"log_watch_update", "import signal_log"):
|
||
assert banned not in src, f"gpu_backtest must not reference {banned}"
|
||
# The stamped record-keeping fields are engine-side, so the replay
|
||
# engine gets them for free without any signal change.
|
||
from backtest.gpu_backtest import GPUTechnicalEngine
|
||
assert "_compute_composite" not in GPUTechnicalEngine.__dict__, \
|
||
"the backtester must keep inheriting the composite maths"
|
||
|
||
|
||
@test("analyze_signal_log: --scan-outcomes reports on the stored labels")
|
||
def _():
|
||
import contextlib
|
||
import io as _io
|
||
import analyze_signal_log as asl
|
||
import signal_log as slog
|
||
fd, path = tempfile.mkstemp(suffix=".db")
|
||
os.close(fd)
|
||
try:
|
||
bars = _m31_bars("2026-08-19T12:00:00+00:00", 300, up=0.1)
|
||
lg = slog.SignalLogger(path)
|
||
anchor = str(pd.Timestamp(bars[0]["timestamp"]))
|
||
lg.log(_m31_report(anchor, quality="A"), source="telegram",
|
||
timeframe="5min",
|
||
dataframes={Timeframe.M5: _m27_df(bars[0:1])})
|
||
lg.log(_m31_report(anchor, quality="A"), source="dashboard",
|
||
timeframe="5min") # same bar, second surface
|
||
_m31_log_stream(lg, bars)
|
||
lg.label_scan_outcomes(budget=500, time_budget=30.0)
|
||
lg.update_watch(1, "hot", ["clock", "extreme"], "line")
|
||
lg.close()
|
||
rows = asl.scan_outcome_rows(path)
|
||
assert rows, "no labelled rows"
|
||
assert len({(r["timeframe"], r["id"]) for r in rows}) == len(rows)
|
||
assert min(r["id"] for r in rows) == 1, "dedup keeps the lowest id"
|
||
buf = _io.StringIO()
|
||
with contextlib.redirect_stdout(buf):
|
||
asl.print_scan_outcomes(rows)
|
||
asl.print_scan_outcomes([])
|
||
out = buf.getvalue()
|
||
assert "SCAN OUTCOMES" in out and "by timeframe x signal" in out
|
||
assert "5min BUY" in out, out
|
||
assert "hot" in out and "No scan_outcomes rows yet" in out
|
||
finally:
|
||
slog.reset_loggers_for_tests()
|
||
os.remove(path)
|
||
|
||
|
||
@test("app_config: legacy oanda.account_id is tolerated and ignored (auto-execution removed 2026-09-03)")
|
||
def _():
|
||
import copy
|
||
import app_config as _ac
|
||
payload = copy.deepcopy(_FULL_CFG)
|
||
payload.setdefault("oanda", {})["account_id"] = "101-004-LEGACY-001"
|
||
payload["oanda"].setdefault("api_token", "tok")
|
||
app = load_app_config(_write_json(payload)) # must NOT raise
|
||
assert app.oanda_settings.get("api_token") == payload["oanda"]["api_token"]
|
||
assert not hasattr(_ac, "oanda_account_id"), "dead accessor must stay deleted"
|
||
assert "account_id" not in open("config.json.example").read(), \
|
||
"the example must not advertise a key nothing reads"
|
||
assert _ac.oanda_api_token(app) == payload["oanda"]["api_token"]
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 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)
|