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    /// The exact batch: until all three WMAs are full it replays `update`, then
79    /// runs the three in one loop with their state in locals ([`Wma::steady`]).
80    /// Per input that is `update`'s own sequence -- skip a non-finite input,
81    /// step both windowed WMAs, and step the smoothing one on `2 * half - full`
82    /// only when that is finite, as its `update` would skip it -- so the same
83    /// bits and the same state, with the three independent chains interleaved.
84    fn batch_nan_into(&mut self, inputs: &[f64], out: &mut [f64]) {
85        assert_eq!(
86            inputs.len(),
87            out.len(),
88            "batch output length must equal input length"
89        );
90        let mut start = 0;
91        while !(self.half_wma.is_ready() && self.full_wma.is_ready() && self.smooth_wma.is_ready())
92            && start < inputs.len()
93        {
94            out[start] = self.update(inputs[start]).unwrap_or(f64::NAN);
95            start += 1;
96        }
97        if start == inputs.len() {
98            return;
99        }
100        let (mut half, mut full, mut smooth) = (
101            self.half_wma.steady(),
102            self.full_wma.steady(),
103            self.smooth_wma.steady(),
104        );
105        for (slot, &x) in out[start..].iter_mut().zip(&inputs[start..]) {
106            if !x.is_finite() {
107                *slot = f64::NAN;
108                continue;
109            }
110            let diff = 2.0 * half.step(x) - full.step(x);
111            *slot = if diff.is_finite() {
112                smooth.step(diff)
113            } else {
114                f64::NAN
115            };
116        }
117    }
118
119    fn reset(&mut self) {
120        self.half_wma.reset();
121        self.full_wma.reset();
122        self.smooth_wma.reset();
123    }
124
125    #[inline]
126    fn warmup_period(&self) -> usize {
127        let sm = (self.period as f64).sqrt().round() as usize;
128        self.period + sm.max(1) - 1
129    }
130
131    #[inline]
132    fn is_ready(&self) -> bool {
133        self.smooth_wma.is_ready()
134    }
135
136    #[inline]
137    fn name(&self) -> &'static str {
138        "HMA"
139    }
140
141    /// SIMD kernel: the half and full WMAs, their `2 · half − full`
142    /// difference and the smoothing WMA over it, each as the WMA prefix-scan
143    /// kernel. Agrees with the exact batch to within a few units in the last
144    /// place; warmup `NaN`s and length are identical. HMA only remembers its
145    /// last `period + smooth − 1` inputs, so afterwards its state is rebuilt
146    /// exactly by replaying them.
147    fn batch_fast_into(&mut self, inputs: &[f64], out: &mut [f64]) {
148        assert_eq!(
149            inputs.len(),
150            out.len(),
151            "batch output length must equal input length"
152        );
153        let (half, full, smooth) = (
154            self.half_wma.period(),
155            self.full_wma.period(),
156            self.smooth_wma.period(),
157        );
158        let n = inputs.len();
159        let span = full + smooth - 1;
160        if !(self.half_wma.is_empty() && self.full_wma.is_empty() && self.smooth_wma.is_empty())
161            || n < span
162            || !crate::fast::in_range(inputs)
163        {
164            self.batch_nan_into(inputs, out);
165            return;
166        }
167        crate::fast::with_scratch(n, |tmp| {
168            wickra_simd::dispatch(crate::fast::HmaFast {
169                x: inputs,
170                half,
171                full,
172                smooth,
173                tmp,
174                out,
175                _borrow: std::marker::PhantomData,
176            });
177        });
178        crate::fast::replay_tail(self, &inputs[n - span..]);
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::traits::BatchExt;
186    use approx::assert_relative_eq;
187
188    #[test]
189    fn constant_series_yields_constant_hma() {
190        let mut hma = Hma::new(9).unwrap();
191        let out = hma.batch(&[10.0_f64; 80]);
192        let last = out.iter().rev().flatten().next().unwrap();
193        assert_relative_eq!(*last, 10.0, epsilon = 1e-9);
194    }
195
196    #[test]
197    fn batch_equals_streaming() {
198        let prices: Vec<f64> = (1..=100).map(|i| f64::from(i) * 0.7).collect();
199        let mut a = Hma::new(9).unwrap();
200        let mut b = Hma::new(9).unwrap();
201        assert_eq!(
202            a.batch(&prices),
203            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
204        );
205    }
206
207    /// The block-wise exact batch is the `update` replay bit for bit: across
208    /// block boundaries, with non-finite inputs, with values large enough that
209    /// the WMA sums overflow and `2 * half - full` itself is `NaN`, and split
210    /// into two calls.
211    #[test]
212    fn batch_nan_into_is_the_update_replay_bit_for_bit() {
213        let mut series: Vec<f64> = (0..2_600)
214            .map(|i| 50.0 + (f64::from(i) * 0.11).sin() * 9.0 + f64::from(i % 13))
215            .collect();
216        series[5] = f64::NAN;
217        series[1_030] = f64::INFINITY;
218        series[2_047] = f64::NEG_INFINITY;
219        for x in &mut series[1_500..1_520] {
220            *x = 1.5e308;
221        }
222        let bits = |v: &[f64]| v.iter().map(|x| x.to_bits()).collect::<Vec<_>>();
223        for period in [1, 2, 4, 9, 20] {
224            let mut replay = Hma::new(period).unwrap();
225            let want: Vec<f64> = series
226                .iter()
227                .map(|&x| replay.update(x).unwrap_or(f64::NAN))
228                .collect();
229            for split in [0, 1, 7, 1_023, 1_024, 1_025, 1_900, series.len()] {
230                let mut hma = Hma::new(period).unwrap();
231                let mut got = vec![0.0; series.len()];
232                let (head, tail) = got.split_at_mut(split);
233                hma.batch_nan_into(&series[..split], head);
234                hma.batch_nan_into(&series[split..], tail);
235                assert_eq!(bits(&got), bits(&want), "period {period} split {split}");
236                assert_eq!(hma.update(55.0), replay.clone().update(55.0));
237            }
238        }
239    }
240
241    #[test]
242    fn reset_clears_state() {
243        let mut hma = Hma::new(9).unwrap();
244        hma.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
245        assert!(hma.is_ready());
246        hma.reset();
247        assert!(!hma.is_ready());
248    }
249
250    #[test]
251    fn rejects_zero_period() {
252        assert!(Hma::new(0).is_err());
253    }
254
255    /// Cover the const accessor `period` (51-53) and the Indicator-impl
256    /// `name` body (87-89). `warmup_period` is covered by
257    /// `first_emission_matches_warmup_period`.
258    #[test]
259    fn accessors_and_metadata() {
260        let hma = Hma::new(9).unwrap();
261        assert_eq!(hma.period(), 9);
262        assert_eq!(hma.name(), "HMA");
263    }
264
265    #[test]
266    fn first_emission_matches_warmup_period() {
267        let prices: Vec<f64> = (1..=40).map(f64::from).collect();
268        let mut hma = Hma::new(9).unwrap();
269        let out = hma.batch(&prices);
270        let warmup = hma.warmup_period();
271        assert_eq!(warmup, 11);
272        for (i, v) in out.iter().enumerate().take(warmup - 1) {
273            assert!(v.is_none(), "index {i} must be None during warmup");
274        }
275        assert!(
276            out[warmup - 1].is_some(),
277            "first HMA value must land at warmup_period - 1"
278        );
279    }
280
281    #[test]
282    fn matches_independent_wmas() {
283        // The two inner WMAs run as independent siblings on the price stream;
284        // HMA must equal feeding three standalone WMAs and combining them.
285        let prices: Vec<f64> = (1..=50)
286            .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
287            .collect();
288        let mut hma = Hma::new(9).unwrap();
289        let mut half = Wma::new(4).unwrap(); // (9 / 2).max(1)
290        let mut full = Wma::new(9).unwrap();
291        let mut smooth = Wma::new(3).unwrap(); // round(sqrt(9))
292        for (i, &p) in prices.iter().enumerate() {
293            let got = hma.update(p);
294            let want = match (half.update(p), full.update(p)) {
295                (Some(h), Some(f)) => smooth.update(2.0 * h - f),
296                _ => None,
297            };
298            // HMA and the independent WMA chain share a warmup formula.
299            assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
300            if let (Some(a), Some(b)) = (got, want) {
301                assert_relative_eq!(a, b, epsilon = 1e-9);
302            }
303        }
304    }
305}