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:
@@ -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,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})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user