Files
foxhunt/crates/ml/tests/dqn_reward_calculation_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

567 lines
16 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,
)]
//! Comprehensive DQN Reward Function Tests
//!
//! This test suite validates the DQN reward calculation function to prevent regression
//! and ensure correct behavior across various market scenarios.
//!
//! Test Coverage:
//! 1. Price increase scenarios (positive rewards)
//! 2. Price decrease scenarios (negative rewards)
//! 3. Flat market (zero reward)
//! 4. Large price movements (reward clamping)
//! 5. Reward normalization (proportional scaling)
//! 6. Non-constant rewards (variance validation)
//! 7. Symmetry (balanced positive/negative rewards)
use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer};
use tracing::info;
/// Helper function to create a default DQN trainer for testing
fn create_test_trainer() -> DQNTrainer {
let hyperparams = DQNHyperparameters::conservative();
DQNTrainer::new(hyperparams).expect("Failed to create test trainer")
}
/// Helper function to calculate reward from price change
///
/// This implements the expected reward calculation logic:
/// reward = (next_price - current_price) / normalization_factor
/// where normalization_factor = 10.0 (to normalize typical ES futures movements)
fn calculate_price_reward(current: f64, next: f64) -> f32 {
let price_change = next - current;
// Normalize by 10.0 (typical ES futures tick movements)
// Clamp to [-1.0, 1.0] range
(price_change / 10.0).clamp(-1.0, 1.0) as f32
}
/// Test 1: Reward for price increase
///
/// Validates that price increases generate positive rewards
/// Example: ES futures rising from 5900.0 to 5914.25 should yield positive reward
#[test]
fn test_reward_price_increase() {
let current = 5900.0;
let next = 5914.25;
let reward = calculate_price_reward(current, next);
// Verify reward is positive
assert!(
reward > 0.0,
"Reward should be positive for price increase. Got: {}",
reward
);
// Verify reward is within valid range [0.0, 1.0]
assert!(
reward <= 1.0,
"Reward should be clamped to 1.0. Got: {}",
reward
);
assert!(
reward >= 0.0,
"Reward should be non-negative for price increase. Got: {}",
reward
);
// Expected: (5914.25 - 5900.0) / 10.0 = 1.425, clamped to 1.0
let expected = 1.0;
assert!(
(reward - expected).abs() < 0.01,
"Expected reward ~{}, got {}",
expected,
reward
);
}
/// Test 2: Reward for price decrease
///
/// Validates that price decreases generate negative rewards
/// Example: ES futures falling from 5914.25 to 5900.0 should yield negative reward
#[test]
fn test_reward_price_decrease() {
let current = 5914.25;
let next = 5900.0;
let reward = calculate_price_reward(current, next);
// Verify reward is negative
assert!(
reward < 0.0,
"Reward should be negative for price decrease. Got: {}",
reward
);
// Verify reward is within valid range [-1.0, 0.0]
assert!(
reward >= -1.0,
"Reward should be clamped to -1.0. Got: {}",
reward
);
assert!(
reward <= 0.0,
"Reward should be non-positive for price decrease. Got: {}",
reward
);
// Expected: (5900.0 - 5914.25) / 10.0 = -1.425, clamped to -1.0
let expected = -1.0;
assert!(
(reward - expected).abs() < 0.01,
"Expected reward ~{}, got {}",
expected,
reward
);
}
/// Test 3: Reward for flat market
///
/// Validates that no price change generates near-zero reward
/// Example: Price staying at 5900.0 should yield ~0.0 reward
#[test]
fn test_reward_flat_market() {
let current = 5900.0;
let next = 5900.0;
let reward = calculate_price_reward(current, next);
// Verify reward is approximately zero (within tolerance)
assert!(
reward.abs() < 0.01,
"Reward should be near zero for flat market. Got: {}",
reward
);
// Expected: (5900.0 - 5900.0) / 10.0 = 0.0
assert_eq!(reward, 0.0, "Flat market should produce exactly 0.0 reward");
}
/// Test 4: Reward clamping for large price movements
///
/// Validates that extremely large price movements are clamped to [-1.0, 1.0]
/// This prevents unbounded rewards from distorting training
#[test]
fn test_reward_large_move() {
// Large upward movement (50 points)
let current_up = 5900.0;
let next_up = 5950.0;
let reward_up = calculate_price_reward(current_up, next_up);
// Should clamp to 1.0
assert!(
(reward_up - 1.0).abs() < 0.01,
"Large upward move should clamp to 1.0. Got: {}",
reward_up
);
// Large downward movement (50 points)
let current_down = 5950.0;
let next_down = 5900.0;
let reward_down = calculate_price_reward(current_down, next_down);
// Should clamp to -1.0
assert!(
(reward_down - (-1.0)).abs() < 0.01,
"Large downward move should clamp to -1.0. Got: {}",
reward_down
);
// Verify symmetry
assert!(
(reward_up + reward_down).abs() < 0.01,
"Large moves should be symmetric: up={}, down={}",
reward_up,
reward_down
);
}
/// Test 5: Reward normalization and proportionality
///
/// Validates that rewards scale proportionally with price movements
/// Ensures reward function is not constant or saturated
#[test]
fn test_reward_normalization() {
let current = 5900.0;
// 10-point move (should be 1.0 after clamping)
let next_10 = current + 10.0;
let reward_10 = calculate_price_reward(current, next_10);
assert!(
(reward_10 - 1.0).abs() < 0.01,
"10-point move: expected ~1.0, got {}",
reward_10
);
// 5-point move (should be 0.5)
let next_5 = current + 5.0;
let reward_5 = calculate_price_reward(current, next_5);
assert!(
(reward_5 - 0.5).abs() < 0.01,
"5-point move: expected ~0.5, got {}",
reward_5
);
// 1-point move (should be 0.1)
let next_1 = current + 1.0;
let reward_1 = calculate_price_reward(current, next_1);
assert!(
(reward_1 - 0.1).abs() < 0.01,
"1-point move: expected ~0.1, got {}",
reward_1
);
// Verify proportionality: reward_10 ≈ 2 * reward_5 ≈ 10 * reward_1
assert!(
(reward_10 / reward_5 - 2.0).abs() < 0.1,
"Proportionality check 10/5 failed: {} / {} ≠ 2.0",
reward_10,
reward_5
);
assert!(
(reward_10 / reward_1 - 10.0).abs() < 0.2,
"Proportionality check 10/1 failed: {} / {} ≠ 10.0",
reward_10,
reward_1
);
}
/// Test 6: Reward variance (non-constant)
///
/// Validates that rewards vary across different price pairs
/// This is critical - if rewards are constant, the DQN cannot learn
#[test]
fn test_reward_not_constant() {
let mut rewards = Vec::with_capacity(100);
// Test 100 different price pairs with varied movements
for i in 0..100 {
let current = 5900.0 + (i as f64 * 0.5);
let next = current + ((i % 20) as f64 - 10.0); // Oscillating movements
let reward = calculate_price_reward(current, next);
rewards.push(reward);
}
// Calculate statistics
let mean = rewards.iter().sum::<f32>() / rewards.len() as f32;
let variance = rewards.iter().map(|r| (r - mean).powi(2)).sum::<f32>() / rewards.len() as f32;
let std = variance.sqrt();
// Verify standard deviation is significant
assert!(
std > 0.1,
"Reward std deviation too low ({:.6}), indicates constant rewards",
std
);
// Verify min ≠ max (rewards actually vary)
let min_reward = rewards.iter().cloned().fold(f32::INFINITY, f32::min);
let max_reward = rewards.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
assert_ne!(
min_reward, max_reward,
"Min and max rewards are equal ({} == {}), indicates constant rewards",
min_reward, max_reward
);
// Verify we have both positive and negative rewards
let has_positive = rewards.iter().any(|&r| r > 0.0);
let has_negative = rewards.iter().any(|&r| r < 0.0);
assert!(
has_positive && has_negative,
"Should have both positive and negative rewards (pos: {}, neg: {})",
has_positive,
has_negative
);
info!(mean, std, min_reward, max_reward, "Reward statistics");
}
/// Test 7: Reward symmetry
///
/// Validates that equal magnitude movements in opposite directions
/// produce equal magnitude rewards with opposite signs
#[test]
fn test_reward_symmetry() {
let current = 5900.0;
// +10 point move
let next_up = current + 10.0;
let reward_up = calculate_price_reward(current, next_up);
// -10 point move
let next_down = current - 10.0;
let reward_down = calculate_price_reward(current, next_down);
// Verify rewards are opposite (symmetric)
assert!(
(reward_up + reward_down).abs() < 0.01,
"Symmetric moves should produce opposite rewards: up={}, down={}",
reward_up,
reward_down
);
assert!(
(reward_up - (-reward_down)).abs() < 0.01,
"Reward magnitudes should be equal: |{}| ≠ |{}|",
reward_up,
reward_down
);
// Test multiple magnitudes
for magnitude in &[1.0, 2.5, 5.0, 7.5, 10.0, 15.0, 20.0] {
let up = calculate_price_reward(current, current + magnitude);
let down = calculate_price_reward(current, current - magnitude);
assert!(
(up + down).abs() < 0.01,
"Symmetry broken at magnitude {}: up={}, down={}",
magnitude,
up,
down
);
}
}
/// Test 8: Edge case - very small price movements
///
/// Validates handling of sub-tick movements (< 0.01 points)
#[test]
fn test_reward_small_movements() {
let current = 5900.0;
// Sub-tick movement (0.001 points)
let next = current + 0.001;
let reward = calculate_price_reward(current, next);
// Should be very small but non-zero
assert!(
reward > 0.0,
"Small positive movement should yield positive reward"
);
assert!(
reward < 0.001,
"Small movement should yield small reward, got {}",
reward
);
// Expected: 0.001 / 10.0 = 0.0001
let expected = 0.0001;
assert!(
(reward - expected).abs() < 0.00001,
"Expected ~{}, got {}",
expected,
reward
);
}
/// Test 9: Edge case - exact boundary conditions
///
/// Validates reward values at exact clamping boundaries
#[test]
fn test_reward_boundary_conditions() {
let current = 5900.0;
// Exactly at upper boundary (+10 points)
let next_upper = current + 10.0;
let reward_upper = calculate_price_reward(current, next_upper);
assert_eq!(
reward_upper, 1.0,
"Upper boundary should be exactly 1.0, got {}",
reward_upper
);
// Exactly at lower boundary (-10 points)
let next_lower = current - 10.0;
let reward_lower = calculate_price_reward(current, next_lower);
assert_eq!(
reward_lower, -1.0,
"Lower boundary should be exactly -1.0, got {}",
reward_lower
);
// Just below upper boundary (+9.99 points)
let next_below_upper = current + 9.99;
let reward_below_upper = calculate_price_reward(current, next_below_upper);
assert!(
reward_below_upper < 1.0 && reward_below_upper > 0.99,
"Just below upper boundary should be < 1.0, got {}",
reward_below_upper
);
}
/// Test 10: Integration test - reward calculation in trainer context
///
/// Validates that the DQN trainer can be instantiated and used
/// (actual reward calculation requires full training context)
#[test]
fn test_trainer_integration() {
let trainer = create_test_trainer();
// Verify trainer was created successfully
// (actual reward calculation is tested through the helper function above)
drop(trainer);
}
/// Test 11: Reward distribution analysis
///
/// Statistical analysis of reward distribution across realistic price scenarios
#[test]
fn test_reward_distribution() {
let mut rewards = Vec::new();
// Simulate realistic ES futures price movements
// Typical daily range: 20-50 points, with most movements < 10 points
let scenarios = vec![
(5900.0, 5900.25), // +0.25 tick
(5900.0, 5900.50), // +0.50 tick
(5900.0, 5901.0), // +1 point
(5900.0, 5902.0), // +2 points
(5900.0, 5905.0), // +5 points
(5900.0, 5910.0), // +10 points (boundary)
(5900.0, 5915.0), // +15 points (clamped)
(5900.0, 5899.75), // -0.25 tick
(5900.0, 5899.50), // -0.50 tick
(5900.0, 5899.0), // -1 point
(5900.0, 5898.0), // -2 points
(5900.0, 5895.0), // -5 points
(5900.0, 5890.0), // -10 points (boundary)
(5900.0, 5885.0), // -15 points (clamped)
];
for (current, next) in scenarios {
let reward = calculate_price_reward(current, next);
rewards.push(reward);
}
// Verify we have a good distribution
let positive_count = rewards.iter().filter(|&&r| r > 0.0).count();
let negative_count = rewards.iter().filter(|&&r| r < 0.0).count();
let zero_count = rewards.iter().filter(|&&r| r == 0.0).count();
assert_eq!(
positive_count, 7,
"Expected 7 positive rewards, got {}",
positive_count
);
assert_eq!(
negative_count, 7,
"Expected 7 negative rewards, got {}",
negative_count
);
assert_eq!(
zero_count, 0,
"Expected 0 zero rewards (no flat scenarios), got {}",
zero_count
);
}
/// Test 12: Precision validation
///
/// Ensures reward calculation maintains precision for small values
#[test]
fn test_reward_precision() {
let current = 5900.0;
// Test incrementally small movements
let test_cases = vec![
(0.01, 0.001), // 0.01 point move
(0.05, 0.005), // 0.05 point move
(0.10, 0.010), // 0.10 point move
(0.25, 0.025), // 0.25 point move
(0.50, 0.050), // 0.50 point move
];
for (movement, expected_reward) in test_cases {
let next = current + movement;
let reward = calculate_price_reward(current, next);
assert!(
(reward - expected_reward).abs() < 0.0001,
"Precision error for {}-point move: expected {}, got {}",
movement,
expected_reward,
reward
);
}
}