Skip to main content

nautilus_indicators/average/
hma.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::fmt::Display;
17
18use nautilus_core::correctness::{FAILED, check_predicate_true};
19use nautilus_model::{
20    data::{Bar, QuoteTick, TradeTick},
21    enums::PriceType,
22};
23
24use crate::{
25    average::wma::WeightedMovingAverage,
26    indicator::{Indicator, MovingAverage},
27    support::MAX_PERIOD,
28};
29
30/// An indicator which calculates a Hull Moving Average (HMA) across a rolling
31/// window. The HMA, developed by Alan Hull, is an extremely fast and smooth
32/// moving average.
33#[repr(C)]
34#[derive(Debug)]
35#[cfg_attr(
36    feature = "python",
37    pyo3::pyclass(module = "nautilus_trader.indicators")
38)]
39#[cfg_attr(
40    feature = "python",
41    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
42)]
43pub struct HullMovingAverage {
44    pub period: usize,
45    pub price_type: PriceType,
46    pub value: f64,
47    pub count: usize,
48    pub initialized: bool,
49    has_inputs: bool,
50    ma1: WeightedMovingAverage,
51    ma2: WeightedMovingAverage,
52    ma3: WeightedMovingAverage,
53}
54
55impl Display for HullMovingAverage {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        write!(f, "{}({})", self.name(), self.period)
58    }
59}
60
61impl Indicator for HullMovingAverage {
62    fn name(&self) -> String {
63        stringify!(HullMovingAverage).to_string()
64    }
65
66    fn has_inputs(&self) -> bool {
67        self.has_inputs
68    }
69
70    fn initialized(&self) -> bool {
71        self.initialized
72    }
73
74    fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
75        self.update_raw(quote.extract_price(self.price_type)?.into());
76        Ok(())
77    }
78
79    fn handle_trade(&mut self, trade: &TradeTick) {
80        self.update_raw((&trade.price).into());
81    }
82
83    fn handle_bar(&mut self, bar: &Bar) {
84        self.update_raw((&bar.close).into());
85    }
86
87    fn reset(&mut self) {
88        self.value = 0.0;
89        self.ma1.reset();
90        self.ma2.reset();
91        self.ma3.reset();
92        self.count = 0;
93        self.has_inputs = false;
94        self.initialized = false;
95    }
96}
97
98impl HullMovingAverage {
99    /// Creates a new [`HullMovingAverage`] instance.
100    ///
101    /// # Panics
102    ///
103    /// Panics if `period` is not a positive integer (> 0).
104    /// Window periods must not exceed the shared indicator period limit.
105    #[must_use]
106    pub fn new(period: usize, price_type: Option<PriceType>) -> Self {
107        Self::new_checked(period, price_type).expect(FAILED)
108    }
109
110    /// Creates a new [`HullMovingAverage`] instance with a validated period.
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if `period` is zero or exceeds `MAX_PERIOD`.
115    pub fn new_checked(period: usize, price_type: Option<PriceType>) -> anyhow::Result<Self> {
116        check_predicate_true(
117            period <= MAX_PERIOD,
118            &format!("window periods cannot exceed {MAX_PERIOD}"),
119        )?;
120
121        check_predicate_true(
122            period > 0,
123            &format!("HullMovingAverage: period must be > 0 (received {period})"),
124        )?;
125
126        let half = usize::max(1, period / 2);
127        // Hull and TradingView truncate sqrt(period) for the smoothing length
128        let root = usize::max(1, (period as f64).sqrt() as usize);
129
130        let pt = price_type.unwrap_or(PriceType::Last);
131
132        let ma1 = WeightedMovingAverage::new(half, Some(pt));
133        let ma2 = WeightedMovingAverage::new(period, Some(pt));
134        let ma3 = WeightedMovingAverage::new(root, Some(pt));
135
136        Ok(Self {
137            period,
138            price_type: pt,
139            value: 0.0,
140            count: 0,
141            has_inputs: false,
142            initialized: false,
143            ma1,
144            ma2,
145            ma3,
146        })
147    }
148}
149
150impl MovingAverage for HullMovingAverage {
151    fn value(&self) -> f64 {
152        self.value
153    }
154
155    fn count(&self) -> usize {
156        self.count
157    }
158
159    fn update_raw(&mut self, value: f64) {
160        if !value.is_finite() {
161            return;
162        }
163        self.has_inputs = true;
164        self.ma1.update_raw(value);
165        self.ma2.update_raw(value);
166        self.count += 1;
167
168        if self.ma1.initialized && self.ma2.initialized {
169            self.ma3.update_raw(2.0 * self.ma1.value - self.ma2.value);
170            self.initialized = self.ma3.initialized;
171            if self.initialized {
172                self.value = self.ma3.value;
173            }
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use nautilus_model::{
181        data::{Bar, QuoteTick, TradeTick},
182        enums::PriceType,
183    };
184    use rstest::rstest;
185
186    use crate::{
187        average::hma::HullMovingAverage,
188        indicator::{Indicator, MovingAverage},
189        stubs::*,
190    };
191
192    #[rstest]
193    fn test_hma_initialized(indicator_hma_10: HullMovingAverage) {
194        let display_str = format!("{indicator_hma_10}");
195        assert_eq!(display_str, "HullMovingAverage(10)");
196        assert_eq!(indicator_hma_10.period, 10);
197        assert!(!indicator_hma_10.initialized);
198        assert!(!indicator_hma_10.has_inputs);
199    }
200
201    #[rstest]
202    fn test_initialized_with_required_input(mut indicator_hma_10: HullMovingAverage) {
203        // Composite warmup for period 10 is 10 + floor(sqrt(10)) - 1 = 12 inputs
204        for i in 1..=11 {
205            indicator_hma_10.update_raw(f64::from(i));
206            assert!(!indicator_hma_10.initialized);
207        }
208        indicator_hma_10.update_raw(12.0);
209        assert!(indicator_hma_10.initialized);
210    }
211
212    #[rstest]
213    fn test_value_with_one_input(mut indicator_hma_10: HullMovingAverage) {
214        indicator_hma_10.update_raw(1.0);
215        assert_eq!(indicator_hma_10.value, 0.0);
216    }
217
218    #[rstest]
219    fn test_value_with_three_inputs(mut indicator_hma_10: HullMovingAverage) {
220        indicator_hma_10.update_raw(1.0);
221        indicator_hma_10.update_raw(2.0);
222        indicator_hma_10.update_raw(3.0);
223        assert_eq!(indicator_hma_10.value, 0.0);
224    }
225
226    #[rstest]
227    fn test_value_with_ten_inputs(mut indicator_hma_10: HullMovingAverage) {
228        indicator_hma_10.update_raw(1.00000);
229        indicator_hma_10.update_raw(1.00010);
230        indicator_hma_10.update_raw(1.00020);
231        indicator_hma_10.update_raw(1.00030);
232        indicator_hma_10.update_raw(1.00040);
233        indicator_hma_10.update_raw(1.00050);
234        indicator_hma_10.update_raw(1.00040);
235        indicator_hma_10.update_raw(1.00030);
236        indicator_hma_10.update_raw(1.00020);
237        indicator_hma_10.update_raw(1.00010);
238        indicator_hma_10.update_raw(1.00000);
239        assert_eq!(indicator_hma_10.value, 0.0);
240    }
241
242    #[rstest]
243    fn test_handle_quote_tick(mut indicator_hma_10: HullMovingAverage, stub_quote: QuoteTick) {
244        indicator_hma_10.handle_quote(&stub_quote).unwrap();
245        assert_eq!(indicator_hma_10.value, 0.0);
246    }
247
248    #[rstest]
249    fn test_handle_trade_tick(mut indicator_hma_10: HullMovingAverage, stub_trade: TradeTick) {
250        indicator_hma_10.handle_trade(&stub_trade);
251        assert_eq!(indicator_hma_10.value, 0.0);
252    }
253
254    #[rstest]
255    fn test_handle_bar(
256        mut indicator_hma_10: HullMovingAverage,
257        bar_ethusdt_binance_minute_bid: Bar,
258    ) {
259        indicator_hma_10.handle_bar(&bar_ethusdt_binance_minute_bid);
260        assert_eq!(indicator_hma_10.value, 0.0);
261        assert!(indicator_hma_10.has_inputs);
262        assert!(!indicator_hma_10.initialized);
263    }
264
265    #[rstest]
266    fn test_reset(mut indicator_hma_10: HullMovingAverage) {
267        indicator_hma_10.update_raw(1.0);
268        assert_eq!(indicator_hma_10.count, 1);
269        assert_eq!(indicator_hma_10.value, 0.0);
270        assert_eq!(indicator_hma_10.ma1.value, 0.0);
271        assert_eq!(indicator_hma_10.ma2.value, 0.0);
272        assert_eq!(indicator_hma_10.ma3.value, 0.0);
273        indicator_hma_10.reset();
274        assert_eq!(indicator_hma_10.value, 0.0);
275        assert_eq!(indicator_hma_10.count, 0);
276        assert_eq!(indicator_hma_10.ma1.value, 0.0);
277        assert_eq!(indicator_hma_10.ma2.value, 0.0);
278        assert_eq!(indicator_hma_10.ma3.value, 0.0);
279        assert!(!indicator_hma_10.has_inputs);
280        assert!(!indicator_hma_10.initialized);
281    }
282
283    #[rstest]
284    #[should_panic(expected = "HullMovingAverage: period must be > 0")]
285    fn test_new_with_zero_period_panics() {
286        let _ = HullMovingAverage::new(0, None);
287    }
288
289    #[rstest]
290    #[case(1)]
291    #[case(5)]
292    #[case(128)]
293    #[case(8_192)]
294    fn test_new_with_positive_period_constructs(#[case] period: usize) {
295        let hma = HullMovingAverage::new(period, None);
296        assert_eq!(hma.period, period);
297        assert_eq!(hma.count(), 0);
298        assert!(!hma.initialized());
299    }
300
301    #[rstest]
302    #[case(PriceType::Bid)]
303    #[case(PriceType::Ask)]
304    #[case(PriceType::Last)]
305    fn test_price_type_propagates_to_inner_wmas(#[case] pt: PriceType) {
306        let hma = HullMovingAverage::new(10, Some(pt));
307        assert_eq!(hma.price_type, pt);
308        assert_eq!(hma.ma1.price_type, pt);
309        assert_eq!(hma.ma2.price_type, pt);
310        assert_eq!(hma.ma3.price_type, pt);
311    }
312
313    #[rstest]
314    fn test_price_type_defaults_to_last() {
315        let hma = HullMovingAverage::new(10, None);
316        assert_eq!(hma.price_type, PriceType::Last);
317        assert_eq!(hma.ma1.price_type, PriceType::Last);
318        assert_eq!(hma.ma2.price_type, PriceType::Last);
319        assert_eq!(hma.ma3.price_type, PriceType::Last);
320    }
321
322    #[rstest]
323    #[case(10.0)]
324    #[case(-5.5)]
325    #[case(42.42)]
326    #[case(0.0)]
327    fn period_one_degenerates_to_price(#[case] price: f64) {
328        let mut hma = HullMovingAverage::new(1, None);
329
330        for _ in 0..5 {
331            hma.update_raw(price);
332            assert!(
333                (hma.value() - price).abs() < f64::EPSILON,
334                "HMA(1) should equal last price {price}, was {}",
335                hma.value()
336            );
337            assert!(hma.initialized(), "HMA(1) must initialize immediately");
338        }
339    }
340
341    #[rstest]
342    #[case(3, 123.456_f64)]
343    #[case(13, 0.001_f64)]
344    fn constant_series_yields_constant_value(#[case] period: usize, #[case] constant: f64) {
345        let mut hma = HullMovingAverage::new(period, None);
346
347        for _ in 0..(period * 4) {
348            hma.update_raw(constant);
349            assert!(
350                (hma.value() - if hma.initialized() { constant } else { 0.0 }).abs() < 1e-12,
351                "Expected {constant}, was {}",
352                hma.value()
353            );
354        }
355        assert!(hma.initialized());
356    }
357
358    #[rstest]
359    fn alternating_extremes_bounded() {
360        let mut hma = HullMovingAverage::new(50, None);
361        let lows_highs = [0.0_f64, 1_000.0_f64];
362
363        for i in 0..200 {
364            let price = lows_highs[i & 1];
365            hma.update_raw(price);
366
367            let v = hma.value();
368            assert!((0.0..=1_000.0).contains(&v), "HMA out of bounds: {v}");
369        }
370    }
371
372    #[rstest]
373    #[case(2, 2)]
374    #[case(14, 16)]
375    #[case(17, 20)]
376    #[case(21, 24)]
377    #[case(128, 138)]
378    fn initialized_boundary(#[case] period: usize, #[case] warmup: usize) {
379        let mut hma = HullMovingAverage::new(period, None);
380
381        for i in 0..(warmup - 1) {
382            hma.update_raw(i as f64);
383            assert!(!hma.initialized(), "HMA wrongly initialized at count {i}");
384        }
385
386        hma.update_raw(0.0);
387        assert!(
388            hma.initialized(),
389            "HMA should initialize at exactly {warmup} ticks"
390        );
391    }
392
393    #[rstest]
394    #[case(2)]
395    #[case(3)]
396    fn small_periods_do_not_panic(#[case] period: usize) {
397        let mut hma = HullMovingAverage::new(period, None);
398        for i in 0..(period * 5) {
399            hma.update_raw(i as f64);
400        }
401        assert!(hma.initialized());
402    }
403
404    #[rstest]
405    fn negative_prices_supported() {
406        let mut hma = HullMovingAverage::new(10, None);
407        let prices = [-5.0, -4.0, -3.0, -2.5, -2.0, -1.5, -1.0, -0.5, 0.0, 0.5];
408
409        for &p in &prices {
410            hma.update_raw(p);
411            let v = hma.value();
412            assert!(
413                v.is_finite(),
414                "HMA produced a non-finite value {v} from negative prices"
415            );
416        }
417    }
418}