Files
foxhunt/crates/ml/tests/volatility_epsilon_test.rs
jgrusewski ca4c38d921 fix(tests): CI GPU test stability, walltime reduction, BF16 tolerance
- Reduce CI GPU test datasets 16x for walltime reduction
- Reduce early-stop epochs 50→10, add --test-threads=1
- Serialize all GPU lib tests to prevent cuBLAS init race
- Align state_dim to 16 for BF16 tensor core HMMA dispatch
- BF16 precision tolerance in ml-dqn tests
- Enable branching DQN + tracing subscriber in smoke tests
- Prevent min_replay_size > buffer_size deadlock in early-stop tests
- Prevent AutoReplaySizer from breaking gradient collapse warmup
- Replace racy tokio::spawn checkpoint counter with AtomicUsize
- Set warmup_steps=0 and max_training_steps_per_epoch=300 in early-stop tests
- RealDataLoader respects TEST_DATA_DIR for CI PVC layout
- Add collapse_warmup_capacity to gpu_smoketest DQNConfig
- Drain CUDA context between test binaries
- Detached HEAD checkout prevents local branch corruption
- GPU pipeline tests: fix BF16 dtype and rank-1 squeeze assertions
- OOD input handling tests use use_gpu: true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 12:00:13 +01:00

563 lines
22 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,
)]
//! TDD Tests for Volatility-Based Epsilon Adaptation
//!
//! Tests for dynamic epsilon adjustment based on market volatility:
//! - Low volatility (σ < 0.01): epsilon × 0.5 (exploit more, explore less)
//! - Medium volatility (0.01 ≤ σ ≤ 0.05): epsilon × 1.0 (no adjustment)
//! - High volatility (σ > 0.05): epsilon × 2.0 (explore more, exploit less)
//!
//! Volatility is calculated as rolling 20-period standard deviation of returns.
//! Final epsilon is always clamped to [0.05, 0.95].
#[cfg(test)]
mod volatility_epsilon_tests {
use approx::assert_abs_diff_eq;
use tracing::info;
/// Calculate rolling standard deviation of returns
/// Returns the standard deviation of the last `window` returns
///
/// # Arguments
/// * `returns` - Historical returns (typically log returns)
/// * `window` - Rolling window size (default: 20)
///
/// # Returns
/// Standard deviation of returns in the window, or 0.0 if insufficient data
fn calculate_returns_volatility(returns: &[f64], window: usize) -> f64 {
if returns.len() < window {
return 0.0;
}
let recent_returns = &returns[returns.len() - window..];
let mean = recent_returns.iter().sum::<f64>() / window as f64;
let variance = recent_returns
.iter()
.map(|r| (r - mean).powi(2))
.sum::<f64>()
/ window as f64;
variance.sqrt()
}
/// Calculate volatility-adjusted epsilon
///
/// # Arguments
/// * `base_epsilon` - Base exploration rate (before volatility adjustment)
/// * `volatility` - Market volatility (standard deviation of returns)
///
/// # Returns
/// Adjusted epsilon, clamped to [0.05, 0.95]
fn calculate_volatility_adjusted_epsilon(base_epsilon: f64, volatility: f64) -> f64 {
let multiplier = if volatility < 0.01 {
0.5 // Low volatility: exploit more
} else if volatility > 0.05 {
2.0 // High volatility: explore more
} else {
// Linear interpolation for medium volatility: 0.01 ≤ σ ≤ 0.05
// At σ=0.01: m=0.5, at σ=0.05: m=2.0
// m(σ) = 0.5 + (σ - 0.01) / 0.04 × 1.5
0.5 + (volatility - 0.01) / 0.04 * 1.5
};
(base_epsilon * multiplier).clamp(0.05, 0.95)
}
/// Convert prices to log returns
///
/// # Arguments
/// * `prices` - Historical prices
///
/// # Returns
/// Vector of log returns (ln(price[t] / price[t-1]))
fn prices_to_log_returns(prices: &[f64]) -> Vec<f64> {
prices
.windows(2)
.map(|w| (w[1] / w[0]).ln())
.collect()
}
// ============================================================================
// TEST 1: Low Volatility Regime
// ============================================================================
#[test]
fn test_epsilon_low_volatility_regime() {
// Scenario: Stable market, very low returns volatility (σ < 0.01)
// Expected: Exploit more (epsilon × 0.5)
let base_epsilon = 0.5;
let volatility = 0.005; // σ = 0.5% (very low)
let adjusted_epsilon = calculate_volatility_adjusted_epsilon(base_epsilon, volatility);
// Expected: 0.5 × 0.5 = 0.25
assert_abs_diff_eq!(adjusted_epsilon, 0.25, epsilon = 1e-6);
info!(vol_pct = volatility * 100.0, base_epsilon, adjusted_epsilon, "Low vol epsilon (exploit boost)");
}
// ============================================================================
// TEST 2: High Volatility Regime
// ============================================================================
#[test]
fn test_epsilon_high_volatility_regime() {
// Scenario: Volatile market, high returns volatility (σ > 0.05)
// Expected: Explore more (epsilon × 2.0)
let base_epsilon = 0.5;
let volatility = 0.08; // σ = 8.0% (high)
let adjusted_epsilon = calculate_volatility_adjusted_epsilon(base_epsilon, volatility);
// Expected: 0.5 × 2.0 = 1.0, clamped to 0.95
assert_abs_diff_eq!(adjusted_epsilon, 0.95, epsilon = 1e-6);
info!(vol_pct = volatility * 100.0, base_epsilon, adjusted_epsilon, "High vol epsilon (exploration boost, clamped)");
}
// ============================================================================
// TEST 3: Medium Volatility Regime
// ============================================================================
#[test]
fn test_epsilon_medium_volatility() {
// Scenario: Normal market, medium returns volatility (0.01 ≤ σ ≤ 0.05)
// Expected: No adjustment (epsilon × 1.0)
let base_epsilon = 0.5;
let volatility = 0.02; // σ = 2.0% (medium)
let adjusted_epsilon = calculate_volatility_adjusted_epsilon(base_epsilon, volatility);
// Linear interpolation: m = 0.5 + (0.02 - 0.01) / 0.04 × 1.5 = 0.5 + 0.375 = 0.875
// ε = 0.5 × 0.875 = 0.4375
let expected = 0.4375;
assert_abs_diff_eq!(adjusted_epsilon, expected, epsilon = 1e-6);
info!(vol_pct = volatility * 100.0, base_epsilon, adjusted_epsilon, "Medium vol epsilon (interpolated)");
}
// ============================================================================
// TEST 4: Volatility Calculation with Rolling Window
// ============================================================================
#[test]
fn test_volatility_calculation_rolling_window() {
// Scenario: Calculate volatility from 25 price points, use 20-period window
// Expected: Rolling std dev matches manual calculation
// Create price series with known volatility
let prices = vec![
100.0, 101.0, 100.5, 102.0, 101.5, 103.0, 102.5, 104.0, 103.5, 105.0, 104.5, 106.0,
105.5, 107.0, 106.5, 108.0, 107.5, 109.0, 108.5, 110.0, 109.5, 111.0, 110.5, 112.0,
111.5,
];
let returns = prices_to_log_returns(&prices);
let volatility = calculate_returns_volatility(&returns, 20);
// Volatility should be positive and reasonable
assert!(volatility > 0.0, "Volatility should be positive");
assert!(volatility < 0.05, "Volatility should be less than 5%");
info!(volatility, "Rolling window (20 periods) volatility");
}
// ============================================================================
// TEST 5: Epsilon Clamping to [0.05, 0.95]
// ============================================================================
#[test]
fn test_epsilon_clamping() {
// Test case 1: Very low epsilon (0.1) in low volatility regime
// Would normally be 0.1 × 0.5 = 0.05 (exactly at floor)
let result1 = calculate_volatility_adjusted_epsilon(0.1, 0.005);
assert_abs_diff_eq!(result1, 0.05, epsilon = 1e-6);
// Test case 2: Very low epsilon (0.05) in high volatility regime
// Would normally be 0.05 × 2.0 = 0.1 (above floor, below cap)
let result2 = calculate_volatility_adjusted_epsilon(0.05, 0.08);
assert_abs_diff_eq!(result2, 0.1, epsilon = 1e-6);
// Test case 3: Very high epsilon (1.0) in high volatility regime
// Would normally be 1.0 × 2.0 = 2.0 (clamped to 0.95)
let result3 = calculate_volatility_adjusted_epsilon(1.0, 0.08);
assert_abs_diff_eq!(result3, 0.95, epsilon = 1e-6);
// Test case 4: Edge case - epsilon at 0.95 in high volatility
// Would normally be 0.95 × 2.0 = 1.9 (clamped to 0.95)
let result4 = calculate_volatility_adjusted_epsilon(0.95, 0.08);
assert_abs_diff_eq!(result4, 0.95, epsilon = 1e-6);
info!(
low_vol_result = result1,
low_eps_high_vol_result = result2,
high_eps_high_vol_result = result3,
at_cap_high_vol_result = result4,
"Epsilon clamping [0.05, 0.95]"
);
}
// ============================================================================
// TEST 6: Volatility Regime Transitions (Smooth vs. Jarring)
// ============================================================================
#[test]
fn test_volatility_regime_transitions() {
// Scenario: Simulate market transitioning from low to high volatility
// Expected: Smooth epsilon adjustment (no jumps)
let base_epsilon = 0.5;
let volatilities = vec![
0.005, 0.006, 0.007, 0.008, 0.009, 0.010, 0.015, 0.020, 0.025, 0.030, 0.035, 0.040,
0.045, 0.050, 0.055, 0.060, 0.070, 0.080,
];
let mut previous_epsilon = calculate_volatility_adjusted_epsilon(base_epsilon, volatilities[0]);
let mut max_jump = 0.0;
info!("Volatility regime transitions (smooth adaptation)");
for vol in &volatilities {
let adjusted = calculate_volatility_adjusted_epsilon(base_epsilon, *vol);
let jump = (adjusted - previous_epsilon).abs();
if jump > max_jump {
max_jump = jump;
}
info!(vol_pct = vol * 100.0, adjusted_epsilon = adjusted, delta_epsilon = jump, "Volatility transition step");
previous_epsilon = adjusted;
}
// Max jump should be reasonable (< 0.1 between consecutive points)
// Worst case transition is from vol=0.045 (m≈1.375, ε≈0.6875) to vol=0.050 (m=2.0, ε=0.95)
// Jump = |0.95 - 0.6875| ≈ 0.2625
assert!(
max_jump <= 0.30,
"Maximum epsilon jump should be ≤0.30, got {:.4}",
max_jump
);
info!(max_jump, "Maximum epsilon jump");
}
// ============================================================================
// TEST 7: Insufficient History (< 20 samples)
// ============================================================================
#[test]
fn test_insufficient_history() {
// Scenario: Early in training with < 20 price observations
// Expected: Use base epsilon (volatility = 0.0 → multiplier = 1.0)
let base_epsilon = 0.5;
// Simulate insufficient returns history
let returns = vec![0.005, -0.003, 0.002, -0.001]; // Only 4 returns
let volatility = calculate_returns_volatility(&returns, 20);
assert_eq!(
volatility, 0.0,
"Volatility should be 0 for insufficient history"
);
let adjusted_epsilon = calculate_volatility_adjusted_epsilon(base_epsilon, volatility);
// With vol=0, multiplier should be between 0.5 and 1.0 (actually at 0.01 boundary)
// But since vol=0 < 0.01, multiplier = 0.5
assert_abs_diff_eq!(adjusted_epsilon, 0.25, epsilon = 1e-6);
info!(base_epsilon, adjusted_epsilon, "Insufficient history (4 samples) epsilon");
}
// ============================================================================
// TEST 8: Volatility Outlier Handling
// ============================================================================
#[test]
fn test_volatility_outlier_handling() {
// Scenario: Price data with occasional extreme movements (flash crashes, gaps)
// Expected: Rolling std dev captures outliers but isn't destroyed by them
// Normal prices with one outlier
let prices = vec![
100.0, 100.5, 101.0, 100.5, 101.0, 100.5, 101.0, 100.5, 101.0, 100.5, 101.0, 100.5,
101.0, 100.5, 101.0, 100.5, 101.0, 100.5, 101.0, 85.0, // Flash crash
95.0, 100.0, 100.5, 101.0, 100.5,
];
let returns = prices_to_log_returns(&prices);
let volatility = calculate_returns_volatility(&returns, 20);
// Volatility should be elevated but not infinite
assert!(volatility > 0.01, "Outlier should increase volatility");
assert!(volatility < 1.0, "Volatility should remain bounded");
let adjusted_epsilon = calculate_volatility_adjusted_epsilon(0.5, volatility);
// With elevated volatility, epsilon should be boosted
assert!(
adjusted_epsilon > 0.5,
"Elevated volatility should boost epsilon"
);
info!(volatility, adjusted_epsilon, "Outlier handling epsilon");
}
// ============================================================================
// TEST 9: Volatility Logging (Every 100 Steps)
// ============================================================================
#[test]
fn test_volatility_logging() {
// Scenario: Track volatility regime over multiple epochs
// Expected: Log regime changes at regular intervals (every 100 steps)
let mut step_count = 0;
let log_interval = 100;
let base_epsilon = 0.5;
// Simulate 5 epochs with different volatility patterns
let volatilities = vec![
vec![0.005; 100], // Epoch 1: Low volatility (100 steps)
vec![0.025; 100], // Epoch 2: Medium volatility
vec![0.075; 100], // Epoch 3: High volatility
vec![0.015; 100], // Epoch 4: Back to low
vec![0.040; 100], // Epoch 5: Medium-high
];
info!(log_interval, "Volatility logging");
for (epoch, vol_series) in volatilities.iter().enumerate() {
for vol in vol_series {
if step_count % log_interval == 0 {
let adjusted = calculate_volatility_adjusted_epsilon(base_epsilon, *vol);
let regime = if vol < &0.01 {
"Low (exploit)"
} else if vol > &0.05 {
"High (explore)"
} else {
"Medium (normal)"
};
info!(epoch = epoch + 1, step = step_count, vol_pct = vol * 100.0, regime, adjusted_epsilon = adjusted, "Volatility log step");
}
step_count += 1;
}
}
// Verify we logged approximately 5 times (one per epoch)
assert!(step_count > 400, "Should have simulated >400 steps");
info!(regime_changes = 5, steps = 500, "Logged regime changes");
}
// ============================================================================
// TEST 10: Epsilon-Volatility Correlation
// ============================================================================
#[test]
fn test_epsilon_correlation_with_vol() {
// Scenario: Verify positive correlation between volatility and adjusted epsilon
// Expected: As volatility increases, epsilon increases
let base_epsilon = 0.5;
let volatilities = vec![
0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05, 0.06, 0.08, 0.10,
];
let mut previous_epsilon = 0.0;
let mut correlation_count = 0;
info!("Epsilon-volatility correlation check");
for vol in &volatilities {
let adjusted = calculate_volatility_adjusted_epsilon(base_epsilon, *vol);
let delta = adjusted - previous_epsilon;
let is_increasing = if delta > -0.001 { "" } else { "" };
if adjusted >= previous_epsilon {
correlation_count += 1;
}
info!(vol_pct = vol * 100.0, adjusted_epsilon = adjusted, delta, is_increasing, "Epsilon-vol correlation step");
previous_epsilon = adjusted;
}
// Out of 10 transitions, nearly all should be monotonically increasing
// (Small decreases at boundaries are acceptable due to clamping)
assert!(
correlation_count >= 9,
"Epsilon should increase with volatility ({})",
correlation_count
);
info!(increasing_transitions = correlation_count, total = 10, "Positive correlation confirmed");
}
// ============================================================================
// TEST 11: Boundary Cases
// ============================================================================
#[test]
fn test_boundary_cases() {
// Edge case 1: Exactly at low/high volatility boundary
let vol_low_boundary = 0.01;
let vol_high_boundary = 0.05;
let eps1 = calculate_volatility_adjusted_epsilon(0.5, vol_low_boundary);
let eps2 = calculate_volatility_adjusted_epsilon(0.5, vol_high_boundary);
info!(low_boundary_eps = eps1, high_boundary_eps = eps2, "Boundary cases");
// At σ=0.01, we're at the transition point
// m = 0.5 + (0.01 - 0.01) / 0.04 × 1.5 = 0.5
// ε = 0.5 × 0.5 = 0.25
assert_abs_diff_eq!(eps1, 0.25, epsilon = 1e-6);
// At σ=0.05, we're at the transition point
// m = 0.5 + (0.05 - 0.01) / 0.04 × 1.5 = 0.5 + 1.5 = 2.0
// ε = 0.5 × 2.0 = 1.0 → clamped to 0.95
assert_abs_diff_eq!(eps2, 0.95, epsilon = 1e-6);
// Edge case 2: Zero epsilon (exploration disabled)
let eps_zero = calculate_volatility_adjusted_epsilon(0.0, 0.05);
assert_abs_diff_eq!(eps_zero, 0.05, epsilon = 1e-6); // Clamped to floor
// Edge case 3: Very small non-zero epsilon
let eps_tiny = calculate_volatility_adjusted_epsilon(0.001, 0.08);
assert_abs_diff_eq!(eps_tiny, 0.05, epsilon = 1e-6); // Clamped to floor
info!("All boundary cases validated");
}
// ============================================================================
// TEST 12: Long-Term Volatility Stability
// ============================================================================
#[test]
fn test_long_term_volatility_stability() {
// Scenario: Simulate 1000 steps with fluctuating volatility
// Expected: Rolling window prevents extreme oscillations
use rand::Rng;
let mut rng = rand::thread_rng();
let base_epsilon = 0.5;
let mut prices = vec![100.0];
let mut epsilon_values = Vec::new();
// Generate 1000 steps of price data with random volatility regimes
for step in 0..1000 {
let regime_switch = step % 200; // Change regime every 200 steps
let volatility_target = match regime_switch / 100 {
0 => 0.008, // Low volatility
_ => 0.060, // High volatility
};
// Add price with controlled randomness
let return_shock = rng.gen_range(-1.0..1.0) * volatility_target;
let new_price = prices.last().unwrap() * (1.0 + return_shock);
prices.push(new_price);
if prices.len() > 20 {
let returns = prices_to_log_returns(&prices);
let volatility = calculate_returns_volatility(&returns, 20);
let adjusted_epsilon = calculate_volatility_adjusted_epsilon(base_epsilon, volatility);
epsilon_values.push(adjusted_epsilon);
}
}
// Calculate statistics
let mean_epsilon = epsilon_values.iter().sum::<f64>() / epsilon_values.len() as f64;
let variance = epsilon_values
.iter()
.map(|e| (e - mean_epsilon).powi(2))
.sum::<f64>()
/ epsilon_values.len() as f64;
let std_dev = variance.sqrt();
// Over 980 steps, epsilon should be relatively stable
// Mean should be somewhere between regimes (0.25 - 0.95)
assert!(mean_epsilon > 0.20, "Mean epsilon should be > 0.20");
assert!(mean_epsilon < 1.0, "Mean epsilon should be < 1.0");
// Std dev should be reasonable (not wildly oscillating)
assert!(std_dev < 0.3, "Epsilon std dev should be < 0.3, got {:.4}", std_dev);
info!(
mean_epsilon,
std_dev,
min_epsilon = epsilon_values.iter().cloned().fold(f64::INFINITY, f64::min),
max_epsilon = epsilon_values.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
"Long-term stability (1000 steps)"
);
}
}