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.
488 lines
20 KiB
Rust
488 lines
20 KiB
Rust
// 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)");
|
||
}
|