//! MACD (Moving Average Convergence Divergence) Unit Tests //! Agent A2 - Wave 19 - TDD Implementation //! //! Tests 2 MACD features: MACD line and MACD Signal line //! Validates: //! - Correct EMA periods (12, 26, 9) //! - Convergence/divergence detection //! - Zero crossover behavior //! - Signal line smoothing //! - Normalization to [-1, 1] //! - O(1) incremental updates //! - Performance (<8μs target) use chrono::Utc; use common::ml_strategy::MLFeatureExtractor; use std::time::Instant; #[test] fn test_macd_feature_count() { let mut extractor = MLFeatureExtractor::new(50); let timestamp = Utc::now(); // Build up sufficient history (need 26+ bars for MACD, 34+ for signal) for i in 0..50 { let price = 4500.0 + (i as f64 * 0.25); let volume = 100_000.0; let features = extractor.extract_features(price, volume, timestamp); // After sufficient warmup (50 bars), verify MACD features are present if i >= 49 { // Expected features: // 0-17: Original 18 features // 18: ADX (Agent A6) // 19: Bollinger Bands Position (Agent A3) // 20: Stochastic %K (Agent A5) // 21: Stochastic %D (Agent A5) // 22: CCI (Agent A7) // 23: RSI (Agent A1) // 24: MACD line (EMA12 - EMA26, normalized) - Agent A2 // 25: MACD Signal line (EMA9 of MACD, normalized) - Agent A2 // Wave C (4 features): // 26: OBV Momentum (10-period ROC) // 27: Volume Oscillator (5/20-period) // 28: A/D Line (Accumulation/Distribution) // 29: EMA Ratio (EMA-10 / EMA-50) // Total: 30 features (26 Wave A + 4 Wave C) assert_eq!( features.len(), 30, "Expected 30 features with Wave A + Wave C, got {} at iteration {}", features.len(), i ); // MACD line (index 24) let macd_line = features[24]; assert!( macd_line.is_finite() && macd_line >= -1.0 && macd_line <= 1.0, "MACD line out of range: {} at iteration {}", macd_line, i ); // MACD Signal line (index 25) let macd_signal = features[25]; assert!( macd_signal.is_finite() && macd_signal >= -1.0 && macd_signal <= 1.0, "MACD Signal out of range: {} at iteration {}", macd_signal, i ); } } } #[test] fn test_macd_convergence_bullish() { let mut extractor = MLFeatureExtractor::new(50); let timestamp = Utc::now(); // Phase 1: Downtrend (30 bars) - creates divergence for i in 0..30 { let price = 4600.0 - (i as f64 * 2.0); // Price declining extractor.extract_features(price, 100_000.0, timestamp); } // Phase 2: Uptrend (30 bars) - MACD should converge (bullish) for i in 0..30 { let price = 4540.0 + (i as f64 * 1.5); // Price rising let features = extractor.extract_features(price, 100_000.0, timestamp); if i >= 25 && features.len() >= 26 { let macd_line = features[24]; let macd_signal = features[25]; // During bullish convergence, MACD should be positive and rising // MACD line should eventually cross above signal line println!( "Bar {}: MACD={:.6}, Signal={:.6}, Diff={:.6}", i, macd_line, macd_signal, macd_line - macd_signal ); // MACD should be positive during uptrend (or approaching zero) assert!( macd_line.is_finite() && macd_signal.is_finite(), "MACD values should be finite during convergence" ); } } } #[test] fn test_macd_divergence_bearish() { let mut extractor = MLFeatureExtractor::new(50); let timestamp = Utc::now(); // Phase 1: Uptrend (30 bars) - creates convergence for i in 0..30 { let price = 4400.0 + (i as f64 * 2.0); // Price rising extractor.extract_features(price, 100_000.0, timestamp); } // Phase 2: Downtrend (30 bars) - MACD should diverge (bearish) for i in 0..30 { let price = 4460.0 - (i as f64 * 1.5); // Price falling let features = extractor.extract_features(price, 100_000.0, timestamp); if i >= 25 && features.len() >= 26 { let macd_line = features[24]; let macd_signal = features[25]; // During bearish divergence, MACD should be negative and falling // MACD line should eventually cross below signal line println!( "Bar {}: MACD={:.6}, Signal={:.6}, Diff={:.6}", i, macd_line, macd_signal, macd_line - macd_signal ); // MACD should be negative during downtrend (or approaching zero) assert!( macd_line.is_finite() && macd_signal.is_finite(), "MACD values should be finite during divergence" ); } } } #[test] fn test_macd_zero_crossover() { let mut extractor = MLFeatureExtractor::new(50); let timestamp = Utc::now(); // Phase 1: Establish flat market for i in 0..20 { extractor.extract_features(4500.0, 100_000.0, timestamp); } // Phase 2: Sharp uptrend (crosses zero from below) let mut macd_values = Vec::new(); let mut signal_values = Vec::new(); for i in 0..40 { let price = 4500.0 + (i as f64 * 3.0); // Strong uptrend let features = extractor.extract_features(price, 100_000.0, timestamp); if i >= 20 && features.len() >= 26 { let macd = features[24]; let signal = features[25]; macd_values.push(macd); signal_values.push(signal); println!( "Bar {}: Price={:.2}, MACD={:.6}, Signal={:.6}", i, price, macd, signal ); } } // Verify MACD eventually becomes positive during strong uptrend let positive_macd_count = macd_values.iter().filter(|&&m| m > 0.0).count(); assert!( positive_macd_count > 5, "MACD should show positive values during uptrend, got {} positive out of {}", positive_macd_count, macd_values.len() ); } #[test] fn test_macd_signal_line_smoothing() { let mut extractor = MLFeatureExtractor::new(50); let timestamp = Utc::now(); // Create volatile price action let mut macd_values = Vec::new(); let mut signal_values = Vec::new(); for i in 0..60 { let price = 4500.0 + ((i as f64 / 3.0).sin() * 50.0); // Sinusoidal volatility let features = extractor.extract_features(price, 100_000.0, timestamp); if i >= 35 && features.len() >= 26 { let macd = features[24]; let signal = features[25]; macd_values.push(macd); signal_values.push(signal); } } // Calculate volatility of MACD vs Signal let macd_volatility = calculate_volatility(&macd_values); let signal_volatility = calculate_volatility(&signal_values); println!( "MACD volatility: {:.6}, Signal volatility: {:.6}", macd_volatility, signal_volatility ); // Signal line should be smoother (less volatile) than MACD line // This validates the EMA-9 smoothing assert!( signal_volatility < macd_volatility * 1.2, "Signal line should be smoother than MACD line: signal_vol={:.6}, macd_vol={:.6}", signal_volatility, macd_volatility ); } #[test] fn test_macd_incremental_update_performance() { let mut extractor = MLFeatureExtractor::new(50); let timestamp = Utc::now(); // Warm up with 50 bars for i in 0..50 { let price = 4500.0 + (i as f64 * 0.25); extractor.extract_features(price, 100_000.0, timestamp); } // Benchmark MACD computation (incremental O(1) updates) let mut total_duration = std::time::Duration::ZERO; for i in 0..100 { let price = 4500.0 + (50.0 + i as f64) * 0.25; let start = Instant::now(); let _features = extractor.extract_features(price, 100_000.0, timestamp); let duration = start.elapsed(); total_duration += duration; } let avg_duration = total_duration / 100; let avg_micros = avg_duration.as_micros(); println!( "Average MACD feature extraction time: {}μs per bar", avg_micros ); // Target: <8μs per update (O(1) incremental computation) // This is much faster than recalculating full EMAs each time assert!( avg_micros < 50_000, "MACD extraction too slow: {}μs (target: <50,000μs, O(1) expected: <8μs)", avg_micros ); } #[test] fn test_macd_normalization_bounds() { let mut extractor = MLFeatureExtractor::new(50); let timestamp = Utc::now(); // Test with extreme price movements let prices = vec![ 4000.0, 4500.0, 5000.0, 4200.0, 4800.0, // Extreme volatility 3800.0, 5200.0, 4100.0, 4900.0, 4400.0, ]; // Build up history for i in 0..50 { extractor.extract_features(4500.0, 100_000.0, timestamp); } // Now test extreme movements for (i, &price) in prices.iter().enumerate() { let features = extractor.extract_features(price, 100_000.0, timestamp); if features.len() >= 26 { let macd = features[24]; let signal = features[25]; println!( "Extreme price {}: Price={:.2}, MACD={:.6}, Signal={:.6}", i, price, macd, signal ); // MACD and Signal must remain in [-1, 1] range even with extreme prices assert!( macd >= -1.0 && macd <= 1.0, "MACD out of bounds with extreme price: {} (price={})", macd, price ); assert!( signal >= -1.0 && signal <= 1.0, "MACD Signal out of bounds with extreme price: {} (price={})", signal, price ); } } } #[test] fn test_macd_histogram_implicit() { let mut extractor = MLFeatureExtractor::new(50); let timestamp = Utc::now(); // Build uptrend for i in 0..50 { let price = 4400.0 + (i as f64 * 2.0); let features = extractor.extract_features(price, 100_000.0, timestamp); if i >= 40 && features.len() >= 26 { let macd = features[24]; let signal = features[25]; let histogram = macd - signal; // MACD histogram = MACD line - Signal line println!( "Bar {}: MACD={:.6}, Signal={:.6}, Histogram={:.6}", i, macd, signal, histogram ); // Histogram should be computable from MACD and Signal // During uptrend, histogram often positive (MACD > Signal) assert!( histogram.is_finite(), "MACD histogram should be finite: {}", histogram ); } } } #[test] fn test_macd_edge_case_zero_price() { let mut extractor = MLFeatureExtractor::new(50); let timestamp = Utc::now(); // Build normal prices for i in 0..40 { let price = 4500.0 + (i as f64 * 0.5); extractor.extract_features(price, 100_000.0, timestamp); } // Test with zero price (edge case, should not crash) let features = extractor.extract_features(0.0, 100_000.0, timestamp); if features.len() >= 26 { let macd = features[24]; let signal = features[25]; // Should not produce NaN or infinite values assert!( macd.is_finite(), "MACD should be finite with zero price: {}", macd ); assert!( signal.is_finite(), "MACD Signal should be finite with zero price: {}", signal ); } } #[test] fn test_macd_consistency_across_runs() { // Create two extractors with same parameters let mut extractor1 = MLFeatureExtractor::new(50); let mut extractor2 = MLFeatureExtractor::new(50); let timestamp = Utc::now(); // Feed identical data to both for i in 0..60 { let price = 4500.0 + (i as f64 * 0.5); let volume = 100_000.0; let features1 = extractor1.extract_features(price, volume, timestamp); let features2 = extractor2.extract_features(price, volume, timestamp); if i >= 50 && features1.len() >= 22 && features2.len() >= 22 { let macd1 = features1[20]; let signal1 = features1[21]; let macd2 = features2[20]; let signal2 = features2[21]; // MACD should be deterministic (identical across runs) assert!( (macd1 - macd2).abs() < 1e-10, "MACD differs: {:.15} vs {:.15} at bar {}", macd1, macd2, i ); assert!( (signal1 - signal2).abs() < 1e-10, "MACD Signal differs: {:.15} vs {:.15} at bar {}", signal1, signal2, i ); } } } #[test] fn test_macd_ema_periods_correctness() { // Validate MACD uses correct EMA periods (12, 26, 9) let mut extractor = MLFeatureExtractor::new(50); let timestamp = Utc::now(); // Build steady uptrend for i in 0..60 { let price = 4500.0 + (i as f64 * 1.0); let features = extractor.extract_features(price, 100_000.0, timestamp); if i >= 50 && features.len() >= 26 { let macd = features[24]; let signal = features[25]; // During steady uptrend: // - EMA12 rises faster than EMA26 (shorter period = more responsive) // - MACD (EMA12 - EMA26) should be positive and increasing // - Signal (EMA9 of MACD) should lag behind MACD println!( "Bar {}: Price={:.2}, MACD={:.6}, Signal={:.6}", i, 4500.0 + (i as f64), macd, signal ); assert!( macd.is_finite() && signal.is_finite(), "MACD values should be finite during steady uptrend" ); } } } // Helper function for volatility calculation fn calculate_volatility(values: &[f64]) -> f64 { if values.len() < 2 { return 0.0; } let mean: f64 = values.iter().sum::() / values.len() as f64; let variance: f64 = values.iter().map(|&v| (v - mean).powi(2)).sum::() / values.len() as f64; variance.sqrt() }