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::WeightedMovingAverage,
26 indicator::{Indicator, MovingAverage},
27 support::MAX_PERIOD,
28};
29
30#[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 #[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 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 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 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}