//! Volume-Based Features for Wave C Feature Engineering //! //! This module implements 10 advanced volume features to complement the existing //! 40 volume features in extraction.rs. These features capture volume dynamics, //! price-volume relationships, and market participation patterns. //! //! ## Features Implemented (Indices 256-265) //! 1. Volume Ratio to SMA-50 (256) //! 2. Volume ROC 5-period (257) //! 3. Volume ROC 10-period (258) //! 4. Volume Acceleration (259) //! 5. Volume Trend Slope (260) //! 6. VWAP Intraday Deviation (261) //! 7. Volume-Price Correlation (262) //! 8. Volume Percentile 10-period (263) //! 9. Volume Concentration HHI (264) //! 10. Volume Imbalance Buy/Sell (265) //! //! ## Performance Target //! - Latency: <150μs for all 10 features per bar //! - Memory: <100 bytes per bar (reuses existing VecDeque) //! //! ## Integration //! These features extend the 256-dimension feature vector to 266 dimensions. //! //! ## References //! - WAVE_C_VOLUME_FEATURES_DESIGN.md (comprehensive design document) //! - ml/src/features/extraction.rs (existing 40 volume features) use anyhow::Result; use std::collections::VecDeque; pub use crate::types::OHLCVBar; /// Volume feature extractor with stateful rolling windows (Wave G17 optimized) /// /// Memory Optimization: /// - OLD: `VecDeque` with capacity 260 → ~32.5KB per symbol (always allocated) /// - NEW: `Option>` → 0 bytes until first bar (lazy allocated) /// - Savings: ~32KB per symbol × 100K symbols (for unused symbols) = 3.2 GB /// /// Note: Lazy allocation via Option reduces memory for unused symbols to zero. #[derive(Debug)] pub struct VolumeFeatureExtractor { /// Rolling window of bars (Wave G17: lazy allocation for 100% savings on unused symbols) bars: Option>, } impl VolumeFeatureExtractor { /// Creates a new volume feature extractor (Wave G17: lazy allocation) pub fn new() -> Self { Self { bars: None } } /// Updates the extractor with a new bar pub fn update(&mut self, bar: &OHLCVBar) { let buffer = self .bars .get_or_insert_with(|| VecDeque::with_capacity(260)); buffer.push_back(*bar); if buffer.len() > 260 { buffer.pop_front(); } } /// Helper: Get bars reference fn bars(&self) -> &VecDeque { static EMPTY: once_cell::sync::Lazy> = once_cell::sync::Lazy::new(VecDeque::new); self.bars.as_ref().unwrap_or(&EMPTY) } /// Extracts all 10 volume features (indices 256-265) /// /// ## Returns /// - Array of 10 features: [256, 257, ..., 265] /// /// ## Performance /// - Target: <150μs per call /// - Complexity: O(1) amortized for most features, O(n) for correlation/HHI pub fn extract_features(&self) -> Result<[f64; 10]> { let mut features = [0.0; 10]; // Feature 256: Volume ratio to SMA-50 features[0] = self.compute_volume_ratio_sma50(); // Feature 257: Volume ROC 5-period features[1] = self.compute_volume_roc(5); // Feature 258: Volume ROC 10-period features[2] = self.compute_volume_roc(10); // Feature 259: Volume acceleration features[3] = self.compute_volume_acceleration(); // Feature 260: Volume trend slope (20-period linear regression) features[4] = self.compute_volume_trend_slope(20); // Feature 261: VWAP intraday deviation features[5] = self.compute_vwap_deviation(); // Feature 262: Volume-price correlation (20-period) features[6] = self.compute_volume_price_correlation(20); // Feature 263: Volume percentile (10-period) features[7] = self.compute_volume_percentile(10); // Feature 264: Volume concentration HHI (20-period) features[8] = self.compute_volume_concentration_hhi(20); // Feature 265: Volume imbalance (5-period buy/sell) features[9] = self.compute_volume_imbalance(5); // Validate no NaN/Inf for (i, &val) in features.iter().enumerate() { if !val.is_finite() { anyhow::bail!("Invalid volume feature at index {}: {}", i + 256, val); } } Ok(features) } // ===== Feature Implementation Methods ===== /// Feature 256: Volume ratio to SMA-50 /// /// Formula: (current_volume - sma_50) / sma_50 /// Range: [-2.0, 5.0] fn compute_volume_ratio_sma50(&self) -> f64 { if self.bars().len() < 50 { return 0.0; } let bar = self.bars().back().unwrap(); let sma_50 = self.compute_volume_sma(50); let ratio = (bar.volume - sma_50) / (sma_50 + 1e-8); safe_clip(ratio, -2.0, 5.0) } /// Feature 257/258: Volume ROC (Rate of Change) /// /// Formula: (current_volume - volume_n_bars_ago) / volume_n_bars_ago /// Range: [-1.0, 3.0] fn compute_volume_roc(&self, period: usize) -> f64 { let len = self.bars().len(); if len <= period { return 0.0; } let curr_vol = self.bars().back().unwrap().volume; let prev_vol = self.bars()[len - period - 1].volume; let roc = (curr_vol - prev_vol) / (prev_vol + 1e-8); safe_clip(roc, -1.0, 3.0) } /// Feature 259: Volume acceleration (second derivative) /// /// Formula: (velocity_1 - velocity_2) / 1000 /// Range: [-5.0, 5.0] fn compute_volume_acceleration(&self) -> f64 { if self.bars().len() < 3 { return 0.0; } let curr = self.bars().back().unwrap().volume; let prev1 = self.bars()[self.bars().len() - 2].volume; let prev2 = self.bars()[self.bars().len() - 3].volume; let vel1 = curr - prev1; let vel2 = prev1 - prev2; let accel = vel1 - vel2; safe_clip(accel / 1000.0, -5.0, 5.0) } /// Feature 260: Volume trend slope (linear regression) /// /// Formula: Linear regression slope over period /// Range: [-1.0, 1.0] fn compute_volume_trend_slope(&self, period: usize) -> f64 { if self.bars().len() < period { return 0.0; } let start = self.bars().len() - period; let n = period as f64; // Linear regression formula: slope = (n*Σxy - Σx*Σy) / (n*Σx² - (Σx)²) let sum_x = (n * (n - 1.0)) / 2.0; let sum_x2 = (n * (n - 1.0) * (2.0 * n - 1.0)) / 6.0; let mut sum_y = 0.0; let mut sum_xy = 0.0; for (i, bar) in self.bars().iter().skip(start).enumerate() { sum_y += bar.volume; sum_xy += i as f64 * bar.volume; } let slope = (n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x * sum_x); safe_clip(slope / 100.0, -1.0, 1.0) } /// Feature 261: VWAP intraday deviation /// /// Formula: (close - vwap) / close /// Range: [-0.1, 0.1] fn compute_vwap_deviation(&self) -> f64 { if self.bars().len() < 20 { return 0.0; } let bar = self.bars().back().unwrap(); let vwap = self.compute_vwap(20); let deviation = (bar.close - vwap) / (bar.close + 1e-8); safe_clip(deviation, -0.1, 0.1) } /// Feature 262: Volume-price correlation (Pearson) /// /// Formula: Pearson correlation coefficient /// Range: [-1.0, 1.0] fn compute_volume_price_correlation(&self, period: usize) -> f64 { if self.bars().len() < period { return 0.0; } let start = self.bars().len() - period; let prices: Vec = self.bars().iter().skip(start).map(|b| b.close).collect(); let volumes: Vec = self.bars().iter().skip(start).map(|b| b.volume).collect(); self.compute_correlation(&prices, &volumes) } /// Feature 263: Volume percentile rank /// /// Formula: count(vol < current_vol) / period /// Range: [0.0, 1.0] fn compute_volume_percentile(&self, period: usize) -> f64 { if self.bars().len() < period { return 0.5; // Neutral } let current_vol = self.bars().back().unwrap().volume; let start = self.bars().len() - period; let count_below = self .bars() .iter() .skip(start) .filter(|b| b.volume < current_vol) .count(); count_below as f64 / period as f64 } /// Feature 264: Volume concentration (Herfindahl-Hirschman Index) /// /// Formula: HHI = Σ(vol_i / total_vol)² /// Range: [0.0, 1.0] (normalized from [1/n, 1]) fn compute_volume_concentration_hhi(&self, period: usize) -> f64 { if self.bars().len() < period { return 0.5; // Neutral } let start = self.bars().len() - period; let total_vol: f64 = self.bars().iter().skip(start).map(|b| b.volume).sum(); if total_vol < 1e-8 { return 0.5; // Neutral for zero volume } let hhi: f64 = self .bars() .iter() .skip(start) .map(|b| { let share = b.volume / total_vol; share * share }) .sum(); // Normalize: HHI ∈ [1/n, 1] → [0, 1] let min_hhi = 1.0 / period as f64; let normalized = (hhi - min_hhi) / (1.0 - min_hhi); safe_clip(normalized, 0.0, 1.0) } /// Feature 265: Volume imbalance (buy vs sell pressure) /// /// Formula: (buy_vol - sell_vol) / total_vol /// Range: [-1.0, 1.0] fn compute_volume_imbalance(&self, period: usize) -> f64 { if self.bars().len() < period { return 0.0; } let start = self.bars().len() - period; let mut buy_vol = 0.0; let mut sell_vol = 0.0; for bar in self.bars().iter().skip(start) { if bar.close > bar.open { buy_vol += bar.volume; } else if bar.close < bar.open { sell_vol += bar.volume; } // Doji bars (close == open) contribute to neither } let total_vol = buy_vol + sell_vol + 1e-8; let imbalance = (buy_vol - sell_vol) / total_vol; safe_clip(imbalance, -1.0, 1.0) } // ===== Helper Methods (reuse extraction.rs patterns) ===== fn compute_volume_sma(&self, period: usize) -> f64 { let start = self.bars().len().saturating_sub(period); let sum: f64 = self.bars().iter().skip(start).map(|b| b.volume).sum(); sum / period as f64 } fn compute_vwap(&self, period: usize) -> f64 { let start = self.bars().len().saturating_sub(period); let (weighted_sum, volume_sum): (f64, f64) = self .bars() .iter() .skip(start) .map(|b| (b.close * b.volume, b.volume)) .fold((0.0, 0.0), |(ws, vs), (w, v)| (ws + w, vs + v)); weighted_sum / (volume_sum + 1e-8) } fn compute_correlation(&self, x: &[f64], y: &[f64]) -> f64 { if x.len() != y.len() || x.is_empty() { return 0.0; } let n = x.len() as f64; let mean_x: f64 = x.iter().sum::() / n; let mean_y: f64 = y.iter().sum::() / n; let mut cov = 0.0; let mut var_x = 0.0; let mut var_y = 0.0; for i in 0..x.len() { let dx = x[i] - mean_x; let dy = y[i] - mean_y; cov += dx * dy; var_x += dx * dx; var_y += dy * dy; } let denom = (var_x * var_y).sqrt(); if denom > 1e-8 { safe_clip(cov / denom, -1.0, 1.0) } else { 0.0 } } } impl Default for VolumeFeatureExtractor { fn default() -> Self { Self::new() } } // ===== Utility Functions ===== /// Safe clipping: Clip value to [min, max] range, handles NaN/Inf fn safe_clip(value: f64, min: f64, max: f64) -> f64 { if !value.is_finite() { return 0.0; } value.clamp(min, max) } #[cfg(test)] mod tests { use super::*; use chrono::Utc; fn create_bars_with_volume(volumes: Vec) -> Vec { volumes .iter() .enumerate() .map(|(i, &vol)| OHLCVBar { timestamp: Utc::now() + chrono::Duration::hours(i as i64), open: 100.0, high: 101.0, low: 99.0, close: 100.5, volume: vol, }) .collect() } fn create_bars_with_price_volume(prices: Vec, volumes: Vec) -> Vec { prices .iter() .zip(volumes.iter()) .enumerate() .map(|(i, (&p, &v))| OHLCVBar { timestamp: Utc::now() + chrono::Duration::hours(i as i64), open: p, high: p + 1.0, low: p - 1.0, close: p, volume: v, }) .collect() } fn create_bars_with_ohlc(ohlc: Vec<(f64, f64)>, volumes: Vec) -> Vec { ohlc.iter() .zip(volumes.iter()) .enumerate() .map(|(i, (&(o, c), &v))| OHLCVBar { timestamp: Utc::now() + chrono::Duration::hours(i as i64), open: o, high: o.max(c) + 1.0, low: o.min(c) - 1.0, close: c, volume: v, }) .collect() } #[test] fn test_volume_ratio_normal() { let mut extractor = VolumeFeatureExtractor::new(); let bars = create_bars_with_volume(vec![1000.0; 51]); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[0] - 0.0).abs() < 0.01, "Expected 0.0, got {}", features[0] ); } #[test] fn test_volume_ratio_2x_spike() { let mut extractor = VolumeFeatureExtractor::new(); let mut volumes = vec![1000.0; 50]; volumes.push(2000.0); let bars = create_bars_with_volume(volumes); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); // SMA-50 = (49*1000 + 2000) / 50 = 1020 // Ratio = (2000 - 1020) / 1020 = 0.96 assert!( (features[0] - 0.96).abs() < 0.02, "Expected 0.96, got {}", features[0] ); } #[test] fn test_volume_ratio_extreme_clipping() { let mut extractor = VolumeFeatureExtractor::new(); let mut volumes = vec![1000.0; 50]; volumes.push(10000.0); let bars = create_bars_with_volume(volumes); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[0] - 5.0).abs() < 0.01, "Expected 5.0 (clipped), got {}", features[0] ); } #[test] fn test_volume_roc_5_flat() { let mut extractor = VolumeFeatureExtractor::new(); let bars = create_bars_with_volume(vec![1000.0; 10]); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[1] - 0.0).abs() < 0.01, "Expected 0.0, got {}", features[1] ); } #[test] fn test_volume_roc_5_doubling() { let mut extractor = VolumeFeatureExtractor::new(); let volumes = vec![1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 2000.0]; let bars = create_bars_with_volume(volumes); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[1] - 1.0).abs() < 0.01, "Expected 1.0, got {}", features[1] ); } #[test] fn test_volume_acceleration_constant() { let mut extractor = VolumeFeatureExtractor::new(); let bars = create_bars_with_volume(vec![1000.0, 1100.0, 1200.0]); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[3] - 0.0).abs() < 0.01, "Expected 0.0, got {}", features[3] ); } #[test] fn test_volume_acceleration_positive() { let mut extractor = VolumeFeatureExtractor::new(); let bars = create_bars_with_volume(vec![1000.0, 1100.0, 1300.0]); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( features[3] > 0.0, "Expected positive acceleration, got {}", features[3] ); } #[test] fn test_volume_trend_flat() { let mut extractor = VolumeFeatureExtractor::new(); let bars = create_bars_with_volume(vec![1000.0; 25]); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[4] - 0.0).abs() < 0.01, "Expected 0.0, got {}", features[4] ); } #[test] fn test_volume_trend_uptrend() { let mut extractor = VolumeFeatureExtractor::new(); let volumes: Vec = (1000..1025).map(|x| x as f64 * 100.0).collect(); let bars = create_bars_with_volume(volumes); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( features[4] > 0.0, "Expected positive slope, got {}", features[4] ); } #[test] fn test_vwap_at_fair_value() { let mut extractor = VolumeFeatureExtractor::new(); let bars = create_bars_with_price_volume(vec![100.0; 25], vec![1000.0; 25]); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[5] - 0.0).abs() < 0.01, "Expected 0.0, got {}", features[5] ); } #[test] fn test_volume_price_correlation_positive() { let mut extractor = VolumeFeatureExtractor::new(); let prices: Vec = (100..120).map(|x| x as f64).collect(); let volumes: Vec = (1000..1020).map(|x| x as f64 * 100.0).collect(); let bars = create_bars_with_price_volume(prices, volumes); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( features[6] > 0.5, "Expected strong positive correlation, got {}", features[6] ); } #[test] fn test_volume_price_correlation_negative() { let mut extractor = VolumeFeatureExtractor::new(); let prices: Vec = (100..120).rev().map(|x| x as f64).collect(); let volumes: Vec = (1000..1020).map(|x| x as f64 * 100.0).collect(); let bars = create_bars_with_price_volume(prices, volumes); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( features[6] < -0.5, "Expected strong negative correlation, got {}", features[6] ); } #[test] fn test_volume_percentile_minimum() { let mut extractor = VolumeFeatureExtractor::new(); let mut volumes = vec![1000.0; 10]; volumes[9] = 500.0; let bars = create_bars_with_volume(volumes); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[7] - 0.0).abs() < 0.01, "Expected 0.0, got {}", features[7] ); } #[test] fn test_volume_percentile_maximum() { let mut extractor = VolumeFeatureExtractor::new(); let mut volumes = vec![1000.0; 10]; volumes[9] = 2000.0; let bars = create_bars_with_volume(volumes); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[7] - 1.0).abs() < 0.11, "Expected 1.0, got {}", features[7] ); } #[test] fn test_volume_concentration_uniform() { let mut extractor = VolumeFeatureExtractor::new(); let bars = create_bars_with_volume(vec![1000.0; 25]); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[8] - 0.0).abs() < 0.01, "Expected 0.0, got {}", features[8] ); } #[test] fn test_volume_concentration_high() { let mut extractor = VolumeFeatureExtractor::new(); // Create more extreme concentration: 19 very small + 1 dominant volume let mut volumes = vec![10.0; 19]; volumes.push(9900.0); let bars = create_bars_with_volume(volumes); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); // Total = 19*10 + 9900 = 10090 // HHI = 19*(10/10090)² + (9900/10090)² ≈ 0.000019 + 0.963 = 0.963 // min_hhi = 1/20 = 0.05 // normalized = (0.963 - 0.05) / (1 - 0.05) = 0.96 assert!( features[8] > 0.9, "Expected high HHI (>0.9), got {}", features[8] ); } #[test] fn test_volume_imbalance_balanced() { let mut extractor = VolumeFeatureExtractor::new(); let ohlc = vec![(100.0, 100.0); 5]; // Doji bars let bars = create_bars_with_ohlc(ohlc, vec![1000.0; 5]); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[9] - 0.0).abs() < 0.01, "Expected 0.0, got {}", features[9] ); } #[test] fn test_volume_imbalance_buying() { let mut extractor = VolumeFeatureExtractor::new(); let ohlc = vec![(100.0, 110.0); 5]; // All bullish bars let bars = create_bars_with_ohlc(ohlc, vec![1000.0; 5]); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[9] - 1.0).abs() < 0.01, "Expected 1.0, got {}", features[9] ); } #[test] fn test_volume_imbalance_selling() { let mut extractor = VolumeFeatureExtractor::new(); let ohlc = vec![(110.0, 100.0); 5]; // All bearish bars let bars = create_bars_with_ohlc(ohlc, vec![1000.0; 5]); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); assert!( (features[9] - -1.0).abs() < 0.01, "Expected -1.0, got {}", features[9] ); } #[test] fn test_insufficient_history_returns_default() { let mut extractor = VolumeFeatureExtractor::new(); let bars = create_bars_with_volume(vec![1000.0; 3]); for bar in &bars { extractor.update(bar); } // Should succeed but return mostly 0.0 values let features = extractor.extract_features().unwrap(); // Most features should be 0.0 or neutral (0.5 for percentile/HHI) assert!((features[0] - 0.0).abs() < 0.01); // Volume ratio (insufficient) assert!((features[7] - 0.5).abs() < 0.01); // Percentile (neutral) assert!((features[8] - 0.5).abs() < 0.01); // HHI (neutral) } #[test] fn test_zero_volume_handling() { let mut extractor = VolumeFeatureExtractor::new(); let bars = create_bars_with_volume(vec![0.0; 55]); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); // All values should be finite (no NaN/Inf) for (i, &val) in features.iter().enumerate() { assert!( val.is_finite(), "Found non-finite value at index {}: {}", i, val ); } } #[test] fn test_extreme_volume_clipping() { let mut extractor = VolumeFeatureExtractor::new(); let bars = create_bars_with_volume(vec![1_000_000.0; 55]); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); // All values should be within expected ranges assert!(features[0] >= -2.0 && features[0] <= 5.0); // Volume ratio assert!(features[1] >= -1.0 && features[1] <= 3.0); // ROC 5 assert!(features[2] >= -1.0 && features[2] <= 3.0); // ROC 10 assert!(features[3] >= -5.0 && features[3] <= 5.0); // Acceleration assert!(features[4] >= -1.0 && features[4] <= 1.0); // Trend slope assert!(features[5] >= -0.1 && features[5] <= 0.1); // VWAP deviation assert!(features[6] >= -1.0 && features[6] <= 1.0); // Correlation assert!(features[7] >= 0.0 && features[7] <= 1.0); // Percentile assert!(features[8] >= 0.0 && features[8] <= 1.0); // HHI assert!(features[9] >= -1.0 && features[9] <= 1.0); // Imbalance } #[test] fn test_all_features_finite() { let mut extractor = VolumeFeatureExtractor::new(); // Create diverse bars with varying volumes let volumes = vec![ 1000.0, 1200.0, 800.0, 1500.0, 900.0, 2000.0, 1100.0, 1300.0, 700.0, 1400.0, 1000.0, 1200.0, 800.0, 1500.0, 900.0, 2000.0, 1100.0, 1300.0, 700.0, 1400.0, 1000.0, 1200.0, 800.0, 1500.0, 900.0, 2000.0, 1100.0, 1300.0, 700.0, 1400.0, 1000.0, 1200.0, 800.0, 1500.0, 900.0, 2000.0, 1100.0, 1300.0, 700.0, 1400.0, 1000.0, 1200.0, 800.0, 1500.0, 900.0, 2000.0, 1100.0, 1300.0, 700.0, 1400.0, 1000.0, 1200.0, 800.0, 1500.0, 900.0, ]; let bars = create_bars_with_volume(volumes); for bar in &bars { extractor.update(bar); } let features = extractor.extract_features().unwrap(); // Validate all features are finite for (i, &val) in features.iter().enumerate() { assert!( val.is_finite(), "Feature {} is not finite: {}", i + 256, val ); } } }