Files
foxhunt/crates/ml-backtesting/src/sim/mod.rs
jgrusewski 0c8b4b5608 refactor(per-horizon): N_HORIZONS 5→3 — ml-backtesting full propagation
Source migration:
- crates/ml-backtesting/src/lob/mod.rs:5 + policy/mod.rs:18: local
  const N_HORIZONS 5→3 via re-export from ml_alpha::heads::N_HORIZONS
  (single source of truth across crates)
- crates/ml-backtesting/src/sim/mod.rs:1751,1773,1784: [IsvKellyStateHost; 5]
  array literals → ; N_HORIZONS]
- crates/ml-backtesting/cuda/lob_state.cuh:9: #define N_HORIZONS 5→3
  (this triggers cubin rebuild of decision_policy + 4 other ml-backtesting
  kernels that #include this header)

Test migration (8 test files + 2 JSON fixtures):
- threshold_and_cost.rs, decision_floor_coldstart.rs, parallel_sim_
  correctness.rs, stop_controller.rs (37+10+1 broadcast_alpha calls),
  lob_sim_integrated_fuzz.rs, lob_sim_fixtures.rs: hardcoded [f32; 5]
  alpha-probs and [IsvKellyStateHost; 5] arrays → N_HORIZONS-sized via
  std::array::from_fn or [v; N_HORIZONS] literals
- trainer_parity.rs:34 + ring3_replay.rs:47: horizons literal
  [30,100,300,1000,6000] → ml_alpha::heads::HORIZONS
- fixtures/decision_alpha_buy_close.json + decision_program_h4_only.json:
  5-element warm_start_isv_kelly / alpha_probs / expected_isv_kelly_after
  trimmed to 3 elements; active horizon relocated to N_HORIZONS-1

Library lib-test rewrite (per pearl_tests_must_prove_not_lock_observations):
- crates/ml-backtesting/src/policy/mod.rs:197-233: lib tests
  default_strategy_has_5_horizon_leaves + ..._flattens_to_5_emits...
  renamed to N_HORIZONS-parametric form (observed-value 5 and 7
  were bug-locks).

cargo check -p ml-backtesting --all-targets: clean.
cargo test -p ml-backtesting --lib: 33 passed.
cargo test -p ml-backtesting --tests (non-CUDA, non-fixture-data): 2 passed,
53 ignored (CUDA-gated or FOXHUNT_TEST_DATA-gated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:37:23 +02:00

2041 lines
94 KiB
Rust
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.
//! Block-per-backtest LOB simulator.
//!
//! One CUDA block = one parallel backtest. The simulator owns per-backtest
//! book state (and, in later commits, orders / pos / ISV-Kelly state +
//! mapped-pinned audit & trade-log rings).
//!
//! See docs/superpowers/specs/2026-05-18-real-lob-integration-design.md §2 + §5b.
// cudarc kernel launches are inherently `unsafe`: the driver API has no way
// to type-check kernel args against the .cubin's signature. Per
// pearl_no_host_branches_in_captured_graph + the surrounding GPU-contract
// pearls, all launches are pre-compiled and the args are matched by hand in
// review. The workspace-wide `-W unsafe-code` lint is therefore noise here.
#![allow(unsafe_code)]
mod batched_config;
pub use batched_config::{BatchedSimConfig, ResolvedSimVariant, UniformSimParams};
use anyhow::{Context, Result};
use cudarc::driver::{CudaSlice, CudaStream, DevicePtr, LaunchConfig, PushKernelArg};
use ml_alpha::pinned_mem::MappedF32Buffer;
use ml_core::device::MlDevice;
use std::sync::Arc;
use crate::lob::{BookFlat, PosFlat, BOOK_LEVELS};
const BOOK_FIELDS: usize = 4; // bid_px, bid_sz, ask_px, ask_sz
/// Precompiled cubins embedded at compile time.
const BOOK_UPDATE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/book_update.cubin"));
const ORDER_MATCH_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/order_match.cubin"));
const PNL_TRACK_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/pnl_track.cubin"));
const DECISION_POLICY_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/decision_policy.cubin"));
const RESTING_ORDERS_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/resting_orders.cubin"));
const N_HORIZONS: usize = ml_alpha::heads::N_HORIZONS;
const ISV_KELLY_STATE_BYTES: usize = 24; // matches lob_state.cuh::IsvKellyState
/// Per-backtest NaN instrumentation counters (S1.20).
///
/// Incremented by the CUDA kernels at specific NaN-producing arithmetic sites.
/// Read via `LobSimCuda::read_nan_counters` at end-of-smoke to identify which
/// code path produces the residual 97 zero + 85 i32::MAX sentinel trade records.
pub struct NanCounters {
/// avg_px = total_cost/filled_lots was NaN/Inf in apply_fill_to_pos.
pub nan_avg_px: Vec<u32>,
/// realised = (avg_px - vwap_entry) * dir * unwind was NaN/Inf.
pub nan_realised: Vec<u32>,
/// pos.realized_pnl became non-finite after += or -= in apply_fill_to_pos.
pub nan_realized_pnl: Vec<u32>,
/// pnl_track open branch saw vwap_entry == 0.
pub zero_vwap_at_open: Vec<u32>,
/// pnl_track open branch saw |vwap_entry| > 21M or NaN/Inf.
pub saturated_vwap_at_open: Vec<u32>,
/// pnl_track close branch defensive clamp fired.
pub defensive_exit_clamp: Vec<u32>,
}
/// Per-backtest per-vwap-write-site NaN instrumentation counters (S1.21).
///
/// Finer-grained than NanCounters: each counter corresponds to exactly one
/// vwap_entry write site inside apply_fill_to_pos (or the session-gap path).
/// Read via `LobSimCuda::read_nan_counters_v2`.
///
/// Path ids stored in last_bad_path:
/// 1 = open-from-flat (line 131)
/// 2 = scale-in zero (line 136)
/// 3 = scale-in huge (line 136)
/// 4 = flip-beyond zero (line 150)
/// 5 = flip-beyond huge (line 150)
/// 6 = session-gap saw already-corrupt vwap_entry
pub struct NanCountersV2 {
/// apply_fill_to_pos open-from-flat branch wrote vwap_entry == 0.
pub vwap_zero_open_flat: Vec<u32>,
/// apply_fill_to_pos open-from-flat branch wrote |vwap_entry| > 21M or non-finite.
pub vwap_huge_open_flat: Vec<u32>,
/// apply_fill_to_pos scale-in branch wrote vwap_entry == 0.
pub vwap_zero_scale_in: Vec<u32>,
/// apply_fill_to_pos scale-in branch wrote |vwap_entry| > 21M or non-finite.
pub vwap_huge_scale_in: Vec<u32>,
/// apply_fill_to_pos flip-beyond branch wrote vwap_entry == 0.
pub vwap_zero_flip: Vec<u32>,
/// apply_fill_to_pos flip-beyond branch wrote |vwap_entry| > 21M or non-finite.
pub vwap_huge_flip: Vec<u32>,
/// session-gap force-close saw pos.vwap_entry already corrupt (zero / non-finite / > 21M).
pub vwap_session_gap_was_bad: Vec<u32>,
/// Last bad vwap_entry value observed (per backtest; 0.0 if none).
pub last_bad_vwap: Vec<f32>,
/// Path id of the last bad vwap_entry write (per backtest; 0 if none).
pub last_bad_path: Vec<u32>,
}
/// CRT.diag: end-of-run empirical measurement battery dump.
///
/// All vectors are flat per-backtest layouts; the harness aggregates
/// across backtests for the log summary. See `LobSimCuda::read_diagnostics`
/// for the read-back path (single batched DtoV at smoke termination).
pub struct CrtDiagnostics {
/// Per-horizon flip count. Layout: `[backtest * N_HORIZONS + h]`.
pub flip_count: Vec<u32>,
/// Per-horizon cumulative run length over completed runs (u64 to avoid
/// overflow at multi-million-event smoke). Layout: `[backtest * N_HORIZONS + h]`.
pub sum_run_length: Vec<u64>,
/// Per-horizon 5-bucket log-scale run-length histogram.
/// Layout: `[(backtest * N_HORIZONS + h) * 5 + bucket]`. Buckets:
/// 0=1-9, 1=10-99, 2=100-999, 3=1k-9.9k, 4=10k+.
pub run_length_hist: Vec<u32>,
/// 10-bucket histogram of smoothed conviction at decision time.
/// Layout: `[backtest * 10 + bucket]`. Bucket k = [k/10, (k+1)/10).
pub conv_hist: Vec<u32>,
/// 6-bucket log-scale hold-time histogram at trade close.
/// Layout: `[backtest * 6 + bucket]`. Buckets:
/// 0=<1s, 1=1-10s, 2=10-60s, 3=1-10m, 4=10-60m, 5=>1h.
pub hold_hist: Vec<u32>,
/// Trade count per entry-conviction bucket. Layout: `[backtest * 10 + bucket]`.
pub outcome_n: Vec<u32>,
/// Cumulative realized PnL per entry-conviction bucket (price-units × lots,
/// pre USD-fp conversion). Layout: `[backtest * 10 + bucket]`.
pub outcome_sum_pnl: Vec<f32>,
/// Win count per entry-conviction bucket (segment_realized > 0).
/// Layout: `[backtest * 10 + bucket]`.
pub outcome_n_wins: Vec<u32>,
/// CRT.diag.2 Group E: per-horizon SMOOTHED-direction flip count.
/// Layout: `[backtest * N_HORIZONS + h]`. Compare head-to-head with
/// `flip_count` (which counts RAW alpha_probs direction flips).
pub smoothed_flip_count: Vec<u32>,
/// CRT.diag.2 Group E: per-horizon cumulative SMOOTHED run length over
/// completed runs (u64 to avoid overflow at multi-million-event smoke).
/// Layout: `[backtest * N_HORIZONS + h]`.
pub smoothed_sum_run_length: Vec<u64>,
}
/// S2.1/S2.2: max_hold enforcement diagnostic counters.
///
/// After S2.2, max_hold enforcement lives in `resting_orders_step` (event-rate).
/// The decision-rate max_hold code in `stop_check_isv` was removed. Two counters
/// remain as ongoing generics:
/// - `mh_kernel_calls`: every `stop_check_isv` entry with position_lots != 0
/// (SL/trail are being evaluated). Should be non-zero whenever a position is
/// open and a decision fires.
/// - `mh_force_flat_seen_by_seed`: every `seed_inflight_limits_batched` entry
/// that reads market_targets[b*2+0] == 3 (force-flat from SL or trail).
pub struct NanCountersMaxHold {
/// Every stop_check_isv entry with position_lots != 0.
pub mh_kernel_calls: Vec<u32>,
/// seed_inflight_limits_batched read market_targets[b*2+0] == 3.
pub mh_force_flat_seen_by_seed: Vec<u32>,
}
pub struct LobSimCuda {
n_backtests: usize,
stream: Arc<CudaStream>,
book_update_fn: cudarc::driver::CudaFunction,
submit_market_fn: cudarc::driver::CudaFunction,
pnl_track_fn: cudarc::driver::CudaFunction,
decision_fn: cudarc::driver::CudaFunction,
decision_program_fn: cudarc::driver::CudaFunction,
isv_kelly_update_fn: cudarc::driver::CudaFunction,
/// CRT Phase A0.5 corrective: tiny kernel that writes one max_conv
/// f32 per decision into `convictions_d` (read end-of-run for the
/// percentile side-channel). Replaces the host-side max-of-5 loop the
/// harness used to run on `self.last_probs` after every decision.
record_max_conviction_fn: cudarc::driver::CudaFunction,
resting_orders_fn: cudarc::driver::CudaFunction,
seed_limit_fn: cudarc::driver::CudaFunction,
/// P2: GPU-side seed of in-flight Limit slots from market_targets[b].
/// Replaces the host roundtrip loop in dispatch_latent_market_orders.
seed_inflight_limits_fn: cudarc::driver::CudaFunction,
/// P3: GPU-side snapshot of pre-submit Pos state + close-transition detection.
/// Replace the host snapshot loop + host close-detect loop at the latency path.
snapshot_pos_state_fn: cudarc::driver::CudaFunction,
detect_close_fn: cudarc::driver::CudaFunction,
seed_stop_fn: cudarc::driver::CudaFunction,
books_d: CudaSlice<f32>,
pos_d: CudaSlice<u8>,
bid_px_d: CudaSlice<f32>,
bid_sz_d: CudaSlice<f32>,
ask_px_d: CudaSlice<f32>,
ask_sz_d: CudaSlice<f32>,
market_targets_d: CudaSlice<i32>,
// Trade-log + open-trade-state buffers (C6).
open_trade_state_d: CudaSlice<u8>, // [n_backtests * 24]
trade_log_d: CudaSlice<u8>, // [n_backtests * TRADE_LOG_CAP * 40]
trade_log_head_d: CudaSlice<u32>, // [n_backtests]
// Decision + ISV-Kelly buffers (C7).
isv_kelly_d: CudaSlice<u8>, // [n_backtests * 5 * 24]
alpha_probs_d: CudaSlice<f32>, // [N_HORIZONS] — broadcast
/// CRT Phase A0.5 corrective: on-device circular conviction history.
/// One f32 per decision written by `record_max_conviction_to_slot`.
/// Capacity sized at construction (`MAX_DECISIONS`). The host owns the
/// write index and increments it once per `record_max_conviction`
/// call. End-of-run, `read_convictions(n)` DtoVs the populated prefix
/// in a single sync — matching the lifecycle of the trade_log / pnl
/// curve reads that already happen after `run()`.
convictions_d: CudaSlice<f32>,
/// Capacity of `convictions_d` in elements. Decisions beyond this are
/// silently dropped by the recording kernel (bounds-check inside the
/// kernel); the host counter still reflects the real decision count.
convictions_capacity: usize,
open_horizon_mask_d: CudaSlice<u32>, // [n_backtests] — entry attribution
closed_horizon_mask_d: CudaSlice<u32>, // [n_backtests] — close-time snapshot
realised_return_d: CudaSlice<f32>, // [n_backtests] — per-block close-trade return
// Resting orders (C11 follow-up).
orders_d: CudaSlice<u8>, // [n_backtests * ORDERS_BYTES]
overflow_flag_d: CudaSlice<u32>, // [1] — set by seed kernels on slot-in-use error
// Audit ring for OrderEvent emission (deferred from C2; needed by C11).
audit_d: CudaSlice<u8>, // [n_backtests * RING_CAP_EVENTS * ORDER_EVENT_BYTES]
audit_head_d: CudaSlice<u32>, // [n_backtests]
// Bytecode program upload (C13 — per-backtest custom compositions).
program_table_d: CudaSlice<u8>, // [n_backtests * MAX_PROGRAM_INSTRUCTIONS * INSTRUCTION_BYTES]
program_lens_d: CudaSlice<u32>, // [n_backtests]
regimes_d: CudaSlice<u32>, // [n_backtests] — host-set current regime id
// P1: per-backtest sim parameter buffers. Uploaded by step_decision_with_latency
// from a host-side BatchedSimConfig. Held as Sim fields to avoid per-call alloc.
target_annual_vol_units_d: CudaSlice<f32>, // [n_backtests]
annualisation_factor_d: CudaSlice<f32>, // [n_backtests]
max_lots_d: CudaSlice<i32>, // [n_backtests] — stored as i32 to match kernel arg
latency_ns_d: CudaSlice<u32>, // [n_backtests] — used by P2 kernel
kelly_frac_floor_d: CudaSlice<f32>, // [n_backtests]
sharpe_weight_floor_d: CudaSlice<f32>, // [n_backtests]
// P3: pre-submit Pos snapshot buffers for GPU close-transition detection.
prev_pos_lots_d: CudaSlice<i32>, // [n_backtests]
prev_realized_pnl_d: CudaSlice<f32>, // [n_backtests]
prev_open_horizon_mask_d: CudaSlice<u32>, // [n_backtests]
// P4: threshold gate + per-fill cost integration.
threshold_d: CudaSlice<f32>, // [n_backtests]
cost_per_lot_per_side_d: CudaSlice<f32>, // [n_backtests]
total_fees_per_b_d: CudaSlice<f32>, // [n_backtests] — accumulator
// Stop-controller state slots (ISV-driven stop controller).
prev_mid_d: CudaSlice<f32>, // [n_backtests] — previous-event mid, sentinel 0 = first-obs bootstrap
atr_mid_ema_d: CudaSlice<f32>, // [n_backtests] — Wiener-α=0.4 EMA on |Δmid|; floor for §5 SL/trail
trail_hwm_d: CudaSlice<f32>, // [n_backtests] — per-position unrealized-P&L-per-lot high-water-mark
// Follow-up gp74n: max-hold force-close per-backtest config.
max_hold_ns_d: CudaSlice<u64>, // [n_backtests] — 0 = disabled
// Bug C-b (smoke v74v4 2026-05-20): corrupt-top-of-book skip counter.
snapshots_skipped_d: CudaSlice<u32>, // [n_backtests] — incremented each time apply_snapshot skips a corrupt snapshot
// Fix 3 (smoke stx9p 2026-05-20): session-gap force-close. Tracks the
// previous event ts per backtest so resting_orders_step can detect halts.
last_event_ts_d: CudaSlice<u64>, // [n_backtests] — 0 = sentinel (first event)
// S1.19: parameterized price-range sanitization. Uploaded once via
// upload_price_range() before the first apply_snapshot call. Defaults
// 0.0 / f32::INFINITY mean no-check (backward compatible).
min_reasonable_px_d: CudaSlice<f32>, // [n_backtests]
max_reasonable_px_d: CudaSlice<f32>, // [n_backtests]
// S1.20: NaN instrumentation slots. Per-backtest counters incremented
// at kernel-internal arithmetic sites that can produce NaN/Inf/sentinel
// values in trade records. Read at end-of-smoke to identify which path
// produces the 97 zero + 85 i32::MAX sentinels.
nan_avg_px_d: CudaSlice<u32>, // [n_backtests]
nan_realised_d: CudaSlice<u32>, // [n_backtests]
nan_realized_pnl_d: CudaSlice<u32>, // [n_backtests]
zero_vwap_at_open_d: CudaSlice<u32>, // [n_backtests]
saturated_vwap_at_open_d: CudaSlice<u32>, // [n_backtests]
defensive_exit_clamp_d: CudaSlice<u32>, // [n_backtests]
// S1.21: per-vwap-write-site counters + last-bad-vwap capture.
vwap_zero_open_flat_d: CudaSlice<u32>, // [n_backtests]
vwap_huge_open_flat_d: CudaSlice<u32>, // [n_backtests]
vwap_zero_scale_in_d: CudaSlice<u32>, // [n_backtests]
vwap_huge_scale_in_d: CudaSlice<u32>, // [n_backtests]
vwap_zero_flip_d: CudaSlice<u32>, // [n_backtests]
vwap_huge_flip_d: CudaSlice<u32>, // [n_backtests]
vwap_session_gap_was_bad_d: CudaSlice<u32>, // [n_backtests]
last_bad_vwap_d: CudaSlice<f32>, // [n_backtests]
last_bad_path_d: CudaSlice<u32>, // [n_backtests]
// S2.1/S2.2: max_hold diagnostic counters (2 remain after S2.2 prune).
mh_kernel_calls_d: CudaSlice<u32>, // [n_backtests]
mh_force_flat_seen_by_seed_d: CudaSlice<u32>, // [n_backtests]
// Anti-calibration fix (2026-05-22): Wiener-α adaptive EMA on the
// SIGNED multi-horizon conviction scalar. The prior magnitude-only
// EMA produced higher-conviction-→-more-negative-PnL anti-calibration
// because the sizing path multiplied `sign(instantaneous) × EMA(|x|)`
// — magnitude and direction came from different signals. Now both
// come from the same smoothed signed EMA: when per-horizon directions
// disagree event-to-event, EMA(signed_x) → 0 → |EMA| → 0 → size 0.
// All three slots start at zero (sentinel = "no observation yet") so
// the first decision-kernel call replaces them directly per
// pearl_first_observation_bootstrap. Updated in-place by both
// decision_policy_default and decision_policy_program.
conv_signed_ema_d: CudaSlice<f32>, // [n_backtests] — smoothed signed conviction ∈ [-1, +1]
conv_signed_diff_var_ema_d: CudaSlice<f32>, // [n_backtests] — 2nd-order EMA of (newprev)²
conv_signed_sample_var_ema_d: CudaSlice<f32>, // [n_backtests] — 2nd-order EMA of x² (x is signed; x² is non-negative)
// CRT.1 C1.3: no-trade band. seed_inflight_limits_batched skips seeding
// when |target_signed effective| < delta_floor[b]. Uploaded once per
// run via step_decision_with_latency from BatchedSimConfig (config-upload
// block). Read by the kernel on every event; zero device-to-host traffic
// on the per-event hot path.
pub(crate) delta_floor_d: CudaSlice<f32>, // [n_backtests] — no-trade band in lots
// CRT.diag: empirical measurement battery (observe-only, end-of-run dump).
// All counters allocated via alloc_zeros and incremented kernel-side; no
// host roundtrip per event. `read_diagnostics()` materialises them at
// smoke termination via a single batch of memcpy_dtov calls.
//
// Group A — per-horizon signal persistence:
pub(crate) diag_flip_count_d: CudaSlice<u32>, // [n_backtests * N_HORIZONS]
pub(crate) diag_current_run_length_d: CudaSlice<u32>, // [n_backtests * N_HORIZONS]
pub(crate) diag_sum_run_length_d: CudaSlice<u64>, // [n_backtests * N_HORIZONS]
pub(crate) diag_run_length_hist_d: CudaSlice<u32>, // [n_backtests * N_HORIZONS * 5]
pub(crate) diag_prev_dir_signed_d: CudaSlice<i8>, // [n_backtests * N_HORIZONS]
// Group B — conviction-EMA histogram:
pub(crate) diag_conv_hist_d: CudaSlice<u32>, // [n_backtests * 10]
// Group C — hold-time histogram at trade close:
pub(crate) diag_hold_hist_d: CudaSlice<u32>, // [n_backtests * 6]
// Group D — outcome by entry-conviction:
pub(crate) diag_outcome_n_d: CudaSlice<u32>, // [n_backtests * 10]
pub(crate) diag_outcome_sum_pnl_d: CudaSlice<f32>, // [n_backtests * 10]
pub(crate) diag_outcome_n_wins_d: CudaSlice<u32>, // [n_backtests * 10]
// CRT.diag.2: per-horizon Wiener-α EMA on raw alpha_probs. Smoothed
// alpha enters the multi-horizon §4.4 conviction formula instead of
// the raw broadcast values — hypothesis test for whether per-event
// output noise is hiding a slower signal. All zero-init: sentinel "no
// observation yet" so the first decision-kernel call bootstraps per
// pearl_first_observation_bootstrap. Floor on Wiener α = 0.1 (stronger
// smoothing than the 0.4 output-side floor; testing input vs output
// smoothing differ). Touched by both decision_policy_default and
// decision_policy_program every event; no host roundtrip.
pub(crate) alpha_ema_per_b_per_h_d: CudaSlice<f32>, // [n_backtests * N_HORIZONS]
pub(crate) alpha_diff_var_per_b_per_h_d: CudaSlice<f32>, // [n_backtests * N_HORIZONS]
pub(crate) alpha_sample_var_per_b_per_h_d: CudaSlice<f32>, // [n_backtests * N_HORIZONS]
// CRT.diag.2 Group E: per-horizon SMOOTHED direction-flip + mean-run-length.
// Mirrors Group A but reading alpha_smoothed[h] instead of alpha_probs[h].
// Skipping the 5-bucket histogram (Group A keeps it). Head-to-head
// comparison: raw flip rate vs smoothed flip rate, per horizon.
pub(crate) diag_smoothed_flip_count_d: CudaSlice<u32>, // [n_backtests * N_HORIZONS]
pub(crate) diag_smoothed_current_run_length_d: CudaSlice<u32>, // [n_backtests * N_HORIZONS]
pub(crate) diag_smoothed_sum_run_length_d: CudaSlice<u64>, // [n_backtests * N_HORIZONS]
pub(crate) diag_smoothed_prev_dir_d: CudaSlice<i8>, // [n_backtests * N_HORIZONS]
}
impl LobSimCuda {
pub fn new(n_backtests: usize, dev: &MlDevice) -> Result<Self> {
anyhow::ensure!(n_backtests > 0, "n_backtests must be > 0");
let stream = dev.cuda_stream().context("cuda_stream")?.clone();
let ctx = dev.cuda_context().context("cuda_context")?;
let book_module = ctx
.load_cubin(BOOK_UPDATE_CUBIN.to_vec())
.context("load book_update cubin")?;
let book_update_fn = book_module
.load_function("book_update_apply_snapshot")
.context("load book_update_apply_snapshot")?;
let match_module = ctx
.load_cubin(ORDER_MATCH_CUBIN.to_vec())
.context("load order_match cubin")?;
let submit_market_fn = match_module
.load_function("submit_market_immediate")
.context("load submit_market_immediate")?;
let pnl_track_module = ctx
.load_cubin(PNL_TRACK_CUBIN.to_vec())
.context("load pnl_track cubin")?;
let pnl_track_fn = pnl_track_module
.load_function("pnl_track_step")
.context("load pnl_track_step")?;
let snapshot_pos_state_fn = pnl_track_module
.load_function("snapshot_pos_state")
.context("load snapshot_pos_state")?;
let detect_close_fn = pnl_track_module
.load_function("detect_close_transitions_batched")
.context("load detect_close_transitions_batched")?;
let decision_module = ctx
.load_cubin(DECISION_POLICY_CUBIN.to_vec())
.context("load decision_policy cubin")?;
let decision_fn = decision_module
.load_function("decision_policy_default")
.context("load decision_policy_default")?;
let decision_program_fn = decision_module
.load_function("decision_policy_program")
.context("load decision_policy_program")?;
let isv_kelly_update_fn = decision_module
.load_function("isv_kelly_update_on_close")
.context("load isv_kelly_update_on_close")?;
let record_max_conviction_fn = decision_module
.load_function("record_max_conviction_to_slot")
.context("load record_max_conviction_to_slot")?;
let resting_module = ctx
.load_cubin(RESTING_ORDERS_CUBIN.to_vec())
.context("load resting_orders cubin")?;
let resting_orders_fn = resting_module
.load_function("resting_orders_step")
.context("load resting_orders_step")?;
let seed_limit_fn = resting_module
.load_function("seed_limit_slot")
.context("load seed_limit_slot")?;
let seed_inflight_limits_fn = resting_module
.load_function("seed_inflight_limits_batched")
.context("load seed_inflight_limits_batched")?;
let seed_stop_fn = resting_module
.load_function("seed_stop_slot")
.context("load seed_stop_slot")?;
let books_d = stream
.alloc_zeros::<f32>(n_backtests * BOOK_FIELDS * BOOK_LEVELS)
.context("alloc books_d")?;
let pos_d = stream
.alloc_zeros::<u8>(n_backtests * std::mem::size_of::<PosFlat>())
.context("alloc pos_d")?;
let bid_px_d = stream.alloc_zeros::<f32>(BOOK_LEVELS).context("bid_px alloc")?;
let bid_sz_d = stream.alloc_zeros::<f32>(BOOK_LEVELS).context("bid_sz alloc")?;
let ask_px_d = stream.alloc_zeros::<f32>(BOOK_LEVELS).context("ask_px alloc")?;
let ask_sz_d = stream.alloc_zeros::<f32>(BOOK_LEVELS).context("ask_sz alloc")?;
let market_targets_d = stream
.alloc_zeros::<i32>(n_backtests * 2)
.context("alloc market_targets_d")?;
let open_trade_state_d = stream
.alloc_zeros::<u8>(n_backtests * crate::lob::OPEN_TRADE_STATE_BYTES)
.context("alloc open_trade_state_d")?;
let trade_log_d = stream
.alloc_zeros::<u8>(n_backtests * crate::lob::TRADE_LOG_CAP * crate::lob::TRADE_RECORD_BYTES)
.context("alloc trade_log_d")?;
let trade_log_head_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc trade_log_head_d")?;
let isv_kelly_d = stream
.alloc_zeros::<u8>(n_backtests * N_HORIZONS * ISV_KELLY_STATE_BYTES)
.context("alloc isv_kelly_d")?;
let alpha_probs_d = stream
.alloc_zeros::<f32>(N_HORIZONS)
.context("alloc alpha_probs_d")?;
let open_horizon_mask_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc open_horizon_mask_d")?;
let closed_horizon_mask_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc closed_horizon_mask_d")?;
let realised_return_d = stream
.alloc_zeros::<f32>(n_backtests)
.context("alloc realised_return_d")?;
let orders_d = stream
.alloc_zeros::<u8>(n_backtests * crate::lob::ORDERS_BYTES)
.context("alloc orders_d")?;
let overflow_flag_d = stream
.alloc_zeros::<u32>(1)
.context("alloc overflow_flag_d")?;
let audit_d = stream
.alloc_zeros::<u8>(n_backtests * crate::lob::RING_CAP_EVENTS * crate::lob::ORDER_EVENT_BYTES)
.context("alloc audit_d")?;
let audit_head_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc audit_head_d")?;
let program_table_d = stream
.alloc_zeros::<u8>(n_backtests * crate::lob::MAX_PROGRAM_INSTRUCTIONS * crate::lob::INSTRUCTION_BYTES)
.context("alloc program_table_d")?;
let program_lens_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc program_lens_d")?;
let regimes_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc regimes_d")?;
// P1: per-backtest sim parameter device buffers (uploaded each
// step_decision_with_latency from BatchedSimConfig).
let target_annual_vol_units_d = stream.alloc_zeros::<f32>(n_backtests)
.context("alloc target_annual_vol_units_d")?;
let annualisation_factor_d = stream.alloc_zeros::<f32>(n_backtests)
.context("alloc annualisation_factor_d")?;
let max_lots_d = stream.alloc_zeros::<i32>(n_backtests)
.context("alloc max_lots_d")?;
let latency_ns_d = stream.alloc_zeros::<u32>(n_backtests)
.context("alloc latency_ns_d")?;
let kelly_frac_floor_d = stream.alloc_zeros::<f32>(n_backtests)
.context("alloc kelly_frac_floor_d")?;
let sharpe_weight_floor_d = stream.alloc_zeros::<f32>(n_backtests)
.context("alloc sharpe_weight_floor_d")?;
// P3: pre-submit Pos snapshot buffers for GPU close-transition detection.
let prev_pos_lots_d = stream.alloc_zeros::<i32>(n_backtests)
.context("alloc prev_pos_lots_d")?;
let prev_realized_pnl_d = stream.alloc_zeros::<f32>(n_backtests)
.context("alloc prev_realized_pnl_d")?;
let prev_open_horizon_mask_d = stream.alloc_zeros::<u32>(n_backtests)
.context("alloc prev_open_horizon_mask_d")?;
// P4: threshold + cost + fees accumulator.
let threshold_d = stream.alloc_zeros::<f32>(n_backtests)
.context("alloc threshold_d")?;
let cost_per_lot_per_side_d = stream.alloc_zeros::<f32>(n_backtests)
.context("alloc cost_per_lot_per_side_d")?;
let total_fees_per_b_d = stream.alloc_zeros::<f32>(n_backtests)
.context("alloc total_fees_per_b_d")?;
let prev_mid_d = stream
.alloc_zeros::<f32>(n_backtests)
.context("alloc prev_mid_d")?;
let atr_mid_ema_d = stream
.alloc_zeros::<f32>(n_backtests)
.context("alloc atr_mid_ema_d")?;
let trail_hwm_d = stream
.alloc_zeros::<f32>(n_backtests)
.context("alloc trail_hwm_d")?;
let max_hold_ns_d = stream
.alloc_zeros::<u64>(n_backtests)
.context("alloc max_hold_ns_d")?;
let snapshots_skipped_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc snapshots_skipped_d")?;
let last_event_ts_d = stream
.alloc_zeros::<u64>(n_backtests)
.context("alloc last_event_ts_d")?;
// S1.19: price-range device buffers. Default to no-check: min=0.0, max=+inf.
// Callers invoke upload_price_range() once before the first apply_snapshot.
let min_reasonable_px_d = stream
.alloc_zeros::<f32>(n_backtests)
.context("alloc min_reasonable_px_d")?;
// Initialize max_reasonable_px_d to f32::INFINITY (all bytes 0x7F80_0000).
let max_reasonable_px_init = vec![f32::INFINITY; n_backtests];
let mut max_reasonable_px_d = stream
.alloc_zeros::<f32>(n_backtests)
.context("alloc max_reasonable_px_d")?;
stream.memcpy_htod(&max_reasonable_px_init, &mut max_reasonable_px_d)
.context("init max_reasonable_px_d")?;
// S1.20: NaN instrumentation counters, all zero-initialized.
let nan_avg_px_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc nan_avg_px_d")?;
let nan_realised_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc nan_realised_d")?;
let nan_realized_pnl_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc nan_realized_pnl_d")?;
let zero_vwap_at_open_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc zero_vwap_at_open_d")?;
let saturated_vwap_at_open_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc saturated_vwap_at_open_d")?;
let defensive_exit_clamp_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc defensive_exit_clamp_d")?;
// S1.21: per-vwap-write-site counters + last-bad-vwap capture.
let vwap_zero_open_flat_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc vwap_zero_open_flat_d")?;
let vwap_huge_open_flat_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc vwap_huge_open_flat_d")?;
let vwap_zero_scale_in_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc vwap_zero_scale_in_d")?;
let vwap_huge_scale_in_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc vwap_huge_scale_in_d")?;
let vwap_zero_flip_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc vwap_zero_flip_d")?;
let vwap_huge_flip_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc vwap_huge_flip_d")?;
let vwap_session_gap_was_bad_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc vwap_session_gap_was_bad_d")?;
let last_bad_vwap_d = stream
.alloc_zeros::<f32>(n_backtests)
.context("alloc last_bad_vwap_d")?;
let last_bad_path_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc last_bad_path_d")?;
// S2.1/S2.2: max_hold diagnostic counters (2 remain after S2.2 prune).
let mh_kernel_calls_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc mh_kernel_calls_d")?;
let mh_force_flat_seen_by_seed_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc mh_force_flat_seen_by_seed_d")?;
// Anti-calibration fix (2026-05-22): SIGNED conviction-EMA state.
// Zero-init means "first observation pending" — the decision kernel
// branches on prev_ema == 0 to bootstrap directly (no warmup zero-
// bias). The fact that 0.0 is also a legitimate near-disagreement
// outcome is harmless: a first observation that lands at exactly
// zero produces the same bootstrap → zero outcome regardless.
let conv_signed_ema_d = stream
.alloc_zeros::<f32>(n_backtests)
.context("alloc conv_signed_ema_d")?;
let conv_signed_diff_var_ema_d = stream
.alloc_zeros::<f32>(n_backtests)
.context("alloc conv_signed_diff_var_ema_d")?;
let conv_signed_sample_var_ema_d = stream
.alloc_zeros::<f32>(n_backtests)
.context("alloc conv_signed_sample_var_ema_d")?;
// CRT.1 C1.3: no-trade band. Uploaded from BatchedSimConfig in the
// config-upload block of step_decision_with_latency. Default zero
// disables the band until the first config upload sets delta_floor.
let delta_floor_d = stream
.alloc_zeros::<f32>(n_backtests)
.context("alloc delta_floor_d")?;
// CRT.diag: empirical measurement battery (observe-only). Per-backtest
// memory footprint ≈ 4*(4+4+8+5*4)+10*4+6*4+10*(4+4+4) = 144+40+24+120
// = 328 bytes. Negligible vs other state. All counters zero-init.
let diag_flip_count_d = stream
.alloc_zeros::<u32>(n_backtests * N_HORIZONS)
.context("alloc diag_flip_count_d")?;
let diag_current_run_length_d = stream
.alloc_zeros::<u32>(n_backtests * N_HORIZONS)
.context("alloc diag_current_run_length_d")?;
let diag_sum_run_length_d = stream
.alloc_zeros::<u64>(n_backtests * N_HORIZONS)
.context("alloc diag_sum_run_length_d")?;
let diag_run_length_hist_d = stream
.alloc_zeros::<u32>(n_backtests * N_HORIZONS * 5)
.context("alloc diag_run_length_hist_d")?;
let diag_prev_dir_signed_d = stream
.alloc_zeros::<i8>(n_backtests * N_HORIZONS)
.context("alloc diag_prev_dir_signed_d")?;
let diag_conv_hist_d = stream
.alloc_zeros::<u32>(n_backtests * 10)
.context("alloc diag_conv_hist_d")?;
let diag_hold_hist_d = stream
.alloc_zeros::<u32>(n_backtests * 6)
.context("alloc diag_hold_hist_d")?;
let diag_outcome_n_d = stream
.alloc_zeros::<u32>(n_backtests * 10)
.context("alloc diag_outcome_n_d")?;
let diag_outcome_sum_pnl_d = stream
.alloc_zeros::<f32>(n_backtests * 10)
.context("alloc diag_outcome_sum_pnl_d")?;
let diag_outcome_n_wins_d = stream
.alloc_zeros::<u32>(n_backtests * 10)
.context("alloc diag_outcome_n_wins_d")?;
// CRT.diag.2: per-horizon alpha-input EMA state. Zero-init = "no
// observation yet" — first decision-kernel call bootstraps per
// pearl_first_observation_bootstrap. Same pattern as the
// conviction-level Wiener-α EMA above (zero-init = sentinel).
let alpha_ema_per_b_per_h_d = stream
.alloc_zeros::<f32>(n_backtests * N_HORIZONS)
.context("alloc alpha_ema_per_b_per_h_d")?;
let alpha_diff_var_per_b_per_h_d = stream
.alloc_zeros::<f32>(n_backtests * N_HORIZONS)
.context("alloc alpha_diff_var_per_b_per_h_d")?;
let alpha_sample_var_per_b_per_h_d = stream
.alloc_zeros::<f32>(n_backtests * N_HORIZONS)
.context("alloc alpha_sample_var_per_b_per_h_d")?;
// CRT.diag.2 Group E: per-horizon SMOOTHED direction-flip counters.
// Same shape as Group A's flip/run-length slots (minus the histogram).
let diag_smoothed_flip_count_d = stream
.alloc_zeros::<u32>(n_backtests * N_HORIZONS)
.context("alloc diag_smoothed_flip_count_d")?;
let diag_smoothed_current_run_length_d = stream
.alloc_zeros::<u32>(n_backtests * N_HORIZONS)
.context("alloc diag_smoothed_current_run_length_d")?;
let diag_smoothed_sum_run_length_d = stream
.alloc_zeros::<u64>(n_backtests * N_HORIZONS)
.context("alloc diag_smoothed_sum_run_length_d")?;
let diag_smoothed_prev_dir_d = stream
.alloc_zeros::<i8>(n_backtests * N_HORIZONS)
.context("alloc diag_smoothed_prev_dir_d")?;
// CRT Phase A0.5 corrective: on-device conviction history buffer.
// Capacity = 5M decisions × 4B/f32 = 20MB. Matches the prior
// host-side `Vec::with_capacity(3_000_000)` plus headroom for
// post-A1 (stride=1 → decisions == events ≈ 5M for a one-day ES
// smoke; multi-day cluster runs that exceed this silently stop
// recording past the cap, the host counter still reports actual
// decision_count). Beats the alternative (Vec<[f32; 5]> per
// decision = 100MB at 5M decisions) by 5x.
let convictions_capacity = 5_000_000_usize;
let convictions_d = stream
.alloc_zeros::<f32>(convictions_capacity)
.context("alloc convictions_d")?;
Ok(Self {
n_backtests,
stream,
book_update_fn,
submit_market_fn,
pnl_track_fn,
decision_fn,
decision_program_fn,
isv_kelly_update_fn,
record_max_conviction_fn,
resting_orders_fn,
seed_limit_fn,
seed_inflight_limits_fn,
seed_stop_fn,
books_d,
pos_d,
bid_px_d,
bid_sz_d,
ask_px_d,
ask_sz_d,
market_targets_d,
open_trade_state_d,
trade_log_d,
trade_log_head_d,
isv_kelly_d,
alpha_probs_d,
convictions_d,
convictions_capacity,
open_horizon_mask_d,
closed_horizon_mask_d,
realised_return_d,
orders_d,
overflow_flag_d,
audit_d,
audit_head_d,
program_table_d,
program_lens_d,
regimes_d,
target_annual_vol_units_d,
annualisation_factor_d,
max_lots_d,
latency_ns_d,
kelly_frac_floor_d,
sharpe_weight_floor_d,
prev_pos_lots_d,
prev_realized_pnl_d,
prev_open_horizon_mask_d,
snapshot_pos_state_fn,
detect_close_fn,
threshold_d,
cost_per_lot_per_side_d,
total_fees_per_b_d,
prev_mid_d,
atr_mid_ema_d,
trail_hwm_d,
max_hold_ns_d,
snapshots_skipped_d,
last_event_ts_d,
min_reasonable_px_d,
max_reasonable_px_d,
nan_avg_px_d,
nan_realised_d,
nan_realized_pnl_d,
zero_vwap_at_open_d,
saturated_vwap_at_open_d,
defensive_exit_clamp_d,
vwap_zero_open_flat_d,
vwap_huge_open_flat_d,
vwap_zero_scale_in_d,
vwap_huge_scale_in_d,
vwap_zero_flip_d,
vwap_huge_flip_d,
vwap_session_gap_was_bad_d,
last_bad_vwap_d,
last_bad_path_d,
mh_kernel_calls_d,
mh_force_flat_seen_by_seed_d,
conv_signed_ema_d,
conv_signed_diff_var_ema_d,
conv_signed_sample_var_ema_d,
delta_floor_d,
diag_flip_count_d,
diag_current_run_length_d,
diag_sum_run_length_d,
diag_run_length_hist_d,
diag_prev_dir_signed_d,
diag_conv_hist_d,
diag_hold_hist_d,
diag_outcome_n_d,
diag_outcome_sum_pnl_d,
diag_outcome_n_wins_d,
alpha_ema_per_b_per_h_d,
alpha_diff_var_per_b_per_h_d,
alpha_sample_var_per_b_per_h_d,
diag_smoothed_flip_count_d,
diag_smoothed_current_run_length_d,
diag_smoothed_sum_run_length_d,
diag_smoothed_prev_dir_d,
})
}
/// Upload a flattened bytecode Program for one backtest. The kernel
/// `decision_policy_program` interprets it if program_lens[b] > 0;
/// otherwise it returns immediately and the host orchestrator falls
/// back to `decision_policy_default`.
pub fn upload_program(
&mut self,
backtest_idx: usize,
program: &crate::policy::Program,
) -> Result<()> {
anyhow::ensure!(backtest_idx < self.n_backtests);
anyhow::ensure!(
program.instructions.len() <= crate::lob::MAX_PROGRAM_INSTRUCTIONS,
"program length {} exceeds MAX_PROGRAM_INSTRUCTIONS={}",
program.instructions.len(),
crate::lob::MAX_PROGRAM_INSTRUCTIONS
);
// Read back current program_table, mutate slot, push back.
let stride = crate::lob::MAX_PROGRAM_INSTRUCTIONS * crate::lob::INSTRUCTION_BYTES;
let mut raw = vec![0u8; self.n_backtests * stride];
self.stream.memcpy_dtoh(&self.program_table_d, raw.as_mut_slice())?;
let off = backtest_idx * stride;
let bytes: &[u8] = bytemuck::cast_slice(program.instructions.as_slice());
raw[off..off + bytes.len()].copy_from_slice(bytes);
// Zero out trailing slots in this backtest's row beyond program length.
for b in &mut raw[off + bytes.len()..off + stride] {
*b = 0;
}
self.stream.memcpy_htod(raw.as_slice(), &mut self.program_table_d)?;
// Update program_lens[b].
let mut lens = vec![0u32; self.n_backtests];
self.stream.memcpy_dtoh(&self.program_lens_d, lens.as_mut_slice())?;
lens[backtest_idx] = program.instructions.len() as u32;
self.stream.memcpy_htod(lens.as_slice(), &mut self.program_lens_d)?;
Ok(())
}
/// Set the current regime id for a backtest. Read by
/// `decision_policy_program` for OP_EVAL_REGIME / OP_BRANCH_IF_REGIME.
pub fn set_regime(&mut self, backtest_idx: usize, regime_id: u32) -> Result<()> {
anyhow::ensure!(backtest_idx < self.n_backtests);
let mut regimes = vec![0u32; self.n_backtests];
self.stream.memcpy_dtoh(&self.regimes_d, regimes.as_mut_slice())?;
regimes[backtest_idx] = regime_id;
self.stream.memcpy_htod(regimes.as_slice(), &mut self.regimes_d)?;
Ok(())
}
/// Read the audit-ring head count + records for one backtest.
/// Returns the raw bytes; tests parse via bytemuck::pod_read_unaligned.
pub fn read_audit_records(
&self,
backtest_idx: usize,
) -> Result<Vec<crate::order::OrderEvent>> {
anyhow::ensure!(backtest_idx < self.n_backtests);
let mut heads = vec![0u32; self.n_backtests];
self.stream.memcpy_dtoh(&self.audit_head_d, heads.as_mut_slice())?;
let head = heads[backtest_idx] as usize;
if head == 0 {
return Ok(vec![]);
}
let cap = crate::lob::RING_CAP_EVENTS;
let n_to_read = head.min(cap);
let rec_bytes = crate::lob::ORDER_EVENT_BYTES;
let mut raw = vec![0u8; self.n_backtests * cap * rec_bytes];
self.stream.memcpy_dtoh(&self.audit_d, raw.as_mut_slice())?;
let base = backtest_idx * cap * rec_bytes;
let mut out = Vec::with_capacity(n_to_read);
for i in 0..n_to_read {
let off = base + i * rec_bytes;
let r: crate::order::OrderEvent =
bytemuck::pod_read_unaligned(&raw[off..off + rec_bytes]);
out.push(r);
}
Ok(out)
}
pub fn n_backtests(&self) -> usize {
self.n_backtests
}
/// Sum of per-backtest trade-log head counters. Cheap DtoH copy of
/// `n_backtests * 4` bytes — used by the harness progress line to
/// surface intra-run trade firing without reading the full ring.
/// Heads count writes (clamped to ring cap inside the kernel), so the
/// sum is a reliable monotone trade counter across the sweep.
pub fn read_total_trade_count(&self) -> Result<u64> {
let mut heads = vec![0u32; self.n_backtests];
self.stream.memcpy_dtoh(&self.trade_log_head_d, heads.as_mut_slice())?;
Ok(heads.iter().map(|&h| h as u64).sum())
}
/// Read `atr_mid_ema_d[backtest_idx]`. Test-only accessor.
pub fn read_atr_mid_ema(&self, backtest_idx: usize) -> Result<f32> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx,
self.n_backtests
);
let mut buf = vec![0.0f32; self.n_backtests];
self.stream.memcpy_dtoh(&self.atr_mid_ema_d, buf.as_mut_slice())?;
Ok(buf[backtest_idx])
}
/// Read `prev_mid_d[backtest_idx]`. Test-only accessor.
pub fn read_prev_mid(&self, backtest_idx: usize) -> Result<f32> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx,
self.n_backtests
);
let mut buf = vec![0.0f32; self.n_backtests];
self.stream.memcpy_dtoh(&self.prev_mid_d, buf.as_mut_slice())?;
Ok(buf[backtest_idx])
}
/// Read `trail_hwm_d[backtest_idx]`. Test-only accessor.
pub fn read_trail_hwm(&self, backtest_idx: usize) -> Result<f32> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx,
self.n_backtests
);
let mut buf = vec![0.0f32; self.n_backtests];
self.stream.memcpy_dtoh(&self.trail_hwm_d, buf.as_mut_slice())?;
Ok(buf[backtest_idx])
}
/// Read the SIGNED conviction EMA for one backtest. Returns the raw
/// signed value in [-1, +1]; callers asserting on magnitude take
/// `.abs()`. Test-only accessor — production paths inspect smoothed
/// conviction on-device only. One DtoH per call.
///
/// Post-fix 2026-05-22: returns the signed EMA, not the magnitude-only
/// EMA from the prior buggy implementation. Sign of the returned value
/// is the trade direction the kernel will commit to on the next event;
/// `|returned|` is the sizing magnitude (post-threshold-gate).
pub fn read_conv_signed_ema(&self, backtest_idx: usize) -> Result<f32> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx,
self.n_backtests
);
let mut buf = vec![0.0f32; self.n_backtests];
self.stream.memcpy_dtoh(&self.conv_signed_ema_d, buf.as_mut_slice())?;
Ok(buf[backtest_idx])
}
/// Read `snapshots_skipped_d`. Returns per-backtest count of snapshots skipped
/// due to corrupt top-of-book (NaN/Inf price or zero/negative size at level 0).
/// Used by tests to verify the skip logic fires; production code can sum for
/// diagnostics.
pub fn read_snapshots_skipped(&self) -> Result<Vec<u32>> {
let mut buf = vec![0u32; self.n_backtests];
self.stream.memcpy_dtoh(&self.snapshots_skipped_d, buf.as_mut_slice())?;
Ok(buf)
}
/// Read the 6 per-backtest NaN instrumentation counters (S1.20).
/// Returns a `NanCounters` with one `Vec<u32>` per counter, length = n_backtests.
/// All counters are zero at construction and increment on NaN-producing events
/// in the fill + pnl_track kernels.
pub fn read_nan_counters(&self) -> Result<NanCounters> {
let read = |slot: &CudaSlice<u32>| -> Result<Vec<u32>> {
let mut buf = vec![0u32; self.n_backtests];
self.stream.memcpy_dtoh(slot, buf.as_mut_slice())?;
Ok(buf)
};
Ok(NanCounters {
nan_avg_px: read(&self.nan_avg_px_d)?,
nan_realised: read(&self.nan_realised_d)?,
nan_realized_pnl: read(&self.nan_realized_pnl_d)?,
zero_vwap_at_open: read(&self.zero_vwap_at_open_d)?,
saturated_vwap_at_open: read(&self.saturated_vwap_at_open_d)?,
defensive_exit_clamp: read(&self.defensive_exit_clamp_d)?,
})
}
/// S1.21: per-vwap-write-site counters.
///
/// All counters are zero at construction. Incremented in
/// `apply_fill_to_pos` at each vwap_entry write site and in the
/// session-gap force-close path in `resting_orders_step`.
pub fn read_nan_counters_v2(&self) -> Result<NanCountersV2> {
let read_u32 = |slot: &CudaSlice<u32>| -> Result<Vec<u32>> {
let mut buf = vec![0u32; self.n_backtests];
self.stream.memcpy_dtoh(slot, buf.as_mut_slice())?;
Ok(buf)
};
let read_f32 = |slot: &CudaSlice<f32>| -> Result<Vec<f32>> {
let mut buf = vec![0f32; self.n_backtests];
self.stream.memcpy_dtoh(slot, buf.as_mut_slice())?;
Ok(buf)
};
Ok(NanCountersV2 {
vwap_zero_open_flat: read_u32(&self.vwap_zero_open_flat_d)?,
vwap_huge_open_flat: read_u32(&self.vwap_huge_open_flat_d)?,
vwap_zero_scale_in: read_u32(&self.vwap_zero_scale_in_d)?,
vwap_huge_scale_in: read_u32(&self.vwap_huge_scale_in_d)?,
vwap_zero_flip: read_u32(&self.vwap_zero_flip_d)?,
vwap_huge_flip: read_u32(&self.vwap_huge_flip_d)?,
vwap_session_gap_was_bad: read_u32(&self.vwap_session_gap_was_bad_d)?,
last_bad_vwap: read_f32(&self.last_bad_vwap_d)?,
last_bad_path: read_u32(&self.last_bad_path_d)?,
})
}
/// S2.1/S2.2: max_hold diagnostic counters.
///
/// After S2.2 the 6 max_hold-specific decision-rate counters were removed.
/// Two generic counters remain:
/// - `mh_kernel_calls`: stop_check_isv entries with position_lots != 0
/// (SL/trail evaluation is running; non-zero when open + decision fires).
/// - `mh_force_flat_seen_by_seed`: force-flat signals (SL or trail) seen
/// by seed_inflight_limits_batched.
pub fn read_max_hold_counters(&self) -> Result<NanCountersMaxHold> {
let read = |slot: &CudaSlice<u32>| -> Result<Vec<u32>> {
let mut buf = vec![0u32; self.n_backtests];
self.stream.memcpy_dtoh(slot, buf.as_mut_slice())?;
Ok(buf)
};
Ok(NanCountersMaxHold {
mh_kernel_calls: read(&self.mh_kernel_calls_d)?,
mh_force_flat_seen_by_seed: read(&self.mh_force_flat_seen_by_seed_d)?,
})
}
/// CRT.diag: end-of-run materialisation of the empirical measurement
/// battery. Reads all 8 host-visible diagnostic slots in one batch (the
/// 9th — `diag_current_run_length_d` — and 10th — `diag_prev_dir_signed_d`
/// — are transient kernel-internal state, not part of the summary).
///
/// Called ONCE at smoke termination from `BacktestHarness::run`. Each
/// `memcpy_dtoh` is a single device-to-host hop; per-event hot path
/// touches none of these.
pub fn read_diagnostics(&self) -> Result<CrtDiagnostics> {
let n_b = self.n_backtests;
let mut flip_count = vec![0u32; n_b * N_HORIZONS];
let mut sum_run_length = vec![0u64; n_b * N_HORIZONS];
let mut run_length_hist = vec![0u32; n_b * N_HORIZONS * 5];
let mut conv_hist = vec![0u32; n_b * 10];
let mut hold_hist = vec![0u32; n_b * 6];
let mut outcome_n = vec![0u32; n_b * 10];
let mut outcome_sum_pnl = vec![0f32; n_b * 10];
let mut outcome_n_wins = vec![0u32; n_b * 10];
// CRT.diag.2 Group E: smoothed-direction flip/run-length.
let mut smoothed_flip_count = vec![0u32; n_b * N_HORIZONS];
let mut smoothed_sum_run_length = vec![0u64; n_b * N_HORIZONS];
self.stream.memcpy_dtoh(&self.diag_flip_count_d, flip_count.as_mut_slice())
.context("diag_flip_count DtoH")?;
self.stream.memcpy_dtoh(&self.diag_sum_run_length_d, sum_run_length.as_mut_slice())
.context("diag_sum_run_length DtoH")?;
self.stream.memcpy_dtoh(&self.diag_run_length_hist_d, run_length_hist.as_mut_slice())
.context("diag_run_length_hist DtoH")?;
self.stream.memcpy_dtoh(&self.diag_conv_hist_d, conv_hist.as_mut_slice())
.context("diag_conv_hist DtoH")?;
self.stream.memcpy_dtoh(&self.diag_hold_hist_d, hold_hist.as_mut_slice())
.context("diag_hold_hist DtoH")?;
self.stream.memcpy_dtoh(&self.diag_outcome_n_d, outcome_n.as_mut_slice())
.context("diag_outcome_n DtoH")?;
self.stream.memcpy_dtoh(&self.diag_outcome_sum_pnl_d, outcome_sum_pnl.as_mut_slice())
.context("diag_outcome_sum_pnl DtoH")?;
self.stream.memcpy_dtoh(&self.diag_outcome_n_wins_d, outcome_n_wins.as_mut_slice())
.context("diag_outcome_n_wins DtoH")?;
self.stream.memcpy_dtoh(&self.diag_smoothed_flip_count_d,
smoothed_flip_count.as_mut_slice())
.context("diag_smoothed_flip_count DtoH")?;
self.stream.memcpy_dtoh(&self.diag_smoothed_sum_run_length_d,
smoothed_sum_run_length.as_mut_slice())
.context("diag_smoothed_sum_run_length DtoH")?;
Ok(CrtDiagnostics {
flip_count,
sum_run_length,
run_length_hist,
conv_hist,
hold_hist,
outcome_n,
outcome_sum_pnl,
outcome_n_wins,
smoothed_flip_count,
smoothed_sum_run_length,
})
}
/// Upload per-backtest price-range bounds. Call ONCE before the first
/// apply_snapshot. min=0.0 / max=f32::INFINITY disables the check for
/// that backtest (default after LobSimCuda::new).
///
/// Used by smoke/production to configure instrument-specific plausible
/// ranges (e.g. ES futures: 1000.0 / 20000.0) so the CUDA kernel can
/// reject real source-data outliers (negative bids, sub-$100 bids,
/// $53k+ asks) that the old hardcoded `< 1e8` threshold missed.
pub fn upload_price_range(&mut self, min: &[f32], max: &[f32]) -> Result<()> {
anyhow::ensure!(
min.len() == self.n_backtests,
"upload_price_range: min len {} != n_backtests {}",
min.len(), self.n_backtests
);
anyhow::ensure!(
max.len() == self.n_backtests,
"upload_price_range: max len {} != n_backtests {}",
max.len(), self.n_backtests
);
self.stream.memcpy_htod(min, &mut self.min_reasonable_px_d)?;
self.stream.memcpy_htod(max, &mut self.max_reasonable_px_d)?;
Ok(())
}
/// Apply one MBP-10 snapshot's top-10 levels to every backtest's book.
/// v1 inputs are single-snapshot broadcast (same market data to all
/// parallel backtests — see spec §7 inference scope, policy sweeps only).
pub fn apply_snapshot(
&mut self,
bid_px: &[f32; BOOK_LEVELS],
bid_sz: &[f32; BOOK_LEVELS],
ask_px: &[f32; BOOK_LEVELS],
ask_sz: &[f32; BOOK_LEVELS],
) -> Result<()> {
self.stream.memcpy_htod(bid_px.as_slice(), &mut self.bid_px_d)?;
self.stream.memcpy_htod(bid_sz.as_slice(), &mut self.bid_sz_d)?;
self.stream.memcpy_htod(ask_px.as_slice(), &mut self.ask_px_d)?;
self.stream.memcpy_htod(ask_sz.as_slice(), &mut self.ask_sz_d)?;
let cfg = LaunchConfig {
grid_dim: (self.n_backtests as u32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let n = self.n_backtests as i32;
let mut launch = self.stream.launch_builder(&self.book_update_fn);
unsafe {
launch
.arg(&self.bid_px_d)
.arg(&self.bid_sz_d)
.arg(&self.ask_px_d)
.arg(&self.ask_sz_d)
.arg(&mut self.books_d)
.arg(&mut self.prev_mid_d)
.arg(&mut self.atr_mid_ema_d)
.arg(&mut self.snapshots_skipped_d)
.arg(&self.min_reasonable_px_d)
.arg(&self.max_reasonable_px_d)
.arg(&n)
.launch(cfg)?;
}
self.stream.synchronize()?;
Ok(())
}
/// Submit a market order to one specific backtest. side: 0=buy, 1=sell.
/// Walks the book + updates Pos, then runs pnl_track to detect
/// segment_complete and emit TradeRecord if the position closed.
/// Convenience entry for fixture testing; production path goes
/// through the decision-policy kernel (C7).
pub fn submit_market(
&mut self,
backtest_idx: usize,
side: u8,
size: u16,
current_ts_ns: u64,
) -> Result<()> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx,
self.n_backtests
);
let mut targets = vec![0i32; self.n_backtests * 2];
for b in 0..self.n_backtests {
targets[b * 2] = 2; // no-op
targets[b * 2 + 1] = 0;
}
targets[backtest_idx * 2] = side as i32;
targets[backtest_idx * 2 + 1] = size as i32;
self.stream
.memcpy_htod(targets.as_slice(), &mut self.market_targets_d)?;
let cfg = LaunchConfig {
grid_dim: (self.n_backtests as u32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let n = self.n_backtests as i32;
let mut launch = self.stream.launch_builder(&self.submit_market_fn);
unsafe {
launch
.arg(&self.books_d)
.arg(&self.market_targets_d)
.arg(&mut self.pos_d)
.arg(&self.min_reasonable_px_d)
.arg(&self.max_reasonable_px_d)
.arg(&n)
.launch(cfg)?;
}
self.stream.synchronize()?;
// Run pnl_track to detect close + emit TradeRecord.
self.step_pnl_track(current_ts_ns)?;
Ok(())
}
/// Run pnl_track_step kernel: detect segment_complete, emit TradeRecord.
/// Called automatically by submit_market; exposed for callers that
/// orchestrate matching themselves.
pub fn step_pnl_track(&mut self, current_ts_ns: u64) -> Result<()> {
let cfg = LaunchConfig {
grid_dim: (self.n_backtests as u32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let n = self.n_backtests as i32;
let cap = crate::lob::TRADE_LOG_CAP as i32;
let mut launch = self.stream.launch_builder(&self.pnl_track_fn);
unsafe {
launch
.arg(&mut self.pos_d)
.arg(&mut self.open_trade_state_d)
.arg(&mut self.trade_log_d)
.arg(&mut self.trade_log_head_d)
.arg(&current_ts_ns)
.arg(&cap)
.arg(&n)
.arg(&mut self.trail_hwm_d)
.arg(&mut self.zero_vwap_at_open_d)
.arg(&mut self.saturated_vwap_at_open_d)
.arg(&mut self.defensive_exit_clamp_d)
// CRT.1 C1.4: open branch snapshots conviction_at_entry +
// initializes the in-trade conviction_ema magnitude mirror
// in open_trade_state. Post-fix 2026-05-22: kernel reads
// the SIGNED EMA and takes fabsf before writing.
.arg(&self.conv_signed_ema_d)
// CRT.diag Groups C + D: hold-time + outcome-by-entry-conviction.
.arg(&mut self.diag_hold_hist_d)
.arg(&mut self.diag_outcome_n_d)
.arg(&mut self.diag_outcome_sum_pnl_d)
.arg(&mut self.diag_outcome_n_wins_d)
.launch(cfg)?;
}
self.stream.synchronize()?;
Ok(())
}
/// Read all closed-trade records for a backtest. Returns up to
/// TRADE_LOG_CAP records (oldest dropped if the ring wrapped, which
/// the v1 head counter exposes as `head_count`).
pub fn read_trade_records(
&self,
backtest_idx: usize,
) -> Result<Vec<crate::order::TradeRecord>> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx,
self.n_backtests
);
let mut heads = vec![0u32; self.n_backtests];
self.stream.memcpy_dtoh(&self.trade_log_head_d, heads.as_mut_slice())?;
let head = heads[backtest_idx] as usize;
if head == 0 {
return Ok(vec![]);
}
let n_to_read = head.min(crate::lob::TRADE_LOG_CAP);
let rec_bytes = crate::lob::TRADE_RECORD_BYTES;
let off = backtest_idx * crate::lob::TRADE_LOG_CAP * rec_bytes;
let mut raw = vec![0u8; self.n_backtests * crate::lob::TRADE_LOG_CAP * rec_bytes];
self.stream.memcpy_dtoh(&self.trade_log_d, raw.as_mut_slice())?;
let mut out = Vec::with_capacity(n_to_read);
for i in 0..n_to_read {
let rec_off = off + i * rec_bytes;
let r: crate::order::TradeRecord =
bytemuck::pod_read_unaligned(&raw[rec_off..rec_off + rec_bytes]);
out.push(r);
}
Ok(out)
}
/// Read back the Pos state for a specific backtest.
pub fn read_pos(&self, backtest_idx: usize) -> Result<PosFlat> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx,
self.n_backtests
);
let pos_bytes = std::mem::size_of::<PosFlat>();
let mut raw = vec![0u8; self.n_backtests * pos_bytes];
self.stream.memcpy_dtoh(&self.pos_d, raw.as_mut_slice())?;
let off = backtest_idx * pos_bytes;
let pos: PosFlat = bytemuck::pod_read_unaligned(&raw[off..off + pos_bytes]);
Ok(pos)
}
/// Read back the market_target written by `step_decision*` for one
/// backtest. Returns `(side, abs_size)` where `side ∈ {0=buy, 1=sell, 2=noop}`.
/// Used by integration tests that need to inspect decision-kernel output
/// without going through the full submit/fill chain.
pub fn read_market_target(&self, backtest_idx: usize) -> Result<(i32, i32)> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx, self.n_backtests
);
let mut raw = vec![0i32; self.n_backtests * 2];
self.stream.memcpy_dtoh(&self.market_targets_d, raw.as_mut_slice())?;
Ok((raw[backtest_idx * 2], raw[backtest_idx * 2 + 1]))
}
/// Broadcast alpha probs (per-horizon) to the decision kernel from a
/// host-side `[f32; N_HORIZONS]`. Single source of truth for v1: all
/// N backtests see the same probs.
///
/// CRT Phase A0.5 (corrective): production hot-path callers should
/// prefer driving `alpha_probs_d` directly via
/// `PerceptionTrainer::forward_step_into(&mut sim.alpha_probs_d_mut())`
/// so the probs never leave device memory. This host-input variant
/// stays for tests + non-CUDA-trainer call sites (e.g., fixtures that
/// inject canned probs into the decision pipeline).
pub fn broadcast_alpha(&mut self, probs: &[f32; N_HORIZONS]) -> Result<()> {
self.stream.memcpy_htod(probs.as_slice(), &mut self.alpha_probs_d)?;
Ok(())
}
/// Mutable accessor for the on-device alpha-probs buffer.
/// Length = `N_HORIZONS` (5). All `n_backtests` simulators read from
/// this same buffer (broadcast layout). The CRT Phase A0.5 incremental
/// inference path uses this so the trainer's GRN heads kernel writes
/// probs directly into the sim's decision-input buffer — zero CPU
/// roundtrip, no `synchronize` on the hot path.
pub fn alpha_probs_d_mut(&mut self) -> &mut CudaSlice<f32> {
&mut self.alpha_probs_d
}
/// Diagnostic-only sampler: copy the current `alpha_probs_d` (5 f32s,
/// broadcast across backtests) into a caller-owned mapped-pinned
/// buffer and synchronise the stream so the host view is coherent.
///
/// Per `feedback_no_htod_htoh_only_mapped_pinned`: the buffer is
/// allocated once in the harness state via `MappedF32Buffer::new(5)`
/// and reused on every sampling tick. The transfer is DtoD into the
/// mapped-pinned device pointer (the only permitted cross-direction
/// channel); a single `stream.synchronize()` makes the host-side
/// `read_record` calls deterministic.
///
/// Intended for low-frequency sampling (e.g. every 1000 events). The
/// per-event production path keeps reading `alpha_probs_d` on-device
/// and is unaffected.
pub fn sample_alpha_probs(&self, dst: &MappedF32Buffer) -> Result<()> {
anyhow::ensure!(
dst.len >= N_HORIZONS,
"sample_alpha_probs: dst buffer len {} < N_HORIZONS {}",
dst.len, N_HORIZONS,
);
let nbytes = N_HORIZONS * std::mem::size_of::<f32>();
unsafe {
let (src_ptr, _g) = self.alpha_probs_d.device_ptr(&self.stream);
cudarc::driver::result::memcpy_dtod_async(
dst.dev_ptr,
src_ptr,
nbytes,
self.stream.cu_stream(),
)
.context("sample_alpha_probs DtoD")?;
}
self.stream.synchronize().context("sample_alpha_probs sync")?;
Ok(())
}
/// CRT Phase A0.5 corrective: record one max-conviction f32 at
/// `convictions_d[write_idx]` from the current `alpha_probs_d`.
/// Single-thread kernel; stream-ordered, no sync. Caller advances
/// `write_idx` on the host side once per decision (matches the
/// existing `decision_count` increment in the harness).
///
/// `write_idx` ≥ `convictions_capacity` is silently dropped by the
/// kernel — the host counter still reflects the real decision count;
/// the percentile side-channel just stops growing past the cap. For
/// the current 5M-entry cap and ES-rate decision flow that's > a
/// trading day of decisions at stride=1.
pub fn record_max_conviction(&mut self, write_idx: u32) -> Result<()> {
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
};
let idx_i32: i32 = write_idx as i32;
let cap_i32: i32 = self.convictions_capacity as i32;
unsafe {
self.stream
.launch_builder(&self.record_max_conviction_fn)
.arg(&self.alpha_probs_d)
.arg(&mut self.convictions_d)
.arg(&idx_i32)
.arg(&cap_i32)
.launch(cfg)
.context("record_max_conviction_to_slot launch")?;
}
Ok(())
}
/// End-of-run read-back of the conviction history. Returns the first
/// `n` entries (clamped to `convictions_capacity`). One DtoV sync.
/// Mirrors the lifecycle of `read_trade_records` / `read_max_hold_counters`
/// which the harness calls after `run()` to materialise diagnostic
/// data for `write_artifacts`.
pub fn read_convictions(&self, n: usize) -> Result<Vec<f32>> {
let take = n.min(self.convictions_capacity);
if take == 0 {
return Ok(Vec::new());
}
let mut host = vec![0.0_f32; self.convictions_capacity];
self.stream
.memcpy_dtoh(&self.convictions_d, host.as_mut_slice())
.context("convictions DtoH")?;
host.truncate(take);
Ok(host)
}
/// Backward-compat wrapper for the no-latency immediate-match path.
/// Callers seeking immediate fill should set `cfg.latency_ns[b] = 0`.
pub fn step_decision(
&mut self,
current_ts_ns: u64,
cfg: &BatchedSimConfig,
) -> Result<()> {
self.step_decision_with_latency(current_ts_ns, cfg)
}
/// Run the C7 decision pipeline with optional latency simulation:
/// 1. decision_policy_default kernel — alpha × per-horizon ISV-Kelly
/// → market target (side, size) per backtest + open_horizon_mask
/// attribution if currently flat.
/// 2a. If latency_ns == 0: submit_market_immediate kernel fills
/// against the current book.
/// 2b. If latency_ns > 0: each non-noop target is converted host-side
/// to an in-flight aggressive Limit (active=2, price=very-high
/// for buy / very-low for sell so the marketability check
/// always crosses, arrival_ts_ns = current + latency_ns).
/// The resting_orders kernel promotes + fills at arrival time
/// against the (possibly-moved) book.
/// 3. pnl_track_step — detect close → emit TradeRecord (immediate path only;
/// in-flight path defers close detection to step_resting_orders).
/// 4. isv_kelly_update_on_close — for closed trades, update per-horizon
/// state. Currently runs only on the immediate path; the latency
/// path triggers it via step_resting_orders → step_pnl_track once
/// the fill lands.
pub fn step_decision_with_latency(
&mut self,
current_ts_ns: u64,
sim_cfg: &BatchedSimConfig,
) -> Result<()> {
anyhow::ensure!(
sim_cfg.n_backtests() == self.n_backtests,
"BatchedSimConfig n_backtests {} != Sim n_backtests {}",
sim_cfg.n_backtests(), self.n_backtests
);
sim_cfg.validate()?;
// P1: upload per-backtest sim params into pre-allocated device buffers.
self.stream.memcpy_htod(&sim_cfg.target_annual_vol_units, &mut self.target_annual_vol_units_d)?;
self.stream.memcpy_htod(&sim_cfg.annualisation_factor, &mut self.annualisation_factor_d)?;
let max_lots_i32: Vec<i32> = sim_cfg.max_lots.iter().map(|&m| m as i32).collect();
self.stream.memcpy_htod(&max_lots_i32, &mut self.max_lots_d)?;
self.stream.memcpy_htod(&sim_cfg.latency_ns, &mut self.latency_ns_d)?;
self.stream.memcpy_htod(&sim_cfg.kelly_frac_floor, &mut self.kelly_frac_floor_d)?;
self.stream.memcpy_htod(&sim_cfg.sharpe_weight_floor, &mut self.sharpe_weight_floor_d)?;
// P4: threshold + cost arrays.
self.stream.memcpy_htod(&sim_cfg.threshold, &mut self.threshold_d)?;
self.stream.memcpy_htod(&sim_cfg.cost_per_lot_per_side, &mut self.cost_per_lot_per_side_d)?;
// Follow-up gp74n: max-hold force-close per-backtest config.
self.stream.memcpy_htod(&sim_cfg.max_hold_ns, &mut self.max_hold_ns_d)?;
// CRT.1 C1.3: no-trade band — uploaded once per run alongside other
// config params. The kernel reads delta_floor_d per-event without any
// host roundtrip on the hot path.
self.stream.memcpy_htod(&sim_cfg.delta_floor, &mut self.delta_floor_d)?;
// Step 1: decision kernels write market_targets + open_horizon_mask.
// 1a — bytecode program kernel handles backtests with plen > 0.
// 1b — default hardcoded kernel handles backtests with plen == 0.
let cfg = LaunchConfig {
grid_dim: (self.n_backtests as u32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let n = self.n_backtests as i32;
let max_instrs = crate::lob::MAX_PROGRAM_INSTRUCTIONS as i32;
// 1a: bytecode VM kernel (skips backtests with plen==0 internally).
let mut launch = self.stream.launch_builder(&self.decision_program_fn);
unsafe {
launch
.arg(&self.alpha_probs_d)
.arg(&self.isv_kelly_d)
.arg(&self.pos_d)
.arg(&self.program_table_d)
.arg(&self.program_lens_d)
.arg(&self.regimes_d)
.arg(&mut self.market_targets_d)
.arg(&mut self.open_horizon_mask_d)
.arg(&self.target_annual_vol_units_d)
.arg(&self.annualisation_factor_d)
.arg(&max_instrs)
.arg(&self.max_lots_d)
.arg(&self.kelly_frac_floor_d)
.arg(&self.sharpe_weight_floor_d)
.arg(&self.threshold_d)
.arg(&self.cost_per_lot_per_side_d)
.arg(&self.atr_mid_ema_d)
.arg(&mut self.trail_hwm_d)
.arg(&self.books_d)
// S2.1: mh_kernel_calls diagnostic counter.
.arg(&mut self.mh_kernel_calls_d)
// Anti-calibration fix 2026-05-22: SIGNED conviction-EMA state.
.arg(&mut self.conv_signed_ema_d)
.arg(&mut self.conv_signed_diff_var_ema_d)
.arg(&mut self.conv_signed_sample_var_ema_d)
// CRT.1 C1.4: trajectory state for composite exit_signal.
.arg(&mut self.open_trade_state_d)
// CRT.diag Groups A + B: signal persistence + conviction histogram.
.arg(&mut self.diag_flip_count_d)
.arg(&mut self.diag_current_run_length_d)
.arg(&mut self.diag_sum_run_length_d)
.arg(&mut self.diag_run_length_hist_d)
.arg(&mut self.diag_prev_dir_signed_d)
.arg(&mut self.diag_conv_hist_d)
// CRT.diag.2: per-horizon alpha-input EMA state.
.arg(&mut self.alpha_ema_per_b_per_h_d)
.arg(&mut self.alpha_diff_var_per_b_per_h_d)
.arg(&mut self.alpha_sample_var_per_b_per_h_d)
// CRT.diag.2 Group E: smoothed-direction flip counters.
.arg(&mut self.diag_smoothed_flip_count_d)
.arg(&mut self.diag_smoothed_current_run_length_d)
.arg(&mut self.diag_smoothed_sum_run_length_d)
.arg(&mut self.diag_smoothed_prev_dir_d)
.arg(&n)
.launch(cfg)?;
}
// 1b: default kernel (skips backtests with plen>0 internally).
// CRT.1 C1.2: the multi-horizon §4.4 conviction formula replaces
// the v2 per-horizon Kelly + sharpe-weighted aggregate, so the
// default kernel no longer takes target_annual_vol_units /
// annualisation_factor / kelly_frac_floor / sharpe_weight_floor.
// The bytecode VM (decision_policy_program) still uses those
// params for VM opcodes — their device slots stay allocated.
let mut launch = self.stream.launch_builder(&self.decision_fn);
unsafe {
launch
.arg(&self.alpha_probs_d)
.arg(&self.isv_kelly_d)
.arg(&self.pos_d)
.arg(&self.program_lens_d)
.arg(&mut self.market_targets_d)
.arg(&mut self.open_horizon_mask_d)
.arg(&self.max_lots_d)
.arg(&self.threshold_d)
.arg(&self.cost_per_lot_per_side_d)
.arg(&self.atr_mid_ema_d)
.arg(&mut self.trail_hwm_d)
.arg(&self.books_d)
// S2.1: mh_kernel_calls diagnostic counter.
.arg(&mut self.mh_kernel_calls_d)
// Anti-calibration fix 2026-05-22: SIGNED conviction-EMA state.
.arg(&mut self.conv_signed_ema_d)
.arg(&mut self.conv_signed_diff_var_ema_d)
.arg(&mut self.conv_signed_sample_var_ema_d)
// CRT.1 C1.4: trajectory state for composite exit_signal.
.arg(&mut self.open_trade_state_d)
// CRT.diag Groups A + B: signal persistence + conviction histogram.
.arg(&mut self.diag_flip_count_d)
.arg(&mut self.diag_current_run_length_d)
.arg(&mut self.diag_sum_run_length_d)
.arg(&mut self.diag_run_length_hist_d)
.arg(&mut self.diag_prev_dir_signed_d)
.arg(&mut self.diag_conv_hist_d)
// CRT.diag.2: per-horizon alpha-input EMA state.
.arg(&mut self.alpha_ema_per_b_per_h_d)
.arg(&mut self.alpha_diff_var_per_b_per_h_d)
.arg(&mut self.alpha_sample_var_per_b_per_h_d)
// CRT.diag.2 Group E: smoothed-direction flip counters.
.arg(&mut self.diag_smoothed_flip_count_d)
.arg(&mut self.diag_smoothed_current_run_length_d)
.arg(&mut self.diag_smoothed_sum_run_length_d)
.arg(&mut self.diag_smoothed_prev_dir_d)
.arg(&n)
.launch(cfg)?;
}
self.stream.synchronize()?;
// Persist open_horizon_mask into Pos.open_horizon_mask via a small
// helper kernel? No — the decision kernel wrote it into open_horizon_mask_d
// already, and pnl_track only needs it when detecting close. Instead
// of changing pnl_track's interface, we copy the device→device value
// into Pos.open_horizon_mask field via the matching kernel's natural
// flow: the submit_market kernel doesn't touch this field, but
// we set it here pre-trade so it persists until close.
//
// Simpler approach: read pos[b] for each backtest with active orders,
// OR the broadcast mask onto pos[b].open_horizon_mask. Done via a
// tiny host-loop here (cold path, runs once per decision; <50 µs).
self.merge_open_mask_into_pos()?;
// Step 2: P3 — GPU-side snapshot of pre-submit Pos state for
// close-detection. Replaces the host-side snapshot trio.
let mut launch = self.stream.launch_builder(&self.snapshot_pos_state_fn);
unsafe {
launch
.arg(&self.pos_d)
.arg(&mut self.prev_pos_lots_d)
.arg(&mut self.prev_realized_pnl_d)
.arg(&mut self.prev_open_horizon_mask_d)
.arg(&n)
.launch(cfg)?;
}
// Step 3: always dispatch through the latency path. When a backtest's
// latency_ns == 0 the in-flight slot's arrival_ts equals current_ts
// and step_resting_orders promotes immediately on the next snapshot,
// functionally equivalent to the legacy submit_market_immediate path
// but supporting per-backtest latencies in one code path.
self.dispatch_latent_market_orders(current_ts_ns, sim_cfg)?;
// Step 4: pnl_track — detect close, emit TradeRecord.
let cap = crate::lob::TRADE_LOG_CAP as i32;
let mut launch = self.stream.launch_builder(&self.pnl_track_fn);
unsafe {
launch
.arg(&mut self.pos_d)
.arg(&mut self.open_trade_state_d)
.arg(&mut self.trade_log_d)
.arg(&mut self.trade_log_head_d)
.arg(&current_ts_ns)
.arg(&cap)
.arg(&n)
.arg(&mut self.trail_hwm_d)
.arg(&mut self.zero_vwap_at_open_d)
.arg(&mut self.saturated_vwap_at_open_d)
.arg(&mut self.defensive_exit_clamp_d)
// CRT.1 C1.4: open branch snapshots conviction_at_entry +
// initializes the in-trade conviction_ema magnitude mirror
// in open_trade_state. Post-fix 2026-05-22: kernel reads
// the SIGNED EMA and takes fabsf before writing.
.arg(&self.conv_signed_ema_d)
// CRT.diag Groups C + D: hold-time + outcome-by-entry-conviction.
.arg(&mut self.diag_hold_hist_d)
.arg(&mut self.diag_outcome_n_d)
.arg(&mut self.diag_outcome_sum_pnl_d)
.arg(&mut self.diag_outcome_n_wins_d)
.launch(cfg)?;
}
self.stream.synchronize()?;
// Step 5: P3 — GPU-side close-transition detection + on-device
// dispatch of isv_kelly_update_on_close. No host roundtrip needed;
// kernel writes closed_horizon_mask_d + realised_return_d directly.
let mut launch = self.stream.launch_builder(&self.detect_close_fn);
unsafe {
launch
.arg(&self.pos_d)
.arg(&self.prev_pos_lots_d)
.arg(&self.prev_realized_pnl_d)
.arg(&self.prev_open_horizon_mask_d)
.arg(&mut self.closed_horizon_mask_d)
.arg(&mut self.realised_return_d)
.arg(&n)
.launch(cfg)?;
}
// isv_kelly_update_on_close always launches; kernel skips backtests
// whose mask is 0 internally. Cheaper than a host any_close branch
// that would require a memcpy_dtoh.
let mut launch = self.stream.launch_builder(&self.isv_kelly_update_fn);
unsafe {
launch
.arg(&mut self.isv_kelly_d)
.arg(&self.closed_horizon_mask_d)
.arg(&self.realised_return_d)
.arg(&n)
.launch(cfg)?;
}
self.stream.synchronize()?;
Ok(())
}
/// For each backtest whose market_targets[b] is a non-noop, allocate
/// an in-flight LimitSlot with arrival_ts_ns = current + latency_ns.
/// Price is set very-aggressive (mid ± 1000) so the marketability
/// check unconditionally crosses at arrival; this is the simplest
/// way to model a "market order with latency" — the slippage that
/// matters is which book the order arrives against, not the limit
/// price boundary.
fn dispatch_latent_market_orders(
&mut self,
current_ts_ns: u64,
_sim_cfg: &BatchedSimConfig,
) -> Result<()> {
// P2: GPU-side seed of in-flight LimitSlots. Replaces the host
// roundtrip loop. Per-backtest latency reads from self.latency_ns_d
// (already uploaded by step_decision_with_latency from sim_cfg).
let cfg_launch = LaunchConfig {
grid_dim: (self.n_backtests as u32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let n = self.n_backtests as i32;
let orders_bytes = crate::lob::ORDERS_BYTES as i32;
let mut launch = self.stream.launch_builder(&self.seed_inflight_limits_fn);
unsafe {
launch
.arg(&mut self.orders_d)
.arg(&orders_bytes)
.arg(&mut self.pos_d)
.arg(&self.market_targets_d)
.arg(&self.latency_ns_d)
.arg(&current_ts_ns)
// S2.1: counter 8 — seed_inflight saw force-flat target.
.arg(&mut self.mh_force_flat_seen_by_seed_d)
// CRT.1 C1.3: no-trade band — kernel skips seeding when
// |target_signed effective| < delta_floor_per_b[b].
.arg(&self.delta_floor_d)
.arg(&n)
.launch(cfg_launch)?;
}
self.stream.synchronize()?;
Ok(())
}
fn merge_open_mask_into_pos(&mut self) -> Result<()> {
// Read open_horizon_mask, OR into pos[b].open_horizon_mask field, push back.
// Pos field layout: position_lots(4) vwap_entry(4) realized_pnl(4)
// peak_equity(4) submission_overflow(4) open_horizon_mask(4).
let pos_bytes = std::mem::size_of::<PosFlat>();
let mut raw = vec![0u8; self.n_backtests * pos_bytes];
self.stream.memcpy_dtoh(&self.pos_d, raw.as_mut_slice())?;
let mut masks = vec![0u32; self.n_backtests];
self.stream.memcpy_dtoh(&self.open_horizon_mask_d, masks.as_mut_slice())?;
for b in 0..self.n_backtests {
let off = b * pos_bytes + 20; // open_horizon_mask offset within PosFlat
let mut cur_bytes = [0u8; 4];
cur_bytes.copy_from_slice(&raw[off..off + 4]);
let cur = u32::from_le_bytes(cur_bytes);
let merged = cur | masks[b];
raw[off..off + 4].copy_from_slice(&merged.to_le_bytes());
}
self.stream.memcpy_htod(raw.as_slice(), &mut self.pos_d)?;
Ok(())
}
/// Write per-horizon ISV-Kelly state for a specific backtest (warm-start).
pub fn write_isv_kelly(
&mut self,
backtest_idx: usize,
states: &[crate::policy::IsvKellyStateHost; N_HORIZONS],
) -> Result<()> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx,
self.n_backtests
);
let total_bytes = self.n_backtests * N_HORIZONS * ISV_KELLY_STATE_BYTES;
let mut raw = vec![0u8; total_bytes];
self.stream.memcpy_dtoh(&self.isv_kelly_d, raw.as_mut_slice())?;
let off = backtest_idx * N_HORIZONS * ISV_KELLY_STATE_BYTES;
let bytes: &[u8] = bytemuck::cast_slice(states);
raw[off..off + bytes.len()].copy_from_slice(bytes);
self.stream.memcpy_htod(raw.as_slice(), &mut self.isv_kelly_d)?;
Ok(())
}
/// Read per-horizon ISV-Kelly state for a specific backtest.
pub fn read_isv_kelly(
&self,
backtest_idx: usize,
) -> Result<[crate::policy::IsvKellyStateHost; N_HORIZONS]> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx,
self.n_backtests
);
let mut raw = vec![0u8; self.n_backtests * N_HORIZONS * ISV_KELLY_STATE_BYTES];
self.stream.memcpy_dtoh(&self.isv_kelly_d, raw.as_mut_slice())?;
let off = backtest_idx * N_HORIZONS * ISV_KELLY_STATE_BYTES;
let slice = &raw[off..off + N_HORIZONS * ISV_KELLY_STATE_BYTES];
let mut out = [crate::policy::IsvKellyStateHost::default(); N_HORIZONS];
let bytes_out: &mut [u8] = bytemuck::cast_slice_mut(&mut out);
bytes_out.copy_from_slice(slice);
Ok(out)
}
/// Seed a limit slot directly (host-side submission). Used for fixture
/// testing + as a v1 entry point until the decision kernel learns to
/// emit resting limits. side: 0=buy, 1=sell. kind: 1=Limit, 2=Ioc, 3=Fok.
/// active_state: 1=resting immediately, 2=in-flight (waits for arrival_ts_ns).
pub fn seed_limit_order(
&mut self,
backtest_idx: usize,
slot_idx: usize,
side: u8,
kind: u8,
active_state: u8,
oco_pair: u8,
price: f32,
size: f32,
queue_position_init: f32,
arrival_ts_ns: u64,
) -> Result<()> {
anyhow::ensure!(backtest_idx < self.n_backtests);
anyhow::ensure!(slot_idx < crate::lob::MAX_LIMITS);
// Reset overflow flag so we can detect double-allocation.
let zero = vec![0u32; 1];
self.stream.memcpy_htod(zero.as_slice(), &mut self.overflow_flag_d)?;
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
};
let orders_bytes = crate::lob::ORDERS_BYTES as i32;
let b = backtest_idx as i32;
let si = slot_idx as i32;
let mut launch = self.stream.launch_builder(&self.seed_limit_fn);
unsafe {
launch
.arg(&mut self.orders_d)
.arg(&orders_bytes)
.arg(&b)
.arg(&si)
.arg(&side)
.arg(&kind)
.arg(&active_state)
.arg(&oco_pair)
.arg(&price)
.arg(&size)
.arg(&queue_position_init)
.arg(&arrival_ts_ns)
.arg(&mut self.overflow_flag_d)
.launch(cfg)?;
}
self.stream.synchronize()?;
let mut overflow = vec![0u32; 1];
self.stream.memcpy_dtoh(&self.overflow_flag_d, overflow.as_mut_slice())?;
anyhow::ensure!(
overflow[0] == 0,
"seed_limit_order: slot {slot_idx} on backtest {backtest_idx} already in use"
);
Ok(())
}
/// Seed a stop slot. kind: 4=StopMarket, 5=StopLimit. For StopMarket
/// pass limit_price=0.0 (unused).
pub fn seed_stop_order(
&mut self,
backtest_idx: usize,
slot_idx: usize,
side: u8,
kind: u8,
active_state: u8,
oco_pair: u8,
trigger_price: f32,
limit_price: f32,
size: f32,
arrival_ts_ns: u64,
) -> Result<()> {
anyhow::ensure!(backtest_idx < self.n_backtests);
anyhow::ensure!(slot_idx < crate::lob::MAX_STOPS);
let zero = vec![0u32; 1];
self.stream.memcpy_htod(zero.as_slice(), &mut self.overflow_flag_d)?;
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
};
let orders_bytes = crate::lob::ORDERS_BYTES as i32;
let b = backtest_idx as i32;
let si = slot_idx as i32;
let mut launch = self.stream.launch_builder(&self.seed_stop_fn);
unsafe {
launch
.arg(&mut self.orders_d)
.arg(&orders_bytes)
.arg(&b)
.arg(&si)
.arg(&side)
.arg(&kind)
.arg(&active_state)
.arg(&oco_pair)
.arg(&trigger_price)
.arg(&limit_price)
.arg(&size)
.arg(&arrival_ts_ns)
.arg(&mut self.overflow_flag_d)
.launch(cfg)?;
}
self.stream.synchronize()?;
let mut overflow = vec![0u32; 1];
self.stream.memcpy_dtoh(&self.overflow_flag_d, overflow.as_mut_slice())?;
anyhow::ensure!(
overflow[0] == 0,
"seed_stop_order: slot {slot_idx} on backtest {backtest_idx} already in use"
);
Ok(())
}
/// Run resting_orders_step kernel: in-flight promotion + queue decay
/// + marketability check + stop trigger + OCO mutual cancellation.
/// Should be called once per event (after apply_snapshot).
pub fn step_resting_orders(&mut self, current_ts_ns: u64, trade_signed_vol: f32) -> Result<()> {
let cfg = LaunchConfig {
grid_dim: (self.n_backtests as u32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let n = self.n_backtests as i32;
let orders_bytes = crate::lob::ORDERS_BYTES as i32;
let pos_bytes = std::mem::size_of::<PosFlat>() as i32;
let cap = crate::lob::RING_CAP_EVENTS as i32;
let tick: f32 = 0.25;
let mut launch = self.stream.launch_builder(&self.resting_orders_fn);
unsafe {
launch
.arg(&self.books_d)
.arg(&mut self.orders_d)
.arg(&mut self.pos_d)
.arg(&mut self.audit_d)
.arg(&mut self.audit_head_d)
.arg(&current_ts_ns)
.arg(&trade_signed_vol)
.arg(&orders_bytes)
.arg(&pos_bytes)
.arg(&cap)
.arg(&tick)
.arg(&self.cost_per_lot_per_side_d)
.arg(&mut self.total_fees_per_b_d)
.arg(&mut self.last_event_ts_d)
// S2.2: max_hold force-close at event rate.
.arg(&self.max_hold_ns_d)
.arg(&self.open_trade_state_d)
.arg(&mut self.nan_avg_px_d)
.arg(&mut self.nan_realised_d)
.arg(&mut self.nan_realized_pnl_d)
.arg(&mut self.vwap_zero_open_flat_d)
.arg(&mut self.vwap_huge_open_flat_d)
.arg(&mut self.vwap_zero_scale_in_d)
.arg(&mut self.vwap_huge_scale_in_d)
.arg(&mut self.vwap_zero_flip_d)
.arg(&mut self.vwap_huge_flip_d)
.arg(&mut self.vwap_session_gap_was_bad_d)
.arg(&mut self.last_bad_vwap_d)
.arg(&mut self.last_bad_path_d)
.arg(&self.min_reasonable_px_d)
.arg(&self.max_reasonable_px_d)
.arg(&n)
.launch(cfg)?;
}
self.stream.synchronize()?;
// Sync pnl_track so any close from a resting fill emits a TradeRecord.
self.step_pnl_track(current_ts_ns)?;
Ok(())
}
/// Read one limit slot. Used for fixture inspection.
pub fn read_limit_slot(
&self,
backtest_idx: usize,
slot_idx: usize,
) -> Result<crate::lob::LimitSlotFlat> {
anyhow::ensure!(backtest_idx < self.n_backtests);
anyhow::ensure!(slot_idx < crate::lob::MAX_LIMITS);
let mut raw = vec![0u8; self.n_backtests * crate::lob::ORDERS_BYTES];
self.stream.memcpy_dtoh(&self.orders_d, raw.as_mut_slice())?;
let off = backtest_idx * crate::lob::ORDERS_BYTES
+ slot_idx * crate::lob::LIMIT_SLOT_BYTES;
let s: crate::lob::LimitSlotFlat =
bytemuck::pod_read_unaligned(&raw[off..off + crate::lob::LIMIT_SLOT_BYTES]);
Ok(s)
}
/// Read one stop slot.
pub fn read_stop_slot(
&self,
backtest_idx: usize,
slot_idx: usize,
) -> Result<crate::lob::StopSlotFlat> {
anyhow::ensure!(backtest_idx < self.n_backtests);
anyhow::ensure!(slot_idx < crate::lob::MAX_STOPS);
let mut raw = vec![0u8; self.n_backtests * crate::lob::ORDERS_BYTES];
self.stream.memcpy_dtoh(&self.orders_d, raw.as_mut_slice())?;
let stops_base = backtest_idx * crate::lob::ORDERS_BYTES
+ crate::lob::MAX_LIMITS * crate::lob::LIMIT_SLOT_BYTES;
let off = stops_base + slot_idx * crate::lob::STOP_SLOT_BYTES;
let s: crate::lob::StopSlotFlat =
bytemuck::pod_read_unaligned(&raw[off..off + crate::lob::STOP_SLOT_BYTES]);
Ok(s)
}
/// CRT.1 C1.3: count active (non-zero) limit slots for one backtest.
/// Returns the number of LimitSlots with active != 0, summing both
/// resting (active=1) and in-flight (active=2) slots. Used to verify
/// that the no-trade band blocks spurious re-seeding.
pub fn read_inflight_count(&self, backtest_idx: usize) -> Result<u32> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx,
self.n_backtests
);
let mut raw = vec![0u8; self.n_backtests * crate::lob::ORDERS_BYTES];
self.stream.memcpy_dtoh(&self.orders_d, raw.as_mut_slice())?;
let base = backtest_idx * crate::lob::ORDERS_BYTES;
let mut count = 0u32;
for s_idx in 0..crate::lob::MAX_LIMITS {
let off = base + s_idx * crate::lob::LIMIT_SLOT_BYTES;
// active is the first byte of LimitSlotFlat.
if raw[off] != 0 {
count += 1;
}
}
Ok(count)
}
/// Read back every backtest's book state from device. For tests + diagnostics.
pub fn read_books(&self) -> Result<Vec<BookFlat>> {
let mut raw = vec![0.0f32; self.n_backtests * BOOK_FIELDS * BOOK_LEVELS];
self.stream.memcpy_dtoh(&self.books_d, raw.as_mut_slice())?;
let mut out = Vec::with_capacity(self.n_backtests);
for b in 0..self.n_backtests {
let off = b * BOOK_FIELDS * BOOK_LEVELS;
let mut bf = BookFlat::default();
for k in 0..BOOK_LEVELS {
bf.bid_px[k] = raw[off + k];
bf.bid_sz[k] = raw[off + BOOK_LEVELS + k];
bf.ask_px[k] = raw[off + 2 * BOOK_LEVELS + k];
bf.ask_sz[k] = raw[off + 3 * BOOK_LEVELS + k];
}
out.push(bf);
}
Ok(out)
}
}