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