diff --git a/crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs b/crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs index 14894ed8d..7efe35548 100644 --- a/crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs +++ b/crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs @@ -96,6 +96,44 @@ const DQN_BACKTEST_CHUNK_SIZE: usize = 512; /// `KELLY_STATS_SIZE` in `backtest_env_kernel.cu`. const BACKTEST_KELLY_STATS_SIZE: usize = 4; +/// SP21 T2.2 Phase 1 (2026-05-10) — per-trade tape capacity per window. +/// 200_000 trades is a conservative upper bound: typical eval windows are +/// 200k bars (xmd6b: window_bars=214654) with at most ~1 close per bar +/// (observed trade_count=139251 at trades_per_bar=0.65). Setting capacity +/// equal to the bar count guarantees no trade is dropped under any +/// trading frequency. Memory cost per window: 4 × 4 bytes × 200k = 3.2MB +/// (5 × 4 with the per-window count u32). For typical n_windows=5 +/// walk-forward setups: 16MB total — acceptable for eval (fold-boundary +/// alloc, not per-step). The kernel guards against overflow via +/// `if (slot < max_trades_per_window)` so larger-than-expected windows +/// gracefully truncate the tape instead of crashing. +pub const MAX_TRADES_PER_WINDOW: usize = 200_000; + +/// SP21 T2.2 Phase 1 (2026-05-10) — host-side per-trade record decoded +/// from the GPU per-trade tape buffers. The kernel writes the SoA tape; +/// `read_per_trade_tape()` decodes packed `dir_mag` and zips the slices +/// into `Vec` for the enrichment phase. Field order matches +/// what the enrichment functions in `trainer/enrichment.rs` consume. +#[derive(Debug, Clone, Copy)] +pub struct EvalTrade { + /// Global bar index (0-based step within the window) when the trade + /// CLOSED. Used by E6 (winner indices) for priority-replay tagging. + pub bar_index: u32, + /// Realized per-trade return = `prev_position × (close − entry_price) + /// / pre_trade_equity`. Dimensionless (fraction of equity). Used by + /// E1 (Q-correction), E5 (agreement-Sharpe), E6/E7/E8. + pub pnl: f32, + /// Trade duration in bars (capture of `hold_time` BEFORE close). Used + /// by E3 (gamma from holding-bars) and E4 (urgency-error rate). + pub holding_bars: u32, + /// Direction at close: 0=Short, 1=Hold, 2=Long, 3=Flat. Decoded from + /// the kernel-packed `dir_mag` (hi-16 bits). + pub direction: u8, + /// Magnitude at close: 0=Quarter, 1=Half, 2=Full. Decoded from + /// `dir_mag` (lo-16 bits). + pub magnitude: u8, +} + // ── DQN backtest forward config ─────────────────────────────────────────────── /// Configuration for cuBLAS-based DQN forward pass in backtest evaluation. @@ -406,6 +444,29 @@ pub struct GpuBacktestEvaluator { done_buf: CudaSlice, // [n_windows] actions_buf: CudaSlice, // [n_windows] actions_history_buf: CudaSlice, // [n_windows * max_len] + + /// SP21 T2.2 Phase 1 (2026-05-10) — per-trade tape device buffers. + /// Written by `backtest_env_step` / `backtest_env_step_batch` at + /// trade-close events (single thread per window writes its own slot + /// — race-free counter increment without atomicAdd per + /// `feedback_no_atomicadd`). Read host-side via + /// `read_per_trade_tape()` after each eval window completes; the + /// resulting `Vec` is the authoritative per-trade signal + /// for the enrichment phase (E1-E8) — replaces the prior + /// fake-trade synthesizer (`extract_eval_trades_from_metrics`) + /// per `feedback_no_stubs`. Reset to zero at `reset_per_trade_tape()` + /// at the start of each eval window. + /// + /// Layout: `[n_windows × MAX_TRADES_PER_WINDOW]` for the SoA buffers, + /// `[n_windows]` for the count. SoA chosen over AoS for GPU coalescing + /// (each kernel thread writes one slot per buffer at the same index). + /// `dir_mag` packs direction (hi-16) and magnitude (lo-16) so the + /// per-trade emission stays at 4 buffers + count. + per_trade_pnl_buf: CudaSlice, // [n_windows * MAX_TRADES_PER_WINDOW] + per_trade_holding_bars_buf: CudaSlice, // [n_windows * MAX_TRADES_PER_WINDOW] + per_trade_bar_index_buf: CudaSlice, // [n_windows * MAX_TRADES_PER_WINDOW] + per_trade_dir_mag_buf: CudaSlice, // [n_windows * MAX_TRADES_PER_WINDOW] + per_trade_count_buf: CudaSlice, // [n_windows] /// Intent magnitude history — pure-policy magnitude argmax from each /// eval step, recorded BEFORE the Hold/Flat dir_idx forcing in the /// action-select kernel and BEFORE Kelly/margin caps modify exposure @@ -907,6 +968,28 @@ impl GpuBacktestEvaluator { .alloc_zeros::(n_windows * BACKTEST_KELLY_STATS_SIZE) .map_err(|e| MLError::ModelError(format!("kelly_stats alloc: {e}")))?; + // SP21 T2.2 Phase 1 (2026-05-10) — per-trade tape device buffers. + // Sized at MAX_TRADES_PER_WINDOW per window (3.2MB per window for + // the 4 SoA buffers + count). Zero-init: per_trade_count[w]=0 so + // the first close in each window writes at slot 0. `reset_per_trade_tape` + // re-zeros at the start of each eval window so trade indices don't + // accumulate across folds. + let per_trade_pnl_buf = stream + .alloc_zeros::(n_windows * MAX_TRADES_PER_WINDOW) + .map_err(|e| MLError::ModelError(format!("per_trade_pnl alloc: {e}")))?; + let per_trade_holding_bars_buf = stream + .alloc_zeros::(n_windows * MAX_TRADES_PER_WINDOW) + .map_err(|e| MLError::ModelError(format!("per_trade_holding_bars alloc: {e}")))?; + let per_trade_bar_index_buf = stream + .alloc_zeros::(n_windows * MAX_TRADES_PER_WINDOW) + .map_err(|e| MLError::ModelError(format!("per_trade_bar_index alloc: {e}")))?; + let per_trade_dir_mag_buf = stream + .alloc_zeros::(n_windows * MAX_TRADES_PER_WINDOW) + .map_err(|e| MLError::ModelError(format!("per_trade_dir_mag alloc: {e}")))?; + let per_trade_count_buf = stream + .alloc_zeros::(n_windows) + .map_err(|e| MLError::ModelError(format!("per_trade_count alloc: {e}")))?; + // plan_isv_buf: [N, SL_PORTFOLIO_PLAN_DIM=7] — live plan_isv per window. // Populated each step by backtest_plan_state_isv (val plan_isv parity, // task #94) and consumed by the next step's backtest_state_gather. @@ -1123,6 +1206,13 @@ impl GpuBacktestEvaluator { done_buf, actions_buf, actions_history_buf, + // SP21 T2.2 Phase 1 (2026-05-10) — per-trade tape buffers + // (see field-decl docs for layout / lifecycle). + per_trade_pnl_buf, + per_trade_holding_bars_buf, + per_trade_bar_index_buf, + per_trade_dir_mag_buf, + per_trade_count_buf, intent_mag_buf: None, picked_action_history_buf: None, states_buf, @@ -1881,9 +1971,95 @@ impl GpuBacktestEvaluator { self.stream.memset_zeros(&mut self.kelly_stats_buf) .map_err(|e| MLError::ModelError(format!("reset kelly_stats: {e}")))?; + // SP21 T2.2 Phase 1 (2026-05-10) — reset per-trade tape counters + // so the first close in each window writes at slot 0. The SoA + // value buffers (pnl, holding_bars, bar_index, dir_mag) don't + // need zeroing — they're only read up to `count[w]` host-side, + // and stale values past `count[w]` are never visited by the + // kernel writer (which guards on `slot < max_trades_per_window`) + // or the host reader (which slices `[0..count[w]]`). + self.stream.memset_zeros(&mut self.per_trade_count_buf) + .map_err(|e| MLError::ModelError(format!("reset per_trade_count: {e}")))?; + Ok(()) } + /// SP21 T2.2 Phase 1 (2026-05-10) — read the per-trade tape from the + /// device into a host-side `Vec`. Called after the eval + /// window completes (post `launch_metrics_and_record_event` + + /// `consume_metrics_after_event`); flattens the per-window SoA + /// buffers into one chronological-by-window concatenated `Vec`. + /// + /// The kernel emits trades in close-event order WITHIN each window + /// (single-threaded per-window writes preserve order). The flatten + /// concatenates by window — call sites that need per-window tapes + /// can reconstruct via `count[]` if needed. + /// + /// Synchronizes the eval stream before reading — assumes caller has + /// already issued the eval kernels and just needs to wait for them + /// to complete. Returns total trade count + the flat `Vec`. + pub fn read_per_trade_tape(&self) -> Result, MLError> { + // 1. Read per-window counts (cheap — n_windows u32 values). + let mut counts = vec![0u32; self.n_windows]; + self.stream + .memcpy_dtoh(&self.per_trade_count_buf, &mut counts) + .map_err(|e| MLError::ModelError(format!("read per_trade_count: {e}")))?; + + // 2. Compute total + early-return if no trades. Saves a 16MB+ + // DtoH copy when the policy was inactive (rare but possible at + // cold-start before warmup). + let total: usize = counts.iter().map(|&c| c as usize).sum(); + if total == 0 { + return Ok(Vec::new()); + } + + // 3. Read the four SoA buffers in full (n_windows * MAX_TRADES_PER_WINDOW + // each). Slicing window-side would need an extra index-list copy + // per window; reading whole buffers is simpler and stays cheap + // (16MB at PCIe ≈ 1ms — well below per-epoch overhead). The host + // loop below filters down to `count[w]` per window. + let buf_len = self.n_windows * MAX_TRADES_PER_WINDOW; + let mut pnl_host = vec![0.0_f32; buf_len]; + let mut holding_host = vec![0u32; buf_len]; + let mut bar_idx_host = vec![0u32; buf_len]; + let mut dir_mag_host = vec![0u32; buf_len]; + + self.stream.memcpy_dtoh(&self.per_trade_pnl_buf, &mut pnl_host) + .map_err(|e| MLError::ModelError(format!("read per_trade_pnl: {e}")))?; + self.stream.memcpy_dtoh(&self.per_trade_holding_bars_buf, &mut holding_host) + .map_err(|e| MLError::ModelError(format!("read per_trade_holding_bars: {e}")))?; + self.stream.memcpy_dtoh(&self.per_trade_bar_index_buf, &mut bar_idx_host) + .map_err(|e| MLError::ModelError(format!("read per_trade_bar_index: {e}")))?; + self.stream.memcpy_dtoh(&self.per_trade_dir_mag_buf, &mut dir_mag_host) + .map_err(|e| MLError::ModelError(format!("read per_trade_dir_mag: {e}")))?; + + // 4. Flatten window-major: for each window, take the first + // count[w] trades and append. + let mut tape = Vec::with_capacity(total); + for w in 0..self.n_windows { + let count = counts[w] as usize; + // Defensive cap: if a future kernel change skips the + // overflow guard, count COULD exceed MAX_TRADES_PER_WINDOW. + // The min() avoids out-of-bounds slicing. + let count = count.min(MAX_TRADES_PER_WINDOW); + let base = w * MAX_TRADES_PER_WINDOW; + for i in 0..count { + let idx = base + i; + let dm = dir_mag_host[idx]; + let direction = ((dm >> 16) & 0xFFFF) as u8; + let magnitude = (dm & 0xFFFF) as u8; + tape.push(EvalTrade { + bar_index: bar_idx_host[idx], + pnl: pnl_host[idx], + holding_bars: holding_host[idx], + direction, + magnitude, + }); + } + } + Ok(tape) + } + /// Submit the cuBLAS-based DQN step loop to the stream (chunked). /// /// Processes `DQN_BACKTEST_CHUNK_SIZE` steps per cuBLAS forward call to @@ -2244,17 +2420,17 @@ impl GpuBacktestEvaluator { .arg(ch_conviction) // conviction_ptr: direction-branch Kelly warmup signal .arg(ch_magnitude_conviction) // magnitude_conviction_ptr: magnitude-branch Kelly warmup signal // SP21 T2.2 Phase 1 (2026-05-10): per-trade tape outputs. - // ABI extension at the kernel; production buffers wired - // in Phase 1 Step B (follow-up commit). NULL preserves - // bit-identical pre-Phase-1 behavior. Same NULL-tolerance - // pattern as the alpha_per_env / is_win_per_env additive - // contracts in `sp20_aggregate_inputs_kernel`. - .arg(&0u64) // per_trade_pnl_out (NULL) - .arg(&0u64) // per_trade_holding_bars_out (NULL) - .arg(&0u64) // per_trade_bar_index_out (NULL) - .arg(&0u64) // per_trade_dir_mag_out (NULL) - .arg(&0u64) // per_trade_count (NULL → emission no-op) - .arg(&0i32) // max_trades_per_window (unused when count=NULL) + // Real device buffers — same tape semantics as the + // single-step variant; the batched step loop writes + // per-step closes into the same per-window SoA slots + // (counter persists across the batched chunk via the + // shared `per_trade_count_buf`). + .arg(&self.per_trade_pnl_buf) + .arg(&self.per_trade_holding_bars_buf) + .arg(&self.per_trade_bar_index_buf) + .arg(&self.per_trade_dir_mag_buf) + .arg(&self.per_trade_count_buf) + .arg(&(MAX_TRADES_PER_WINDOW as i32)) .launch(LaunchConfig { grid_dim: (env_blocks.max(1), 1, 1), block_dim: (256, 1, 1), @@ -2836,19 +3012,18 @@ impl GpuBacktestEvaluator { .arg(&0u64) // conviction_ptr (NULL) — fallback 1.0 in-kernel .arg(&0u64) // magnitude_conviction_ptr (NULL) — fallback 0.0 in-kernel; max(dir, mag) → dir alone // SP21 T2.2 Phase 1 (2026-05-10): per-trade tape outputs. - // ABI extension at the kernel; production buffers wired in - // Phase 1 Step B (follow-up commit) — passing NULL preserves - // bit-identical behavior in this commit. Per - // `feedback_no_partial_refactor`: the kernel signature - // change + launcher migration land atomically here; the - // host-allocated buffers + readback + enrichment consumer - // wire-up follow incrementally as additive contracts. - .arg(&0u64) // per_trade_pnl_out (NULL) - .arg(&0u64) // per_trade_holding_bars_out (NULL) - .arg(&0u64) // per_trade_bar_index_out (NULL) - .arg(&0u64) // per_trade_dir_mag_out (NULL) - .arg(&0u64) // per_trade_count (NULL → emission no-op) - .arg(&0i32) // max_trades_per_window (unused when count=NULL) + // Real device buffers wired — kernel emits per-trade + // records at every trade close into the SoA buffers, with + // race-free per-window counter increment. Buffers are + // reset to zero at the start of each eval window via + // `reset_per_trade_tape()`. Read host-side via + // `read_per_trade_tape()` after the eval window completes. + .arg(&self.per_trade_pnl_buf) + .arg(&self.per_trade_holding_bars_buf) + .arg(&self.per_trade_bar_index_buf) + .arg(&self.per_trade_dir_mag_buf) + .arg(&self.per_trade_count_buf) + .arg(&(MAX_TRADES_PER_WINDOW as i32)) .launch(env_cfg) .map_err(|e| { MLError::ModelError(format!("backtest_env_step launch step {step}: {e}")) diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index 6776dcf60..b1bc44c7f 100644 --- a/docs/dqn-wire-up-audit.md +++ b/docs/dqn-wire-up-audit.md @@ -2,6 +2,103 @@ **Status:** Populated during Plan 1 Task 6 (A.5 orphan audit). Updated on every commit per Invariant 7. +## 2026-05-10 — SP21 T2.2 Phase 1 Step B: per-trade tape buffers + readback (atomic) + +**Branch:** `sp20-aux-h-fixed` (off `5d01190e1`). +**Commits:** 1 atomic (this commit). **Step B replaces Step A's NULL +launcher pass with real device buffers, adds host-side readback +infrastructure, and resets the counter at fold start.** + +Drops the NULL launcher arguments introduced in Step A. Both kernel +launch sites in `gpu_backtest_evaluator.rs` (`backtest_env_step` at +~:2786 and `backtest_env_step_batch` at ~:2208) now pass real device +pointers. The kernel's per-trade emission block fires unconditionally +on close events — single-threaded per-window writes preserve event +ordering and enable race-free counter increment without atomicAdd. + +**New constants + types:** + +- `MAX_TRADES_PER_WINDOW = 200_000` — per-window trade-tape capacity. + Set to typical eval window bar count (xmd6b: 214k bars, 139k trades) + so no trade is dropped under any trading frequency. Memory cost + per window: 4 SoA buffers × 4 bytes × 200k = 3.2MB, plus a + count[n_windows] u32. For typical 5-window walk-forward setups: + ~16MB total (acceptable for eval — fold-boundary alloc, not per-step). +- `pub struct EvalTrade` (`crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs`) + with 5 fields: `bar_index: u32`, `pnl: f32`, `holding_bars: u32`, + `direction: u8`, `magnitude: u8`. **Does NOT include `predicted_q` + or `ensemble_var`** — those require entry-time captures + (`entry_q`, `entry_var` tracked across the trade lifecycle in + portfolio state). Phase 1.5 follow-up will add them. + +**New struct fields on `GpuBacktestEvaluator`:** + +- `per_trade_pnl_buf: CudaSlice` — `[n_windows × MAX_TRADES_PER_WINDOW]` +- `per_trade_holding_bars_buf: CudaSlice` — same shape +- `per_trade_bar_index_buf: CudaSlice` — same shape +- `per_trade_dir_mag_buf: CudaSlice` — same shape (packed) +- `per_trade_count_buf: CudaSlice` — `[n_windows]` + +**New methods:** + +- `reset_per_trade_tape()` (folded into `reset_evaluation_state`): + zeros `per_trade_count_buf` at the start of each eval window. The + SoA value buffers don't need zeroing — read up to `count[w]` only, + stale slots past `count[w]` never visited. +- `pub fn read_per_trade_tape(&self) -> Result, MLError>`: + reads `per_trade_count_buf` (cheap, n_windows u32), early-returns + `Vec::new()` if total = 0, else reads the full 4 SoA buffers + (~16MB DtoH at PCIe ≈ 1ms — well below per-epoch overhead) and + flattens window-major into a chronological `Vec` for + the enrichment phase consumer (Phase 2 follow-up). + +**Phase 2 follow-up (next commit):** + +- Wire `read_per_trade_tape()` to the enrichment caller in + `training_loop.rs:1510-1568`, replacing + `extract_eval_trades_from_metrics` (the fake-trade synthesizer). +- Update `enrichment::EvalTrade` to match the GPU evaluator's + 5-field struct, removing the `predicted_q` and `ensemble_var` + fields that have no producer yet. +- E1 (q_correction) and E5 (agreement_threshold) — refactor to + consume signals other than per-trade `predicted_q`/`ensemble_var` + OR DELETE per `feedback_v7_gem_methodology` (measure → wire or + delete; outputs already unused in production code path). + +**Phase 1.5 follow-up (if Phase 2 keeps E1/E5):** + +- Add `entry_q` and `entry_var` to `portfolio_state`, captured at + trade open. Extend per-trade tape with corresponding 6th and 7th + SoA buffers. Update `EvalTrade` struct + `read_per_trade_tape`. + +**Affected files:** + +- `crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs` — new + `MAX_TRADES_PER_WINDOW` const, `EvalTrade` struct, 5 buffer + fields, alloc in constructor, struct-construction line, replace + NULL with real pointers in both launchers, add reset to + `reset_evaluation_state`, new `read_per_trade_tape` method. + +**Verification:** + +- `cargo check -p ml --tests` — passes (warnings only). +- GPU oracle tests: behavior preserved by construction. The kernel's + per-trade emission block runs unconditionally on real buffers, but + the existing aggregator/EMA tests don't check per-trade buffers + (they check WindowMetrics aggregates, which are computed in a + separate kernel from `record_kelly_trade_outcome`'s win/loss + accumulators — still correct). + +**Open question for Phase 2 design**: with `predicted_q` and +`ensemble_var` deferred to Phase 1.5, E1 and E5 consume signals we +don't yet emit. Three options for Phase 2: (a) delete E1+E5 +(unused per `feedback_v7_gem_methodology` 3-step audit — E1 output +discarded, E5 output consumed but no useful computation possible); +(b) refactor E1+E5 to use alternate signals (avg_predicted_q from +WindowMetrics for E1, ensemble_disagreement EMA from ISV for E5); +(c) accept temporary no-op behavior in Phase 2 with explicit +Phase 1.5 follow-up scheduled. + ## 2026-05-10 — SP21 T2.2 Phase 1 Step A: per-trade tape kernel ABI extension (atomic) **Branch:** `sp20-aux-h-fixed` (off `4ab1c132e`).