Files
foxhunt/crates/ml/tests/regime_wr_oracle_tests.rs
jgrusewski cc40780b80 chore(ml): file-level allow(unsafe_code) on 12 CUDA-launch files
CUDA kernel launches via cudarc::launch_builder and MappedF32::new
(cuMemHostAlloc DEVICEMAP FFI) inherently require unsafe blocks. The
workspace-wide '-W unsafe-code' lint produced ~80 warnings across these
files, all structurally identical. Match the established pattern from
ml-backtesting/src/harness.rs and ml-backtesting/src/sim/mod.rs: single
file-level allow with a comment explaining the rationale.

Files: alpha_dqn_h600_smoke, alpha_baseline, cublaslt_debug,
gpu_walk_forward, cuda_pipeline/mod, hyperopt adapters (mamba2, ppo),
DQN smoke_tests (helpers, td_propagation), trainers/ppo, and two
ml/tests files. Documented in dqn-wire-up-audit.md.
2026-05-22 20:13:09 +02:00

488 lines
20 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.
// CUDA kernel launches via cudarc + MappedF32::new (cuMemHostAlloc DEVICEMAP) require
// unsafe FFI. Workspace -W unsafe-code is structural noise here. Same rationale as
// ml-backtesting/src/harness.rs file-level allow.
#![allow(unsafe_code)]
//! Per-regime WR GPU oracle tests (audit follow-up 2026-05-09).
//!
//! Validates that `compute_backtest_metrics` correctly bucketises
//! per-trade win rate by ADX-norm regime, matching a CPU oracle to
//! ε=1e-5. Trades are attributed at trade-OPEN by the bar's ADX-norm
//! value (feature[40]) per the structural thresholds:
//! - Trending (T): ADX > 0.4
//! - Ranging (R): ADX < 0.2
//! - Volatile (V): otherwise
//!
//! Run on a GPU host:
//!
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//! cargo test -p ml --test regime_wr_oracle_tests \
//! --features cuda -- --ignored --nocapture
//!
//! Per `feedback_no_htod_htoh_only_mapped_pinned`, all buffers are
//! `MappedF32Buffer` / `MappedI32Buffer` and reads happen through the
//! mapped host alias after stream sync.
#![cfg(feature = "cuda")]
use std::sync::Arc;
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
use ml::cuda_pipeline::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
/// Cubin handle for the per-window metrics kernel.
const METRICS_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/backtest_metrics_kernel.cubin"));
const KERNEL_NAME: &str = "compute_backtest_metrics";
/// 4-branch action encoding constants (must match production).
const NUM_ACTIONS: i32 = 108; // 4 dir × 3 mag × 3 ord × 3 urg
const ORDER_ACTIONS: i32 = 3;
const URGENCY_ACTIONS: i32 = 3;
/// Direction divisor: action / (mag × ord × urg) = action / 27 = exp_idx,
/// then dir_idx = exp_idx / 3. So dir-only multiplier = 27.
const DIR_DIV: i32 = 27;
/// Direction encoding (must match `state_layout.cuh`). Tests exercise
/// only Short/Long transitions — Hold and Flat are kernel-side
/// "no-exposure" sentinels that never become trade-open bars under the
/// boundary key (`signed_dir != bnd_cur_action` collapses both into
/// the 0 bucket which doesn't open a trade).
const DIR_SHORT: i32 = 0;
const DIR_LONG: i32 = 2;
/// Per-regime ADX threshold mirrors of the kernel constants.
const REGIME_TRENDING_THRESHOLD: f32 = 0.4;
const REGIME_RANGING_THRESHOLD: f32 = 0.2;
const REGIME_ADX_FEATURE_IDX: usize = 40;
/// Output stride per window (must match kernel `metrics_out` layout).
const METRICS_STRIDE: usize = 19;
fn make_test_stream() -> Arc<CudaStream> {
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
ctx.default_stream()
}
fn load_metrics_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(METRICS_CUBIN.to_vec())
.expect("load backtest_metrics_kernel cubin");
module
.load_function(KERNEL_NAME)
.expect("load compute_backtest_metrics function")
}
/// Encode a packed action from a direction (short=0/long=2/flat=3).
/// Magnitude/order/urgency = 0 (canonical "Quarter Market Aggressive"
/// — irrelevant for trade-boundary purposes since the kernel collapses
/// magnitude into the direction key).
fn encode_dir(dir: i32) -> i32 {
dir * DIR_DIV
}
/// Classify a bar's regime from its ADX value (CPU oracle, mirrors the
/// kernel's `classify_bar_regime`).
fn classify_regime(adx: f32) -> usize {
if adx > REGIME_TRENDING_THRESHOLD {
0 // T
} else if adx < REGIME_RANGING_THRESHOLD {
1 // R
} else {
2 // V
}
}
/// Generic per-window oracle launcher. Builds a single-window batch
/// from `(actions, step_returns, adx_per_bar)`, launches the metrics
/// kernel, and returns the 19-float metrics output for that window.
///
/// The `adx_per_bar` slice produces a synthetic feature row per bar:
/// `feature[40] = adx`, all other slots zero. Feature dim = 42.
fn launch_metrics_for_window(
actions: &[i32],
step_returns: &[f32],
adx_per_bar: &[f32],
) -> Vec<f32> {
assert_eq!(actions.len(), step_returns.len());
assert_eq!(actions.len(), adx_per_bar.len());
let wlen = actions.len();
let max_len = wlen;
let n_windows = 1;
let feature_dim = 42usize;
// CUDA context must be initialized before MappedF32Buffer alloc
// (the cuMemHostAlloc DEVICEMAP path needs an active context).
// Stream creation binds the context to this thread; reuse the
// resulting stream for the kernel launch below.
let stream = make_test_stream();
let kernel = load_metrics_kernel(&stream);
// ── Buffers ──────────────────────────────────────────────────────
let step_returns_buf = unsafe { MappedF32Buffer::new(wlen) }
.expect("alloc step_returns");
step_returns_buf.write_from_slice(step_returns);
// 8 floats per window for portfolio_state (unused by the metrics
// kernel beyond `[w * 8]` indexing — kernel reads but doesn't act
// on portfolio fields).
let portfolio_buf = unsafe { MappedF32Buffer::new(8) }
.expect("alloc portfolio");
portfolio_buf.write_from_slice(&[0.0_f32; 8]);
let window_lens_buf = unsafe { MappedI32Buffer::new(1) }
.expect("alloc window_lens");
window_lens_buf.write_from_slice(&[wlen as i32]);
let actions_buf = unsafe { MappedI32Buffer::new(wlen) }
.expect("alloc actions_history");
actions_buf.write_from_slice(actions);
// Features [n_windows × max_len × feature_dim] — slot 40 = ADX.
let mut flat_features = vec![0.0_f32; n_windows * max_len * feature_dim];
for (i, &adx) in adx_per_bar.iter().enumerate() {
flat_features[i * feature_dim + REGIME_ADX_FEATURE_IDX] = adx;
}
let features_buf = unsafe { MappedF32Buffer::new(flat_features.len()) }
.expect("alloc features");
features_buf.write_from_slice(&flat_features);
let metrics_buf = unsafe { MappedF32Buffer::new(n_windows * METRICS_STRIDE) }
.expect("alloc metrics");
metrics_buf.write_from_slice(&vec![f32::NAN; n_windows * METRICS_STRIDE]);
// ── Launch ────────────────────────────────────────────────────────
// Shared memory: 11 reduction arrays × 256 threads × 4 bytes
// + 4096 floats for sort scratch / boundary data
let shmem_bytes: u32 = (256 * 11 + 4096) * std::mem::size_of::<f32>() as u32;
let cfg = LaunchConfig {
grid_dim: (n_windows as u32, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: shmem_bytes,
};
let n_win_i32 = n_windows as i32;
let max_len_i32 = max_len as i32;
let annualisation: f32 = 1.0; // not exercised by these tests
let num_actions = NUM_ACTIONS;
let order_actions = ORDER_ACTIONS;
let urgency_actions = URGENCY_ACTIONS;
let feature_dim_i32 = feature_dim as i32;
unsafe {
stream
.launch_builder(&kernel)
.arg(&step_returns_buf.dev_ptr)
.arg(&portfolio_buf.dev_ptr)
.arg(&window_lens_buf.dev_ptr)
.arg(&actions_buf.dev_ptr)
.arg(&features_buf.dev_ptr)
.arg(&feature_dim_i32)
.arg(&metrics_buf.dev_ptr)
.arg(&n_win_i32)
.arg(&max_len_i32)
.arg(&annualisation)
.arg(&num_actions)
.arg(&order_actions)
.arg(&urgency_actions)
.launch(cfg)
.expect("launch compute_backtest_metrics");
}
stream.synchronize().expect("sync after metrics kernel");
metrics_buf.read_all()
}
/// CPU oracle for per-regime WR. Walks the `(actions, step_returns,
/// adx)` series, replicates the kernel's trade-boundary key (collapses
/// Hold/Flat into "no exposure"), and computes per-regime
/// `(wins_T/R/V, trades_T/R/V)` attributing each trade to the regime
/// at its OPEN bar.
fn cpu_oracle_per_regime(
actions: &[i32],
step_returns: &[f32],
adx: &[f32],
) -> [u32; 6] {
let mut wins = [0u32; 3];
let mut trades = [0u32; 3];
let mut cur_signed_dir: i32 = 0;
let mut cur_open_regime: usize = 0;
let mut cur_return: f32 = 0.0;
let mut active = false;
for (i, &act) in actions.iter().enumerate() {
if act < 0 { continue; }
let exp_idx = act / (ORDER_ACTIONS * URGENCY_ACTIONS);
let raw_dir = exp_idx / 3;
let signed_dir = match raw_dir {
d if d == DIR_SHORT => -1,
d if d == DIR_LONG => 1,
_ => 0, // Hold(1) or Flat(3)
};
if !active {
cur_signed_dir = signed_dir;
cur_open_regime = classify_regime(adx[i]);
cur_return = step_returns[i];
active = true;
continue;
}
if signed_dir != cur_signed_dir {
// Close previous trade, attribute to its open regime.
trades[cur_open_regime] += 1;
if cur_return > 0.0 {
wins[cur_open_regime] += 1;
}
// Open new trade at this bar; its regime = bar's regime.
cur_signed_dir = signed_dir;
cur_open_regime = classify_regime(adx[i]);
cur_return = step_returns[i];
} else {
cur_return += step_returns[i];
}
}
// Final trailing trade.
if active {
trades[cur_open_regime] += 1;
if cur_return > 0.0 {
wins[cur_open_regime] += 1;
}
}
[
wins[0], trades[0],
wins[1], trades[1],
wins[2], trades[2],
]
}
/// CPU oracle test — sanity check for the oracle itself. Mixes 3
/// trades each in T/R/V buckets with known outcomes and verifies the
/// classifier + accounting.
#[test]
fn regime_wr_cpu_oracle_sanity() {
// 9 bars, 3 distinct trades per regime (transitions between
// long/short directions force a new trade each time).
// Bar layout (action, step_return, adx):
// bar 0: long +0.10 adx=0.5 (T)
// bar 1: short -0.05 adx=0.5 (T) ← closes trade 0 (T win=1)
// bar 2: long +0.20 adx=0.5 (T) ← closes trade 1 (T win=0, ret -0.05)
// bar 3: short +0.10 adx=0.1 (R) ← closes trade 2 (T win=1, ret +0.20)
// bar 4: long -0.10 adx=0.1 (R) ← closes trade 3 (R win=1, ret +0.10)
// bar 5: short -0.20 adx=0.1 (R) ← closes trade 4 (R win=0, ret -0.10)
// bar 6: long +0.30 adx=0.3 (V) ← closes trade 5 (R win=0, ret -0.20)
// bar 7: short +0.10 adx=0.3 (V) ← closes trade 6 (V win=1, ret +0.30)
// bar 8: long +0.05 adx=0.3 (V) ← closes trade 7 (V win=1, ret +0.10)
// final trailing trade 8: V at bar 8, ret +0.05 → win=1
let actions: Vec<i32> = vec![
encode_dir(DIR_LONG), // 0 — opens trade 0 (T)
encode_dir(DIR_SHORT), // 1 — closes 0 (+0.10), opens 1 (T)
encode_dir(DIR_LONG), // 2 — closes 1 (-0.05), opens 2 (T)
encode_dir(DIR_SHORT), // 3 — closes 2 (+0.20), opens 3 (R)
encode_dir(DIR_LONG), // 4 — closes 3 (+0.10), opens 4 (R)
encode_dir(DIR_SHORT), // 5 — closes 4 (-0.10), opens 5 (R)
encode_dir(DIR_LONG), // 6 — closes 5 (-0.20), opens 6 (V)
encode_dir(DIR_SHORT), // 7 — closes 6 (+0.30), opens 7 (V)
encode_dir(DIR_LONG), // 8 — closes 7 (+0.10), opens 8 (V)
];
let step_ret: Vec<f32> = vec![
0.10, -0.05, 0.20, 0.10, -0.10, -0.20, 0.30, 0.10, 0.05,
];
let adx: Vec<f32> = vec![0.5, 0.5, 0.5, 0.1, 0.1, 0.1, 0.3, 0.3, 0.3];
let result = cpu_oracle_per_regime(&actions, &step_ret, &adx);
// T: trades 0,1,2 → outcomes +0.10, -0.05, +0.20 → 2 wins / 3 trades
assert_eq!(result[1], 3, "T trades count");
assert_eq!(result[0], 2, "T wins count");
// R: trades 3,4,5 → outcomes +0.10, -0.10, -0.20 → 1 win / 3 trades
assert_eq!(result[3], 3, "R trades count");
assert_eq!(result[2], 1, "R wins count");
// V: trades 6,7 (closed) + trade 8 (trailing, +0.05) → outcomes
// +0.30, +0.10, +0.05 → 3 wins / 3 trades
assert_eq!(result[5], 3, "V trades count");
assert_eq!(result[4], 3, "V wins count");
}
/// GPU oracle test — bit-exact match of the kernel's per-regime WR
/// against the CPU oracle on the same 9-bar synthetic batch from the
/// CPU sanity test.
#[test]
#[ignore = "requires GPU"]
fn regime_wr_gpu_oracle_matches_cpu() {
let actions: Vec<i32> = vec![
encode_dir(DIR_LONG),
encode_dir(DIR_SHORT),
encode_dir(DIR_LONG),
encode_dir(DIR_SHORT),
encode_dir(DIR_LONG),
encode_dir(DIR_SHORT),
encode_dir(DIR_LONG),
encode_dir(DIR_SHORT),
encode_dir(DIR_LONG),
];
let step_ret: Vec<f32> = vec![
0.10, -0.05, 0.20, 0.10, -0.10, -0.20, 0.30, 0.10, 0.05,
];
let adx: Vec<f32> = vec![0.5, 0.5, 0.5, 0.1, 0.1, 0.1, 0.3, 0.3, 0.3];
let cpu = cpu_oracle_per_regime(&actions, &step_ret, &adx);
let metrics = launch_metrics_for_window(&actions, &step_ret, &adx);
let wr_t = metrics[13];
let n_t = metrics[14];
let wr_r = metrics[15];
let n_r = metrics[16];
let wr_v = metrics[17];
let n_v = metrics[18];
let exp_wr_t = cpu[0] as f32 / cpu[1].max(1) as f32;
let exp_wr_r = cpu[2] as f32 / cpu[3].max(1) as f32;
let exp_wr_v = cpu[4] as f32 / cpu[5].max(1) as f32;
let eps = 1e-5_f32;
assert!((wr_t - exp_wr_t).abs() < eps,
"T WR: kernel={wr_t:.6} cpu={exp_wr_t:.6}");
assert!((wr_r - exp_wr_r).abs() < eps,
"R WR: kernel={wr_r:.6} cpu={exp_wr_r:.6}");
assert!((wr_v - exp_wr_v).abs() < eps,
"V WR: kernel={wr_v:.6} cpu={exp_wr_v:.6}");
assert!((n_t - cpu[1] as f32).abs() < eps,
"T trades: kernel={n_t} cpu={}", cpu[1]);
assert!((n_r - cpu[3] as f32).abs() < eps,
"R trades: kernel={n_r} cpu={}", cpu[3]);
assert!((n_v - cpu[5] as f32).abs() < eps,
"V trades: kernel={n_v} cpu={}", cpu[5]);
// Per-regime trade counts must sum to the aggregate trade count.
let total_trades = metrics[4];
let regime_total = n_t + n_r + n_v;
assert!((regime_total - total_trades).abs() < eps,
"regime total {regime_total} != aggregate trade count {total_trades}");
}
/// GPU oracle test — known-distribution stratified batch.
///
/// 30% trending / 40% ranging / 30% volatile by bar count, with
/// per-regime trade outcomes that fix exactly:
/// T: 2/3 win rate (2 wins, 1 loss)
/// R: 1/4 win rate (1 win, 3 losses)
/// V: 1/3 win rate (1 win, 2 losses)
///
/// Verifies the kernel honours the regime-conditional skew. Uses a
/// 30-bar window — large enough to exercise the bitonic-sort path
/// without being so large that boundary stitching dominates the
/// regime-attribution test.
#[test]
#[ignore = "requires GPU"]
fn regime_wr_known_distribution_30_40_30() {
// 30 bars total. Trade transitions every 3 bars (10 trades).
// Bars 0-8: T regime (3 trades). Bars 9-20: R regime (4 trades).
// Bars 21-29: V regime (3 trades).
let mut actions: Vec<i32> = Vec::with_capacity(30);
let mut step_ret: Vec<f32> = Vec::with_capacity(30);
let mut adx: Vec<f32> = Vec::with_capacity(30);
// Helper: push a trade chunk with `dir` direction and per-bar
// returns. The trade closes when the next chunk has a different
// direction.
let push_trade = |actions: &mut Vec<i32>,
step_ret: &mut Vec<f32>,
adx: &mut Vec<f32>,
dir: i32,
returns: &[f32],
adx_value: f32| {
for &r in returns {
actions.push(encode_dir(dir));
step_ret.push(r);
adx.push(adx_value);
}
};
// T regime, ADX=0.7 (>0.4 → Trending). 3 trades.
// Trade T0: long, returns sum to +0.15 (win)
// Trade T1: short, returns sum to +0.10 (win)
// Trade T2: long, returns sum to -0.05 (loss)
push_trade(&mut actions, &mut step_ret, &mut adx, DIR_LONG, &[0.05, 0.05, 0.05], 0.7);
push_trade(&mut actions, &mut step_ret, &mut adx, DIR_SHORT, &[0.05, 0.04, 0.01], 0.7);
push_trade(&mut actions, &mut step_ret, &mut adx, DIR_LONG, &[-0.02, -0.02, -0.01], 0.7);
// R regime, ADX=0.1 (<0.2 → Ranging). 4 trades.
// Trade R0: short, returns sum to +0.05 (win)
// Trade R1: long, returns sum to -0.05 (loss)
// Trade R2: short, returns sum to -0.10 (loss)
// Trade R3: long, returns sum to -0.05 (loss)
push_trade(&mut actions, &mut step_ret, &mut adx, DIR_SHORT, &[0.02, 0.02, 0.01], 0.1);
push_trade(&mut actions, &mut step_ret, &mut adx, DIR_LONG, &[-0.02, -0.02, -0.01], 0.1);
push_trade(&mut actions, &mut step_ret, &mut adx, DIR_SHORT, &[-0.04, -0.03, -0.03], 0.1);
push_trade(&mut actions, &mut step_ret, &mut adx, DIR_LONG, &[-0.02, -0.02, -0.01], 0.1);
// V regime, ADX=0.3 (between → Volatile). 3 trades.
// Trade V0: short, +0.15 (win)
// Trade V1: long, -0.05 (loss)
// Trade V2: short, -0.10 (loss)
push_trade(&mut actions, &mut step_ret, &mut adx, DIR_SHORT, &[0.05, 0.05, 0.05], 0.3);
push_trade(&mut actions, &mut step_ret, &mut adx, DIR_LONG, &[-0.02, -0.02, -0.01], 0.3);
push_trade(&mut actions, &mut step_ret, &mut adx, DIR_SHORT, &[-0.04, -0.03, -0.03], 0.3);
assert_eq!(actions.len(), 30, "30-bar window");
// Sanity-check the CPU oracle matches the design.
let cpu = cpu_oracle_per_regime(&actions, &step_ret, &adx);
assert_eq!(cpu[1], 3, "T trades = 3 (CPU oracle)");
assert_eq!(cpu[0], 2, "T wins = 2 (CPU oracle)");
assert_eq!(cpu[3], 4, "R trades = 4 (CPU oracle)");
assert_eq!(cpu[2], 1, "R wins = 1 (CPU oracle)");
assert_eq!(cpu[5], 3, "V trades = 3 (CPU oracle)");
assert_eq!(cpu[4], 1, "V wins = 1 (CPU oracle)");
let metrics = launch_metrics_for_window(&actions, &step_ret, &adx);
let eps = 1e-5_f32;
let wr_t = metrics[13];
let n_t = metrics[14];
let wr_r = metrics[15];
let n_r = metrics[16];
let wr_v = metrics[17];
let n_v = metrics[18];
let exp_wr_t = 2.0 / 3.0;
let exp_wr_r = 1.0 / 4.0;
let exp_wr_v = 1.0 / 3.0;
assert!((wr_t - exp_wr_t).abs() < eps, "T WR: kernel={wr_t} expected={exp_wr_t}");
assert!((wr_r - exp_wr_r).abs() < eps, "R WR: kernel={wr_r} expected={exp_wr_r}");
assert!((wr_v - exp_wr_v).abs() < eps, "V WR: kernel={wr_v} expected={exp_wr_v}");
assert!((n_t - 3.0).abs() < eps, "T trades: kernel={n_t} expected=3");
assert!((n_r - 4.0).abs() < eps, "R trades: kernel={n_r} expected=4");
assert!((n_v - 3.0).abs() < eps, "V trades: kernel={n_v} expected=3");
}
/// GPU oracle test — empty-trade resilience. When all bars have the
/// same direction (single trade), only one regime bucket is non-zero
/// and the others must report exactly 0 trades / 0 WR (matches the
/// aggregate WR's empty-trades convention).
#[test]
#[ignore = "requires GPU"]
fn regime_wr_single_regime_only_one_bucket_populated() {
// 5-bar long-only trade in the trending regime.
let actions: Vec<i32> = (0..5).map(|_| encode_dir(DIR_LONG)).collect();
let step_ret: Vec<f32> = vec![0.01, 0.01, 0.01, 0.01, 0.01];
let adx: Vec<f32> = vec![0.6; 5]; // ADX>0.4 → all T
let metrics = launch_metrics_for_window(&actions, &step_ret, &adx);
let eps = 1e-5_f32;
// 1 trade, T bucket only.
assert!((metrics[14] - 1.0).abs() < eps, "T trades = 1");
assert!((metrics[13] - 1.0).abs() < eps, "T WR = 1.0 (winner)");
assert!(metrics[16].abs() < eps, "R trades = 0");
assert!(metrics[15].abs() < eps, "R WR = 0 (no trades)");
assert!(metrics[18].abs() < eps, "V trades = 0");
assert!(metrics[17].abs() < eps, "V WR = 0 (no trades)");
}