Skip to content

Commit 7caf1a5

Browse files
authored
Improve HMA indicator parity with Cython (#2648)
1 parent ce13903 commit 7caf1a5

File tree

1 file changed

+161
-15
lines changed
  • crates/indicators/src/average

1 file changed

+161
-15
lines changed

crates/indicators/src/average/hma.rs

Lines changed: 161 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -88,31 +88,40 @@ impl Indicator for HullMovingAverage {
8888
}
8989
}
9090

91-
fn _get_weights(size: usize) -> Vec<f64> {
92-
let mut weights: Vec<f64> = (1..=size).map(|x| x as f64).collect();
93-
let divisor: f64 = weights.iter().sum();
94-
weights = weights.iter().map(|x| x / divisor).collect();
95-
weights
91+
fn get_weights(size: usize) -> Vec<f64> {
92+
let mut w: Vec<f64> = (1..=size).map(|x| x as f64).collect();
93+
let divisor: f64 = w.iter().sum();
94+
for v in &mut w {
95+
*v /= divisor;
96+
}
97+
w
9698
}
9799

98100
impl HullMovingAverage {
99101
/// Creates a new [`HullMovingAverage`] instance.
102+
///
103+
/// # Panics
104+
///
105+
/// Panics if `period` is not positive (> 0).
100106
#[must_use]
101107
pub fn new(period: usize, price_type: Option<PriceType>) -> Self {
102-
let period_halved = period / 2;
103-
let period_sqrt = (period as f64).sqrt() as usize;
108+
assert!(
109+
period > 0,
110+
"HullMovingAverage: period must be > 0 (received {period})"
111+
);
112+
113+
let half = usize::max(1, period / 2);
114+
let root = usize::max(1, (period as f64).sqrt() as usize);
104115

105-
let w1 = _get_weights(period_halved);
106-
let w2 = _get_weights(period);
107-
let w3 = _get_weights(period_sqrt);
116+
let pt = price_type.unwrap_or(PriceType::Last);
108117

109-
let ma1 = WeightedMovingAverage::new(period_halved, w1, price_type);
110-
let ma2 = WeightedMovingAverage::new(period, w2, price_type);
111-
let ma3 = WeightedMovingAverage::new(period_sqrt, w3, price_type);
118+
let ma1 = WeightedMovingAverage::new(half, get_weights(half), Some(pt));
119+
let ma2 = WeightedMovingAverage::new(period, get_weights(period), Some(pt));
120+
let ma3 = WeightedMovingAverage::new(root, get_weights(root), Some(pt));
112121

113122
Self {
114123
period,
115-
price_type: price_type.unwrap_or(PriceType::Last),
124+
price_type: pt,
116125
value: 0.0,
117126
count: 0,
118127
has_inputs: false,
@@ -158,7 +167,10 @@ impl MovingAverage for HullMovingAverage {
158167
////////////////////////////////////////////////////////////////////////////////
159168
#[cfg(test)]
160169
mod tests {
161-
use nautilus_model::data::{Bar, QuoteTick, TradeTick};
170+
use nautilus_model::{
171+
data::{Bar, QuoteTick, TradeTick},
172+
enums::PriceType,
173+
};
162174
use rstest::rstest;
163175

164176
use crate::{
@@ -256,4 +268,138 @@ mod tests {
256268
assert!(!indicator_hma_10.has_inputs);
257269
assert!(!indicator_hma_10.initialized);
258270
}
271+
272+
#[rstest]
273+
#[should_panic(expected = "HullMovingAverage: period must be > 0")]
274+
fn test_new_with_zero_period_panics() {
275+
let _ = HullMovingAverage::new(0, None);
276+
}
277+
278+
#[rstest]
279+
#[case(1)]
280+
#[case(5)]
281+
#[case(128)]
282+
#[case(10_000)]
283+
fn test_new_with_positive_period_constructs(#[case] period: usize) {
284+
let hma = HullMovingAverage::new(period, None);
285+
assert_eq!(hma.period, period);
286+
assert_eq!(hma.count(), 0);
287+
assert!(!hma.initialized());
288+
}
289+
290+
#[rstest]
291+
#[case(PriceType::Bid)]
292+
#[case(PriceType::Ask)]
293+
#[case(PriceType::Last)]
294+
fn test_price_type_propagates_to_inner_wmas(#[case] pt: PriceType) {
295+
let hma = HullMovingAverage::new(10, Some(pt));
296+
assert_eq!(hma.price_type, pt);
297+
assert_eq!(hma.ma1.price_type, pt);
298+
assert_eq!(hma.ma2.price_type, pt);
299+
assert_eq!(hma.ma3.price_type, pt);
300+
}
301+
302+
#[rstest]
303+
fn test_price_type_defaults_to_last() {
304+
let hma = HullMovingAverage::new(10, None);
305+
assert_eq!(hma.price_type, PriceType::Last);
306+
assert_eq!(hma.ma1.price_type, PriceType::Last);
307+
assert_eq!(hma.ma2.price_type, PriceType::Last);
308+
assert_eq!(hma.ma3.price_type, PriceType::Last);
309+
}
310+
311+
#[rstest]
312+
#[case(10.0)]
313+
#[case(-5.5)]
314+
#[case(42.42)]
315+
#[case(0.0)]
316+
fn period_one_degenerates_to_price(#[case] price: f64) {
317+
let mut hma = HullMovingAverage::new(1, None);
318+
319+
for _ in 0..5 {
320+
hma.update_raw(price);
321+
assert!(
322+
(hma.value() - price).abs() < f64::EPSILON,
323+
"HMA(1) should equal last price {price}, got {}",
324+
hma.value()
325+
);
326+
assert!(hma.initialized(), "HMA(1) must initialise immediately");
327+
}
328+
}
329+
330+
#[rstest]
331+
#[case(3, 123.456_f64)]
332+
#[case(13, 0.001_f64)]
333+
fn constant_series_yields_constant_value(#[case] period: usize, #[case] constant: f64) {
334+
let mut hma = HullMovingAverage::new(period, None);
335+
336+
for _ in 0..(period * 4) {
337+
hma.update_raw(constant);
338+
assert!(
339+
(hma.value() - constant).abs() < 1e-12,
340+
"Expected {constant}, got {}",
341+
hma.value()
342+
);
343+
}
344+
assert!(hma.initialized());
345+
}
346+
347+
#[rstest]
348+
fn alternating_extremes_bounded() {
349+
let mut hma = HullMovingAverage::new(50, None);
350+
let lows_highs = [0.0_f64, 1_000.0_f64];
351+
352+
for i in 0..200 {
353+
let price = lows_highs[i & 1];
354+
hma.update_raw(price);
355+
356+
let v = hma.value();
357+
assert!((0.0..=1_000.0).contains(&v), "HMA out of bounds: {v}");
358+
}
359+
}
360+
361+
#[rstest]
362+
#[case(2)]
363+
#[case(17)]
364+
#[case(128)]
365+
fn initialized_boundary(#[case] period: usize) {
366+
let mut hma = HullMovingAverage::new(period, None);
367+
368+
for i in 0..(period - 1) {
369+
hma.update_raw(i as f64);
370+
assert!(!hma.initialized(), "HMA wrongly initialised at count {i}");
371+
}
372+
373+
hma.update_raw(0.0);
374+
assert!(
375+
hma.initialized(),
376+
"HMA should initialise at exactly {period} ticks"
377+
);
378+
}
379+
380+
#[rstest]
381+
#[case(2)]
382+
#[case(3)]
383+
fn small_periods_do_not_panic(#[case] period: usize) {
384+
let mut hma = HullMovingAverage::new(period, None);
385+
for i in 0..(period * 5) {
386+
hma.update_raw(i as f64);
387+
}
388+
assert!(hma.initialized());
389+
}
390+
391+
#[rstest]
392+
fn negative_prices_supported() {
393+
let mut hma = HullMovingAverage::new(10, None);
394+
let prices = [-5.0, -4.0, -3.0, -2.5, -2.0, -1.5, -1.0, -0.5, 0.0, 0.5];
395+
396+
for &p in &prices {
397+
hma.update_raw(p);
398+
let v = hma.value();
399+
assert!(
400+
v.is_finite(),
401+
"HMA produced a non-finite value {v} from negative prices"
402+
);
403+
}
404+
}
259405
}

0 commit comments

Comments
 (0)