diff --git a/crates/wickra-core/src/indicators/aroon.rs b/crates/wickra-core/src/indicators/aroon.rs index 060fefb7..dd43c6e9 100644 --- a/crates/wickra-core/src/indicators/aroon.rs +++ b/crates/wickra-core/src/indicators/aroon.rs @@ -153,4 +153,17 @@ mod tests { assert!((0.0..=100.0).contains(&o.down)); } } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=20) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut a = Aroon::new(14).unwrap(); + a.batch(&candles); + assert!(a.is_ready()); + a.reset(); + assert!(!a.is_ready()); + assert_eq!(a.update(candles[0]), None); + } } diff --git a/crates/wickra-core/src/indicators/atr.rs b/crates/wickra-core/src/indicators/atr.rs index 4ab06e86..621b0336 100644 --- a/crates/wickra-core/src/indicators/atr.rs +++ b/crates/wickra-core/src/indicators/atr.rs @@ -100,6 +100,36 @@ mod tests { Candle::new(cl, h, l, cl, 1.0, 0).unwrap() } + /// Independent reference: Wilder ATR computed straight from the definition. + fn atr_naive(hlc: &[(f64, f64, f64)], period: usize) -> Vec> { + let n = period as f64; + let mut out = Vec::with_capacity(hlc.len()); + let mut trs: Vec = Vec::new(); + let mut avg: Option = None; + let mut prev_close: Option = None; + for &(h, l, cl) in hlc { + let tr = match prev_close { + None => h - l, + Some(pc) => (h - l).max((h - pc).abs()).max((l - pc).abs()), + }; + prev_close = Some(cl); + if let Some(a) = avg { + let na = (a * (n - 1.0) + tr) / n; + avg = Some(na); + out.push(Some(na)); + } else { + trs.push(tr); + if trs.len() == period { + avg = Some(trs.iter().sum::() / n); + out.push(avg); + } else { + out.push(None); + } + } + } + out + } + #[test] fn rejects_zero_period() { assert!(matches!(Atr::new(0), Err(Error::PeriodZero))); @@ -187,4 +217,37 @@ mod tests { assert!(v >= 0.0, "ATR must be non-negative: {v}"); } } + + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(48))] + #[test] + fn atr_matches_naive( + period in 1usize..15, + bars in proptest::collection::vec( + (10.0_f64..1000.0, 0.0_f64..50.0, 0.0_f64..1.0), + 0..120, + ), + ) { + // bars: (low, range, close_fraction) -> a valid OHLC candle. + let hlc: Vec<(f64, f64, f64)> = bars + .iter() + .map(|&(low, range, frac)| (low + range, low, low + range * frac)) + .collect(); + let candles: Vec = hlc.iter().map(|&(h, l, cl)| c(h, l, cl)).collect(); + let mut atr = Atr::new(period).unwrap(); + let got = atr.batch(&candles); + let want = atr_naive(&hlc, period); + proptest::prop_assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want.iter()) { + match (g, w) { + (None, None) => {} + (Some(a), Some(b)) => proptest::prop_assert!( + (a - b).abs() <= 1e-9 * a.abs().max(1.0), + "got={a} want={b}" + ), + _ => proptest::prop_assert!(false, "warmup mismatch"), + } + } + } + } } diff --git a/crates/wickra-core/src/indicators/awesome_oscillator.rs b/crates/wickra-core/src/indicators/awesome_oscillator.rs index d04aa663..5e19ac5e 100644 --- a/crates/wickra-core/src/indicators/awesome_oscillator.rs +++ b/crates/wickra-core/src/indicators/awesome_oscillator.rs @@ -114,4 +114,17 @@ mod tests { candles.iter().map(|x| b.update(*x)).collect::>() ); } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..50) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut ao = AwesomeOscillator::classic(); + ao.batch(&candles); + assert!(ao.is_ready()); + ao.reset(); + assert!(!ao.is_ready()); + assert_eq!(ao.update(candles[0]), None); + } } diff --git a/crates/wickra-core/src/indicators/bollinger.rs b/crates/wickra-core/src/indicators/bollinger.rs index 37f89bdb..2e0ee91f 100644 --- a/crates/wickra-core/src/indicators/bollinger.rs +++ b/crates/wickra-core/src/indicators/bollinger.rs @@ -240,4 +240,17 @@ mod tests { bb.reset(); assert!(!bb.is_ready()); } + + #[test] + fn ignores_non_finite_input() { + let mut bb = BollingerBands::new(5, 2.0).unwrap(); + let ready = bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let last = ready.last().unwrap().unwrap(); + // Non-finite inputs return the current bands without mutating the window. + assert_eq!(bb.update(f64::NAN).unwrap(), last); + assert_eq!(bb.update(f64::INFINITY).unwrap(), last); + // The window still holds 1..=5, so a real input slides it to 2..=6. + let after = bb.update(6.0).unwrap(); + assert_relative_eq!(after.middle, (2.0 + 3.0 + 4.0 + 5.0 + 6.0) / 5.0, epsilon = 1e-12); + } } diff --git a/crates/wickra-core/src/indicators/donchian.rs b/crates/wickra-core/src/indicators/donchian.rs index 8c4438ed..8627a816 100644 --- a/crates/wickra-core/src/indicators/donchian.rs +++ b/crates/wickra-core/src/indicators/donchian.rs @@ -138,4 +138,17 @@ mod tests { fn rejects_zero_period() { assert!(Donchian::new(0).is_err()); } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut d = Donchian::new(5).unwrap(); + d.batch(&candles); + assert!(d.is_ready()); + d.reset(); + assert!(!d.is_ready()); + assert_eq!(d.update(candles[0]), None); + } } diff --git a/crates/wickra-core/src/indicators/ema.rs b/crates/wickra-core/src/indicators/ema.rs index b768e95c..f35ac639 100644 --- a/crates/wickra-core/src/indicators/ema.rs +++ b/crates/wickra-core/src/indicators/ema.rs @@ -126,6 +126,27 @@ mod tests { use crate::traits::BatchExt; use approx::assert_relative_eq; + /// Independent reference: SMA-seeded EMA computed straight from the definition. + fn ema_naive(prices: &[f64], period: usize) -> Vec> { + let alpha = 2.0 / (period as f64 + 1.0); + let mut out = Vec::with_capacity(prices.len()); + let mut state: Option = None; + for (i, &p) in prices.iter().enumerate() { + if let Some(prev) = state { + let v = alpha * p + (1.0 - alpha) * prev; + state = Some(v); + out.push(Some(v)); + } else if i + 1 == period { + let seed = prices[..period].iter().sum::() / period as f64; + state = Some(seed); + out.push(Some(seed)); + } else { + out.push(None); + } + } + out + } + #[test] fn new_rejects_zero_period() { assert!(matches!(Ema::new(0), Err(Error::PeriodZero))); @@ -221,4 +242,28 @@ mod tests { assert_eq!(ema.update(f64::NAN), before); assert_eq!(ema.update(f64::INFINITY), before); } + + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(48))] + #[test] + fn ema_matches_naive( + period in 1usize..20, + prices in proptest::collection::vec(-1000.0_f64..1000.0, 0..150), + ) { + let mut ema = Ema::new(period).unwrap(); + let got = ema.batch(&prices); + let want = ema_naive(&prices, period); + proptest::prop_assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want.iter()) { + match (g, w) { + (None, None) => {} + (Some(a), Some(b)) => proptest::prop_assert!( + (a - b).abs() <= 1e-9 * a.abs().max(1.0), + "got={a} want={b}" + ), + _ => proptest::prop_assert!(false, "warmup mismatch"), + } + } + } + } } diff --git a/crates/wickra-core/src/indicators/kama.rs b/crates/wickra-core/src/indicators/kama.rs index 087e1866..6aa40c66 100644 --- a/crates/wickra-core/src/indicators/kama.rs +++ b/crates/wickra-core/src/indicators/kama.rs @@ -154,4 +154,15 @@ mod tests { k.reset(); assert!(!k.is_ready()); } + + #[test] + fn ignores_non_finite_input() { + let mut k = Kama::classic(); + k.batch(&(1..=40).map(f64::from).collect::>()); + let before = k.update(41.0); + assert!(before.is_some()); + // Non-finite inputs return the last state without sliding the window. + assert_eq!(k.update(f64::NAN), before); + assert_eq!(k.update(f64::INFINITY), before); + } } diff --git a/crates/wickra-core/src/indicators/keltner.rs b/crates/wickra-core/src/indicators/keltner.rs index 9cc7cf92..c1dcd0ef 100644 --- a/crates/wickra-core/src/indicators/keltner.rs +++ b/crates/wickra-core/src/indicators/keltner.rs @@ -139,4 +139,17 @@ mod tests { assert!(Keltner::new(20, 10, 0.0).is_err()); assert!(Keltner::new(20, 10, -1.0).is_err()); } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..50) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut k = Keltner::classic(); + k.batch(&candles); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + assert_eq!(k.update(candles[0]), None); + } } diff --git a/crates/wickra-core/src/indicators/macd.rs b/crates/wickra-core/src/indicators/macd.rs index 3cabc1b4..64d365b6 100644 --- a/crates/wickra-core/src/indicators/macd.rs +++ b/crates/wickra-core/src/indicators/macd.rs @@ -227,4 +227,16 @@ mod tests { assert!(!macd.is_ready()); assert_eq!(macd.update(1.0), None); } + + #[test] + fn ignores_non_finite_input() { + let mut macd = MacdIndicator::classic(); + macd.batch(&(1..=80).map(f64::from).collect::>()); + let before = macd.value(); + assert!(before.is_some()); + // Non-finite inputs return the last value without advancing any EMA. + assert_eq!(macd.update(f64::NAN), before); + assert_eq!(macd.update(f64::INFINITY), before); + assert_eq!(macd.value(), before); + } } diff --git a/crates/wickra-core/src/indicators/rsi.rs b/crates/wickra-core/src/indicators/rsi.rs index df2861e8..69612bbc 100644 --- a/crates/wickra-core/src/indicators/rsi.rs +++ b/crates/wickra-core/src/indicators/rsi.rs @@ -140,6 +140,50 @@ mod tests { use crate::traits::BatchExt; use approx::assert_relative_eq; + /// Independent reference: Wilder RSI computed straight from the definition. + fn rsi_naive(prices: &[f64], period: usize) -> Vec> { + let n = period as f64; + let mut out = vec![None; prices.len()]; + let mut gains: Vec = Vec::new(); + let mut losses: Vec = Vec::new(); + let mut avg_gain: Option = None; + let mut avg_loss: Option = None; + let rsi_val = |ag: f64, al: f64| -> f64 { + if al == 0.0 { + if ag == 0.0 { + 50.0 + } else { + 100.0 + } + } else { + 100.0 - 100.0 / (1.0 + ag / al) + } + }; + for i in 1..prices.len() { + let diff = prices[i] - prices[i - 1]; + let gain = if diff > 0.0 { diff } else { 0.0 }; + let loss = if diff < 0.0 { -diff } else { 0.0 }; + if let (Some(ag), Some(al)) = (avg_gain, avg_loss) { + let nag = (ag * (n - 1.0) + gain) / n; + let nal = (al * (n - 1.0) + loss) / n; + avg_gain = Some(nag); + avg_loss = Some(nal); + out[i] = Some(rsi_val(nag, nal)); + } else { + gains.push(gain); + losses.push(loss); + if gains.len() == period { + let ag = gains.iter().sum::() / n; + let al = losses.iter().sum::() / n; + avg_gain = Some(ag); + avg_loss = Some(al); + out[i] = Some(rsi_val(ag, al)); + } + } + } + out + } + #[test] fn new_rejects_zero_period() { assert!(matches!(Rsi::new(0), Err(Error::PeriodZero))); @@ -244,4 +288,39 @@ mod tests { prices.iter().map(|p| b.update(*p)).collect::>() ); } + + #[test] + fn ignores_non_finite_input() { + let mut rsi = Rsi::new(3).unwrap(); + rsi.batch(&[1.0, 2.0, 3.0, 4.0]); + let before = rsi.value(); + assert!(before.is_some()); + assert_eq!(rsi.update(f64::NAN), before); + assert_eq!(rsi.update(f64::INFINITY), before); + assert_eq!(rsi.value(), before); + } + + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(48))] + #[test] + fn rsi_matches_naive( + period in 1usize..20, + prices in proptest::collection::vec(1.0_f64..1000.0, 0..150), + ) { + let mut rsi = Rsi::new(period).unwrap(); + let got = rsi.batch(&prices); + let want = rsi_naive(&prices, period); + proptest::prop_assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want.iter()) { + match (g, w) { + (None, None) => {} + (Some(a), Some(b)) => proptest::prop_assert!( + (a - b).abs() < 1e-7, + "got={a} want={b}" + ), + _ => proptest::prop_assert!(false, "warmup mismatch"), + } + } + } + } } diff --git a/crates/wickra-core/src/indicators/vwap.rs b/crates/wickra-core/src/indicators/vwap.rs index a2e2301a..23b95b2e 100644 --- a/crates/wickra-core/src/indicators/vwap.rs +++ b/crates/wickra-core/src/indicators/vwap.rs @@ -210,4 +210,26 @@ mod tests { fn rolling_rejects_zero_period() { assert!(RollingVwap::new(0).is_err()); } + + #[test] + fn cumulative_reset_clears_state() { + let candles = vec![c(10.0, 1.0), c(20.0, 1.0), c(30.0, 1.0)]; + let mut v = Vwap::new(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.value(), None); + } + + #[test] + fn rolling_reset_clears_state() { + let candles: Vec = (1..=10).map(|i| c(f64::from(i), 1.0)).collect(); + let mut v = RollingVwap::new(5).unwrap(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.update(candles[0]), None); + } } diff --git a/crates/wickra-core/src/indicators/williams_r.rs b/crates/wickra-core/src/indicators/williams_r.rs index e2f087bf..48c59e5a 100644 --- a/crates/wickra-core/src/indicators/williams_r.rs +++ b/crates/wickra-core/src/indicators/williams_r.rs @@ -138,4 +138,17 @@ mod tests { fn rejects_zero_period() { assert!(WilliamsR::new(0).is_err()); } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut w = WilliamsR::new(5).unwrap(); + w.batch(&candles); + assert!(w.is_ready()); + w.reset(); + assert!(!w.is_ready()); + assert_eq!(w.update(candles[0]), None); + } } diff --git a/crates/wickra-core/src/indicators/wma.rs b/crates/wickra-core/src/indicators/wma.rs index 96a85a8f..d66df301 100644 --- a/crates/wickra-core/src/indicators/wma.rs +++ b/crates/wickra-core/src/indicators/wma.rs @@ -203,6 +203,23 @@ mod tests { ); } + #[test] + fn ignores_non_finite_input_but_keeps_state() { + let mut wma = Wma::new(3).unwrap(); + wma.update(1.0); + wma.update(2.0); + let ready = wma.update(3.0).expect("WMA(3) ready after three inputs"); + // Non-finite inputs return the last value without mutating the window. + assert_eq!(wma.update(f64::NAN), Some(ready)); + assert_eq!(wma.update(f64::INFINITY), Some(ready)); + // The window still holds 1, 2, 3 -> next real input slides it to 2, 3, 4. + assert_relative_eq!( + wma.update(4.0).unwrap(), + (2.0 * 1.0 + 3.0 * 2.0 + 4.0 * 3.0) / 6.0, + epsilon = 1e-12 + ); + } + proptest::proptest! { #![proptest_config(proptest::test_runner::Config::with_cases(48))] #[test]