Audit fixes: cache boundary staleness, 6e session bypass, GPU engine parity
Fixes all high+medium findings from the 2026-07-16 full-codebase audit. High: - data_provider: cache now rejects snapshots missing the latest closed bar (_has_latest_closed_bar, weekend-exempt). The wall-clock TTL served mid-candle snapshots at the next boundary, so live M15/H1 scans ran one full bar behind and the bar-close session check evaluated the wrong hour. - signal_aggregator: gate 6e (SELL quality) only acts on actionable grades. It was re-grading session-blocked F reports to D, which re-opened the M5 pullback path (entry check: quality != "F") on session-blocked bars. - gpu_backtest: port the live EMA-ribbon compression/expansion logic (+10 conf on expansion) into the GPU engine. Its absence diverged composite score/conf on 7-9% of bars and flipped signals on ~0.2%. Medium: - gpu_backtest: Bollinger precompute uses the exact pandas rolling calls (cumsum E[x2]-E[x]2 lost precision at gold price levels; bb_position error up to 7.6e-3 crossed signal thresholds, worst during squeezes). - gpu_backtest: --confirm-tfs defaults to the pair factory's live confirmation map instead of a hardcoded 1h,4h. - gpu_backtest: entries re-anchor to the first RAW bar after the signal bar, so >10-sigma spike bars dropped by cleaning are no longer skipped at entry (favorable bias). - gpu_backtest: per-TF warmup pad — D1 confirms get >=210 bars at window start (62-day pad gave ~61 bars: no EMA200, unlike live). - data_provider: thread-safety locks for cache + throttle (shared provider across the bot's 4-thread pool); outlier filter keeps the snap-back bar after a spike instead of punching a two-bar hole. - telegram_bot: alert fingerprint gains a risk-relative entry bucket (materially-moved setups re-alert) and is committed only after a successful send (failed sends no longer consume the alert for 1h). - engine: disabled_indicators=["macd"] now removes the MACD pillar from scoring (histogram still computed for divergence). Verification: test_all.py 187/187 (new Module 11: 8 audit-fix regressions incl. a live-vs-GPU parity sweep with ribbon coverage); real-data parity sweep over 813 slices of 14mo XAU M15: zero divergence (pre-fix: 7-9% of bars diverged); both original bug repros no longer reproduce; cached end-to-end backtest run passes with the live confirm map. Books shift slightly vs pre-fix runs — comparability note added to CLAUDE.md; pinned tuned numbers need re-validation under the fixed backtester.
This commit is contained in:
+231
@@ -2341,6 +2341,237 @@ def _():
|
||||
assert eur_report.display_name == "EUR/USD"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# 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}"
|
||||
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("disabled_indicators=['macd'] removes the pillar but keeps the histogram")
|
||||
def _():
|
||||
from engine import TechnicalEngine
|
||||
from config import TechnicalConfig
|
||||
df = make_xau_ohlcv(n=300, seed=5)
|
||||
result = TechnicalEngine(TechnicalConfig(disabled_indicators=["macd"])).analyze(
|
||||
df, "XAU/USD", "1h")
|
||||
assert "macd" not in result.indicators, "disabled MACD still scored"
|
||||
assert getattr(result, "_macd_histogram", None) is not None, \
|
||||
"histogram must survive for divergence"
|
||||
baseline = TechnicalEngine(TechnicalConfig()).analyze(df, "XAU/USD", "1h")
|
||||
assert "macd" in baseline.indicators
|
||||
|
||||
|
||||
@test("Outlier filter drops the spike bar but keeps the snap-back bar")
|
||||
def _():
|
||||
provider = ForexDataProvider(PlatformConfig())
|
||||
df = make_ohlcv(n=300, seed=3)
|
||||
k = 150
|
||||
spike_ts, after_ts = df.index[k], df.index[k + 1]
|
||||
spike_val = df["Close"].iloc[k - 1] * 1.05 # ~+5% on a ~0.1%-σ series
|
||||
df.loc[spike_ts, ["Open", "High", "Low", "Close"]] = spike_val
|
||||
cleaned = provider._validate_and_clean(df, "EUR/USD", Timeframe.H1)
|
||||
assert spike_ts not in cleaned.index, "spike bar must be removed"
|
||||
assert after_ts in cleaned.index, \
|
||||
"snap-back bar after the spike must be kept (two-bar hole regression)"
|
||||
|
||||
|
||||
@test("Cache: pre-boundary snapshot refetched; boundary-complete snapshot served")
|
||||
def _():
|
||||
def bars_ending(last):
|
||||
idx = pd.date_range(end=last, periods=300, freq="15min", tz="UTC")
|
||||
v = np.linspace(3000.0, 3100.0, 300)
|
||||
return pd.DataFrame({"Open": v, "High": v + 1, "Low": v - 1,
|
||||
"Close": v, "Volume": 1000.0}, index=idx)
|
||||
|
||||
for _attempt in range(2):
|
||||
provider = ForexDataProvider(PlatformConfig())
|
||||
started = pd.Timestamp.now(tz="UTC").floor("15min")
|
||||
last_closed = started - pd.Timedelta(minutes=15)
|
||||
stale = bars_ending(last_closed - pd.Timedelta(minutes=15))
|
||||
fresh = bars_ending(last_closed)
|
||||
key = "XAU/USD_15min"
|
||||
with patch("data_provider._is_forex_weekend", return_value=False):
|
||||
# (a) snapshot cached mid-candle 5 min ago, missing the
|
||||
# just-closed bar: TTL says fresh, boundary check must refetch.
|
||||
provider._cache[key] = stale
|
||||
provider._cache_timestamps[key] = time.time() - 300
|
||||
with patch.object(provider, "_fetch_twelve_data",
|
||||
return_value=fresh.copy()) as mock_fetch:
|
||||
served = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
|
||||
# (b) cache now boundary-complete: served without any fetch.
|
||||
with patch.object(provider, "_fetch_twelve_data",
|
||||
side_effect=AssertionError("must not refetch")):
|
||||
served2 = provider.fetch_ohlcv("XAU/USD", Timeframe.M15)
|
||||
if pd.Timestamp.now(tz="UTC").floor("15min") != started:
|
||||
continue # boundary crossed mid-test — retry once
|
||||
assert mock_fetch.called, "stale pre-boundary snapshot was served from cache"
|
||||
assert served.index[-1] == last_closed
|
||||
assert served2.index[-1] == last_closed
|
||||
break
|
||||
|
||||
|
||||
@test("Alert dedup: drift dedups, materially-moved setup re-alerts, no early commit")
|
||||
def _():
|
||||
import telegram_bot as tb
|
||||
from signal_aggregator import SignalReport, TradeSetup
|
||||
|
||||
def rpt(entry, sl):
|
||||
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
|
||||
r.final_signal = SignalStrength.BUY
|
||||
r.signal_quality = "B"
|
||||
r.trade_setup = TradeSetup(
|
||||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||||
entry_price=entry, stop_loss=sl, take_profit_1=entry + 12,
|
||||
take_profit_2=entry + 20, risk_reward=1.5,
|
||||
risk_pips=80.0, reward_pips=120.0, pip_value=0.10,
|
||||
)
|
||||
return r
|
||||
|
||||
key = "TEST_ALERT_FP"
|
||||
tb._last_alert_fp.pop(key, None)
|
||||
tb._last_alert_ts.pop(key, None)
|
||||
r1 = rpt(3400.0, 3392.0) # risk $8 → $4 buckets
|
||||
assert tb._should_alert(key, r1)
|
||||
assert tb._should_alert(key, r1), "_should_alert must not commit state itself"
|
||||
tb._commit_alert(key, r1)
|
||||
assert not tb._should_alert(key, r1), "identical signal must dedup"
|
||||
assert not tb._should_alert(key, rpt(3400.5, 3392.5)), \
|
||||
"sub-bucket drift must dedup"
|
||||
assert tb._should_alert(key, rpt(3412.0, 3404.0)), \
|
||||
"setup moved 1.5x risk — must re-alert"
|
||||
tb._last_alert_fp.pop(key, None)
|
||||
tb._last_alert_ts.pop(key, None)
|
||||
|
||||
|
||||
@test("Backtester --confirm-tfs defaults to None (factory's live map)")
|
||||
def _():
|
||||
from gpu_backtest import _parse_args
|
||||
args = _parse_args(["--pair", "XAUUSD"])
|
||||
assert args.confirm_tfs is None
|
||||
|
||||
|
||||
@test("Backtest entries re-anchor to the first RAW bar after the signal bar")
|
||||
def _():
|
||||
from gpu_backtest import Trade, _reanchor_entries_to_raw
|
||||
idx = pd.date_range("2026-01-05 10:00", periods=6, freq="5min", tz="UTC")
|
||||
opens = np.array([100.0, 101.0, 110.0, 102.0, 103.0, 104.0])
|
||||
sim = pd.DataFrame({"Open": opens, "High": opens + 1,
|
||||
"Low": opens - 1, "Close": opens}, index=idx)
|
||||
|
||||
def mk_trade(entry_time, entry_price):
|
||||
return Trade(pair="XAU/USD", entry_time=entry_time, entry_price=entry_price,
|
||||
sl=entry_price - 8, tp1=entry_price + 12, tp2=entry_price + 20,
|
||||
direction="BUY", pip_value=0.10, risk_pips=80.0,
|
||||
reward_pips=120.0, rr_planned=1.5, quality="B",
|
||||
final_score=30.0, final_confidence=60.0,
|
||||
market_regime="trending_up")
|
||||
|
||||
# Cleaned frame lacked the spike at idx[2], so the replay entered at idx[3].
|
||||
t = mk_trade(idx[3], 102.0)
|
||||
_reanchor_entries_to_raw([t], [idx[1]], sim)
|
||||
assert t.entry_time == idx[2] and t.entry_price == 110.0
|
||||
assert t.sl == 102.0 and t.tp1 == 122.0 and t.tp2 == 130.0, \
|
||||
"SL/TP distances must be preserved relative to the corrected entry"
|
||||
|
||||
# Entry already the first raw bar after the signal → untouched.
|
||||
t2 = mk_trade(idx[2], 110.0)
|
||||
_reanchor_entries_to_raw([t2], [idx[1]], sim)
|
||||
assert t2.entry_time == idx[2] and t2.entry_price == 110.0 and t2.sl == 102.0
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# SUMMARY
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user