//! Volume-based technical indicators validation tests //! //! Tests for OBV (On-Balance Volume), MFI (Money Flow Index), and VWAP //! (Volume-Weighted Average Price) implementation in ML feature extraction. use chrono::Utc; use common::ml_strategy::MLFeatureExtractor; #[test] fn test_obv_accumulation_on_uptrend() { let mut extractor = MLFeatureExtractor::new(20); // Simulate uptrend with increasing prices and volume let prices = vec![100.0, 101.0, 102.0, 103.0, 104.0]; let volumes = vec![1000.0, 1100.0, 1200.0, 1300.0, 1400.0]; let mut features_list = Vec::new(); for (price, volume) in prices.iter().zip(volumes.iter()) { let features = extractor.extract_features(*price, *volume, Utc::now()); features_list.push(features); } // OBV should be increasing (positive accumulation) // Feature index for OBV is 7 (after hour, day_of_week) let obv_feature_idx = 7; // First data point has no previous price, so OBV should be 0 assert_eq!(features_list[0][obv_feature_idx], 0.0); // Subsequent OBV values should be positive and increasing for i in 1..features_list.len() { let obv = features_list[i][obv_feature_idx]; assert!( obv > 0.0, "OBV should be positive in uptrend at index {}", i ); if i > 1 { // Each OBV should be greater than or equal to previous (accumulation) assert!( obv >= features_list[i - 1][obv_feature_idx], "OBV should increase in uptrend: {} < {}", obv, features_list[i - 1][obv_feature_idx] ); } } } #[test] fn test_obv_distribution_on_downtrend() { let mut extractor = MLFeatureExtractor::new(20); // Simulate downtrend with decreasing prices let prices = vec![104.0, 103.0, 102.0, 101.0, 100.0]; let volumes = vec![1000.0, 1100.0, 1200.0, 1300.0, 1400.0]; let mut features_list = Vec::new(); for (price, volume) in prices.iter().zip(volumes.iter()) { let features = extractor.extract_features(*price, *volume, Utc::now()); features_list.push(features); } let obv_feature_idx = 7; // OBV should be decreasing (negative accumulation/distribution) for i in 1..features_list.len() { let obv = features_list[i][obv_feature_idx]; assert!( obv < 0.0, "OBV should be negative in downtrend at index {}", i ); if i > 1 { // Each OBV should be less than or equal to previous (distribution) assert!( obv <= features_list[i - 1][obv_feature_idx], "OBV should decrease in downtrend" ); } } } #[test] fn test_mfi_overbought_signal() { let mut extractor = MLFeatureExtractor::new(20); // Generate 15 bars (need 15 for MFI 14-period calculation) // Strong uptrend with high volume = overbought condition for i in 0..15 { let price = 100.0 + (i as f64 * 2.0); // Strong uptrend let volume = 1000.0 + (i as f64 * 100.0); // Increasing volume extractor.extract_features(price, volume, Utc::now()); } // Last feature extraction should have MFI calculated let features = extractor.extract_features(130.0, 2500.0, Utc::now()); let mfi_feature_idx = 8; let mfi_normalized = features[mfi_feature_idx]; // MFI normalized from [0, 100] to [-1, 1] via ((mfi/50) - 1).tanh() // High MFI (>70 = overbought) should map to positive normalized value // MFI of 100 -> (100/50 - 1).tanh() = 1.0.tanh() = 0.76 assert!( mfi_normalized > 0.5, "MFI should indicate overbought condition (positive normalized value): {}", mfi_normalized ); } #[test] fn test_mfi_oversold_signal() { let mut extractor = MLFeatureExtractor::new(20); // Generate 15 bars with strong downtrend = oversold condition for i in 0..15 { let price = 130.0 - (i as f64 * 2.0); // Strong downtrend let volume = 1000.0 + (i as f64 * 100.0); // Increasing volume on decline extractor.extract_features(price, volume, Utc::now()); } // Last feature extraction let features = extractor.extract_features(100.0, 2500.0, Utc::now()); let mfi_feature_idx = 8; let mfi_normalized = features[mfi_feature_idx]; // MFI normalized from [0, 100] to [-1, 1] // Low MFI (<30 = oversold) should map to negative normalized value // MFI of 0 -> (0/50 - 1).tanh() = -1.0.tanh() = -0.76 assert!( mfi_normalized < -0.3, "MFI should indicate oversold condition (negative normalized value): {}", mfi_normalized ); } #[test] fn test_vwap_price_benchmark() { let mut extractor = MLFeatureExtractor::new(20); // Trade at consistent price with varying volume let base_price = 100.0; let prices = vec![100.0, 102.0, 98.0, 101.0, 99.0, 100.0]; let volumes = vec![1000.0, 500.0, 1500.0, 800.0, 1200.0, 1000.0]; let mut features_list = Vec::new(); for (price, volume) in prices.iter().zip(volumes.iter()) { let features = extractor.extract_features(*price, *volume, Utc::now()); features_list.push(features); } let vwap_feature_idx = 9; // Last VWAP should be close to base price (oscillating around it) let vwap_ratio = features_list.last().unwrap()[vwap_feature_idx]; // VWAP ratio = (current_price - VWAP) / VWAP, normalized with tanh // Since prices oscillate around 100, VWAP should be near 100, ratio near 0 assert!( vwap_ratio.abs() < 0.3, "VWAP ratio should be near 0 when price oscillates around average: {}", vwap_ratio ); } #[test] fn test_vwap_above_price_signal() { let mut extractor = MLFeatureExtractor::new(20); // Start with high volume at high prices, then drop price with low volume // This will create VWAP above current price (bearish signal) extractor.extract_features(110.0, 5000.0, Utc::now()); // High price, high volume extractor.extract_features(109.0, 4000.0, Utc::now()); extractor.extract_features(108.0, 3000.0, Utc::now()); // Drop price with low volume let features = extractor.extract_features(100.0, 500.0, Utc::now()); let vwap_feature_idx = 9; let vwap_ratio = features[vwap_feature_idx]; // Price dropped below VWAP -> negative ratio assert!( vwap_ratio < 0.0, "VWAP ratio should be negative when price drops below VWAP: {}", vwap_ratio ); } #[test] fn test_vwap_below_price_signal() { let mut extractor = MLFeatureExtractor::new(20); // Start with high volume at low prices, then raise price with low volume // This will create VWAP below current price (bullish signal) extractor.extract_features(100.0, 5000.0, Utc::now()); // Low price, high volume extractor.extract_features(101.0, 4000.0, Utc::now()); extractor.extract_features(102.0, 3000.0, Utc::now()); // Raise price with low volume let features = extractor.extract_features(110.0, 500.0, Utc::now()); let vwap_feature_idx = 9; let vwap_ratio = features[vwap_feature_idx]; // Price rose above VWAP -> positive ratio assert!( vwap_ratio > 0.0, "VWAP ratio should be positive when price rises above VWAP: {}", vwap_ratio ); } #[test] fn test_all_volume_indicators_normalized() { let mut extractor = MLFeatureExtractor::new(20); // Generate sufficient data for all indicators (15+ bars for MFI) for i in 0..20 { let price = 100.0 + (i as f64 * 0.5); let volume = 1000.0 + (i as f64 * 50.0); extractor.extract_features(price, volume, Utc::now()); } // Final feature extraction let features = extractor.extract_features(110.0, 2000.0, Utc::now()); // Check that OBV, MFI, VWAP are all normalized to [-1, 1] let obv_idx = 7; let mfi_idx = 8; let vwap_idx = 9; assert!( features[obv_idx] >= -1.0 && features[obv_idx] <= 1.0, "OBV should be normalized to [-1, 1]: {}", features[obv_idx] ); assert!( features[mfi_idx] >= -1.0 && features[mfi_idx] <= 1.0, "MFI should be normalized to [-1, 1]: {}", features[mfi_idx] ); assert!( features[vwap_idx] >= -1.0 && features[vwap_idx] <= 1.0, "VWAP should be normalized to [-1, 1]: {}", features[vwap_idx] ); } #[test] fn test_feature_vector_length_increased() { let mut extractor = MLFeatureExtractor::new(20); // Generate sufficient data for i in 0..20 { let price = 100.0 + i as f64; let volume = 1000.0 + (i as f64 * 10.0); extractor.extract_features(price, volume, Utc::now()); } let features = extractor.extract_features(120.0, 1200.0, Utc::now()); // Original features: 5 price features + 2 volume features + 2 time features = 9 // Added: 3 volume indicators (OBV, MFI, VWAP) = 3 // But all features go through tanh normalization at the end, which doesn't change count // Expected total: 9 + 3 = 12 features (before final tanh normalization) // After final tanh normalization, still 12 features (just all re-normalized) // Check: price(1) + short_ma(1) + volatility(1) + volume_ratio(1) + volume_ma_ratio(1) // + hour(1) + day_of_week(1) + OBV(1) + MFI(1) + VWAP(1) = 10 features assert_eq!( features.len(), 10, "Feature vector should have 10 elements (7 original + 3 volume indicators)" ); } #[test] fn test_insufficient_data_graceful_handling() { let mut extractor = MLFeatureExtractor::new(20); // Only 1-2 data points (insufficient for MFI which needs 15) let features1 = extractor.extract_features(100.0, 1000.0, Utc::now()); let features2 = extractor.extract_features(101.0, 1100.0, Utc::now()); let obv_idx = 7; let mfi_idx = 8; let vwap_idx = 9; // OBV should work with 2 data points assert_eq!( features1[obv_idx], 0.0, "OBV should be 0 for first data point" ); assert!( features2[obv_idx] != 0.0 || features2[obv_idx] == 0.0, "OBV should be calculated or 0 for second data point" ); // MFI should default to 0 with insufficient data assert_eq!( features1[mfi_idx], 0.0, "MFI should be 0 with insufficient data" ); assert_eq!( features2[mfi_idx], 0.0, "MFI should be 0 with insufficient data" ); // VWAP should work with any amount of data assert!( features1[vwap_idx] != 0.0 || features1[vwap_idx] == 0.0, "VWAP should be calculated or 0" ); }