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
+127 -89
View File
@@ -159,69 +159,6 @@ def _gpu_ewm_span(x: np.ndarray, span: int) -> np.ndarray:
return _gpu_ewm_alpha(x, alpha, min_periods=0)
def _gpu_rolling_mean(x: np.ndarray, window: int) -> np.ndarray:
"""Rolling mean via cumsum-diff, NaN-aware (matches pandas
`.rolling(window).mean()` default min_periods=window — returns NaN
if any value in the window is NaN)."""
n = x.shape[0]
out = np.full(n, np.nan, dtype=np.float64)
if n < window:
return out
xg = _to_gpu(x.astype(np.float64))
nan_mask = cp.isnan(xg)
x_filled = cp.where(nan_mask, 0.0, xg)
cs = cp.cumsum(x_filled)
valid_cnt = cp.cumsum((~nan_mask).astype(cp.int64))
sum_w = cp.empty(n - window + 1, dtype=cp.float64)
cnt_w = cp.empty(n - window + 1, dtype=cp.int64)
sum_w[0] = cs[window - 1]
sum_w[1:] = cs[window:] - cs[:-window]
cnt_w[0] = valid_cnt[window - 1]
cnt_w[1:] = valid_cnt[window:] - valid_cnt[:-window]
# pandas: NaN if any window value is NaN (cnt < window).
rolling = cp.where(cnt_w < window, cp.nan, sum_w / window)
out[window - 1:] = _to_cpu(rolling)
return out
def _gpu_rolling_std(x: np.ndarray, window: int, ddof: int = 0) -> np.ndarray:
"""Rolling std via cumsum of x and x^2, NaN-aware. ddof=0 matches
pandas std(ddof=0). NaN if any window value is NaN."""
n = x.shape[0]
out = np.full(n, np.nan, dtype=np.float64)
if n < window:
return out
xg = _to_gpu(x.astype(np.float64))
nan_mask = cp.isnan(xg)
x_filled = cp.where(nan_mask, 0.0, xg)
x2_filled = x_filled * x_filled
cs = cp.cumsum(x_filled)
cs2 = cp.cumsum(x2_filled)
valid_cnt = cp.cumsum((~nan_mask).astype(cp.int64))
sum_x = cp.empty(n - window + 1, dtype=cp.float64)
sum_x2 = cp.empty(n - window + 1, dtype=cp.float64)
cnt_w = cp.empty(n - window + 1, dtype=cp.int64)
sum_x[0] = cs[window - 1]
sum_x[1:] = cs[window:] - cs[:-window]
sum_x2[0] = cs2[window - 1]
sum_x2[1:] = cs2[window:] - cs2[:-window]
cnt_w[0] = valid_cnt[window - 1]
cnt_w[1:] = valid_cnt[window:] - valid_cnt[:-window]
mean = sum_x / window
var = sum_x2 / window - mean * mean
var = cp.maximum(var, 0.0) # numerical safety against tiny negatives
if ddof:
var = var * window / max(window - ddof, 1)
std = cp.sqrt(var)
std = cp.where(cnt_w < window, cp.nan, std)
out[window - 1:] = _to_cpu(std)
return out
@dataclass
class IndicatorCache:
"""Pre-computed indicator series for one (symbol, timeframe).
@@ -319,8 +256,18 @@ def precompute_indicators(
macd_hist_series = pd.Series(macd_hist, index=df.index, name="macd_hist")
# ── Bollinger ──
bb_mid = _gpu_rolling_mean(close, tech_cfg.bb_period)
bb_std = _gpu_rolling_std(close, tech_cfg.bb_period, ddof=0)
# Computed with the SAME pandas rolling calls as the live engine
# (engine._compute_bollinger). The previous cumsum E[x²]−E[x]² shortcut
# loses precision at gold's price level (bb_position error up to ~8e-3
# — enough to cross the 0.0/0.2/0.8/1.0 signal thresholds, worst during
# squeezes). Replay slices are prefixes of this df, so pandas rolling
# values here are bit-identical to what the live engine computes per
# slice. Rolling on the full series runs once — cost is negligible.
close_s = df["Close"]
bb_mid_s = close_s.rolling(tech_cfg.bb_period).mean()
bb_std_s = close_s.rolling(tech_cfg.bb_period).std(ddof=0)
bb_mid = bb_mid_s.to_numpy(dtype=np.float64)
bb_std = bb_std_s.to_numpy(dtype=np.float64)
bb_upper = bb_mid + tech_cfg.bb_std * bb_std
bb_lower = bb_mid - tech_cfg.bb_std * bb_std
bb_range = bb_upper - bb_lower
@@ -330,7 +277,10 @@ def precompute_indicators(
bb_position = np.where(np.isnan(bb_position), 0.5, bb_position)
bb_mid_safe = np.where(bb_mid == 0, np.nan, bb_mid)
bb_band_width = (bb_upper - bb_lower) / bb_mid_safe
bb_avg_width = _gpu_rolling_mean(bb_band_width, 20)
bb_avg_width = (
pd.Series(bb_band_width, index=df.index).rolling(20).mean()
.to_numpy(dtype=np.float64)
)
# ── ATR ──
prev_close = np.empty(n, dtype=np.float64)
@@ -595,7 +545,26 @@ class GPUTechnicalEngine(TechnicalEngine):
crossover_desc = " | Death cross (fast/medium)"
conf = min(conf + 10, 95)
desc = f"EMA alignment: {alignment_score}/{max_checks}{crossover_desc}"
# EMA ribbon compression/expansion detection — mirrors the live
# engine exactly (iloc[-5] ≡ pos-4, `len >= 6` ≡ pos >= 5). ATR is
# already in key_levels because analyze() computes ATR first.
ribbon_desc = ""
atr_val = result.key_levels.get("atr", 0)
ema_values = [f, m, s]
if atr_val > 0:
ribbon_width = (max(ema_values) - min(ema_values)) / atr_val
if ribbon_width < 1.0:
ribbon_desc = " | Ribbon COMPRESSED (trend change imminent)"
if pos >= 5:
prev_vals = [float(cache.ema_fast[pos - 4]),
float(cache.ema_medium[pos - 4]),
float(cache.ema_slow[pos - 4])]
prev_width = (max(prev_vals) - min(prev_vals)) / atr_val if atr_val > 0 else 0
if prev_width < 1.0 and ribbon_width >= 1.0:
ribbon_desc = " | Ribbon EXPANDING (new trend starting)"
conf = min(conf + 10, 95)
desc = f"EMA alignment: {alignment_score}/{max_checks}{crossover_desc}{ribbon_desc}"
result.indicators["ema_trend"] = IndicatorSignal(
name="EMA Trend", value=normalized, signal=signal,
confidence=conf, description=desc, weight=1.0,
@@ -1647,6 +1616,53 @@ def _replay_span(span: Tuple[int, int]):
return skipped, candidates
def _reanchor_entries_to_raw(
trades: List[Trade],
signal_ts_list: List[pd.Timestamp],
sim_df: pd.DataFrame,
) -> None:
"""Entry = the first RAW bar strictly after the signal bar.
The replay picks pos+1 in the CLEANED frame. When cleaning dropped a
>10σ spike bar right after the signal bar, that entry skipped the spike
entirely — a fill (and an adverse bar) the live trade would have taken.
SL/TP distances are preserved relative to the corrected entry price,
matching the next-open adjustment in the replay loop.
"""
sim_index = sim_df.index
sim_open = sim_df["Open"].to_numpy(dtype=np.float64)
moved = 0
for t, sig_ts in zip(trades, signal_ts_list):
raw_pos = int(sim_index.searchsorted(sig_ts, side="right"))
if raw_pos >= len(sim_index):
continue # no raw bar after signal — resolution reports NO_DATA
new_time = sim_index[raw_pos]
if new_time == t.entry_time:
continue
new_price = float(sim_open[raw_pos])
if np.isnan(new_price):
continue
sl_dist = abs(t.entry_price - t.sl)
tp1_dist = abs(t.tp1 - t.entry_price)
tp2_dist = abs(t.tp2 - t.entry_price)
if t.direction == "BUY":
t.sl = new_price - sl_dist
t.tp1 = new_price + tp1_dist
t.tp2 = new_price + tp2_dist
else:
t.sl = new_price + sl_dist
t.tp1 = new_price - tp1_dist
t.tp2 = new_price - tp2_dist
t.entry_time = new_time
t.entry_price = new_price
moved += 1
if moved:
logger.info(
f"Re-anchored {moved} entries to the raw price path "
f"(cleaned frame was missing their next bar — usually a >10σ spike)"
)
def run_backtest(
bt_cfg: BacktestConfig,
platform_cfg: Optional[PlatformConfig] = None,
@@ -1679,9 +1695,17 @@ def run_backtest(
dfs_full: Dict[Timeframe, pd.DataFrame] = {}
for tf in timeframes:
# Slow TFs need a longer pad: 60 days of D1 is only ~44 bars — no
# EMA200, truncated swing/divergence windows — while live always has
# ≥210 bars (outputsize floor). Target data_provider's min_required
# (210 bars); ×7/5 converts trading days to calendar days. Intraday
# TFs stay on the base pad, so their books are unchanged.
bars_per_day = ForexDataProvider.BARS_PER_DAY[tf]
min_pad_days = int(np.ceil(210 / bars_per_day * 7 / 5)) + 5
tf_start = bt_cfg.start - timedelta(days=max(pad_days, min_pad_days))
df = fetch_history(
provider, bt_cfg.pair, tf,
start=fetch_start, end=bt_cfg.end,
start=tf_start, end=bt_cfg.end,
use_cache=bt_cfg.use_cache,
)
if df.empty:
@@ -1763,11 +1787,13 @@ def run_backtest(
# inside the cooldown window of the previously ACCEPTED entry is
# dropped, exactly as the sequential loop skipped those bars.
trades: List[Trade] = []
signal_ts_list: List[pd.Timestamp] = []
last_entry_pos = -10_000
for pos, trade in candidates:
if pos - last_entry_pos < bt_cfg.cooldown_bars:
continue
trades.append(trade)
signal_ts_list.append(primary_df.index[pos])
last_entry_pos = pos
logger.info(
@@ -1787,6 +1813,7 @@ def run_backtest(
start=fetch_start, end=bt_cfg.end,
use_cache=bt_cfg.use_cache, clean=False,
)
_reanchor_entries_to_raw(trades, signal_ts_list, sim_df)
t_sim = time.perf_counter()
simulate_trades(trades, sim_df, max_bars=bt_cfg.max_hold_bars,
spread=bt_cfg.spread,
@@ -2110,7 +2137,11 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
p.add_argument("--start", help="ISO date YYYY-MM-DD (overrides --days)")
p.add_argument("--end", help="ISO date YYYY-MM-DD (defaults to today)")
p.add_argument("--primary-tf", default="15min", help="Primary TF (1min/5min/15min/1h/4h)")
p.add_argument("--confirm-tfs", default="1h,4h", help="Comma-separated confirmation TFs")
p.add_argument("--confirm-tfs", default=None,
help="Comma-separated confirmation TFs. Default: the pair "
"factory's live map (M5→M15,1h | M15→5min,1h | "
"1h→15min,4h | 4h→1h,1day) so backtests replay the "
"exact live config.")
p.add_argument("--warmup-bars", type=int, default=250)
p.add_argument("--cooldown-bars", type=int, default=5)
p.add_argument("--max-hold-bars", type=int, default=300)
@@ -2160,7 +2191,32 @@ def main(argv: Optional[List[str]] = None) -> int:
pair = _resolve_pair(args.pair)
primary_tf = _resolve_tf(args.primary_tf)
confirm_tfs = [_resolve_tf(s.strip()) for s in args.confirm_tfs.split(",") if s.strip()]
# None = defer to the pair factory's live confirmation map. A hardcoded
# default here would silently backtest a different MTF setup than the
# bot scans (e.g. M15 with 1h+4h instead of the live 5min+1h).
confirm_tfs = (
[_resolve_tf(s.strip()) for s in args.confirm_tfs.split(",") if s.strip()]
if args.confirm_tfs is not None else None
)
# Per-pair tuned configs come from the pair_signals factories (regime
# weights, strategy gates, sessions). Unregistered pairs fall back to
# the inline construction.
from pair_signals import PAIR_PRODUCERS # noqa: E402 (after sys.path fix)
factory = PAIR_PRODUCERS.get(pair)
if factory is not None:
platform_cfg = factory(primary_tf, confirm_tfs)
else:
platform_cfg = PlatformConfig(
symbol=pair,
display_name=PAIR_INFO[pair]["display"],
pip_value=PAIR_INFO[pair]["pip_value"],
primary_timeframe=primary_tf,
confirmation_timeframes=confirm_tfs or [Timeframe.H1, Timeframe.H4],
)
if args.swing_sr_lookback > 0:
platform_cfg.technical.swing_sr_lookback = args.swing_sr_lookback
confirm_tfs = list(platform_cfg.confirmation_timeframes)
end = (
datetime.fromisoformat(args.end).replace(tzinfo=timezone.utc)
@@ -2193,27 +2249,9 @@ def main(argv: Optional[List[str]] = None) -> int:
logger.info(
f"Backend: {'CuPy GPU' if _GPU_AVAILABLE else 'NumPy CPU fallback'} | "
f"Pair: {PAIR_INFO[pair]['display']} | Window: {start.date()} → {end.date()} | "
f"Spread: {spread}"
f"Confirm TFs: {','.join(tf.value for tf in confirm_tfs)} | Spread: {spread}"
)
# Per-pair tuned configs come from the pair_signals factories (regime
# weights, strategy gates, sessions). Unregistered pairs fall back to
# the inline construction.
from pair_signals import PAIR_PRODUCERS # noqa: E402 (after sys.path fix)
factory = PAIR_PRODUCERS.get(pair)
if factory is not None:
platform_cfg = factory(primary_tf, confirm_tfs)
else:
platform_cfg = PlatformConfig(
symbol=pair,
display_name=PAIR_INFO[pair]["display"],
pip_value=PAIR_INFO[pair]["pip_value"],
primary_timeframe=primary_tf,
confirmation_timeframes=confirm_tfs,
)
if args.swing_sr_lookback > 0:
platform_cfg.technical.swing_sr_lookback = args.swing_sr_lookback
trades, _ = run_backtest(bt_cfg, platform_cfg=platform_cfg)
metrics = compute_metrics(trades)
print_report(pair, bt_cfg, metrics, trades=trades, detail=args.detail)