Files
foxhunt/crates/ml/tests/gpu_backtest_validation.rs
jgrusewski 71eab9a253 fix(tests): gpu_backtest_validation action constants — Long100 is 72, not 4
Two `gpu_backtest_validation` tests were failing with bit-identical
deterministic values for 2+ months: `test_always_long_on_downtrend`
(expected negative PnL, got +0.00023627281) and
`test_multiple_windows_produce_results` (uptrend < downtrend instead
of > ).

Root cause: SP21 Phase 8.5 (2026-05-12) wired the factored 4-3-3-3
action decoder into the eval (`backtest_state_gather` + env_step),
but the test's hardcoded `constant_action_model(4, ...)` integer
literal wasn't migrated. Pre-Phase-8.5 the eval used a flat
4-action enum where `4` reportedly meant Long100; the factored
decoder now interprets `4` as:

  decode_direction_4b(4, b1=3, b2=3, b3=3) = 4 / 27 = 0 = DIR_SHORT
  decode_magnitude_4b(4, b1=3, b2=3, b3=3) = (4/9) % 3 = 0 = MAG_QUARTER

So the test was running Short-Quarter (-0.25 position) on the trend
fixtures. On random-walk synthetic prices with drift ±0.001 vs σ=0.01
noise per bar, the 24-step eval window has S/N ≈ 0.49 — specific
seeds can produce net-against-drift trajectories, making the actual
short-quarter PnL small but deterministic, with sign flipped relative
to test intent.

Fix: change `constant_action_model(4, ...)` → `constant_action_model(72, ...)`
in the 2 failing tests. Action 72 = dir=LONG (2) * 27 + mag=FULL (2)
* 9 + 0 + 0 — the actual "Long100" under 4-3-3-3 factoring. Both
tests now pass; no regressions on the 4 previously-passing tests.

Verification
────────────
- gpu_backtest_validation pre-fix: 4 passed, 2 failed
- gpu_backtest_validation post-fix: 6 passed, 0 failed

Out of scope for this commit (follow-up audit needed)
─────────────────────────────────────────────────────
Three other tests in the same file have the same stale `4` constant
with misleading "Always Long100" comments, but currently pass
incidentally:

- `test_always_long_on_uptrend` (line 218): asserts `total_pnl > 0`.
  Currently passes BY ACCIDENT — action=4 (Short-Quarter) on seed-42's
  net-down 24-bar trajectory produces +PnL, satisfying the assertion
  for the wrong reason. Fixing to action=72 alone would break this
  test (true Long100 on seed-42's net-down trajectory is negative);
  the test needs BOTH the action fix AND a seed/window change so the
  "uptrend" trajectory actually trends up over the eval window
  (e.g., 250-bar window or drift=0.01).
- `test_extended_metrics_populated` (line 465) and
  `test_active_model_records_trades` (line 524): assertions are
  direction-agnostic (VaR/CVaR/Calmar/Omega NaN-check + CVaR≤VaR;
  total_trades > 0 + win_rate range), so they pass legitimately
  under whatever-direction action=4 produces. The "Long100" comments
  are misleading but the tests are correctly covering their stated
  behavior.

A separate audit-and-fix pass should address all three at once:
either correct the action constants + adjust seed/window to ensure
each test's named trajectory direction is statistically reliable,
or introduce a named constant (e.g., LONG100_ACTION) and helper to
prevent the same drift recurring.

Refs
────
- SP21 T2.2 Phase 8.5 commit 5694eb4df: "wire factored-action branch
  sizes into closure-based eval (atomic)"
- crates/ml/src/cuda_pipeline/trade_physics.cuh: `decode_direction_4b`,
  `decode_magnitude_4b` — canonical factored action decoders
- crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs:189:
  `DqnBacktestConfig::from_network_dims` — sets branch_sizes (4,3,3,3)

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

555 lines
20 KiB
Rust
Raw Permalink 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.
#![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<Vec<f32>> {
(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<LobBar>; 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<LobBar> {
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<Arc<CudaStream>> {
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<f32>, n_windows, state_dim)` and
/// returns `CudaSlice<i32>` of length `n_windows` filled with `action`.
fn constant_action_model(
action: i32,
stream: &Arc<CudaStream>,
) -> impl Fn(&CudaSlice<f32>, usize, usize) -> Result<CudaSlice<i32>, MLError> {
let stream = Arc::clone(stream);
move |_states: &CudaSlice<f32>, n_windows: usize, _state_dim: usize| {
let host_actions = vec![action; n_windows];
let mut gpu_actions = stream
.alloc_zeros::<i32>(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
);
}
}