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::{MAX_PERIOD, WeightedMovingAverage},
26    indicator::{Indicator, MovingAverage},
27};
28
29/// An indicator which calculates a Hull Moving Average (HMA) across a rolling
30/// window. The HMA, developed by Alan Hull, is an extremely fast and smooth
31/// moving average.
32#[repr(C)]
33#[derive(Debug)]
34#[cfg_attr(
35    feature = "python",
36    pyo3::pyclass(module = "nautilus_trader.indicators")
37)]
38#[cfg_attr(
39    feature = "python",
40    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
41)]
42pub struct HullMovingAverage {
43    pub period: usize,
44    pub price_type: PriceType,
45    pub value: f64,
46    pub count: usize,
47    pub initialized: bool,
48    has_inputs: bool,
49    ma1: WeightedMovingAverage,
50    ma2: WeightedMovingAverage,
51    ma3: WeightedMovingAverage,
52}
53
54impl Display for HullMovingAverage {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(f, "{}({})", self.name(), self.period)
57    }
58}
59
60impl Indicator for HullMovingAverage {
61    fn name(&self) -> String {
62        stringify!(HullMovingAverage).to_string()
63    }
64
65    fn has_inputs(&self) -> bool {
66        self.has_inputs
67    }
68
69    fn initialized(&self) -> bool {
70        self.initialized
71    }
72
73    fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
74        self.update_raw(quote.extract_price(self.price_type)?.into());
75        Ok(())
76    }
77
78    fn handle_trade(&mut self, trade: &TradeTick) {
79        self.update_raw((&trade.price).into());
80    }
81
82    fn handle_bar(&mut self, bar: &Bar) {
83        self.update_raw((&bar.close).into());
84    }
85
86    fn reset(&mut self) {
87        self.value = 0.0;
88        self.ma1.reset();
89        self.ma2.reset();
90        self.ma3.reset();
91        self.count = 0;
92        self.has_inputs = false;
93        self.initialized = false;
94    }
95}
96
97fn get_weights(size: usize) -> Vec<f64> {
98    let mut w: Vec<f64> = (1..=size).map(|x| x as f64).collect();
99    let divisor: f64 = w.iter().sum();
100    for v in &mut w {
101        *v /= divisor;
102    }
103    w
104}
105
106impl HullMovingAverage {
107    /// Creates a new [`HullMovingAverage`] instance.
108    ///
109    /// # Panics
110    ///
111    /// Panics if `period` is not a positive integer (> 0), or exceeds `MAX_PERIOD`.
112    #[must_use]
113    pub fn new(period: usize, price_type: Option<PriceType>) -> Self {
114        Self::new_checked(period, price_type).expect(FAILED)
115    }
116
117    /// Creates a new [`HullMovingAverage`] instance with the given period.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if:
122    /// - `period` is not a positive integer (> 0).
123    /// - `period` exceeds `MAX_PERIOD`.
124    pub fn new_checked(period: usize, price_type: Option<PriceType>) -> anyhow::Result<Self> {
125        check_predicate_true(
126            period > 0,
127            &format!("HullMovingAverage: period must be > 0 (received {period})"),
128        )?;
129        // `ma2` below is a `WeightedMovingAverage` over the full `period`, so this
130        // indicator cannot support a period its inner averages cannot buffer.
131        check_predicate_true(
132            period <= MAX_PERIOD,
133            &format!("HullMovingAverage: period {period} exceeds MAX_PERIOD ({MAX_PERIOD})"),
134        )?;
135
136        let half = usize::max(1, period / 2);
137        let root = usize::max(1, (period as f64).sqrt() as usize);
138
139        let pt = price_type.unwrap_or(PriceType::Last);
140
141        let ma1 = WeightedMovingAverage::new(half, get_weights(half), Some(pt));
142        let ma2 = WeightedMovingAverage::new(period, get_weights(period), Some(pt));
143        let ma3 = WeightedMovingAverage::new(root, get_weights(root), Some(pt));
144
145        Ok(Self {
146            period,
147            price_type: pt,
148            value: 0.0,
149            count: 0,
150            has_inputs: false,
151            initialized: false,
152            ma1,
153            ma2,
154            ma3,
155        })
156    }
157}
158
159impl MovingAverage for HullMovingAverage {
160    fn value(&self) -> f64 {
161        self.value
162    }
163
164    fn count(&self) -> usize {
165        self.count
166    }
167
168    fn update_raw(&mut self, value: f64) {
169        if !self.has_inputs {
170            self.has_inputs = true;
171            self.value = value;
172        }
173
174        self.ma1.update_raw(value);
175        self.ma2.update_raw(value);
176        self.ma3
177            .update_raw(2.0f64.mul_add(self.ma1.value, -self.ma2.value));
178
179        self.value = self.ma3.value;
180        self.count += 1;
181
182        if !self.initialized && self.count >= self.period {
183            self.initialized = true;
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use nautilus_model::{
191        data::{Bar, QuoteTick, TradeTick},
192        enums::PriceType,
193    };
194    use rstest::rstest;
195
196    use crate::{
197        average::{hma::HullMovingAverage, wma::MAX_PERIOD},
198        indicator::{Indicator, MovingAverage},
199        stubs::*,
200        testing::assert_approx_equal,
201    };
202
203    #[rstest]
204    fn test_hma_initialized(indicator_hma_10: HullMovingAverage) {
205        let display_str = format!("{indicator_hma_10}");
206        assert_eq!(display_str, "HullMovingAverage(10)");
207        assert_eq!(indicator_hma_10.period, 10);
208        assert!(!indicator_hma_10.initialized);
209        assert!(!indicator_hma_10.has_inputs);
210    }
211
212    #[rstest]
213    fn test_initialized_with_required_input(mut indicator_hma_10: HullMovingAverage) {
214        for i in 1..10 {
215            indicator_hma_10.update_raw(f64::from(i));
216        }
217        assert!(!indicator_hma_10.initialized);
218        indicator_hma_10.update_raw(10.0);
219        assert!(indicator_hma_10.initialized);
220    }
221
222    #[rstest]
223    fn test_value_with_one_input(mut indicator_hma_10: HullMovingAverage) {
224        indicator_hma_10.update_raw(1.0);
225        assert_eq!(indicator_hma_10.value, 1.0);
226    }
227
228    #[rstest]
229    fn test_value_with_three_inputs(mut indicator_hma_10: HullMovingAverage) {
230        indicator_hma_10.update_raw(1.0);
231        indicator_hma_10.update_raw(2.0);
232        indicator_hma_10.update_raw(3.0);
233        assert_approx_equal(indicator_hma_10.value, 1.82456140351);
234    }
235
236    #[rstest]
237    fn test_value_with_ten_inputs(mut indicator_hma_10: HullMovingAverage) {
238        indicator_hma_10.update_raw(1.00000);
239        indicator_hma_10.update_raw(1.00010);
240        indicator_hma_10.update_raw(1.00020);
241        indicator_hma_10.update_raw(1.00030);
242        indicator_hma_10.update_raw(1.00040);
243        indicator_hma_10.update_raw(1.00050);
244        indicator_hma_10.update_raw(1.00040);
245        indicator_hma_10.update_raw(1.00030);
246        indicator_hma_10.update_raw(1.00020);
247        indicator_hma_10.update_raw(1.00010);
248        indicator_hma_10.update_raw(1.00000);
249        assert_approx_equal(indicator_hma_10.value, 1.00014039282);
250    }
251
252    #[rstest]
253    fn test_handle_quote_tick(mut indicator_hma_10: HullMovingAverage, stub_quote: QuoteTick) {
254        indicator_hma_10.handle_quote(&stub_quote).unwrap();
255        assert_eq!(indicator_hma_10.value, 1501.0);
256    }
257
258    #[rstest]
259    fn test_handle_trade_tick(mut indicator_hma_10: HullMovingAverage, stub_trade: TradeTick) {
260        indicator_hma_10.handle_trade(&stub_trade);
261        assert_eq!(indicator_hma_10.value, 1500.0);
262    }
263
264    #[rstest]
265    fn test_handle_bar(
266        mut indicator_hma_10: HullMovingAverage,
267        bar_ethusdt_binance_minute_bid: Bar,
268    ) {
269        indicator_hma_10.handle_bar(&bar_ethusdt_binance_minute_bid);
270        assert_eq!(indicator_hma_10.value, 1522.0);
271        assert!(indicator_hma_10.has_inputs);
272        assert!(!indicator_hma_10.initialized);
273    }
274
275    #[rstest]
276    fn test_reset(mut indicator_hma_10: HullMovingAverage) {
277        indicator_hma_10.update_raw(1.0);
278        assert_eq!(indicator_hma_10.count, 1);
279        assert_eq!(indicator_hma_10.value, 1.0);
280        assert_eq!(indicator_hma_10.ma1.value, 1.0);
281        assert_eq!(indicator_hma_10.ma2.value, 1.0);
282        assert_eq!(indicator_hma_10.ma3.value, 1.0);
283        indicator_hma_10.reset();
284        assert_eq!(indicator_hma_10.value, 0.0);
285        assert_eq!(indicator_hma_10.count, 0);
286        assert_eq!(indicator_hma_10.ma1.value, 0.0);
287        assert_eq!(indicator_hma_10.ma2.value, 0.0);
288        assert_eq!(indicator_hma_10.ma3.value, 0.0);
289        assert!(!indicator_hma_10.has_inputs);
290        assert!(!indicator_hma_10.initialized);
291    }
292
293    #[rstest]
294    #[should_panic(expected = "HullMovingAverage: period must be > 0")]
295    fn test_new_with_zero_period_panics() {
296        let _ = HullMovingAverage::new(0, None);
297    }
298
299    #[rstest]
300    #[should_panic(expected = "exceeds MAX_PERIOD")]
301    fn test_new_with_period_above_max_panics() {
302        let _ = HullMovingAverage::new(MAX_PERIOD + 1, None);
303    }
304
305    #[rstest]
306    fn test_new_checked_with_period_above_max_errors() {
307        // The Python binding constructs through `new_checked`, so this is the path
308        // that has to return rather than panic.
309        assert!(HullMovingAverage::new_checked(MAX_PERIOD + 1, None).is_err());
310    }
311
312    #[rstest]
313    fn test_new_checked_with_zero_period_errors() {
314        assert!(HullMovingAverage::new_checked(0, None).is_err());
315    }
316
317    #[rstest]
318    #[case(1)]
319    #[case(5)]
320    #[case(128)]
321    #[case(MAX_PERIOD)]
322    fn test_new_with_positive_period_constructs(#[case] period: usize) {
323        let hma = HullMovingAverage::new(period, None);
324        assert_eq!(hma.period, period);
325        assert_eq!(hma.count(), 0);
326        assert!(!hma.initialized());
327    }
328
329    #[rstest]
330    #[case(PriceType::Bid)]
331    #[case(PriceType::Ask)]
332    #[case(PriceType::Last)]
333    fn test_price_type_propagates_to_inner_wmas(#[case] pt: PriceType) {
334        let hma = HullMovingAverage::new(10, Some(pt));
335        assert_eq!(hma.price_type, pt);
336        assert_eq!(hma.ma1.price_type, pt);
337        assert_eq!(hma.ma2.price_type, pt);
338        assert_eq!(hma.ma3.price_type, pt);
339    }
340
341    #[rstest]
342    fn test_price_type_defaults_to_last() {
343        let hma = HullMovingAverage::new(10, None);
344        assert_eq!(hma.price_type, PriceType::Last);
345        assert_eq!(hma.ma1.price_type, PriceType::Last);
346        assert_eq!(hma.ma2.price_type, PriceType::Last);
347        assert_eq!(hma.ma3.price_type, PriceType::Last);
348    }
349
350    #[rstest]
351    #[case(10.0)]
352    #[case(-5.5)]
353    #[case(42.42)]
354    #[case(0.0)]
355    fn period_one_degenerates_to_price(#[case] price: f64) {
356        let mut hma = HullMovingAverage::new(1, None);
357
358        for _ in 0..5 {
359            hma.update_raw(price);
360            assert!(
361                (hma.value() - price).abs() < f64::EPSILON,
362                "HMA(1) should equal last price {price}, was {}",
363                hma.value()
364            );
365            assert!(hma.initialized(), "HMA(1) must initialize immediately");
366        }
367    }
368
369    #[rstest]
370    #[case(3, 123.456_f64)]
371    #[case(13, 0.001_f64)]
372    fn constant_series_yields_constant_value(#[case] period: usize, #[case] constant: f64) {
373        let mut hma = HullMovingAverage::new(period, None);
374
375        for _ in 0..(period * 4) {
376            hma.update_raw(constant);
377            assert!(
378                (hma.value() - constant).abs() < 1e-12,
379                "Expected {constant}, was {}",
380                hma.value()
381            );
382        }
383        assert!(hma.initialized());
384    }
385
386    #[rstest]
387    fn alternating_extremes_bounded() {
388        let mut hma = HullMovingAverage::new(50, None);
389        let lows_highs = [0.0_f64, 1_000.0_f64];
390
391        for i in 0..200 {
392            let price = lows_highs[i & 1];
393            hma.update_raw(price);
394
395            let v = hma.value();
396            assert!((0.0..=1_000.0).contains(&v), "HMA out of bounds: {v}");
397        }
398    }
399
400    #[rstest]
401    #[case(2)]
402    #[case(17)]
403    #[case(128)]
404    fn initialized_boundary(#[case] period: usize) {
405        let mut hma = HullMovingAverage::new(period, None);
406
407        for i in 0..(period - 1) {
408            hma.update_raw(i as f64);
409            assert!(!hma.initialized(), "HMA wrongly initialized at count {i}");
410        }
411
412        hma.update_raw(0.0);
413        assert!(
414            hma.initialized(),
415            "HMA should initialize at exactly {period} ticks"
416        );
417    }
418
419    #[rstest]
420    #[case(2)]
421    #[case(3)]
422    fn small_periods_do_not_panic(#[case] period: usize) {
423        let mut hma = HullMovingAverage::new(period, None);
424        for i in 0..(period * 5) {
425            hma.update_raw(i as f64);
426        }
427        assert!(hma.initialized());
428    }
429
430    #[rstest]
431    fn negative_prices_supported() {
432        let mut hma = HullMovingAverage::new(10, None);
433        let prices = [-5.0, -4.0, -3.0, -2.5, -2.0, -1.5, -1.0, -0.5, 0.0, 0.5];
434
435        for &p in &prices {
436            hma.update_raw(p);
437            let v = hma.value();
438            assert!(
439                v.is_finite(),
440                "HMA produced a non-finite value {v} from negative prices"
441            );
442        }
443    }
444}