//! Comprehensive Normalization and Metrics Tests for Hyperopt MAMBA-2 //! //! This test suite covers: //! 1. Target normalization/denormalization //! 2. Metrics computation (directional accuracy, MAE, MSE) //! 3. Edge cases (empty data, single value, extreme ranges) //! 4. Integration tests with real training pipeline //! //! Purpose: Prevent regression in normalization logic and ensure metrics correctness use approx::assert_relative_eq; // ============================================================================ // TEST UTILITIES // ============================================================================ /// Create synthetic price data for testing fn create_test_price_data(n: usize, start: f64, end: f64) -> Vec { (0..n) .map(|i| start + (end - start) * (i as f64 / (n - 1) as f64)) .collect() } /// Assert all values in slice are normalized (within [0, 1]) fn assert_normalized(values: &[f64], label: &str) { for (i, &val) in values.iter().enumerate() { assert!( (0.0..=1.0).contains(&val), "{} value at index {} is not normalized: {} (expected [0, 1])", label, i, val ); } } /// Assert two floats are approximately equal with custom epsilon fn assert_approx_eq(a: f64, b: f64, epsilon: f64, label: &str) { assert!( (a - b).abs() < epsilon, "{}: expected {}, got {} (difference: {}, epsilon: {})", label, a, b, (a - b).abs(), epsilon ); } // ============================================================================ // NORMALIZATION MODULE // ============================================================================ /// Target normalization state (min-max scaling to [0, 1]) #[derive(Debug, Clone)] struct NormalizationParams { min: f64, max: f64, } impl NormalizationParams { /// Create normalization params from target values fn from_targets(targets: &[f64]) -> Self { let min = targets.iter().copied().fold(f64::INFINITY, f64::min); let max = targets.iter().copied().fold(f64::NEG_INFINITY, f64::max); Self { min, max } } /// Normalize targets to [0, 1] range fn normalize(&self, targets: &[f64]) -> Vec { let range = self.max - self.min; if range < 1e-8 { // All values are the same return vec![0.5; targets.len()]; } targets.iter().map(|&x| (x - self.min) / range).collect() } /// Denormalize targets from [0, 1] back to original range fn denormalize(&self, normalized: &[f64]) -> Vec { let range = self.max - self.min; normalized .iter() .map(|&x| x * range + self.min) .collect() } } // ============================================================================ // METRICS MODULE // ============================================================================ /// Calculate directional accuracy (percentage of correct up/down predictions) fn directional_accuracy(predictions: &[f64], targets: &[f64]) -> f64 { assert_eq!( predictions.len(), targets.len(), "Predictions and targets must have same length" ); if predictions.len() < 2 { return 0.5; // Not enough data points } let mut correct = 0; let mut total = 0; for i in 1..predictions.len() { let pred_dir = predictions[i] - predictions[i - 1]; let target_dir = targets[i] - targets[i - 1]; // Both same direction (both up or both down) if pred_dir * target_dir > 0.0 { correct += 1; } total += 1; } if total == 0 { return 0.5; } correct as f64 / total as f64 } /// Calculate Mean Absolute Error fn mae(predictions: &[f64], targets: &[f64]) -> f64 { assert_eq!( predictions.len(), targets.len(), "Predictions and targets must have same length" ); if predictions.is_empty() { return 0.0; } let sum: f64 = predictions .iter() .zip(targets.iter()) .map(|(p, t)| (p - t).abs()) .sum(); sum / predictions.len() as f64 } /// Calculate Mean Squared Error fn mse(predictions: &[f64], targets: &[f64]) -> f64 { assert_eq!( predictions.len(), targets.len(), "Predictions and targets must have same length" ); if predictions.is_empty() { return 0.0; } let sum: f64 = predictions .iter() .zip(targets.iter()) .map(|(p, t)| (p - t).powi(2)) .sum(); sum / predictions.len() as f64 } // ============================================================================ // NORMALIZATION TESTS // ============================================================================ #[test] fn test_target_normalization_range() { // Create targets with known range let targets = create_test_price_data(100, 4000.0, 5000.0); let params = NormalizationParams::from_targets(&targets); // Normalize let normalized = params.normalize(&targets); // Verify all values in [0, 1] assert_normalized(&normalized, "Normalized targets"); // Verify min/max are mapped to 0/1 assert_approx_eq(normalized[0], 0.0, 1e-6, "First value (min)"); assert_approx_eq( normalized[normalized.len() - 1], 1.0, 1e-6, "Last value (max)", ); } #[test] fn test_target_denormalization_recovers_original() { // Create targets let targets = create_test_price_data(50, 100.0, 200.0); let params = NormalizationParams::from_targets(&targets); // Normalize then denormalize let normalized = params.normalize(&targets); let recovered = params.denormalize(&normalized); // Verify recovery for (i, (&original, &recovered_val)) in targets.iter().zip(recovered.iter()).enumerate() { assert_approx_eq( original, recovered_val, 1e-6, &format!("Target recovery at index {}", i), ); } } #[test] fn test_normalization_edge_case_all_same() { // All targets are identical let targets = vec![42.0; 100]; let params = NormalizationParams::from_targets(&targets); let normalized = params.normalize(&targets); // Should all be 0.5 (middle of range) for (i, &val) in normalized.iter().enumerate() { assert_approx_eq(val, 0.5, 1e-6, &format!("Same value normalization at {}", i)); } } #[test] fn test_normalization_edge_case_single_value() { // Single target value let targets = vec![123.45]; let params = NormalizationParams::from_targets(&targets); let normalized = params.normalize(&targets); // Single value should normalize to 0.5 assert_eq!(normalized.len(), 1); assert_approx_eq(normalized[0], 0.5, 1e-6, "Single value normalization"); } #[test] fn test_normalization_edge_case_extreme_ranges() { // Very small values let small_targets = vec![1e-8, 2e-8, 3e-8, 4e-8, 5e-8]; let small_params = NormalizationParams::from_targets(&small_targets); let small_normalized = small_params.normalize(&small_targets); assert_normalized(&small_normalized, "Small values"); // Very large values let large_targets = vec![1e8, 2e8, 3e8, 4e8, 5e8]; let large_params = NormalizationParams::from_targets(&large_targets); let large_normalized = large_params.normalize(&large_targets); assert_normalized(&large_normalized, "Large values"); // Wide range let wide_targets = vec![1e-8, 1e8]; let wide_params = NormalizationParams::from_targets(&wide_targets); let wide_normalized = wide_params.normalize(&wide_targets); assert_normalized(&wide_normalized, "Wide range"); assert_approx_eq(wide_normalized[0], 0.0, 1e-6, "Wide range min"); assert_approx_eq(wide_normalized[1], 1.0, 1e-6, "Wide range max"); } #[test] fn test_normalization_negative_values() { // Mix of negative and positive let targets = vec![-100.0, -50.0, 0.0, 50.0, 100.0]; let params = NormalizationParams::from_targets(&targets); let normalized = params.normalize(&targets); assert_normalized(&normalized, "Negative values"); // Verify mapping assert_approx_eq(normalized[0], 0.0, 1e-6, "Negative min"); assert_approx_eq(normalized[2], 0.5, 1e-6, "Zero middle"); assert_approx_eq(normalized[4], 1.0, 1e-6, "Positive max"); } #[test] fn test_denormalization_without_range_info() { // Denormalize without knowing original range (should fail gracefully) let params = NormalizationParams { min: 0.0, max: 0.0 }; let normalized = vec![0.0, 0.5, 1.0]; let denormalized = params.denormalize(&normalized); // All should be 0.0 (min == max) for (i, &val) in denormalized.iter().enumerate() { assert_approx_eq(val, 0.0, 1e-6, &format!("Zero range denorm at {}", i)); } } // ============================================================================ // METRICS TESTS // ============================================================================ #[test] fn test_directional_accuracy_perfect() { // Perfect predictions let targets = vec![1.0, 2.0, 3.0, 2.5, 4.0, 3.5, 5.0]; let predictions = targets.clone(); let accuracy = directional_accuracy(&predictions, &targets); assert_approx_eq(accuracy, 1.0, 1e-6, "Perfect directional accuracy"); } #[test] fn test_directional_accuracy_random() { // Random predictions (should be ~50% on average) let targets = vec![1.0, 2.0, 1.5, 3.0, 2.0, 4.0, 3.5]; let predictions = vec![1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0]; // Different directions let accuracy = directional_accuracy(&predictions, &targets); // Should be between 0.3 and 0.7 (roughly random) assert!( accuracy >= 0.3 && accuracy <= 0.7, "Random accuracy should be ~0.5, got {}", accuracy ); } #[test] fn test_directional_accuracy_opposite() { // Predictions are opposite direction of targets let targets = vec![1.0, 2.0, 3.0, 4.0, 5.0]; let predictions = vec![5.0, 4.0, 3.0, 2.0, 1.0]; let accuracy = directional_accuracy(&predictions, &targets); assert_approx_eq(accuracy, 0.0, 1e-6, "Opposite directional accuracy"); } #[test] fn test_directional_accuracy_edge_cases() { // Empty vectors let empty_preds: Vec = vec![]; let empty_targets: Vec = vec![]; let empty_accuracy = directional_accuracy(&empty_preds, &empty_targets); assert_approx_eq(empty_accuracy, 0.5, 1e-6, "Empty directional accuracy"); // Single value let single_preds = vec![42.0]; let single_targets = vec![42.0]; let single_accuracy = directional_accuracy(&single_preds, &single_targets); assert_approx_eq(single_accuracy, 0.5, 1e-6, "Single value accuracy"); } #[test] fn test_mae_calculation() { // Known MAE let predictions = vec![1.0, 2.0, 3.0, 4.0, 5.0]; let targets = vec![1.5, 2.5, 3.5, 4.5, 5.5]; let mae_val = mae(&predictions, &targets); assert_approx_eq(mae_val, 0.5, 1e-6, "MAE calculation"); } #[test] fn test_mae_zero_error() { // Perfect predictions let predictions = vec![1.0, 2.0, 3.0, 4.0, 5.0]; let targets = predictions.clone(); let mae_val = mae(&predictions, &targets); assert_approx_eq(mae_val, 0.0, 1e-6, "Perfect MAE (zero error)"); } #[test] fn test_mae_edge_cases() { // Empty vectors let empty_preds: Vec = vec![]; let empty_targets: Vec = vec![]; let empty_mae = mae(&empty_preds, &empty_targets); assert_approx_eq(empty_mae, 0.0, 1e-6, "Empty MAE"); // Single value let single_preds = vec![42.0]; let single_targets = vec![40.0]; let single_mae = mae(&single_preds, &single_targets); assert_approx_eq(single_mae, 2.0, 1e-6, "Single value MAE"); } #[test] fn test_mse_calculation() { // Known MSE let predictions = vec![1.0, 2.0, 3.0, 4.0, 5.0]; let targets = vec![1.5, 2.5, 3.5, 4.5, 5.5]; let mse_val = mse(&predictions, &targets); assert_approx_eq(mse_val, 0.25, 1e-6, "MSE calculation"); // (0.5)^2 = 0.25 } #[test] fn test_mse_zero_error() { // Perfect predictions let predictions = vec![1.0, 2.0, 3.0, 4.0, 5.0]; let targets = predictions.clone(); let mse_val = mse(&predictions, &targets); assert_approx_eq(mse_val, 0.0, 1e-6, "Perfect MSE (zero error)"); } #[test] fn test_mse_on_normalized_targets() { // Normalized targets [0, 1] let predictions = vec![0.1, 0.3, 0.5, 0.7, 0.9]; let targets = vec![0.2, 0.4, 0.6, 0.8, 1.0]; let mse_val = mse(&predictions, &targets); // MSE should be in [0, 1] range (normalized) assert!( mse_val >= 0.0 && mse_val <= 1.0, "Normalized MSE should be in [0, 1], got {}", mse_val ); assert_approx_eq(mse_val, 0.01, 1e-6, "Normalized MSE calculation"); } #[test] fn test_mse_edge_cases() { // Empty vectors let empty_preds: Vec = vec![]; let empty_targets: Vec = vec![]; let empty_mse = mse(&empty_preds, &empty_targets); assert_approx_eq(empty_mse, 0.0, 1e-6, "Empty MSE"); // Single value let single_preds = vec![42.0]; let single_targets = vec![40.0]; let single_mse = mse(&single_preds, &single_targets); assert_approx_eq(single_mse, 4.0, 1e-6, "Single value MSE"); // (42-40)^2 = 4 } // ============================================================================ // PROPERTY-BASED TESTS (using quickcheck if available) // ============================================================================ #[test] fn test_normalization_preserves_ordering() { // Property: If a < b, then norm(a) <= norm(b) let targets = vec![10.0, 20.0, 15.0, 30.0, 25.0]; let params = NormalizationParams::from_targets(&targets); let normalized = params.normalize(&targets); // Check ordering for i in 0..targets.len() { for j in i + 1..targets.len() { if targets[i] < targets[j] { assert!( normalized[i] <= normalized[j], "Normalization should preserve ordering: {} < {} but {} > {}", targets[i], targets[j], normalized[i], normalized[j] ); } } } } #[test] fn test_denormalization_is_inverse_of_normalization() { // Property: denorm(norm(x)) = x for scale in &[1.0, 100.0, 1e6, 1e-6] { let targets: Vec = (0..20).map(|i| i as f64 * scale).collect(); let params = NormalizationParams::from_targets(&targets); let normalized = params.normalize(&targets); let recovered = params.denormalize(&normalized); for (i, (&original, &recovered_val)) in targets.iter().zip(recovered.iter()).enumerate() { assert_relative_eq!( original, recovered_val, epsilon = 1e-6 * scale.abs(), "Denorm is inverse of norm at index {} (scale {})", i, scale ); } } } #[test] fn test_metrics_are_in_valid_ranges() { // Property: All metrics should be in valid ranges let targets = create_test_price_data(50, 100.0, 200.0); let predictions = create_test_price_data(50, 110.0, 190.0); // Directional accuracy: [0, 1] let dir_acc = directional_accuracy(&predictions, &targets); assert!( (0.0..=1.0).contains(&dir_acc), "Directional accuracy should be in [0, 1], got {}", dir_acc ); // MAE: >= 0 let mae_val = mae(&predictions, &targets); assert!(mae_val >= 0.0, "MAE should be >= 0, got {}", mae_val); // MSE: >= 0 let mse_val = mse(&predictions, &targets); assert!(mse_val >= 0.0, "MSE should be >= 0, got {}", mse_val); // MSE >= MAE^2 / n (Cauchy-Schwarz inequality doesn't apply directly, but MSE >= 0) assert!( mse_val >= 0.0, "MSE should be non-negative, got {}", mse_val ); } // ============================================================================ // INTEGRATION TESTS // ============================================================================ #[test] fn test_normalization_denormalization_roundtrip() { // Full roundtrip with multiple scales let test_cases = vec![ ("Small values", create_test_price_data(30, 1e-6, 1e-5)), ("Normal prices", create_test_price_data(30, 4000.0, 5000.0)), ("Large values", create_test_price_data(30, 1e6, 1e7)), ("Wide range", vec![1.0, 1e6]), ("Negative range", create_test_price_data(30, -100.0, 100.0)), ]; for (label, targets) in test_cases { let params = NormalizationParams::from_targets(&targets); // Normalize let normalized = params.normalize(&targets); assert_normalized(&normalized, label); // Denormalize let recovered = params.denormalize(&normalized); // Verify recovery for (i, (&original, &recovered_val)) in targets.iter().zip(recovered.iter()).enumerate() { let scale = targets .iter() .map(|x| x.abs()) .fold(0.0_f64, f64::max) .max(1.0); assert_relative_eq!( original, recovered_val, epsilon = 1e-6 * scale, "{}: Roundtrip failed at index {}", label, i ); } } } #[test] fn test_metrics_integration() { // Integration test: compute all metrics on same dataset let targets = create_test_price_data(100, 4000.0, 5000.0); let params = NormalizationParams::from_targets(&targets); // Normalize targets let normalized_targets = params.normalize(&targets); // Create predictions (slightly noisy) let predictions: Vec = normalized_targets .iter() .enumerate() .map(|(i, &x)| x + 0.01 * ((i as f64).sin())) .collect(); // Compute metrics let dir_acc = directional_accuracy(&predictions, &normalized_targets); let mae_val = mae(&predictions, &normalized_targets); let mse_val = mse(&predictions, &normalized_targets); // Validate ranges assert!( (0.0..=1.0).contains(&dir_acc), "Directional accuracy out of range: {}", dir_acc ); assert!(mae_val >= 0.0, "MAE negative: {}", mae_val); assert!(mse_val >= 0.0, "MSE negative: {}", mse_val); assert!( mae_val <= 1.0, "MAE > 1.0 on normalized targets: {}", mae_val ); assert!( mse_val <= 1.0, "MSE > 1.0 on normalized targets: {}", mse_val ); // MSE should be >= MAE^2 for identical errors (not always true, but check positive) assert!( mse_val >= 0.0 && mae_val >= 0.0, "Metrics should be non-negative" ); } #[test] fn test_batch_size_validation() { // Verify batch_size <= dataset_size (ES_FUT_180d has ~108 sequences) let dataset_sizes = vec![50, 100, 108, 200]; let batch_sizes = vec![16, 32, 64, 128, 256]; for &dataset_size in &dataset_sizes { for &batch_size in &batch_sizes { // Valid batch size: <= dataset_size if batch_size <= dataset_size { assert!( batch_size <= dataset_size, "Batch size {} exceeds dataset size {}", batch_size, dataset_size ); } else { // Invalid batch size: should use dataset_size instead let effective_batch_size = batch_size.min(dataset_size); assert_eq!( effective_batch_size, dataset_size, "Batch size {} should be clamped to dataset size {}", batch_size, dataset_size ); } } } } #[test] fn test_normalization_with_nan_values() { // Test robustness to NaN values (should be filtered out) let mut targets = create_test_price_data(20, 100.0, 200.0); targets[5] = f64::NAN; targets[10] = f64::NAN; // Filter NaN before normalization let filtered_targets: Vec = targets.iter().copied().filter(|x| x.is_finite()).collect(); assert_eq!( filtered_targets.len(), 18, "Should have 18 finite values after filtering" ); let params = NormalizationParams::from_targets(&filtered_targets); let normalized = params.normalize(&filtered_targets); // All normalized values should be finite for (i, &val) in normalized.iter().enumerate() { assert!( val.is_finite(), "Normalized value at {} is not finite: {}", i, val ); } } #[test] fn test_metrics_with_constant_predictions() { // Edge case: all predictions are the same let targets = vec![1.0, 2.0, 3.0, 4.0, 5.0]; let predictions = vec![3.0; 5]; // All predictions = 3.0 let dir_acc = directional_accuracy(&predictions, &targets); let mae_val = mae(&predictions, &targets); let mse_val = mse(&predictions, &targets); // Directional accuracy should be 0.0 (no direction changes in predictions) assert_approx_eq(dir_acc, 0.0, 1e-6, "Constant predictions directional accuracy"); // MAE should be average absolute deviation from 3.0 let expected_mae = (2.0 + 1.0 + 0.0 + 1.0 + 2.0) / 5.0; // 1.2 assert_approx_eq(mae_val, expected_mae, 1e-6, "Constant predictions MAE"); // MSE should be average squared deviation let expected_mse = (4.0 + 1.0 + 0.0 + 1.0 + 4.0) / 5.0; // 2.0 assert_approx_eq(mse_val, expected_mse, 1e-6, "Constant predictions MSE"); }