diff --git a/crates/wickra-core/src/indicators/hma.rs b/crates/wickra-core/src/indicators/hma.rs index 1cc3003f..dabdc6f4 100644 --- a/crates/wickra-core/src/indicators/hma.rs +++ b/crates/wickra-core/src/indicators/hma.rs @@ -45,8 +45,13 @@ impl Indicator for Hma { type Output = f64; fn update(&mut self, input: f64) -> Option { - let h = self.half_wma.update(input)?; - let f = self.full_wma.update(input)?; + // Feed both windowed WMAs on every input so they warm up in parallel. + // Gating `full_wma.update` behind `half_wma.update(...)?` would starve + // the longer WMA during the shorter one's warmup, delaying the first + // emission past `warmup_period()`. + let h = self.half_wma.update(input); + let f = self.full_wma.update(input); + let (h, f) = (h?, f?); let diff = 2.0 * h - f; self.smooth_wma.update(diff) } @@ -109,4 +114,45 @@ mod tests { fn rejects_zero_period() { assert!(Hma::new(0).is_err()); } + + #[test] + fn first_emission_matches_warmup_period() { + let prices: Vec = (1..=40).map(f64::from).collect(); + let mut hma = Hma::new(9).unwrap(); + let out = hma.batch(&prices); + let warmup = hma.warmup_period(); + assert_eq!(warmup, 11); + for (i, v) in out.iter().enumerate().take(warmup - 1) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!( + out[warmup - 1].is_some(), + "first HMA value must land at warmup_period - 1" + ); + } + + #[test] + fn matches_independent_wmas() { + // The two inner WMAs run as independent siblings on the price stream; + // HMA must equal feeding three standalone WMAs and combining them. + let prices: Vec = (1..=50) + .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0) + .collect(); + let mut hma = Hma::new(9).unwrap(); + let mut half = Wma::new(4).unwrap(); // (9 / 2).max(1) + let mut full = Wma::new(9).unwrap(); + let mut smooth = Wma::new(3).unwrap(); // round(sqrt(9)) + for &p in &prices { + let got = hma.update(p); + let want = match (half.update(p), full.update(p)) { + (Some(h), Some(f)) => smooth.update(2.0 * h - f), + _ => None, + }; + match (got, want) { + (None, None) => {} + (Some(a), Some(b)) => assert_relative_eq!(a, b, epsilon = 1e-9), + _ => panic!("HMA and the independent-WMA reference disagree on readiness"), + } + } + } } diff --git a/crates/wickra-core/src/indicators/keltner.rs b/crates/wickra-core/src/indicators/keltner.rs index c1dcd0ef..097206dd 100644 --- a/crates/wickra-core/src/indicators/keltner.rs +++ b/crates/wickra-core/src/indicators/keltner.rs @@ -59,8 +59,14 @@ impl Indicator for Keltner { type Output = KeltnerOutput; fn update(&mut self, candle: Candle) -> Option { - let mid = self.ema.update(candle.typical_price())?; - let atr = self.atr.update(candle)?; + // Feed both sub-indicators on every candle so they warm up in parallel. + // Gating `atr.update` behind `ema.update(...)?` would starve the ATR of + // every candle consumed during the EMA's warmup, delaying the first + // emission past `warmup_period()` and seeding the ATR over the wrong + // window. + let mid = self.ema.update(candle.typical_price()); + let atr = self.atr.update(candle); + let (mid, atr) = (mid?, atr?); Some(KeltnerOutput { upper: mid + self.multiplier * atr, middle: mid, @@ -152,4 +158,55 @@ mod tests { assert!(!k.is_ready()); assert_eq!(k.update(candles[0]), None); } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..60) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 1.0, base - 1.0, base) + }) + .collect(); + let mut k = Keltner::classic(); + let out = k.batch(&candles); + let warmup = k.warmup_period(); + assert_eq!(warmup, 20); + for (i, v) in out.iter().enumerate().take(warmup - 1) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!( + out[warmup - 1].is_some(), + "first KeltnerOutput must land at warmup_period - 1" + ); + } + + #[test] + fn matches_independent_ema_and_atr() { + // The EMA (on typical price) and the ATR (on the candle) run as + // independent siblings; Keltner must equal feeding two standalone + // instances and combining them once both are ready. + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.5, m - 1.5, m) + }) + .collect(); + let mut k = Keltner::classic(); + let mut ema = Ema::new(20).unwrap(); + let mut atr = Atr::new(10).unwrap(); + for (i, candle) in candles.iter().enumerate() { + let got = k.update(*candle); + let mid = ema.update(candle.typical_price()); + let a = atr.update(*candle); + match (mid, a) { + (Some(m), Some(av)) => { + let o = got.expect("Keltner emits once EMA and ATR are both ready"); + assert_relative_eq!(o.middle, m, epsilon = 1e-9); + assert_relative_eq!(o.upper, m + 2.0 * av, epsilon = 1e-9); + assert_relative_eq!(o.lower, m - 2.0 * av, epsilon = 1e-9); + } + _ => assert!(got.is_none(), "Keltner must be None until both ready (i={i})"), + } + } + } }