//! Hull Moving Average (HMA). use crate::error::{Error, Result}; use crate::indicators::wma::Wma; use crate::traits::Indicator; /// Hull Moving Average: `WMA(2 * WMA(n/2) - WMA(n), sqrt(n))`. /// /// Designed by Alan Hull as a lag-free moving average that is also responsive. /// The square root of the period is rounded to the nearest integer (minimum 1). /// /// # Example /// /// ``` /// use wickra_core::{Indicator, Hma}; /// /// let mut indicator = Hma::new(9).unwrap(); /// let mut last = None; /// for i in 0..80 { /// last = indicator.update(100.0 + f64::from(i)); /// } /// assert!(last.is_some()); /// ``` #[derive(Debug, Clone)] pub struct Hma { period: usize, half_wma: Wma, full_wma: Wma, smooth_wma: Wma, } impl Hma { /// # Errors /// Returns [`Error::PeriodZero`] if `period == 0`. pub fn new(period: usize) -> Result { if period == 0 { return Err(Error::PeriodZero); } let half = (period / 2).max(1); let smooth = (period as f64).sqrt().round() as usize; let smooth = smooth.max(1); Ok(Self { period, half_wma: Wma::new(half)?, full_wma: Wma::new(period)?, smooth_wma: Wma::new(smooth)?, }) } /// Configured period. pub const fn period(&self) -> usize { self.period } } impl Indicator for Hma { type Input = f64; type Output = f64; fn update(&mut self, input: f64) -> Option { // Feed both windowed WMAs on every input so they warm up in parallel. // Gating `full_wma.update` behind `half_wma.update(...)?` would starve // the longer WMA during the shorter one's warmup, delaying the first // emission past `warmup_period()`. let h = self.half_wma.update(input); let f = self.full_wma.update(input); let (h, f) = (h?, f?); let diff = 2.0 * h - f; self.smooth_wma.update(diff) } fn reset(&mut self) { self.half_wma.reset(); self.full_wma.reset(); self.smooth_wma.reset(); } fn warmup_period(&self) -> usize { let sm = (self.period as f64).sqrt().round() as usize; self.period + sm.max(1) - 1 } fn is_ready(&self) -> bool { self.smooth_wma.is_ready() } fn name(&self) -> &'static str { "HMA" } } #[cfg(test)] mod tests { use super::*; use crate::traits::BatchExt; use approx::assert_relative_eq; #[test] fn constant_series_yields_constant_hma() { let mut hma = Hma::new(9).unwrap(); let out = hma.batch(&[10.0_f64; 80]); let last = out.iter().rev().flatten().next().unwrap(); assert_relative_eq!(*last, 10.0, epsilon = 1e-9); } #[test] fn batch_equals_streaming() { let prices: Vec = (1..=100).map(|i| f64::from(i) * 0.7).collect(); let mut a = Hma::new(9).unwrap(); let mut b = Hma::new(9).unwrap(); assert_eq!( a.batch(&prices), prices.iter().map(|p| b.update(*p)).collect::>() ); } #[test] fn reset_clears_state() { let mut hma = Hma::new(9).unwrap(); hma.batch(&(1..=80).map(f64::from).collect::>()); assert!(hma.is_ready()); hma.reset(); assert!(!hma.is_ready()); } #[test] fn rejects_zero_period() { assert!(Hma::new(0).is_err()); } /// Cover the const accessor `period` (51-53) and the Indicator-impl /// `name` body (87-89). `warmup_period` is covered by /// `first_emission_matches_warmup_period`. #[test] fn accessors_and_metadata() { let hma = Hma::new(9).unwrap(); assert_eq!(hma.period(), 9); assert_eq!(hma.name(), "HMA"); } #[test] fn first_emission_matches_warmup_period() { let prices: Vec = (1..=40).map(f64::from).collect(); let mut hma = Hma::new(9).unwrap(); let out = hma.batch(&prices); let warmup = hma.warmup_period(); assert_eq!(warmup, 11); for (i, v) in out.iter().enumerate().take(warmup - 1) { assert!(v.is_none(), "index {i} must be None during warmup"); } assert!( out[warmup - 1].is_some(), "first HMA value must land at warmup_period - 1" ); } #[test] fn matches_independent_wmas() { // The two inner WMAs run as independent siblings on the price stream; // HMA must equal feeding three standalone WMAs and combining them. let prices: Vec = (1..=50) .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0) .collect(); let mut hma = Hma::new(9).unwrap(); let mut half = Wma::new(4).unwrap(); // (9 / 2).max(1) let mut full = Wma::new(9).unwrap(); let mut smooth = Wma::new(3).unwrap(); // round(sqrt(9)) for (i, &p) in prices.iter().enumerate() { let got = hma.update(p); let want = match (half.update(p), full.update(p)) { (Some(h), Some(f)) => smooth.update(2.0 * h - f), _ => None, }; // HMA and the independent WMA chain share a warmup formula. assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}"); if let (Some(a), Some(b)) = (got, want) { assert_relative_eq!(a, b, epsilon = 1e-9); } } } }