//! Volatility indicators: ATR, Bollinger Bands. use crate::core::error::RaptorError; use crate::core::Result; /// Average True Range (ATR). /// /// # Arguments /// * `high` - High prices /// * `low` - Low prices /// * `close` - Close prices /// * `period` - Lookback period (default: 14) /// /// # Returns /// Vector of ATR values (NaN for warmup period) pub fn atr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result> { let n = close.len(); if n != high.len() || n != low.len() { return Err(RaptorError::length_mismatch(n, high.len())); } if period == 0 { return Err(RaptorError::invalid_parameter("ATR period must be > 0")); } Ok(ferro_ta_core::volatility::atr(high, low, close, period)) } /// True Range calculation (single bar). #[inline] pub fn true_range(high: f64, low: f64, prev_close: f64) -> f64 { let hl = high - low; let hc = (high - prev_close).abs(); let lc = (low - prev_close).abs(); hl.max(hc).max(lc) } /// Bollinger Bands result. #[derive(Debug, Clone)] pub struct BollingerBandsResult { /// Middle band (SMA). pub middle: Vec, /// Upper band (SMA + std_dev * multiplier). pub upper: Vec, /// Lower band (SMA - std_dev * multiplier). pub lower: Vec, /// Bandwidth: (upper - lower) / middle. pub bandwidth: Vec, /// %B: (price - lower) / (upper - lower). pub percent_b: Vec, } /// Bollinger Bands. /// /// # Arguments /// * `data` - Price data (typically close prices) /// * `period` - Lookback period (default: 20) /// * `std_dev` - Standard deviation multiplier (default: 2.0) /// /// # Returns /// BollingerBandsResult with middle, upper, lower bands, bandwidth, and %B pub fn bollinger_bands(data: &[f64], period: usize, std_dev: f64) -> Result { if period == 0 { return Err(RaptorError::invalid_parameter("Bollinger Bands period must be > 0")); } if std_dev <= 0.0 { return Err(RaptorError::invalid_parameter("Bollinger Bands std_dev must be > 0")); } let n = data.len(); let (upper, middle, lower) = ferro_ta_core::overlap::bbands(data, period, std_dev, std_dev); let mut bandwidth = vec![f64::NAN; n]; let mut percent_b = vec![f64::NAN; n]; for i in 0..n { if !middle[i].is_nan() && middle[i].abs() > f64::EPSILON { bandwidth[i] = (upper[i] - lower[i]) / middle[i].abs(); } let band_width = upper[i] - lower[i]; if band_width > f64::EPSILON { percent_b[i] = (data[i] - lower[i]) / band_width; } } Ok(BollingerBandsResult { middle, upper, lower, bandwidth, percent_b }) } /// Keltner Channels (ATR-based bands). /// /// # Arguments /// * `high` - High prices /// * `low` - Low prices /// * `close` - Close prices /// * `ema_period` - EMA period for middle band /// * `atr_period` - ATR period /// * `multiplier` - ATR multiplier /// /// # Returns /// Tuple of (middle, upper, lower) bands pub fn keltner_channels( high: &[f64], low: &[f64], close: &[f64], ema_period: usize, atr_period: usize, multiplier: f64, ) -> Result<(Vec, Vec, Vec)> { let n = close.len(); if n != high.len() || n != low.len() { return Err(RaptorError::length_mismatch(n, high.len())); } // Calculate EMA for middle band let middle = super::trend::ema(close, ema_period)?; // Calculate ATR let atr_values = atr(high, low, close, atr_period)?; // Calculate bands let mut upper = vec![f64::NAN; n]; let mut lower = vec![f64::NAN; n]; for i in 0..n { if !middle[i].is_nan() && !atr_values[i].is_nan() { upper[i] = middle[i] + multiplier * atr_values[i]; lower[i] = middle[i] - multiplier * atr_values[i]; } } Ok((middle, upper, lower)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_atr() { let high = vec![50.0, 51.0, 52.0, 51.5, 50.5, 51.0, 52.0, 53.0, 52.5, 51.5]; let low = vec![48.0, 49.0, 50.0, 49.5, 48.5, 49.0, 50.0, 51.0, 50.5, 49.5]; let close = vec![49.0, 50.0, 51.0, 50.0, 49.0, 50.0, 51.0, 52.0, 51.0, 50.0]; let result = atr(&high, &low, &close, 5).unwrap(); // ATR should be valid from index 4 assert!(result[3].is_nan()); assert!(!result[4].is_nan()); assert!(result[4] > 0.0); } #[test] fn test_bollinger_bands() { let data: Vec = (1..=30).map(|x| x as f64 + (x as f64 * 0.1).sin()).collect(); let result = bollinger_bands(&data, 20, 2.0).unwrap(); // Bands should be valid from index 19 assert!(result.middle[18].is_nan()); assert!(!result.middle[19].is_nan()); // Upper > Middle > Lower assert!(result.upper[19] > result.middle[19]); assert!(result.middle[19] > result.lower[19]); // %B should be between 0 and 1 for data within bands assert!(result.percent_b[19] >= -0.5 && result.percent_b[19] <= 1.5); } #[test] fn test_true_range() { // Simple case assert!((true_range(52.0, 48.0, 50.0) - 4.0).abs() < 1e-10); // Gap up case assert!((true_range(55.0, 53.0, 50.0) - 5.0).abs() < 1e-10); // Gap down case assert!((true_range(48.0, 45.0, 50.0) - 5.0).abs() < 1e-10); } }