- 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>
567 lines
16 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|