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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Harwinder Sekhon
2026-07-16 10:24:39 -04:00
parent bdcd886722
commit f118ade3cf
7 changed files with 483 additions and 134 deletions
+231
View File
@@ -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
# ══════════════════════════════════════════════════════════════════════