//! Technical Indicator Calculations for ML Feature Extraction //! //! This module provides stateful, high-performance technical indicator calculations //! for ML training data pipelines. All indicators support incremental updates with //! O(1) amortized complexity for HFT requirements. //! //! ## Supported Indicators (36 Total) //! //! ### Original Indicators (16) //! - **RSI (Relative Strength Index)**: 14-period momentum oscillator (0-100 range) //! - **EMA (Exponential Moving Average)**: Fast (12) and slow (26) EMAs //! - **MACD (Moving Average Convergence Divergence)**: Trend following indicator //! - **Bollinger Bands**: Volatility indicator with SMA ± 2σ (middle, upper, lower, width) //! - **ATR (Average True Range)**: Volatility measurement //! //! ### NEW Momentum Indicators (3) //! - **MFI (Money Flow Index)**: Volume-weighted RSI (0-100, overbought >80, oversold <20) //! - **CMF (Chaikin Money Flow)**: Accumulation/distribution indicator (-1 to +1, buying pressure) //! - **Chaikin Oscillator**: Fast/slow EMA of A/D Line (trend strength) //! //! ### NEW Volatility Indicators (8) //! - **Keltner Channels**: EMA ± ATR multiplier (middle, upper, lower, width) //! - **Donchian Channels**: Highest high/lowest low (middle, upper, lower, width) //! //! ### NEW Volume Indicators (5) //! - **OBV (On-Balance Volume)**: Cumulative volume direction indicator //! - **VWAP (Volume-Weighted Average Price)**: Intraday benchmark price //! - **VWAP Deviation**: Price distance from VWAP (%) //! - **Volume Oscillator**: Fast/slow EMA of volume (%) //! //! ### Feature Count Breakdown //! - OHLCV: 5 features (open, high, low, close, volume) //! - Original indicators: 11 features (RSI, EMA×2, MACD×3, Bollinger×4, ATR) //! - New momentum: 3 features (MFI, CMF, Chaikin) //! - New volatility: 8 features (Keltner×4, Donchian×4) //! - New volume: 4 features (OBV, VWAP, VWAP deviation, Vol Oscillator) //! - Time-based: 5 features (hour, day, month, is_market_hours, time_since_open) //! **Total: 36 features** (16 → 36, +125% increase) //! //! ## Architecture //! //! ```rust //! use ml_training_service::technical_indicators::TechnicalIndicatorCalculator; //! use ml_training_service::technical_indicators::IndicatorConfig; //! //! let config = IndicatorConfig::default(); //! let mut calculator = TechnicalIndicatorCalculator::new("AAPL".to_string(), config); //! //! // Feed price data //! calculator.update(100.0, 50000.0); // price, volume //! //! // Extract indicators //! let indicators = calculator.current_indicators(); //! ``` use std::collections::VecDeque; /// Configuration for technical indicator calculations #[derive(Debug, Clone)] pub struct IndicatorConfig { /// `RSI` period (default: 14) pub rsi_period: usize, /// Fast `EMA` period (default: 12) pub ema_fast_period: usize, /// Slow `EMA` period (default: 26) pub ema_slow_period: usize, /// `MACD` signal line period (default: 9) pub macd_signal_period: usize, /// Bollinger Bands period (default: 20) pub bollinger_period: usize, /// Bollinger Bands standard deviations (default: 2.0) pub bollinger_std_dev: f64, /// `ATR` period (default: 14) pub atr_period: usize, /// Minimum data points before calculating indicators pub warmup_period: usize, // === NEW MOMENTUM INDICATORS === /// Money Flow Index period (default: 14) pub mfi_period: usize, /// Chaikin Money Flow period (default: 20) pub cmf_period: usize, /// Chaikin Oscillator fast/slow periods (default: 3, 10) pub chaikin_fast_period: usize, pub chaikin_slow_period: usize, // === NEW VOLATILITY INDICATORS === /// Keltner Channel period (default: 20) pub keltner_period: usize, /// Keltner Channel multiplier (default: 2.0) pub keltner_multiplier: f64, /// Donchian Channel period (default: 20) pub donchian_period: usize, // === NEW VOLUME INDICATORS === /// VWAP reset period in bars (default: 390 for daily) pub vwap_reset_period: usize, /// Volume Oscillator fast/slow periods (default: 5, 10) pub vol_osc_fast_period: usize, pub vol_osc_slow_period: usize, } impl Default for IndicatorConfig { fn default() -> Self { Self { rsi_period: 14, ema_fast_period: 12, ema_slow_period: 26, macd_signal_period: 9, bollinger_period: 20, bollinger_std_dev: 2.0, atr_period: 14, warmup_period: 26, // Max of all periods for full indicator calculation // New momentum indicators mfi_period: 14, cmf_period: 20, chaikin_fast_period: 3, chaikin_slow_period: 10, // New volatility indicators keltner_period: 20, keltner_multiplier: 2.0, donchian_period: 20, // New volume indicators vwap_reset_period: 390, // ~1 trading day (6.5 hours) vol_osc_fast_period: 5, vol_osc_slow_period: 10, } } } /// Stateful technical indicator calculator /// /// Maintains rolling windows and state for efficient indicator calculation. /// /// Designed for O(1) amortized updates with minimal allocations. pub struct TechnicalIndicatorCalculator { /// Trading symbol this calculator tracks symbol: String, /// Configuration config: IndicatorConfig, /// Price history for windowed calculations price_history: VecDeque, /// Volume history volume_history: VecDeque, /// High prices for ATR high_history: VecDeque, /// Low prices for ATR low_history: VecDeque, /// `RSI` internal state rsi_state: RsiState, /// `EMA` states ema_fast_state: Option, ema_slow_state: Option, /// `MACD` signal line state macd_signal_state: Option, /// `ATR` state atr_state: Option, /// Total updates received update_count: usize, // === NEW INDICATOR STATES === /// Money Flow Index state mfi_state: MFIState, /// Chaikin Money Flow accumulator cmf_accumulator: f64, cmf_volume_sum: f64, /// Accumulation/Distribution Line for Chaikin Oscillator ad_line: f64, chaikin_fast_ema: Option, chaikin_slow_ema: Option, /// Donchian Channel state (highest high, lowest low) donchian_highs: VecDeque, donchian_lows: VecDeque, /// On-Balance Volume state obv: f64, /// VWAP state (cumulative price * volume, cumulative volume) vwap_cumulative_pv: f64, vwap_cumulative_volume: f64, vwap_bar_count: usize, /// Volume EMA states for Volume Oscillator vol_ema_fast: Option, vol_ema_slow: Option, } /// `RSI` calculator state using Wilder's smoothing #[derive(Debug, Clone)] struct RsiState { /// Average gain (Wilder's smoothed) avg_gain: f64, /// Average loss (Wilder's smoothed) avg_loss: f64, /// Previous price for change calculation prev_price: Option, /// Initialization complete flag initialized: bool, } impl Default for RsiState { fn default() -> Self { Self { avg_gain: 0.0, avg_loss: 0.0, prev_price: None, initialized: false, } } } /// Money Flow Index state #[derive(Debug, Clone)] struct MFIState { /// Positive money flow sum positive_mf: f64, /// Negative money flow sum negative_mf: f64, /// Previous typical price prev_typical_price: Option, /// Initialization complete initialized: bool, } impl Default for MFIState { fn default() -> Self { Self { positive_mf: 0.0, negative_mf: 0.0, prev_typical_price: None, initialized: false, } } } impl TechnicalIndicatorCalculator { /// Get the trading symbol this calculator is tracking pub fn symbol(&self) -> &str { &self.symbol } /// Create a new technical indicator calculator /// /// # Arguments /// /// * `symbol` - Trading symbol /// * `config` - Indicator configuration pub fn new(symbol: String, config: IndicatorConfig) -> Self { let max_window = config .warmup_period .max(config.bollinger_period) .max(config.keltner_period) .max(config.donchian_period) .max(config.cmf_period); Self { symbol, config, price_history: VecDeque::with_capacity(max_window), volume_history: VecDeque::with_capacity(max_window), high_history: VecDeque::with_capacity(max_window), low_history: VecDeque::with_capacity(max_window), rsi_state: RsiState::default(), ema_fast_state: None, ema_slow_state: None, macd_signal_state: None, atr_state: None, update_count: 0, // Initialize new indicator states mfi_state: MFIState::default(), cmf_accumulator: 0.0, cmf_volume_sum: 0.0, ad_line: 0.0, chaikin_fast_ema: None, chaikin_slow_ema: None, donchian_highs: VecDeque::with_capacity(max_window), donchian_lows: VecDeque::with_capacity(max_window), obv: 0.0, vwap_cumulative_pv: 0.0, vwap_cumulative_volume: 0.0, vwap_bar_count: 0, vol_ema_fast: None, vol_ema_slow: None, } } /// Update calculator with new `OHLC` data /// /// # Arguments /// /// * `price` - Current price (close) /// * `volume` - Current volume /// /// * `high` - High price (optional, defaults to price) /// * `low` - Low price (optional, defaults to price) pub fn update(&mut self, price: f64, volume: f64, high: Option, low: Option) { // Add to history with window management self.price_history.push_back(price); self.volume_history.push_back(volume); self.high_history.push_back(high.unwrap_or(price)); self.low_history.push_back(low.unwrap_or(price)); // Maintain maximum window size let max_window = self.config.warmup_period.max(self.config.bollinger_period); if self.price_history.len() > max_window { self.price_history.pop_front(); self.volume_history.pop_front(); self.high_history.pop_front(); self.low_history.pop_front(); } self.update_count += 1; // Update stateful indicators incrementally self.update_rsi(price); self.update_ema(price); self.update_atr(); // Update new indicators self.update_mfi(high.unwrap_or(price), low.unwrap_or(price), price, volume); self.update_cmf(high.unwrap_or(price), low.unwrap_or(price), price, volume); self.update_chaikin(high.unwrap_or(price), low.unwrap_or(price), price, volume); self.update_donchian(high.unwrap_or(price), low.unwrap_or(price)); self.update_obv(price, volume); self.update_vwap(price, volume); self.update_volume_oscillator(volume); } /// Update `RSI` using Wilder's smoothing method fn update_rsi(&mut self, price: f64) { if let Some(prev) = self.rsi_state.prev_price { let change = price - prev; let gain = if change > 0.0 { change } else { 0.0 }; let loss = if change < 0.0 { -change } else { 0.0 }; if !self.rsi_state.initialized && self.update_count >= self.config.rsi_period { // Initial average using SMA let gains: Vec = self .price_history .iter() .zip(self.price_history.iter().skip(1)) .map(|(p1, p2)| { let change = p2 - p1; if change > 0.0 { change } else { 0.0 } }) .collect(); let losses: Vec = self .price_history .iter() .zip(self.price_history.iter().skip(1)) .map(|(p1, p2)| { let change = p2 - p1; if change < 0.0 { -change } else { 0.0 } }) .collect(); self.rsi_state.avg_gain = gains.iter().sum::() / self.config.rsi_period as f64; self.rsi_state.avg_loss = losses.iter().sum::() / self.config.rsi_period as f64; self.rsi_state.initialized = true; } else if self.rsi_state.initialized { // Wilder's smoothing: avg = (prev_avg * (n-1) + current) / n let period = self.config.rsi_period as f64; self.rsi_state.avg_gain = (self.rsi_state.avg_gain * (period - 1.0) + gain) / period; self.rsi_state.avg_loss = (self.rsi_state.avg_loss * (period - 1.0) + loss) / period; } } self.rsi_state.prev_price = Some(price); } /// Update `EMA` states incrementally fn update_ema(&mut self, price: f64) { // Fast EMA (12-period) if let Some(ema) = self.ema_fast_state { let k = 2.0 / (self.config.ema_fast_period as f64 + 1.0); self.ema_fast_state = Some(price * k + ema * (1.0 - k)); } else if self.update_count >= self.config.ema_fast_period { // Initialize with SMA let sum: f64 = self .price_history .iter() .rev() .take(self.config.ema_fast_period) .sum(); self.ema_fast_state = Some(sum / self.config.ema_fast_period as f64); } // Slow EMA (26-period) if let Some(ema) = self.ema_slow_state { let k = 2.0 / (self.config.ema_slow_period as f64 + 1.0); self.ema_slow_state = Some(price * k + ema * (1.0 - k)); } else if self.update_count >= self.config.ema_slow_period { // Initialize with SMA let sum: f64 = self .price_history .iter() .rev() .take(self.config.ema_slow_period) .sum(); self.ema_slow_state = Some(sum / self.config.ema_slow_period as f64); } // MACD signal line (9-period EMA of MACD) if let Some(macd) = self.calculate_macd() { if let Some(signal) = self.macd_signal_state { let k = 2.0 / (self.config.macd_signal_period as f64 + 1.0); self.macd_signal_state = Some(macd * k + signal * (1.0 - k)); } else if self.update_count >= self.config.ema_slow_period + self.config.macd_signal_period { self.macd_signal_state = Some(macd); } } } /// Update `ATR` (Average True Range) fn update_atr(&mut self) { if self.update_count < 2 { return; } // Calculate True Range let Some(&high) = self.high_history.back() else { return; }; let Some(&low) = self.low_history.back() else { return; }; let len = self.price_history.len(); let Some(prev_close) = self.price_history.get(len.saturating_sub(2)) else { return; }; let tr = (high - low) .max((high - prev_close).abs()) .max((low - prev_close).abs()); // Update ATR using Wilder's smoothing if let Some(atr) = self.atr_state { let period = self.config.atr_period as f64; self.atr_state = Some((atr * (period - 1.0) + tr) / period); } else if self.update_count >= self.config.atr_period { // Initialize with SMA of TR self.atr_state = Some(tr); } } // ===== NEW INDICATOR UPDATE METHODS ===== /// Update Money Flow Index (MFI) - momentum indicator with volume fn update_mfi(&mut self, high: f64, low: f64, close: f64, volume: f64) { let typical_price = (high + low + close) / 3.0; let money_flow = typical_price * volume; if let Some(prev_tp) = self.mfi_state.prev_typical_price { if typical_price > prev_tp { // Positive money flow self.mfi_state.positive_mf = self.mfi_state.positive_mf * ((self.config.mfi_period - 1) as f64 / self.config.mfi_period as f64) + money_flow; } else if typical_price < prev_tp { // Negative money flow self.mfi_state.negative_mf = self.mfi_state.negative_mf * ((self.config.mfi_period - 1) as f64 / self.config.mfi_period as f64) + money_flow; } if self.update_count >= self.config.mfi_period { self.mfi_state.initialized = true; } } self.mfi_state.prev_typical_price = Some(typical_price); } /// Update Chaikin Money Flow (CMF) - accumulates money flow over period fn update_cmf(&mut self, high: f64, low: f64, close: f64, volume: f64) { let range = high - low; if range > 0.0 { let multiplier = ((close - low) - (high - close)) / range; let mf_volume = multiplier * volume; self.cmf_accumulator = self.cmf_accumulator * ((self.config.cmf_period - 1) as f64 / self.config.cmf_period as f64) + mf_volume; self.cmf_volume_sum = self.cmf_volume_sum * ((self.config.cmf_period - 1) as f64 / self.config.cmf_period as f64) + volume; } } /// Update Chaikin Oscillator (A/D Line with fast/slow EMAs) fn update_chaikin(&mut self, high: f64, low: f64, close: f64, volume: f64) { // Update Accumulation/Distribution Line let range = high - low; if range > 0.0 { let multiplier = ((close - low) - (high - close)) / range; self.ad_line += multiplier * volume; } // Fast EMA of A/D Line if let Some(ema) = self.chaikin_fast_ema { let k = 2.0 / (self.config.chaikin_fast_period as f64 + 1.0); self.chaikin_fast_ema = Some(self.ad_line * k + ema * (1.0 - k)); } else if self.update_count >= self.config.chaikin_fast_period { self.chaikin_fast_ema = Some(self.ad_line); } // Slow EMA of A/D Line if let Some(ema) = self.chaikin_slow_ema { let k = 2.0 / (self.config.chaikin_slow_period as f64 + 1.0); self.chaikin_slow_ema = Some(self.ad_line * k + ema * (1.0 - k)); } else if self.update_count >= self.config.chaikin_slow_period { self.chaikin_slow_ema = Some(self.ad_line); } } /// Update Donchian Channels (highest high, lowest low over period) fn update_donchian(&mut self, high: f64, low: f64) { self.donchian_highs.push_back(high); self.donchian_lows.push_back(low); if self.donchian_highs.len() > self.config.donchian_period { self.donchian_highs.pop_front(); self.donchian_lows.pop_front(); } } /// Update On-Balance Volume (OBV) fn update_obv(&mut self, price: f64, volume: f64) { if let Some(prev_close) = self .price_history .get(self.price_history.len().saturating_sub(2)) { if price > *prev_close { self.obv += volume; } else if price < *prev_close { self.obv -= volume; } // If price unchanged, OBV unchanged } } /// Update VWAP (Volume-Weighted Average Price) fn update_vwap(&mut self, price: f64, volume: f64) { self.vwap_cumulative_pv += price * volume; self.vwap_cumulative_volume += volume; self.vwap_bar_count += 1; // Reset VWAP periodically (e.g., daily) if self.vwap_bar_count >= self.config.vwap_reset_period { self.vwap_cumulative_pv = price * volume; self.vwap_cumulative_volume = volume; self.vwap_bar_count = 1; } } /// Update Volume Oscillator (fast/slow EMAs of volume) fn update_volume_oscillator(&mut self, volume: f64) { // Fast volume EMA if let Some(ema) = self.vol_ema_fast { let k = 2.0 / (self.config.vol_osc_fast_period as f64 + 1.0); self.vol_ema_fast = Some(volume * k + ema * (1.0 - k)); } else if self.update_count >= self.config.vol_osc_fast_period { self.vol_ema_fast = Some(volume); } // Slow volume EMA if let Some(ema) = self.vol_ema_slow { let k = 2.0 / (self.config.vol_osc_slow_period as f64 + 1.0); self.vol_ema_slow = Some(volume * k + ema * (1.0 - k)); } else if self.update_count >= self.config.vol_osc_slow_period { self.vol_ema_slow = Some(volume); } } // ===== NEW INDICATOR CALCULATION METHODS ===== /// Calculate Money Flow Index (0-100, overbought >80, oversold <20) pub fn calculate_mfi(&self) -> Option { if !self.mfi_state.initialized { return None; } if self.mfi_state.negative_mf == 0.0 { return Some(100.0); } let money_ratio = self.mfi_state.positive_mf / self.mfi_state.negative_mf; Some(100.0 - (100.0 / (1.0 + money_ratio))) } /// Calculate Chaikin Money Flow (-1.0 to 1.0, >0 = buying pressure) pub fn calculate_cmf(&self) -> Option { if self.cmf_volume_sum == 0.0 || self.update_count < self.config.cmf_period { return None; } Some(self.cmf_accumulator / self.cmf_volume_sum) } /// Calculate Chaikin Oscillator (fast EMA - slow EMA of A/D Line) pub fn calculate_chaikin_oscillator(&self) -> Option { match (self.chaikin_fast_ema, self.chaikin_slow_ema) { (Some(fast), Some(slow)) => Some(fast - slow), _ => None, } } /// Calculate Keltner Channels (EMA ± multiplier * ATR) pub fn calculate_keltner_channels(&self) -> Option<(f64, f64, f64)> { let ema = self.ema_fast_state?; let atr = self.atr_state?; if self.update_count < self.config.keltner_period { return None; } let upper = ema + self.config.keltner_multiplier * atr; let lower = ema - self.config.keltner_multiplier * atr; Some((ema, upper, lower)) } /// Calculate Donchian Channels (highest high, lowest low, middle) pub fn calculate_donchian_channels(&self) -> Option<(f64, f64, f64)> { if self.donchian_highs.len() < self.config.donchian_period { return None; } let highest = self .donchian_highs .iter() .copied() .fold(f64::NEG_INFINITY, f64::max); let lowest = self .donchian_lows .iter() .copied() .fold(f64::INFINITY, f64::min); let middle = (highest + lowest) / 2.0; Some((middle, highest, lowest)) } /// Get On-Balance Volume pub fn calculate_obv(&self) -> f64 { self.obv } /// Calculate VWAP pub fn calculate_vwap(&self) -> Option { if self.vwap_cumulative_volume == 0.0 { return None; } Some(self.vwap_cumulative_pv / self.vwap_cumulative_volume) } /// Calculate Volume Oscillator (%) pub fn calculate_volume_oscillator(&self) -> Option { match (self.vol_ema_fast, self.vol_ema_slow) { (Some(fast), Some(slow)) if slow != 0.0 => Some(((fast - slow) / slow) * 100.0), _ => None, } } /// Calculate current `RSI` (0-100 range) pub fn calculate_rsi(&self) -> Option { if !self.rsi_state.initialized { return None; } if self.rsi_state.avg_loss == 0.0 { return Some(100.0); } let rs = self.rsi_state.avg_gain / self.rsi_state.avg_loss; Some(100.0 - (100.0 / (1.0 + rs))) } /// Calculate current `MACD` (difference between fast and slow `EMA`) pub fn calculate_macd(&self) -> Option { match (self.ema_fast_state, self.ema_slow_state) { (Some(fast), Some(slow)) => Some(fast - slow), _ => None, } } /// Calculate `MACD` signal line pub fn calculate_macd_signal(&self) -> Option { self.macd_signal_state } /// Calculate `MACD` histogram pub fn calculate_macd_histogram(&self) -> Option { match (self.calculate_macd(), self.macd_signal_state) { (Some(macd), Some(signal)) => Some(macd - signal), _ => None, } } /// Calculate Bollinger Bands (middle, upper, lower) pub fn calculate_bollinger_bands(&self) -> Option<(f64, f64, f64)> { if self.price_history.len() < self.config.bollinger_period { return None; } let prices: Vec = self .price_history .iter() .rev() .take(self.config.bollinger_period) .copied() .collect(); // Calculate SMA (middle band) let sma = prices.iter().sum::() / prices.len() as f64; // Calculate standard deviation let variance = prices.iter().map(|p| (p - sma).powi(2)).sum::() / prices.len() as f64; let std_dev = variance.sqrt(); let upper = sma + self.config.bollinger_std_dev * std_dev; let lower = sma - self.config.bollinger_std_dev * std_dev; Some((sma, upper, lower)) } /// Get current `ATR` value pub fn calculate_atr(&self) -> Option { self.atr_state } /// Get fast EMA pub fn calculate_ema_fast(&self) -> Option { self.ema_fast_state } /// Get slow EMA pub fn calculate_ema_slow(&self) -> Option { self.ema_slow_state } /// Get current price pub fn current_price(&self) -> Option { self.price_history.back().copied() } /// Check if warmup period is complete pub fn is_warmed_up(&self) -> bool { self.update_count >= self.config.warmup_period } /// Get all current indicators as HashMap (36 indicators total) pub fn current_indicators(&self) -> std::collections::HashMap { let mut indicators = std::collections::HashMap::new(); // Current price (for fallback when indicators not ready) if let Some(price) = self.current_price() { indicators.insert("price".to_string(), price); } // RSI if let Some(rsi) = self.calculate_rsi() { indicators.insert("rsi".to_string(), rsi); } // EMA if let Some(ema_fast) = self.ema_fast_state { indicators.insert("ema_fast".to_string(), ema_fast); } if let Some(ema_slow) = self.ema_slow_state { indicators.insert("ema_slow".to_string(), ema_slow); } // MACD if let Some(macd) = self.calculate_macd() { indicators.insert("macd".to_string(), macd); } if let Some(signal) = self.macd_signal_state { indicators.insert("macd_signal".to_string(), signal); } if let Some(histogram) = self.calculate_macd_histogram() { indicators.insert("macd_histogram".to_string(), histogram); } // Bollinger Bands if let Some((middle, upper, lower)) = self.calculate_bollinger_bands() { indicators.insert("bollinger_middle".to_string(), middle); indicators.insert("bollinger_upper".to_string(), upper); indicators.insert("bollinger_lower".to_string(), lower); // Bollinger Band width (volatility proxy) let width = (upper - lower) / middle; indicators.insert("bollinger_width".to_string(), width); } // ATR if let Some(atr) = self.atr_state { indicators.insert("atr".to_string(), atr); } // === NEW MOMENTUM INDICATORS === // Money Flow Index (MFI) if let Some(mfi) = self.calculate_mfi() { indicators.insert("mfi".to_string(), mfi); } // Chaikin Money Flow (CMF) if let Some(cmf) = self.calculate_cmf() { indicators.insert("cmf".to_string(), cmf); } // Chaikin Oscillator if let Some(chaikin) = self.calculate_chaikin_oscillator() { indicators.insert("chaikin_oscillator".to_string(), chaikin); } // === NEW VOLATILITY INDICATORS === // Keltner Channels if let Some((middle, upper, lower)) = self.calculate_keltner_channels() { indicators.insert("keltner_middle".to_string(), middle); indicators.insert("keltner_upper".to_string(), upper); indicators.insert("keltner_lower".to_string(), lower); // Keltner width let width = (upper - lower) / middle; indicators.insert("keltner_width".to_string(), width); } // Donchian Channels if let Some((middle, upper, lower)) = self.calculate_donchian_channels() { indicators.insert("donchian_middle".to_string(), middle); indicators.insert("donchian_upper".to_string(), upper); indicators.insert("donchian_lower".to_string(), lower); // Donchian width let width = (upper - lower) / middle; indicators.insert("donchian_width".to_string(), width); } // === NEW VOLUME INDICATORS === // On-Balance Volume (OBV) indicators.insert("obv".to_string(), self.calculate_obv()); // VWAP if let Some(vwap) = self.calculate_vwap() { indicators.insert("vwap".to_string(), vwap); // VWAP deviation (price distance from VWAP) if let Some(price) = self.current_price() { let vwap_deviation = (price - vwap) / vwap; indicators.insert("vwap_deviation".to_string(), vwap_deviation); } } // Volume Oscillator if let Some(vol_osc) = self.calculate_volume_oscillator() { indicators.insert("volume_oscillator".to_string(), vol_osc); } indicators } } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; #[test] fn test_rsi_calculation() { let config = IndicatorConfig { rsi_period: 14, ..Default::default() }; let mut calc = TechnicalIndicatorCalculator::new("TEST".to_string(), config); // Feed price data with clear uptrend let prices = vec![ 100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0, 111.0, 112.0, 113.0, 114.0, ]; for price in prices { calc.update(price, 1000.0, None, None); } let rsi = calc.calculate_rsi().expect("RSI should be calculated"); assert!(rsi > 50.0, "Uptrend should have RSI > 50, got {}", rsi); assert!(rsi <= 100.0, "RSI should be <= 100, got {}", rsi); } #[test] fn test_ema_calculation() { let config = IndicatorConfig { ema_fast_period: 5, ema_slow_period: 10, ..Default::default() }; let mut calc = TechnicalIndicatorCalculator::new("TEST".to_string(), config); // Feed constant price - EMA should converge to price for _ in 0..20 { calc.update(100.0, 1000.0, None, None); } let ema_fast = calc.calculate_ema_fast().expect("Fast EMA should exist"); let ema_slow = calc.calculate_ema_slow().expect("Slow EMA should exist"); assert!( (ema_fast - 100.0).abs() < 0.1, "Fast EMA should converge to 100" ); assert!( (ema_slow - 100.0).abs() < 0.1, "Slow EMA should converge to 100" ); } #[test] fn test_macd_calculation() { let config = IndicatorConfig { ema_fast_period: 12, ema_slow_period: 26, macd_signal_period: 9, ..Default::default() }; let mut calc = TechnicalIndicatorCalculator::new("TEST".to_string(), config); // Feed trending price data for i in 0..50 { calc.update(100.0 + i as f64, 1000.0, None, None); } let macd = calc.calculate_macd().expect("MACD should exist"); let signal = calc.calculate_macd_signal().expect("Signal should exist"); let histogram = calc .calculate_macd_histogram() .expect("Histogram should exist"); assert!(macd > 0.0, "Uptrend should have positive MACD"); assert!( (histogram - (macd - signal)).abs() < 0.001, "Histogram should equal MACD - Signal" ); } #[test] fn test_bollinger_bands() { let config = IndicatorConfig { bollinger_period: 20, bollinger_std_dev: 2.0, ..Default::default() }; let mut calc = TechnicalIndicatorCalculator::new("TEST".to_string(), config); // Feed price data with volatility for i in 0..30 { let price = 100.0 + (i as f64 * 0.5).sin() * 5.0; calc.update(price, 1000.0, None, None); } let (middle, upper, lower) = calc .calculate_bollinger_bands() .expect("Bollinger bands should be calculated"); assert!(upper > middle, "Upper band should be > middle"); assert!(middle > lower, "Middle should be > lower band"); assert!( (upper - middle) - (middle - lower) < 0.001, "Bands should be symmetric" ); } #[test] fn test_atr_calculation() { let config = IndicatorConfig { atr_period: 14, ..Default::default() }; let mut calc = TechnicalIndicatorCalculator::new("TEST".to_string(), config); // Feed OHLC data with varying ranges for i in 0..20 { let price = 100.0 + i as f64; let high = price + 1.0; let low = price - 1.0; calc.update(price, 1000.0, Some(high), Some(low)); } let atr = calc.calculate_atr().expect("ATR should be calculated"); assert!(atr > 0.0, "ATR should be positive"); assert!(atr < 10.0, "ATR should be reasonable for this data"); } #[test] fn test_warmup_period() { let config = IndicatorConfig { warmup_period: 26, ..Default::default() }; let mut calc = TechnicalIndicatorCalculator::new("TEST".to_string(), config); assert!(!calc.is_warmed_up(), "Should not be warmed up initially"); for i in 0..30 { calc.update(100.0 + i as f64, 1000.0, None, None); } assert!(calc.is_warmed_up(), "Should be warmed up after 30 updates"); } }