- Remove ALL #[cfg(feature = "cuda")] guards (~400+ occurrences) - Remove ALL #[cfg_attr(not(feature = "cuda"), ignore)] test annotations (~250) - Make cuda default feature in 9 ML crates (ml, ml-core, ml-dqn, ml-ppo, etc.) - Convert nvrtc JIT compilation to precompiled nvcc (searchsorted, prefix_sum) - Move compile_ptx_for_device() to ml-core for shared access - Delete dead CPU code: multi_step.rs, self_supervised_pretraining.rs, training_guard_gpu_tests.rs, CPU PER buffer paths, CPU Q-diagnostics - Replace unwrap_or(Device::Cpu) with hard errors everywhere - Remove dead is_cuda() else branches in DQN/PPO/hyperopt trainers - Change config defaults from "cpu" to "cuda" (rainbow, tlob, pipeline) - Port IQL value network to GPU kernel (5 CUDA entry points) - Port HER goal relabeling to GPU kernel (warp-per-sample) - Wire DSR GPU-to-CPU sync in training loop - cfg!(feature = "cuda") → true in inference_validator Zero warnings, zero errors across entire workspace. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
358 lines
11 KiB
Rust
358 lines
11 KiB
Rust
#![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,
|
|
)]
|
|
//! End-to-end integration tests for the validation harness pipeline.
|
|
//!
|
|
//! Tests the full flow: DQN strategy creation -> walk-forward splitting ->
|
|
//! train/evaluate per fold -> DSR/PBO/permutation -> report with verdict.
|
|
|
|
use chrono::{Duration, TimeZone, Utc};
|
|
use ml::dqn::DQNConfig;
|
|
use ml::validation::{
|
|
deflated_sharpe_ratio, probability_of_backtest_overfitting, walk_forward_split,
|
|
DqnStrategy, TimeSeriesData, ValidationHarness,
|
|
ValidationHarnessConfig, WalkForwardConfig,
|
|
};
|
|
use tracing::info;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Create `n` UTC timestamps starting from 2024-01-01, one day apart.
|
|
fn make_timestamps(n: usize) -> Vec<chrono::DateTime<Utc>> {
|
|
(0..n)
|
|
.map(|i| {
|
|
Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0)
|
|
.single()
|
|
.unwrap_or_else(Utc::now)
|
|
+ Duration::days(i as i64)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Create `n` feature rows of dimension `dim` with deterministic non-zero values.
|
|
fn make_features(n: usize, dim: usize) -> Vec<Vec<f32>> {
|
|
(0..n)
|
|
.map(|i| {
|
|
(0..dim)
|
|
.map(|j| ((i * 7 + j * 3) % 100) as f32 * 0.01)
|
|
.collect()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Build a minimal DQNConfig suitable for fast integration testing.
|
|
fn make_small_dqn_config() -> DQNConfig {
|
|
let mut config = DQNConfig::default();
|
|
config.state_dim = 8;
|
|
config.num_actions = 3;
|
|
config.hidden_dims = vec![16, 8];
|
|
config.batch_size = 4;
|
|
config.min_replay_size = 4;
|
|
config.warmup_steps = 0;
|
|
config.use_iqn = false;
|
|
config.use_dueling = false;
|
|
config.use_per = true; // GPU PER mandatory on CUDA
|
|
config.epsilon_start = 0.3;
|
|
config
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 1: Full validation pipeline with DQN
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_full_validation_pipeline_with_dqn() {
|
|
// 1. Create DQN strategy
|
|
let config = make_small_dqn_config();
|
|
let mut strategy =
|
|
DqnStrategy::new(config).unwrap_or_else(|e| panic!("DqnStrategy::new failed: {}", e));
|
|
|
|
// 2. Create synthetic TimeSeriesData (300 bars, feature_dim=8)
|
|
// Prices follow sine + trend: 100.0 + sin(i*0.1)*5.0 + i*0.01
|
|
let n = 300_usize;
|
|
let prices: Vec<f64> = (0..n)
|
|
.map(|i| 100.0 + (i as f64 * 0.1).sin() * 5.0 + i as f64 * 0.01)
|
|
.collect();
|
|
let timestamps = make_timestamps(n);
|
|
let features = make_features(n, 8);
|
|
|
|
let data = TimeSeriesData::new(timestamps, features, prices)
|
|
.unwrap_or_else(|e| panic!("TimeSeriesData::new failed: {}", e));
|
|
|
|
// 3. Configure harness
|
|
let harness_config = ValidationHarnessConfig {
|
|
wf_config: WalkForwardConfig {
|
|
train_bars: 50,
|
|
test_bars: 30,
|
|
embargo_bars: 5,
|
|
step_bars: 30,
|
|
min_train_samples: 20,
|
|
},
|
|
num_permutations: 100,
|
|
num_trials: 1,
|
|
seed: 42,
|
|
};
|
|
|
|
let harness = ValidationHarness::new(harness_config);
|
|
|
|
// 4. Run validation
|
|
let report = harness
|
|
.validate(&mut strategy, &data)
|
|
.unwrap_or_else(|e| panic!("validate failed: {}", e));
|
|
|
|
// 5. Assertions
|
|
assert_eq!(
|
|
report.strategy_name, "DQN",
|
|
"Strategy name should be 'DQN', got '{}'",
|
|
report.strategy_name
|
|
);
|
|
|
|
assert!(
|
|
report.num_folds >= 2,
|
|
"Expected at least 2 folds, got {}",
|
|
report.num_folds
|
|
);
|
|
|
|
assert!(
|
|
report.aggregate_sharpe.is_finite(),
|
|
"Aggregate Sharpe should be finite, got {}",
|
|
report.aggregate_sharpe
|
|
);
|
|
|
|
// DSR p-value in [0, 1]
|
|
assert!(
|
|
(0.0..=1.0).contains(&report.dsr.pvalue),
|
|
"DSR p-value out of range [0,1]: {}",
|
|
report.dsr.pvalue
|
|
);
|
|
|
|
// PBO in [0, 1]
|
|
assert!(
|
|
(0.0..=1.0).contains(&report.pbo.pbo),
|
|
"PBO out of range [0,1]: {}",
|
|
report.pbo.pbo
|
|
);
|
|
|
|
// Permutation p-value in [0, 1]
|
|
assert!(
|
|
(0.0..=1.0).contains(&report.permutation.pvalue),
|
|
"Permutation p-value out of range [0,1]: {}",
|
|
report.permutation.pvalue
|
|
);
|
|
|
|
// Per-regime metrics should not be empty
|
|
assert!(
|
|
!report.per_regime_metrics.is_empty(),
|
|
"per_regime_metrics should not be empty"
|
|
);
|
|
|
|
// Print summary
|
|
info!(
|
|
strategy = %report.strategy_name,
|
|
folds = report.num_folds,
|
|
aggregate_sharpe = report.aggregate_sharpe,
|
|
dsr_pvalue = report.dsr.pvalue,
|
|
pbo = report.pbo.pbo,
|
|
permutation_pvalue = report.permutation.pvalue,
|
|
verdict = %report.verdict,
|
|
regimes = report.per_regime_metrics.len(),
|
|
"Validation Report Summary"
|
|
);
|
|
for (regime, metrics) in &report.per_regime_metrics {
|
|
info!(
|
|
regime = ?regime,
|
|
sharpe = metrics.sharpe,
|
|
bars = metrics.num_bars,
|
|
win_rate = metrics.win_rate,
|
|
avg_return = metrics.avg_return,
|
|
"Regime metrics"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 2: Walk-forward split standalone
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_walk_forward_split_standalone() {
|
|
let config = WalkForwardConfig {
|
|
train_bars: 50,
|
|
test_bars: 20,
|
|
embargo_bars: 5,
|
|
step_bars: 20,
|
|
min_train_samples: 20,
|
|
};
|
|
|
|
let folds = walk_forward_split(200, &config);
|
|
|
|
assert!(
|
|
!folds.is_empty(),
|
|
"Expected at least one fold from 200 bars"
|
|
);
|
|
|
|
for fold in &folds {
|
|
// test_range.end must not exceed total bars
|
|
assert!(
|
|
fold.test_range.end <= 200,
|
|
"Fold {} test_range.end ({}) exceeds 200",
|
|
fold.fold_index,
|
|
fold.test_range.end
|
|
);
|
|
|
|
// train.end <= embargo.start (they should be equal by construction)
|
|
assert!(
|
|
fold.train_range.end <= fold.embargo_range.start,
|
|
"Fold {} train.end ({}) > embargo.start ({})",
|
|
fold.fold_index,
|
|
fold.train_range.end,
|
|
fold.embargo_range.start
|
|
);
|
|
|
|
// embargo.end <= test.start (they should be equal by construction)
|
|
assert!(
|
|
fold.embargo_range.end <= fold.test_range.start,
|
|
"Fold {} embargo.end ({}) > test.start ({})",
|
|
fold.fold_index,
|
|
fold.embargo_range.end,
|
|
fold.test_range.start
|
|
);
|
|
}
|
|
|
|
info!(num_folds = folds.len(), total_bars = 200, "Walk-forward split complete");
|
|
for fold in &folds {
|
|
info!(
|
|
fold = fold.fold_index,
|
|
train = ?fold.train_range,
|
|
embargo = ?fold.embargo_range,
|
|
test = ?fold.test_range,
|
|
"Fold ranges"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 3: DSR and PBO standalone
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_dsr_and_pbo_standalone() {
|
|
// --- DSR ---
|
|
// High Sharpe (2.5) with few trials (5) should be significant -> p < 0.1
|
|
let dsr = deflated_sharpe_ratio(2.5, 5, 0.5, 0.1, 3.5, 500);
|
|
assert!(
|
|
dsr.pvalue < 0.1,
|
|
"DSR: SR=2.5 with 5 trials should have p < 0.1, got {:.4}",
|
|
dsr.pvalue
|
|
);
|
|
assert!(
|
|
dsr.observed_sharpe.is_finite(),
|
|
"DSR observed_sharpe should be finite"
|
|
);
|
|
assert!(
|
|
dsr.deflated_sharpe.is_finite(),
|
|
"DSR deflated_sharpe should be finite"
|
|
);
|
|
assert!(
|
|
dsr.sharpe_std_error.is_finite() && dsr.sharpe_std_error >= 0.0,
|
|
"DSR sharpe_std_error should be non-negative and finite, got {}",
|
|
dsr.sharpe_std_error
|
|
);
|
|
|
|
info!(observed_sharpe = dsr.observed_sharpe, deflated_sharpe = dsr.deflated_sharpe, pvalue = dsr.pvalue, "DSR result");
|
|
|
|
// --- PBO ---
|
|
// Consistent positive Sharpes across 8 folds -> should have num_combinations > 0
|
|
let sharpes = vec![1.0, 1.2, 0.8, 1.1, 0.9, 1.3, 1.0, 0.95];
|
|
let pbo = probability_of_backtest_overfitting(&sharpes);
|
|
assert!(
|
|
pbo.num_combinations > 0,
|
|
"PBO should have evaluated combinations, got {}",
|
|
pbo.num_combinations
|
|
);
|
|
assert!(
|
|
(0.0..=1.0).contains(&pbo.pbo),
|
|
"PBO value should be in [0,1], got {}",
|
|
pbo.pbo
|
|
);
|
|
assert!(
|
|
!pbo.logit_distribution.is_empty(),
|
|
"PBO logit_distribution should not be empty"
|
|
);
|
|
|
|
info!(pbo = pbo.pbo, num_combinations = pbo.num_combinations, logit_entries = pbo.logit_distribution.len(), "PBO result");
|
|
}
|