#![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, )] //! Sample Weights Test Suite (TDD) //! //! Tests for sample weight calculation to address: //! - Label imbalance (buy/sell/hold distribution) //! - Temporal decay (recent samples weighted higher) //! - Numerical stability (normalized weights) //! //! Based on MLFinLab methodology for reducing overfitting use chrono::{DateTime, Duration, Utc}; // We'll import from the module we're about to create use ml::features::sample_weights::{SampleWeightCalculator, WeightingScheme}; use ml::labeling::meta_labeling::primary_model::Label; /// Test helper: create timestamps with specified day offsets from now fn create_timestamps(day_offsets: Vec) -> Vec> { let base_time = Utc::now(); day_offsets .into_iter() .map(|offset| base_time - Duration::days(offset)) .collect() } #[test] fn test_temporal_decay_only() { // Test temporal decay without label balancing let calculator = SampleWeightCalculator::new( 0.95, // decay_factor WeightingScheme::TemporalDecay, // scheme ); // Create labels (all Buy, so no label imbalance effect) let labels = vec![Label::Buy; 5]; // Create timestamps: 4 days ago, 3 days ago, ..., today let timestamps = create_timestamps(vec![4, 3, 2, 1, 0]); let weights = calculator .calculate(&labels, ×tamps) .expect("Weight calculation should succeed"); // Verify weights are normalized (sum to 1.0) let sum: f64 = weights.iter().sum(); assert!( (sum - 1.0).abs() < 1e-6, "Weights should sum to 1.0, got {}", sum ); // Verify temporal decay pattern: more recent samples have higher weights assert!( weights[0] < weights[4], "Oldest sample ({}) should have lower weight than newest ({})", weights[0], weights[4] ); // Verify exponential decay relationship // decay_factor^1 = 0.95, so weight ratios should approximately match for i in 0..weights.len() - 1 { let ratio = weights[i + 1] / weights[i]; assert!( (ratio - 1.0 / 0.95).abs() < 0.01, "Adjacent weight ratio should be ~1.053, got {}", ratio ); } } #[test] fn test_label_balancing_only() { // Test label balancing without temporal decay let calculator = SampleWeightCalculator::new( 1.0, // No decay (decay_factor = 1.0) WeightingScheme::LabelBalancing, // scheme ); // Create imbalanced labels: 3 Buy, 1 Sell, 1 Hold let labels = vec![Label::Buy, Label::Buy, Label::Buy, Label::Sell, Label::Hold]; // All timestamps the same (no temporal effect) let timestamps = vec![Utc::now(); 5]; let weights = calculator .calculate(&labels, ×tamps) .expect("Weight calculation should succeed"); // Verify weights are normalized let sum: f64 = weights.iter().sum(); assert!( (sum - 1.0).abs() < 1e-6, "Weights should sum to 1.0, got {}", sum ); // Buy appears 3 times, so each Buy sample gets 1/3 weight factor // Sell appears 1 time, so Sell sample gets 1/1 = 1 weight factor // Hold appears 1 time, so Hold sample gets 1/1 = 1 weight factor // After normalization, Sell and Hold should have higher weights than Buy let buy_weight = weights[0]; // First Buy sample let sell_weight = weights[3]; // Sell sample let hold_weight = weights[4]; // Hold sample assert!( sell_weight > buy_weight, "Sell (rare) should have higher weight than Buy (common)" ); assert!( hold_weight > buy_weight, "Hold (rare) should have higher weight than Buy (common)" ); // Sell and Hold should have approximately equal weights (both appear once) assert!( (sell_weight - hold_weight).abs() < 1e-6, "Sell and Hold should have equal weights (both appear once)" ); } #[test] fn test_combined_weighting() { // Test combining temporal decay and label balancing let calculator = SampleWeightCalculator::new( 0.95, // decay_factor WeightingScheme::Combined, // Both temporal and label balancing ); // Create imbalanced labels with temporal spread let labels = vec![ Label::Buy, // 4 days ago Label::Buy, // 3 days ago Label::Sell, // 2 days ago Label::Hold, // 1 day ago Label::Buy, // today ]; let timestamps = create_timestamps(vec![4, 3, 2, 1, 0]); let weights = calculator .calculate(&labels, ×tamps) .expect("Weight calculation should succeed"); // Verify normalization let sum: f64 = weights.iter().sum(); assert!( (sum - 1.0).abs() < 1e-6, "Weights should sum to 1.0, got {}", sum ); // Buy appears 3 times (indices 0, 1, 4) // Sell appears 1 time (index 2) // Hold appears 1 time (index 3) // The most recent Buy (index 4) should have higher weight than oldest Buy (index 0) assert!( weights[4] > weights[0], "Most recent Buy should have higher weight than oldest Buy" ); // Recent Sell (index 2) should have high weight (recent + rare) // Recent Hold (index 3) should have high weight (recent + rare) // These two should be among the highest weights assert!( weights[2] > weights[0], "Recent Sell should have higher weight than old Buy" ); assert!( weights[3] > weights[0], "Recent Hold should have higher weight than old Buy" ); } #[test] fn test_numerical_stability_large_time_gaps() { // Test with large time gaps to ensure numerical stability let calculator = SampleWeightCalculator::new(0.95, WeightingScheme::TemporalDecay); let labels = vec![Label::Buy; 3]; // Very old sample (365 days ago), medium (30 days), recent (1 day) let timestamps = create_timestamps(vec![365, 30, 1]); let weights = calculator .calculate(&labels, ×tamps) .expect("Weight calculation should succeed"); // Verify normalization let sum: f64 = weights.iter().sum(); assert!( (sum - 1.0).abs() < 1e-6, "Weights should sum to 1.0 even with large time gaps, got {}", sum ); // Verify all weights are positive for (i, &weight) in weights.iter().enumerate() { assert!( weight > 0.0, "Weight at index {} should be positive, got {}", i, weight ); } // Very old sample should have negligible weight compared to recent assert!( weights[0] < weights[2] * 0.001, "Very old sample should have negligible weight compared to recent" ); } #[test] fn test_numerical_stability_equal_labels() { // Test with perfectly balanced labels let calculator = SampleWeightCalculator::new(1.0, WeightingScheme::LabelBalancing); // Equal distribution: 3 Buy, 3 Sell, 3 Hold let labels = vec![ Label::Buy, Label::Sell, Label::Hold, Label::Buy, Label::Sell, Label::Hold, Label::Buy, Label::Sell, Label::Hold, ]; let timestamps = vec![Utc::now(); 9]; let weights = calculator .calculate(&labels, ×tamps) .expect("Weight calculation should succeed"); // With equal labels and no temporal decay, all weights should be equal let expected_weight = 1.0 / 9.0; for (i, &weight) in weights.iter().enumerate() { assert!( (weight - expected_weight).abs() < 1e-6, "Weight at index {} should be {}, got {}", i, expected_weight, weight ); } } #[test] fn test_numerical_stability_single_sample() { // Edge case: single sample let calculator = SampleWeightCalculator::new(0.95, WeightingScheme::Combined); let labels = vec![Label::Buy]; let timestamps = vec![Utc::now()]; let weights = calculator .calculate(&labels, ×tamps) .expect("Weight calculation should succeed"); // Single sample should have weight 1.0 assert_eq!(weights.len(), 1); assert!( (weights[0] - 1.0).abs() < 1e-6, "Single sample should have weight 1.0, got {}", weights[0] ); } #[test] fn test_empty_input_error() { // Test error handling for empty inputs let calculator = SampleWeightCalculator::new(0.95, WeightingScheme::Combined); let labels = vec![]; let timestamps = vec![]; let result = calculator.calculate(&labels, ×tamps); assert!(result.is_err(), "Empty input should return an error"); } #[test] fn test_mismatched_lengths_error() { // Test error handling for mismatched input lengths let calculator = SampleWeightCalculator::new(0.95, WeightingScheme::Combined); let labels = vec![Label::Buy, Label::Sell]; let timestamps = vec![Utc::now()]; // Only 1 timestamp for 2 labels let result = calculator.calculate(&labels, ×tamps); assert!( result.is_err(), "Mismatched input lengths should return an error" ); } #[test] fn test_invalid_decay_factor_error() { // Test that decay factor must be positive // This should panic or return error during construction // Test decay_factor = 0 (invalid) let calculator = SampleWeightCalculator::new(0.0, WeightingScheme::TemporalDecay); let labels = vec![Label::Buy]; let timestamps = vec![Utc::now()]; let result = calculator.calculate(&labels, ×tamps); assert!(result.is_err(), "Decay factor 0.0 should produce an error"); // Test decay_factor > 1.0 (unusual but mathematically valid - future weighted higher) let calculator = SampleWeightCalculator::new(1.5, WeightingScheme::TemporalDecay); let result = calculator.calculate(&labels, ×tamps); // Should succeed (mathematically valid, just unusual) assert!( result.is_ok(), "Decay factor > 1.0 should be allowed (future-weighted)" ); } #[test] fn test_weights_non_negative() { // Ensure all weights are non-negative in all schemes let schemes = vec![ WeightingScheme::TemporalDecay, WeightingScheme::LabelBalancing, WeightingScheme::Combined, ]; let labels = vec![Label::Buy, Label::Sell, Label::Hold, Label::Buy]; let timestamps = create_timestamps(vec![3, 2, 1, 0]); for scheme in schemes { let calculator = SampleWeightCalculator::new(0.95, scheme); let weights = calculator .calculate(&labels, ×tamps) .expect("Weight calculation should succeed"); for (i, &weight) in weights.iter().enumerate() { assert!( weight >= 0.0, "Weight at index {} should be non-negative, got {}", i, weight ); } } } #[test] fn test_extreme_imbalance() { // Test with extreme label imbalance (99:1 ratio) let calculator = SampleWeightCalculator::new(1.0, WeightingScheme::LabelBalancing); // 99 Buy labels, 1 Sell label let mut labels = vec![Label::Buy; 99]; labels.push(Label::Sell); let timestamps = vec![Utc::now(); 100]; let weights = calculator .calculate(&labels, ×tamps) .expect("Weight calculation should succeed"); // Verify normalization let sum: f64 = weights.iter().sum(); assert!( (sum - 1.0).abs() < 1e-6, "Weights should sum to 1.0, got {}", sum ); // The single Sell should have much higher weight than any Buy let sell_weight = weights[99]; let buy_weight = weights[0]; assert!( sell_weight > buy_weight * 50.0, "Rare Sell should have 50x+ weight compared to common Buy" ); // Total weight for all Sell samples should roughly equal total weight for all Buy samples let total_sell_weight = sell_weight; let total_buy_weight: f64 = weights[0..99].iter().sum(); assert!( (total_sell_weight - total_buy_weight).abs() < 0.1, "Total weight for Sell should approximately equal total weight for Buy (balanced classes)" ); }