Skip to main content

wickra_core/indicators/
hma.rs

1//! Hull Moving Average (HMA).
2
3use crate::error::{Error, Result};
4use crate::indicators::wma::Wma;
5use crate::traits::Indicator;
6
7/// Hull Moving Average: `WMA(2 * WMA(n/2) - WMA(n), sqrt(n))`.
8///
9/// Designed by Alan Hull as a lag-free moving average that is also responsive.
10/// The square root of the period is rounded to the nearest integer (minimum 1).
11///
12/// # Example
13///
14/// ```
15/// use wickra_core::{Indicator, Hma};
16///
17/// let mut indicator = Hma::new(9).unwrap();
18/// let mut last = None;
19/// for i in 0..80 {
20///     last = indicator.update(100.0 + f64::from(i));
21/// }
22/// assert!(last.is_some());
23/// ```
24#[derive(Debug, Clone)]
25pub struct Hma {
26    period: usize,
27    half_wma: Wma,
28    full_wma: Wma,
29    smooth_wma: Wma,
30}
31
32impl Hma {
33    /// # Errors
34    /// Returns [`Error::PeriodZero`] if `period == 0`.
35    pub fn new(period: usize) -> Result<Self> {
36        if period == 0 {
37            return Err(Error::PeriodZero);
38        }
39        let half = (period / 2).max(1);
40        let smooth = (period as f64).sqrt().round() as usize;
41        let smooth = smooth.max(1);
42        Ok(Self {
43            period,
44            half_wma: Wma::new(half)?,
45            full_wma: Wma::new(period)?,
46            smooth_wma: Wma::new(smooth)?,
47        })
48    }
49
50    /// Configured period.
51    pub const fn period(&self) -> usize {
52        self.period
53    }
54}
55
56impl Indicator for Hma {
57    type Input = f64;
58    type Output = f64;
59
60    fn update(&mut self, input: f64) -> Option<f64> {
61        // Feed both windowed WMAs on every input so they warm up in parallel.
62        // Gating `full_wma.update` behind `half_wma.update(...)?` would starve
63        // the longer WMA during the shorter one's warmup, delaying the first
64        // emission past `warmup_period()`.
65        let h = self.half_wma.update(input);
66        let f = self.full_wma.update(input);
67        let (h, f) = (h?, f?);
68        let diff = 2.0 * h - f;
69        self.smooth_wma.update(diff)
70    }
71
72    fn reset(&mut self) {
73        self.half_wma.reset();
74        self.full_wma.reset();
75        self.smooth_wma.reset();
76    }
77
78    fn warmup_period(&self) -> usize {
79        let sm = (self.period as f64).sqrt().round() as usize;
80        self.period + sm.max(1) - 1
81    }
82
83    fn is_ready(&self) -> bool {
84        self.smooth_wma.is_ready()
85    }
86
87    fn name(&self) -> &'static str {
88        "HMA"
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::traits::BatchExt;
96    use approx::assert_relative_eq;
97
98    #[test]
99    fn constant_series_yields_constant_hma() {
100        let mut hma = Hma::new(9).unwrap();
101        let out = hma.batch(&[10.0_f64; 80]);
102        let last = out.iter().rev().flatten().next().unwrap();
103        assert_relative_eq!(*last, 10.0, epsilon = 1e-9);
104    }
105
106    #[test]
107    fn batch_equals_streaming() {
108        let prices: Vec<f64> = (1..=100).map(|i| f64::from(i) * 0.7).collect();
109        let mut a = Hma::new(9).unwrap();
110        let mut b = Hma::new(9).unwrap();
111        assert_eq!(
112            a.batch(&prices),
113            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
114        );
115    }
116
117    #[test]
118    fn reset_clears_state() {
119        let mut hma = Hma::new(9).unwrap();
120        hma.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
121        assert!(hma.is_ready());
122        hma.reset();
123        assert!(!hma.is_ready());
124    }
125
126    #[test]
127    fn rejects_zero_period() {
128        assert!(Hma::new(0).is_err());
129    }
130
131    /// Cover the const accessor `period` (51-53) and the Indicator-impl
132    /// `name` body (87-89). `warmup_period` is covered by
133    /// `first_emission_matches_warmup_period`.
134    #[test]
135    fn accessors_and_metadata() {
136        let hma = Hma::new(9).unwrap();
137        assert_eq!(hma.period(), 9);
138        assert_eq!(hma.name(), "HMA");
139    }
140
141    #[test]
142    fn first_emission_matches_warmup_period() {
143        let prices: Vec<f64> = (1..=40).map(f64::from).collect();
144        let mut hma = Hma::new(9).unwrap();
145        let out = hma.batch(&prices);
146        let warmup = hma.warmup_period();
147        assert_eq!(warmup, 11);
148        for (i, v) in out.iter().enumerate().take(warmup - 1) {
149            assert!(v.is_none(), "index {i} must be None during warmup");
150        }
151        assert!(
152            out[warmup - 1].is_some(),
153            "first HMA value must land at warmup_period - 1"
154        );
155    }
156
157    #[test]
158    fn matches_independent_wmas() {
159        // The two inner WMAs run as independent siblings on the price stream;
160        // HMA must equal feeding three standalone WMAs and combining them.
161        let prices: Vec<f64> = (1..=50)
162            .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
163            .collect();
164        let mut hma = Hma::new(9).unwrap();
165        let mut half = Wma::new(4).unwrap(); // (9 / 2).max(1)
166        let mut full = Wma::new(9).unwrap();
167        let mut smooth = Wma::new(3).unwrap(); // round(sqrt(9))
168        for (i, &p) in prices.iter().enumerate() {
169            let got = hma.update(p);
170            let want = match (half.update(p), full.update(p)) {
171                (Some(h), Some(f)) => smooth.update(2.0 * h - f),
172                _ => None,
173            };
174            // HMA and the independent WMA chain share a warmup formula.
175            assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
176            if let (Some(a), Some(b)) = (got, want) {
177                assert_relative_eq!(a, b, epsilon = 1e-9);
178            }
179        }
180    }
181}