#![allow( clippy::assertions_on_constants, clippy::assertions_on_result_states, clippy::clone_on_copy, clippy::decimal_literal_representation, clippy::doc_markdown, clippy::empty_line_after_doc_comments, clippy::field_reassign_with_default, clippy::get_unwrap, clippy::identity_op, clippy::inconsistent_digit_grouping, clippy::indexing_slicing, clippy::integer_division, clippy::len_zero, clippy::let_underscore_must_use, clippy::manual_div_ceil, clippy::manual_let_else, clippy::manual_range_contains, clippy::modulo_arithmetic, clippy::needless_range_loop, clippy::non_ascii_literal, clippy::redundant_clone, clippy::shadow_reuse, clippy::shadow_same, clippy::shadow_unrelated, clippy::single_match_else, clippy::str_to_string, clippy::string_slice, clippy::tests_outside_test_module, clippy::too_many_lines, clippy::unnecessary_wraps, clippy::unseparated_literal_suffix, clippy::use_debug, clippy::useless_vec, clippy::wildcard_enum_match_arm, clippy::else_if_without_else, clippy::expect_used, clippy::missing_const_for_fn, clippy::similar_names, clippy::type_complexity, clippy::collapsible_else_if, clippy::doc_lazy_continuation, clippy::items_after_test_module, clippy::map_clone, clippy::multiple_unsafe_ops_per_block, clippy::unwrap_or_default, clippy::assign_op_pattern, clippy::needless_borrow, clippy::println_empty_string, clippy::unnecessary_cast, clippy::used_underscore_binding, clippy::create_dir, clippy::implicit_saturating_sub, clippy::exit, clippy::expect_fun_call, clippy::too_many_arguments, clippy::unnecessary_map_or, clippy::unwrap_used, dead_code, unused_imports, unused_variables, clippy::cloned_ref_to_slice_refs, clippy::neg_multiply, clippy::while_let_loop, clippy::bool_assert_comparison, clippy::excessive_precision, clippy::trivially_copy_pass_by_ref, clippy::op_ref, clippy::redundant_closure, clippy::unnecessary_lazy_evaluations, clippy::if_then_some_else_none, clippy::unnecessary_to_owned, clippy::single_component_path_imports, )] //! Validates GPU backtest evaluator produces reasonable metrics //! using synthetic data and deterministic action models. //! //! These tests require a CUDA GPU and are skipped gracefully when none is available. //! Run with: `cargo test -p ml --test gpu_backtest_validation -- --ignored` use std::sync::Arc; /// Generate deterministic synthetic price data (random walk with drift) using LCG. fn generate_prices(n_bars: usize, seed: u64, drift: f32) -> Vec<[f32; 4]> { let mut rng_state = seed; let mut prices = Vec::with_capacity(n_bars); let mut price = 100.0_f32; for _ in 0..n_bars { // Simple LCG for determinism rng_state = rng_state .wrapping_mul(6_364_136_223_846_793_005) .wrapping_add(1_442_695_040_888_963_407); let rand_f = ((rng_state >> 33) as f32) / (u32::MAX as f32) - 0.5; let ret = drift + rand_f * 0.02; price *= 1.0 + ret; let ohlc = [price * 0.999, price * 1.001, price * 0.998, price]; prices.push(ohlc); } prices } /// Generate minimal synthetic features (just enough for the evaluator). fn generate_features(n_bars: usize, feature_dim: usize) -> Vec> { (0..n_bars) .map(|i| { let mut fv = vec![0.0_f32; feature_dim]; // Put some variation in features so they are not all zero if let Some(f) = fv.get_mut(0) { *f = (i as f32) * 0.001; } fv }) .collect() } mod gpu_tests { use super::*; use cudarc::driver::{CudaContext, CudaSlice, CudaStream}; use ml::cuda_pipeline::gpu_backtest_evaluator::{ DqnBacktestConfig, GpuBacktestConfig, GpuBacktestEvaluator, }; use ml::cuda_pipeline::lob_bar::LobBar; use ml::MLError; use tracing::warn; /// SP15 Wave 3b — build a deterministic `[Vec; 1]` shaped to /// match the single-window OHLC `prices` slice passed to /// `GpuBacktestEvaluator::new`. Uses the close price for `LobBar.price` /// and zeroes `spread`/`ofi` (these tests don't exercise cost-net /// sharpe — they assert raw PnL/drawdown semantics — so the LobBar /// streams are inert by construction). fn lob_bars_from_prices(prices: &[[f32; 4]]) -> Vec { prices .iter() .map(|ohlc| LobBar::new(ohlc[3], 0.0, 0.0)) .collect() } /// Skip test gracefully if no CUDA device is available. /// Returns the stream (owned Arc) on success. fn try_cuda_stream() -> Option> { match CudaContext::new(0) { Ok(ctx) => Some(ctx.default_stream()), Err(_) => { warn!("CUDA not available, skipping GPU backtest test"); None } } } /// Build a closure that always returns action indices for the given `action`. /// /// The closure receives `(&CudaSlice, n_windows, state_dim)` and /// returns `CudaSlice` of length `n_windows` filled with `action`. fn constant_action_model( action: i32, stream: &Arc, ) -> impl Fn(&CudaSlice, usize, usize) -> Result, MLError> { let stream = Arc::clone(stream); move |_states: &CudaSlice, n_windows: usize, _state_dim: usize| { let host_actions = vec![action; n_windows]; let mut gpu_actions = stream .alloc_zeros::(n_windows) .map_err(|e| MLError::ModelError(format!("constant_action_model alloc: {e}")))?; stream .memcpy_htod(&host_actions, &mut gpu_actions) .map_err(|e| MLError::ModelError(format!("constant_action_model HtoD: {e}")))?; Ok(gpu_actions) } } // -- Individual test cases -- /// Always-long model on upward-trending data should produce positive PnL. #[test] fn test_always_long_on_uptrend() { let stream = match try_cuda_stream() { Some(s) => s, None => return, }; // FEATURE_DIM=42 mirrors production eval-baseline.rs (Market 42 from // extract_ml_features). The gather kernel computes `market_dim = // feat_dim - SL_OFI_DIM (32)` so anything < 32 produces a negative // market_dim → kernel OOB. 42 is the canonical production value. const FEATURE_DIM: usize = 42; const N_BARS: usize = 500; let prices = generate_prices(N_BARS, 42, 0.001); // positive drift let features = generate_features(N_BARS, FEATURE_DIM); let config = GpuBacktestConfig { max_position: 1.0, tx_cost_bps: 0.0, // zero costs for a clean signal spread_cost: 0.0, initial_capital: 100_000.0, ..Default::default() }; let bars = lob_bars_from_prices(&prices); let mut evaluator = GpuBacktestEvaluator::new( &[prices], &[features], &[bars], FEATURE_DIM, config, &stream, ) .expect("evaluator creation should succeed"); // Phase 8.5 (2026-05-12) — wire factored-action branch sizes for // env_step decoder. Without this, `evaluate()` bails on the // zero-b-size defensive guard. Match production eval-baseline.rs. evaluator.set_branch_sizes(&DqnBacktestConfig::from_network_dims((256, 256, 128, 128))); // Action 4 = Long100 let model = constant_action_model(4, &stream); let metrics = evaluator .evaluate(&model, 24) .expect("evaluation should succeed"); assert_eq!(metrics.len(), 1, "expected exactly one window result"); let m = metrics .first() .expect("metrics vec must have at least one element"); // With positive drift and always-long, should be profitable assert!( m.total_pnl > 0.0, "expected positive PnL for long on uptrend, got {}", m.total_pnl ); assert!( m.max_drawdown >= 0.0, "drawdown should be non-negative, got {}", m.max_drawdown ); assert!( (0.0..=1.0).contains(&m.win_rate), "win_rate {} is out of [0, 1] range", m.win_rate ); } /// Always-long model on downward-trending data should produce negative PnL. #[test] fn test_always_long_on_downtrend() { let stream = match try_cuda_stream() { Some(s) => s, None => return, }; // FEATURE_DIM=42 mirrors production eval-baseline.rs (Market 42 from // extract_ml_features). The gather kernel computes `market_dim = // feat_dim - SL_OFI_DIM (32)` so anything < 32 produces a negative // market_dim → kernel OOB. 42 is the canonical production value. const FEATURE_DIM: usize = 42; const N_BARS: usize = 500; let prices = generate_prices(N_BARS, 77, -0.001); // negative drift let features = generate_features(N_BARS, FEATURE_DIM); let config = GpuBacktestConfig { max_position: 1.0, tx_cost_bps: 0.0, spread_cost: 0.0, initial_capital: 100_000.0, ..Default::default() }; let bars = lob_bars_from_prices(&prices); let mut evaluator = GpuBacktestEvaluator::new( &[prices], &[features], &[bars], FEATURE_DIM, config, &stream, ) .expect("evaluator creation should succeed"); // Phase 8.5 (2026-05-12) — wire factored-action branch sizes for // env_step decoder. Match production eval-baseline.rs. evaluator.set_branch_sizes(&DqnBacktestConfig::from_network_dims((256, 256, 128, 128))); // Action 72 = Long×Full×order=0×urgency=0 under 4-3-3-3 factored // decoding (action = dir*27 + mag*9 + order*3 + urgency = 2*27 + // 2*9 + 0 + 0). Pre-Phase-8.5 the eval used a flat 4-action enum // where `4` reportedly meant Long100, but SP21 Phase 8.5 // (2026-05-12) wired the factored decoder where `4` now decodes // to Short-Quarter (dir=0, mag=0). The "Always Long100" comment // was correct intent but the constant wasn't migrated. let model = constant_action_model(72, &stream); // Always Long100 (factored) let metrics = evaluator .evaluate(&model, 24) .expect("evaluation should succeed"); assert_eq!(metrics.len(), 1); let m = metrics .first() .expect("metrics vec must have at least one element"); assert!( m.total_pnl < 0.0, "expected negative PnL for long on downtrend, got {}", m.total_pnl ); assert!( m.max_drawdown >= 0.0, "drawdown should be non-negative, got {}", m.max_drawdown ); } /// Always-flat model should produce ~zero PnL and minimal trades. #[test] fn test_always_flat_produces_no_pnl() { let stream = match try_cuda_stream() { Some(s) => s, None => return, }; // FEATURE_DIM=42 mirrors production eval-baseline.rs (Market 42 from // extract_ml_features). The gather kernel computes `market_dim = // feat_dim - SL_OFI_DIM (32)` so anything < 32 produces a negative // market_dim → kernel OOB. 42 is the canonical production value. const FEATURE_DIM: usize = 42; const N_BARS: usize = 200; let prices = generate_prices(N_BARS, 99, 0.0); let features = generate_features(N_BARS, FEATURE_DIM); let config = GpuBacktestConfig::default(); let bars = lob_bars_from_prices(&prices); let mut evaluator = GpuBacktestEvaluator::new( &[prices], &[features], &[bars], FEATURE_DIM, config, &stream, ) .expect("evaluator creation should succeed"); // Phase 8.5 (2026-05-12) — wire factored-action branch sizes for // env_step decoder. Without this, `evaluate()` bails on the // zero-b-size defensive guard. Match production eval-baseline.rs. evaluator.set_branch_sizes(&DqnBacktestConfig::from_network_dims((256, 256, 128, 128))); // Action 2 = Flat -- never enters a position let model = constant_action_model(2, &stream); let metrics = evaluator .evaluate(&model, 24) .expect("evaluation should succeed"); assert_eq!(metrics.len(), 1); let m = metrics .first() .expect("metrics vec must have at least one element"); // Flat action means no position changes, so PnL should be approximately zero assert!( m.total_pnl.abs() < 0.01, "expected ~zero PnL for flat model, got {}", m.total_pnl ); } /// Multiple windows must produce one result per window with sensible ordering. #[test] fn test_multiple_windows_produce_results() { let stream = match try_cuda_stream() { Some(s) => s, None => return, }; // FEATURE_DIM=42 mirrors production eval-baseline.rs (Market 42 from // extract_ml_features). The gather kernel computes `market_dim = // feat_dim - SL_OFI_DIM (32)` so anything < 32 produces a negative // market_dim → kernel OOB. 42 is the canonical production value. const FEATURE_DIM: usize = 42; const N_BARS: usize = 300; let prices_up = generate_prices(N_BARS, 42, 0.001); let prices_down = generate_prices(N_BARS, 123, -0.001); let features1 = generate_features(N_BARS, FEATURE_DIM); let features2 = generate_features(N_BARS, FEATURE_DIM); let config = GpuBacktestConfig { max_position: 1.0, tx_cost_bps: 0.0, spread_cost: 0.0, initial_capital: 100_000.0, ..Default::default() }; let bars_up = lob_bars_from_prices(&prices_up); let bars_down = lob_bars_from_prices(&prices_down); let mut evaluator = GpuBacktestEvaluator::new( &[prices_up, prices_down], &[features1, features2], &[bars_up, bars_down], FEATURE_DIM, config, &stream, ) .expect("evaluator creation should succeed"); // Phase 8.5 (2026-05-12) — wire factored-action branch sizes for // env_step decoder. Match production eval-baseline.rs. evaluator.set_branch_sizes(&DqnBacktestConfig::from_network_dims((256, 256, 128, 128))); // Action 72 = Long×Full×order=0×urgency=0 under 4-3-3-3 factored // decoding (see test_always_long_on_downtrend for full derivation). let model = constant_action_model(72, &stream); // Always Long100 (factored) let metrics = evaluator .evaluate(&model, 24) .expect("evaluation should succeed"); assert_eq!(metrics.len(), 2, "expected exactly 2 window results"); let m0 = metrics.first().expect("window 0 result must exist"); let m1 = metrics.get(1).expect("window 1 result must exist"); // Uptrend window (0) should be more profitable than downtrend window (1) assert!( m0.total_pnl > m1.total_pnl, "expected uptrend window more profitable: {} vs {}", m0.total_pnl, m1.total_pnl ); // Both drawdowns must be non-negative assert!(m0.max_drawdown >= 0.0, "window 0 drawdown negative"); assert!(m1.max_drawdown >= 0.0, "window 1 drawdown negative"); } /// Extended metrics (VaR, CVaR, Calmar, Omega) must be finite and self-consistent. #[test] fn test_extended_metrics_populated() { let stream = match try_cuda_stream() { Some(s) => s, None => return, }; // FEATURE_DIM=42 mirrors production eval-baseline.rs (Market 42 from // extract_ml_features). The gather kernel computes `market_dim = // feat_dim - SL_OFI_DIM (32)` so anything < 32 produces a negative // market_dim → kernel OOB. 42 is the canonical production value. const FEATURE_DIM: usize = 42; const N_BARS: usize = 500; let prices = generate_prices(N_BARS, 42, 0.001); let features = generate_features(N_BARS, FEATURE_DIM); let config = GpuBacktestConfig::default(); let bars = lob_bars_from_prices(&prices); let mut evaluator = GpuBacktestEvaluator::new( &[prices], &[features], &[bars], FEATURE_DIM, config, &stream, ) .expect("evaluator creation should succeed"); // Phase 8.5 (2026-05-12) — wire factored-action branch sizes for // env_step decoder. Match production eval-baseline.rs. evaluator.set_branch_sizes(&DqnBacktestConfig::from_network_dims((256, 256, 128, 128))); let model = constant_action_model(4, &stream); let metrics = evaluator .evaluate(&model, 24) .expect("evaluation should succeed"); let m = metrics .first() .expect("metrics vec must have at least one element"); assert!(!m.var_95.is_nan(), "VaR should not be NaN"); assert!(!m.cvar_95.is_nan(), "CVaR should not be NaN"); assert!(!m.calmar.is_nan(), "Calmar should not be NaN"); assert!(!m.omega_ratio.is_nan(), "Omega ratio should not be NaN"); // CVaR (conditional VaR / expected shortfall) must be <= VaR because CVaR // averages the worst returns that are already worse than the VaR threshold. // Add a small tolerance for floating-point rounding. assert!( m.cvar_95 <= m.var_95 + 1e-3, "CVaR {} should be <= VaR {} (mean of tail should not exceed threshold)", m.cvar_95, m.var_95 ); } /// total_trades must be positive when the model takes an active position. #[test] fn test_active_model_records_trades() { let stream = match try_cuda_stream() { Some(s) => s, None => return, }; // FEATURE_DIM=42 mirrors production eval-baseline.rs (Market 42 from // extract_ml_features). The gather kernel computes `market_dim = // feat_dim - SL_OFI_DIM (32)` so anything < 32 produces a negative // market_dim → kernel OOB. 42 is the canonical production value. const FEATURE_DIM: usize = 42; const N_BARS: usize = 300; let prices = generate_prices(N_BARS, 55, 0.001); let features = generate_features(N_BARS, FEATURE_DIM); let config = GpuBacktestConfig::default(); let bars = lob_bars_from_prices(&prices); let mut evaluator = GpuBacktestEvaluator::new( &[prices], &[features], &[bars], FEATURE_DIM, config, &stream, ) .expect("evaluator creation should succeed"); // Phase 8.5 (2026-05-12) — wire factored-action branch sizes for // env_step decoder. Match production eval-baseline.rs. evaluator.set_branch_sizes(&DqnBacktestConfig::from_network_dims((256, 256, 128, 128))); let model = constant_action_model(4, &stream); // Always Long100 let metrics = evaluator .evaluate(&model, 24) .expect("evaluation should succeed"); let m = metrics .first() .expect("metrics vec must have at least one element"); // An always-long model must execute at least the initial entry trade assert!( m.total_trades > 0.0, "expected at least one trade for active model, got {}", m.total_trades ); assert!( (0.0..=1.0).contains(&m.win_rate), "win_rate {} out of [0, 1]", m.win_rate ); } }