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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UnZkzeFcTu9agFg8BDh2tn
This commit is contained in:
@@ -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, {}, {}, {})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user