A5: feed Keltner and HMA sibling sub-indicators in parallel

Keltner::update gated atr.update behind ema.update(...)? and Hma::update
gated full_wma.update behind half_wma.update(...)?. The ? short-circuit
starved the trailing sibling of every candle consumed during the leading
one's warmup, so warmup_period() understated the true first emission
(Keltner classic: 29 instead of 20; HMA(9): 14 instead of 11) and
Keltner's ATR seeded over the wrong window.

Both now feed every sub-indicator unconditionally and gate only the
output, matching the MACD / Awesome Oscillator pattern. warmup_period()
is now exact. Adds first-emission tests and cross-checks against
independent EMA+ATR (Keltner) and independent WMAs (HMA).
This commit is contained in:
kingchenc
2026-05-22 03:37:40 +02:00
parent 8aa480101e
commit 014e9afa51
2 changed files with 107 additions and 4 deletions
+48 -2
View File
@@ -45,8 +45,13 @@ impl Indicator for Hma {
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
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<f64> = (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<f64> = (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"),
}
}
}
}
+59 -2
View File
@@ -59,8 +59,14 @@ impl Indicator for Keltner {
type Output = KeltnerOutput;
fn update(&mut self, candle: Candle) -> Option<KeltnerOutput> {
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<Candle> = (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<Candle> = (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})"),
}
}
}
}