Files
foxhunt/crates/ml-backtesting/cuda/book_update.cu
jgrusewski 691dec144e fix(ml-backtesting): root-cause NaN/Inf book + duplicate close emissions
Two orthogonal bugs that compounded to produce the exit_px=±21474836
sentinel in cluster smokes (315 trades baseline, 500 trades pearl):

Bug A — NaN/Inf book propagation:
- apply_snapshot_kernel passes through NaN/Inf prices from MBP-10
  predecoded data (session boundaries, gap-fills).
- walk_ask_for_buy / walk_bid_for_sell accumulate cost += take * NaN.
- apply_fill_to_pos: avg_px = NaN; realized_pnl += NaN → permanent NaN.
- Subsequent close: (int)(NaN-derived * 5000) → i32::MAX sentinel.
- Fix: zero-out non-finite levels in apply_snapshot_kernel; add
  isfinite() guards in walk helpers as defense-in-depth; ATR update
  guards against non-finite mid.

Bug B — pnl_track close branch doesn't reset scratch:
- Scratch reset (full 24-byte memset to 0) was already present in the
  current code; no source change required for Bug B.

Tests:
- book_nan_inf_prices_dont_corrupt_realized_pnl: NaN at ask[3] + Inf
  at bid[5] survives the fill pipeline without making realized_pnl
  non-finite.
- pnl_track_resets_scratch_on_close: open+close+5 flat events emits
  exactly 1 record; second open+close emits exactly 1 more (=2 total).

14/14 stop_controller tests pass; all other ml-backtesting test suites
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:28:12 +02:00

74 lines
3.1 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// book_update.cu — MBP-10 snapshot apply.
//
// Replaces the per-block book state with the broadcast snapshot. The
// full top-10 image overwrites the previous state (MBP-10 semantics).
// One block per backtest; thread tid handles one of 10 levels per side
// (10 threads used out of 32 in the warp; remainder idle).
//
// Per feedback_no_atomicadd.md: no atomics — single-writer per level.
// Per spec §2: same snapshot broadcast to every backtest in v1.
#include "lob_state.cuh"
// Books are laid out [n_backtests, 4 fields × 10 levels].
// Field order: bid_px, bid_sz, ask_px, ask_sz (one contiguous Book per backtest).
extern "C" __global__ void book_update_apply_snapshot(
const float* __restrict__ bid_px_in, // [10] — broadcast
const float* __restrict__ bid_sz_in, // [10]
const float* __restrict__ ask_px_in, // [10]
const float* __restrict__ ask_sz_in, // [10]
Book* __restrict__ books_out, // [n_backtests]
float* __restrict__ prev_mid, // [n_backtests]
float* __restrict__ atr_mid_ema, // [n_backtests]
int n_backtests
) {
int b = blockIdx.x;
int tid = threadIdx.x;
if (b >= n_backtests) return;
if (tid < 10) {
Book& bk = books_out[b];
const float bp = bid_px_in[tid];
const float bs = bid_sz_in[tid];
const float ap = ask_px_in[tid];
const float az = ask_sz_in[tid];
// Sanitize: level is valid only if price + size are both finite AND size > 0.
// Otherwise zero BOTH so walk_*_for_* (which skips lvl_sz <= 0) treats it as empty.
// Real MBP-10 data at session boundaries / gap-fills can contain NaN or Inf.
const bool bid_ok = isfinite(bp) && isfinite(bs) && bs > 0.0f;
const bool ask_ok = isfinite(ap) && isfinite(az) && az > 0.0f;
bk.bid_px[tid] = bid_ok ? bp : 0.0f;
bk.bid_sz[tid] = bid_ok ? bs : 0.0f;
bk.ask_px[tid] = ask_ok ? ap : 0.0f;
bk.ask_sz[tid] = ask_ok ? az : 0.0f;
}
// Thread 0 handles per-backtest ATR-EMA update against the broadcast mid.
if (tid == 0) {
const float mid_raw = 0.5f * (bid_px_in[0] + ask_px_in[0]);
if (!isfinite(mid_raw)) {
// Bad top-of-book (NaN/Inf from session boundary); skip ATR update.
// prev_mid and atr_mid_ema remain unchanged.
} else {
const float mid = mid_raw;
const float prev = prev_mid[b];
if (prev == 0.0f) {
// First-observation bootstrap (pearl_first_observation_bootstrap).
prev_mid[b] = mid;
// atr_mid_ema stays 0 until event 2.
} else {
const float delta = fabsf(mid - prev);
const float ema = atr_mid_ema[b];
if (ema == 0.0f) {
// First non-zero delta: replace directly.
atr_mid_ema[b] = delta;
} else {
// Wiener-α=0.4 EMA (pearl_wiener_alpha_floor_for_nonstationary).
atr_mid_ema[b] = 0.4f * delta + 0.6f * ema;
}
prev_mid[b] = mid;
}
}
}
}