audit: GPU-engine cache-miss observability + dead-guard alignment + full parity test
2026-08-04 audit, Batch B. Verified first (h1 harness): the two engines are byte-identical on every reachable input — 370-prefix flat-bar deck, warmup band, 322 real-data M15 positions incl. all 7 zero-range bars, zero diffs. The claimed ADX NaN desync / warmup-guard divergences are unreachable below the shared 30-bar analyze() floor. Changes here are therefore observability + hygiene, proven behavior-neutral by exact reproduction of the pinned M15 12mo book (397 trades, +78.7111R, byte-identical trade rows). - _resolve_pos: cache-miss fallback to live-engine recompute now warns on first miss and counts every miss (was fully silent — a run could switch math with no trace). - ROC/Keltner warmup guards textually aligned with the live engine so a future period/floor change can't open a silent divergence window. - New regression: full-sweep parity test comparing ALL indicator signals/confidences, composite, and regimes on every prefix incl. a flat zero-DM/zero-TR run (the old parity test skipped ADX/MACD/ROC/Keltner/RSI entirely); plus a loud-fallback test. Suite: 314 green. Book gate: oanda_m15_12mo_030 reproduced identically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UnZkzeFcTu9agFg8BDh2tn
This commit is contained in:
@@ -402,6 +402,11 @@ class GPUTechnicalEngine(TechnicalEngine):
|
||||
# cache key = timeframe.value (e.g. "15min", "1h", "4h")
|
||||
self.caches = caches
|
||||
self._current_tf: Optional[str] = None
|
||||
# Cache-miss fallbacks recompute with the live engine's pandas
|
||||
# math (different EWM seeding than the precomputed cache). That
|
||||
# is a parity hazard, so it must never happen silently.
|
||||
self.fallback_count = 0
|
||||
self._fallback_warned = False
|
||||
|
||||
def analyze(self, df: pd.DataFrame, symbol: str, timeframe: str) -> TechnicalAnalysis:
|
||||
# Latch the timeframe so per-indicator overrides know which cache to use.
|
||||
@@ -419,9 +424,23 @@ class GPUTechnicalEngine(TechnicalEngine):
|
||||
return None, -1
|
||||
cache = self.caches.get(self._current_tf)
|
||||
if cache is None:
|
||||
self._note_fallback(f"no cache for TF {self._current_tf!r}")
|
||||
return None, -1
|
||||
pos = cache.index_pos.get(df.index[-1])
|
||||
return (cache, pos) if pos is not None else (None, -1)
|
||||
if pos is None:
|
||||
self._note_fallback(
|
||||
f"{self._current_tf}: slice end {df.index[-1]} not in cache")
|
||||
return None, -1
|
||||
return cache, pos
|
||||
|
||||
def _note_fallback(self, why: str) -> None:
|
||||
self.fallback_count += 1
|
||||
if not self._fallback_warned:
|
||||
self._fallback_warned = True
|
||||
logger.warning(
|
||||
"GPU cache miss → falling back to live-engine recompute "
|
||||
"(%s). Further misses counted silently; check "
|
||||
"fallback_count at end of run.", why)
|
||||
|
||||
# ── EMA overrides ──────────────────────────────────────────────
|
||||
|
||||
@@ -826,7 +845,12 @@ class GPUTechnicalEngine(TechnicalEngine):
|
||||
c = self.config
|
||||
n = c.roc_period
|
||||
|
||||
if pos < n + 1:
|
||||
# Mirror the live guard (engine._compute_roc: len < n + 5 → skip,
|
||||
# i.e. emit only from pos >= n + 4). Unreachable in practice —
|
||||
# analyze() floors at 30 bars and n+4 < 30 for shipped periods —
|
||||
# but keep the engines textually aligned so a future period/floor
|
||||
# change can't open a silent divergence window.
|
||||
if pos < n + 4:
|
||||
return
|
||||
|
||||
roc_val = float(cache.roc[pos])
|
||||
@@ -876,6 +900,11 @@ class GPUTechnicalEngine(TechnicalEngine):
|
||||
return super()._compute_keltner(df, result)
|
||||
|
||||
c = self.config
|
||||
# Mirror the live guard (engine._compute_keltner: len < max(kc_period,
|
||||
# kc_atr_period) + 5 → skip). Unreachable below the 30-bar analyze()
|
||||
# floor with shipped periods; kept aligned for the same reason as ROC.
|
||||
if pos < max(c.kc_period, c.kc_atr_period) + 4:
|
||||
return
|
||||
latest = float(df["Close"].iloc[-1])
|
||||
kc_upper_val = float(cache.kc_upper[pos])
|
||||
kc_lower_val = float(cache.kc_lower[pos])
|
||||
|
||||
Reference in New Issue
Block a user