nautilus_indicators/average/
hma.rs1use 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#[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 #[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 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 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 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}