audit: alert delivery, provider robustness, backtester NaN/cache guards (Batches C+E+F)

2026-08-04 Fable 5 QA audit. Three batches sharing one test file, all
verified by runnable repros before fixing, all gated below.

Batch C — telegram alert delivery (verified by h4 harness):
- Failed breach rescan shrinks the cooldown to 30 s instead of
  silencing the symbol for 5 min; breach during cooldown is PARKED and
  fired on the first tick after expiry (was dropped forever).
- Level cache keyed symbol→TF (M5 scans no longer wipe H1/H4 swing
  levels within minutes); breach labels TF-qualified.
- Markdown alert sends get a plain-text fallback (mirroring _safe_edit);
  fingerprint commits only when a message actually landed.
- Per-key asyncio.Lock closes the dedup TOCTOU between auto-scan and
  breach sends (forced-interleave repro produced duplicate alerts).
- _start_scheduler respawns dead per-TF loops (was all-or-nothing).
- Clean WS close now backs off (was a zero-delay reconnect hot loop);
  backoff resets only after a connection that received ticks; TD
  iter_ticks fires on_connect on the first RECEIVED frame (invalid key
  can no longer pin the backoff at minimum).
- Scan-failure logs carry the real traceback (was "NoneType: None").

Batch E — providers (verified by h5 harness):
- fetch_range: 3 consecutive chunk ERRORS raise instead of returning
  silently truncated history (verified 731-day silent gap repro; also
  covers 429 storms); pagination no-progress guard; head-truncation
  ERROR log.
- Single-flight per (symbol,TF): 4 concurrent scans = 1 API call (was
  4); cache records requested depth so a longer-lookback call refetches.
- Multi-bar forming/future tail fully dropped (was iloc[:-1] once).
- Oanda: malformed candles skipped, never 0.0-defaulted; cleaning drops
  non-positive-price rows BEFORE OHLC repair; inf returns neutralized so
  one corrupt bar can no longer disarm the whole 10-sigma filter.

Batch F — backtester internals (verified by h6 harness):
- NaN bar in a trailing window exits at the last valid bar (was NaN R
  poisoning every metric); static-path TIMEOUT NaN guard;
  compute_metrics excludes non-finite R loudly. Zero real-data exposure
  (0 NaN rows in all 10 Oanda parquets) — defensive.
- fetch_history ERROR-logs pre-2026-07-08 cache files (cleaned-bars era)
  when loaded for raw-path resolution.

Gates: suite 333/333 green; pinned M15 12mo book reproduced identically
(397 trades, +78.7111R) after E and after F; M5 HEAD-config baseline
reproduced identically (556 trades, +97.5604R) after F.

Claude-Session: https://claude.ai/code/session_01UnZkzeFcTu9agFg8BDh2tn
This commit is contained in:
Harwinder Sekhon
2026-08-04 20:02:57 -04:00
parent 18e4fd0403
commit 99eb268cf3
6 changed files with 1117 additions and 141 deletions
+40
View File
@@ -1008,6 +1008,16 @@ def fetch_history(
path = _cache_path(symbol, timeframe, start, end, provider.name)
if use_cache and path.exists():
logger.info(f"Loading cached {symbol} {timeframe.value}: {path.name}")
# Files written before the raw-cache change (2026-07-08, commit
# 6a96ed6) contain CLEANED bars; loading one with clean=False
# would silently miss the spike bars the raw path exists to model.
# The key format predates versioning, so detect by mtime.
_RAW_CACHE_EPOCH = 1783468800.0 # 2026-07-08 00:00 UTC
if path.stat().st_mtime < _RAW_CACHE_EPOCH:
logger.error(
f"{path.name} predates the raw-cache format (2026-07-08) "
f"and likely contains CLEANED bars — delete it and refetch "
f"before trusting trade resolution on this window")
df = pd.read_parquet(path)
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
@@ -1159,6 +1169,15 @@ def _simulate_trailing(
reason = "TIMEOUT"
for j in range(n):
if np.isnan(h[j]):
# Data ends here: close at the LAST VALID bar, not at
# c[n-1] (which may itself be NaN — a NaN exit price used
# to poison r_multiple and every downstream metric).
if j == 0:
exit_idx = 0
reason = "NO_DATA"
else:
exit_idx = j - 1
exit_price = float(c[exit_idx])
break
hit = (l[j] <= cur_sl) if buy else (h[j] >= cur_sl)
if hit:
@@ -1180,8 +1199,15 @@ def _simulate_trailing(
else peak + dist * risk)
cur_sl = max(cur_sl, lvl) if buy else min(cur_sl, lvl)
if reason == "NO_DATA":
t.exit_reason = reason
continue
if exit_price is None:
exit_price = float(c[exit_idx])
if not np.isfinite(exit_price):
# Last-resort guard: never emit a NaN exit/R into the book.
t.exit_reason = "NO_DATA"
continue
t.exit_reason = reason
t.bars_held = exit_idx + 1
t.exit_price = float(exit_price)
@@ -1354,6 +1380,12 @@ def simulate_trades(
last_idx = int(n_valid) - 1
t.exit_price = float(closes_arr[i, last_idx])
t.exit_time = exit_times[i][last_idx]
if not np.isfinite(t.exit_price):
# NaN close on the final window bar (corrupt raw data)
# must not become a NaN R.
t.exit_reason = "NO_DATA"
t.exit_price = None
continue
# Realized R from actual fill prices, net of round-trip spread.
risk = abs(t.entry_price - t.sl)
@@ -1857,6 +1889,14 @@ def _summarize(rs: List[float]) -> Dict:
def compute_metrics(trades: List[Trade]) -> Metrics:
resolved = [t for t in trades if t.exit_reason and t.exit_reason != "NO_DATA"]
# Last line of defense: one non-finite R (NaN exit on corrupt data)
# would silently NaN every aggregate below. Exclude loudly.
bad = [t for t in resolved if not np.isfinite(t.r_multiple)]
if bad:
logger.error(
"compute_metrics: excluding %d trade(s) with non-finite R "
"(corrupt exit data) — first at %s", len(bad), bad[0].entry_time)
resolved = [t for t in resolved if np.isfinite(t.r_multiple)]
if not resolved:
return Metrics(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, {}, {}, {})