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>
2041 lines
94 KiB
Rust
2041 lines
94 KiB
Rust
//! 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 (new−prev)²
|
||
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(¤t_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(¤t_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(¤t_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(¤t_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)
|
||
}
|
||
}
|