#![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, )] //! Feature Normalization Tests //! //! Tests for percentile-based feature clipping to prevent outliers //! from crushing the feature distribution during min-max normalization. //! //! ## Problem //! //! OBV (On-Balance Volume) features accumulate signed volume over time, //! leading to extreme outliers (e.g., -863K to +863K). When using //! min-max normalization, these outliers compress 51/54 other features //! into a narrow range [0.48, 0.52], making them indistinguishable. //! //! ## Solution //! //! Apply percentile clipping (1st to 99th percentile) BEFORE min-max //! normalization. This preserves 98% of data while preventing outliers //! from dominating the normalization scale. use std::f64; use tracing::info; /// Compute percentile value from sorted data fn percentile(sorted_data: &[f64], p: f64) -> f64 { assert!( !sorted_data.is_empty(), "Cannot compute percentile of empty data" ); assert!(p >= 0.0 && p <= 1.0, "Percentile must be in [0, 1]"); let idx = (sorted_data.len() as f64 * p).round() as usize; let idx = idx.min(sorted_data.len() - 1); sorted_data[idx] } /// Apply percentile clipping to features fn clip_features_by_percentile(features: &[f64], p_low: f64, p_high: f64) -> Vec { let mut sorted = features.to_vec(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); let p1 = percentile(&sorted, p_low); let p99 = percentile(&sorted, p_high); info!(p_low, p1, "Percentile lower bound"); info!(p_high, p99, "Percentile upper bound"); features.iter().map(|&x| x.clamp(p1, p99)).collect() } /// Normalize features to [0, 1] range fn normalize_min_max(features: &[f64]) -> Vec { let min = features.iter().copied().fold(f64::INFINITY, f64::min); let max = features.iter().copied().fold(f64::NEG_INFINITY, f64::max); let range = max - min; if range.abs() < 1e-10 { // All values are the same, return 0.5 return vec![0.5; features.len()]; } features.iter().map(|&x| (x - min) / range).collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_percentile_computation() { let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]; // Test extremes assert!((percentile(&data, 0.0) - 1.0).abs() < 1e-10); assert!((percentile(&data, 1.0) - 10.0).abs() < 1e-10); // Test median (50th percentile) let p50 = percentile(&data, 0.5); assert!( p50 >= 5.0 && p50 <= 6.0, "Median should be ~5.5, got {}", p50 ); // Test 99th percentile let p99 = percentile(&data, 0.99); assert!( p99 >= 9.0 && p99 <= 10.0, "99th percentile should be ~10, got {}", p99 ); } #[test] fn test_clip_features_without_outliers() { // Data without outliers - clipping should have minimal effect let features = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]; let clipped = clip_features_by_percentile(&features, 0.01, 0.99); // Most values should be unchanged for i in 1..9 { assert!( (clipped[i] - features[i]).abs() < 1.0, "Value {} should be mostly unchanged", i ); } } #[test] fn test_clip_features_with_extreme_outliers() { // Test that clipping works when percentiles exclude outliers // Key insight: outliers must be OUTSIDE the 1st-99th percentile range let mut features = Vec::new(); // Add 100 normal values in range [-100, 100] for i in -50..50 { features.push(i as f64 * 2.0); } // Add extreme outliers at beginning and end // These will be at the 0.5% and 99.5% positions features.insert(0, -863_000.0); features.push(863_000.0); info!(total_features = features.len(), "Total features"); let clipped = clip_features_by_percentile(&features, 0.02, 0.98); // Extreme outliers should be clipped to 2nd and 98th percentile values let min_clipped = clipped.iter().copied().fold(f64::INFINITY, f64::min); let max_clipped = clipped.iter().copied().fold(f64::NEG_INFINITY, f64::max); info!(min_clipped, max_clipped, "Clipped range"); // After clipping, outliers should be replaced with percentile boundary values // which are within the normal range assert!( max_clipped < 200.0, "Max should be clipped to reasonable range, got {}", max_clipped ); assert!( min_clipped > -200.0, "Min should be clipped to reasonable range, got {}", min_clipped ); } #[test] fn test_normalize_min_max_basic() { let features = vec![0.0, 25.0, 50.0, 75.0, 100.0]; let normalized = normalize_min_max(&features); // Check bounds assert!((normalized[0] - 0.0).abs() < 1e-10, "Min should map to 0"); assert!((normalized[4] - 1.0).abs() < 1e-10, "Max should map to 1"); // Check midpoint assert!( (normalized[2] - 0.5).abs() < 1e-10, "Midpoint should map to 0.5" ); // Check all values in [0, 1] for val in &normalized { assert!( *val >= 0.0 && *val <= 1.0, "Normalized value {} out of range", val ); } } #[test] fn test_normalize_constant_features() { // All values the same - should return 0.5 let features = vec![42.0; 10]; let normalized = normalize_min_max(&features); for val in &normalized { assert!( (val - 0.5).abs() < 1e-10, "Constant features should normalize to 0.5" ); } } #[test] fn test_full_pipeline_with_outliers() { // Simulate realistic scenario: 54 features with OBV outliers let mut features = Vec::new(); // 51 normal features (range: 0-100) for _ in 0..51 { for i in 0..10 { features.push(i as f64 * 10.0); } } // 3 OBV features with extreme outliers for _ in 0..3 { features.push(-863_000.0); features.push(863_000.0); for i in -5..5 { features.push(i as f64 * 100.0); } } info!("Feature Normalization Test"); info!(total_features = features.len(), "Total features"); // BEFORE: Direct normalization (broken) let normalized_before = normalize_min_max(&features); let min_before = normalized_before .iter() .copied() .fold(f64::INFINITY, f64::min); let max_before = normalized_before .iter() .copied() .fold(f64::NEG_INFINITY, f64::max); info!("BEFORE percentile clipping"); info!( feature_min = features.iter().copied().fold(f64::INFINITY, f64::min), feature_max = features.iter().copied().fold(f64::NEG_INFINITY, f64::max), "Feature range before clipping" ); info!(min_before, max_before, "Normalized range before clipping"); // Count how many values are in narrow range [0.48, 0.52] let crushed_before = normalized_before .iter() .filter(|&&x| x >= 0.48 && x <= 0.52) .count(); info!( crushed_before, crushed_before_pct = 100.0 * crushed_before as f64 / normalized_before.len() as f64, "Values crushed to [0.48, 0.52] before clipping" ); // AFTER: Percentile clipping + normalization (fixed) let clipped = clip_features_by_percentile(&features, 0.01, 0.99); let normalized_after = normalize_min_max(&clipped); let min_after = normalized_after .iter() .copied() .fold(f64::INFINITY, f64::min); let max_after = normalized_after .iter() .copied() .fold(f64::NEG_INFINITY, f64::max); info!("AFTER percentile clipping"); info!( clipped_min = clipped.iter().copied().fold(f64::INFINITY, f64::min), clipped_max = clipped.iter().copied().fold(f64::NEG_INFINITY, f64::max), "Clipped feature range" ); info!(min_after, max_after, "Normalized range after clipping"); // Count distribution after fix let crushed_after = normalized_after .iter() .filter(|&&x| x >= 0.48 && x <= 0.52) .count(); info!( crushed_after, crushed_after_pct = 100.0 * crushed_after as f64 / normalized_after.len() as f64, "Values crushed to [0.48, 0.52] after clipping" ); // Assert fix works assert!( crushed_after < crushed_before / 2, "Percentile clipping should reduce feature crushing significantly" ); // Verify full utilization of [0, 1] range assert!( (min_after - 0.0).abs() < 0.1, "Min should be close to 0 after fix" ); assert!( (max_after - 1.0).abs() < 0.1, "Max should be close to 1 after fix" ); info!("Fix Validated"); info!("Percentile clipping prevents outliers from crushing feature distribution"); } #[test] fn test_obv_realistic_scenario() { // Realistic OBV outlier scenario from ES_FUT_180d.parquet let mut features = Vec::new(); // Generate OBV-like data: accumulates over time let mut obv = 0.0; for i in 0..1000 { let volume = 100.0 + (i as f64 % 50.0); let direction = if i % 3 == 0 { 1.0 } else { -1.0 }; obv += volume * direction; features.push(obv); } // Add other normal features (RSI, MACD, etc.) for _ in 0..53 { for i in 0..1000 { features.push((i % 100) as f64); } } info!("OBV Realistic Scenario"); let orig_min = features.iter().copied().fold(f64::INFINITY, f64::min); let orig_max = features.iter().copied().fold(f64::NEG_INFINITY, f64::max); info!(orig_min, orig_max, "Original feature range"); // Apply percentile clipping let clipped = clip_features_by_percentile(&features, 0.01, 0.99); let clipped_min = clipped.iter().copied().fold(f64::INFINITY, f64::min); let clipped_max = clipped.iter().copied().fold(f64::NEG_INFINITY, f64::max); info!(clipped_min, clipped_max, "Clipped feature range"); // Range should be much smaller after clipping let orig_range = orig_max - orig_min; let clipped_range = clipped_max - clipped_min; info!( orig_range, clipped_range, reduction_pct = 100.0 * (1.0 - clipped_range / orig_range), "Range reduction after clipping" ); assert!( clipped_range < orig_range * 0.5, "Clipping should reduce range by at least 50%" ); } #[test] fn test_edge_case_all_same_value() { let features = vec![42.0; 100]; let clipped = clip_features_by_percentile(&features, 0.01, 0.99); let normalized = normalize_min_max(&clipped); // All values should normalize to 0.5 for val in &normalized { assert!((val - 0.5).abs() < 1e-10); } } #[test] fn test_edge_case_two_values() { let features = vec![0.0, 100.0]; let clipped = clip_features_by_percentile(&features, 0.01, 0.99); let normalized = normalize_min_max(&clipped); assert!((normalized[0] - 0.0).abs() < 1e-10); assert!((normalized[1] - 1.0).abs() < 1e-10); } #[test] fn test_preserves_98_percent_of_data() { // Generate 10000 normal values + 200 outliers let mut features = Vec::new(); // 98% normal (0-100) for i in 0..9800 { features.push((i % 100) as f64); } // 2% outliers (-100000, +100000) for _ in 0..100 { features.push(-100_000.0); features.push(100_000.0); } let clipped = clip_features_by_percentile(&features, 0.01, 0.99); // Count how many normal values are preserved exactly let preserved = features .iter() .filter(|&&x| x >= 0.0 && x <= 100.0) .filter(|&&x| clipped.contains(&x)) .count(); let preservation_rate = preserved as f64 / 9800.0; info!(preservation_rate_pct = preservation_rate * 100.0, "Preservation rate"); assert!( preservation_rate > 0.95, "At least 95% of normal data should be preserved" ); } }