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