//! Stochastic Momentum Index (SMI). use std::collections::VecDeque; use crate::error::{Error, Result}; use crate::indicators::ema::Ema; use crate::ohlcv::Candle; use crate::traits::Indicator; /// William Blau's Stochastic Momentum Index — a doubly-smoothed, /// `±100`-bounded oscillator built from the close's distance to the centre /// of the recent high-low range. /// /// Over the lookback `period`, let `HH = max(high)`, `LL = min(low)`, /// `C = (HH + LL) / 2` and `R = HH - LL`. The raw displacement is /// `d_t = close_t - C_t`. Both `d` and `R` are smoothed twice with `EMA`s, /// then combined into the bounded reading: /// /// ```text /// D_smoothed = EMA(EMA(d, d_period), d2_period) /// HL_smoothed = EMA(EMA(R, d_period), d2_period) /// SMI = 100 · D_smoothed / (HL_smoothed / 2) /// ``` /// /// Blau's recommended defaults are `(period = 5, d = 3, d2 = 3)`. Wickra /// publishes the SMI value only; the optional signal `EMA(SMI, k)` is left /// to the consumer via `Chain` / their own `Ema`. /// /// # Example /// /// ``` /// use wickra_core::{Candle, Indicator, Smi}; /// /// let mut smi = Smi::new(5, 3, 3).unwrap(); /// let mut last = None; /// for i in 0..40 { /// let p = 100.0 + f64::from(i); /// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap(); /// last = smi.update(candle); /// } /// assert!(last.is_some()); /// ``` #[derive(Debug, Clone)] pub struct Smi { period: usize, d_period: usize, d2_period: usize, highs: VecDeque, lows: VecDeque, ema_d1: Ema, ema_d2: Ema, ema_r1: Ema, ema_r2: Ema, current: Option, } impl Smi { /// # Errors /// Returns [`Error::PeriodZero`] if any period is zero. pub fn new(period: usize, d_period: usize, d2_period: usize) -> Result { if period == 0 || d_period == 0 || d2_period == 0 { return Err(Error::PeriodZero); } Ok(Self { period, d_period, d2_period, highs: VecDeque::with_capacity(period), lows: VecDeque::with_capacity(period), ema_d1: Ema::new(d_period)?, ema_d2: Ema::new(d2_period)?, ema_r1: Ema::new(d_period)?, ema_r2: Ema::new(d2_period)?, current: None, }) } /// Blau's recommended defaults `(period = 5, d = 3, d2 = 3)`. pub fn classic() -> Self { Self::new(5, 3, 3).expect("classic SMI parameters are valid") } /// Configured `(period, d_period, d2_period)`. pub const fn periods(&self) -> (usize, usize, usize) { (self.period, self.d_period, self.d2_period) } } impl Indicator for Smi { type Input = Candle; type Output = f64; fn update(&mut self, candle: Candle) -> Option { if self.highs.len() == self.period { self.highs.pop_front(); self.lows.pop_front(); } self.highs.push_back(candle.high); self.lows.push_back(candle.low); if self.highs.len() < self.period { return None; } let hh = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); let ll = self.lows.iter().copied().fold(f64::INFINITY, f64::min); let center = f64::midpoint(hh, ll); let displacement = candle.close - center; let range = hh - ll; // Feed every EMA on every candle so both stacks warm in parallel — // gating the range stack behind the displacement stack would starve // it by one input. let d1 = self.ema_d1.update(displacement); let r1 = self.ema_r1.update(range); let d2 = d1.and_then(|x| self.ema_d2.update(x)); let r2 = r1.and_then(|x| self.ema_r2.update(x)); let (d2, r2) = (d2?, r2?); if r2 <= 0.0 { // Window where the smoothed range collapses to zero: the formula // is undefined. Hold the previous reading rather than emit inf. return self.current; } let value = 100.0 * d2 / (r2 / 2.0); self.current = Some(value); Some(value) } fn reset(&mut self) { self.highs.clear(); self.lows.clear(); self.ema_d1.reset(); self.ema_d2.reset(); self.ema_r1.reset(); self.ema_r2.reset(); self.current = None; } fn warmup_period(&self) -> usize { // The high-low window needs `period` candles; then both EMA stacks // need `d_period + d2_period - 1` more values to fully warm up. self.period + self.d_period + self.d2_period - 2 } fn is_ready(&self) -> bool { self.current.is_some() } fn name(&self) -> &'static str { "SMI" } } #[cfg(test)] mod tests { use super::*; use crate::traits::BatchExt; use approx::assert_relative_eq; fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle { Candle::new(close, high, low, close, 1.0, ts).unwrap() } #[test] fn rejects_zero_period() { assert!(matches!(Smi::new(0, 3, 3), Err(Error::PeriodZero))); assert!(matches!(Smi::new(5, 0, 3), Err(Error::PeriodZero))); assert!(matches!(Smi::new(5, 3, 0), Err(Error::PeriodZero))); } #[test] fn accessors_and_metadata() { let smi = Smi::new(5, 3, 3).unwrap(); assert_eq!(smi.periods(), (5, 3, 3)); assert_eq!(smi.warmup_period(), 9); assert_eq!(smi.name(), "SMI"); } #[test] fn classic_factory() { let smi = Smi::classic(); assert_eq!(smi.periods(), (5, 3, 3)); } #[test] fn close_at_high_pushes_toward_plus_100() { // Every candle's close equals its high in a rising series: the // displacement is at the top of the range every bar, so SMI sits in // the strongly positive region. After enough double-smoothing it // approaches the upper bound. let mut smi = Smi::classic(); let mut last = None; for i in 0..80 { let h = 100.0 + f64::from(i); let l = h - 2.0; last = smi.update(candle(h, l, h, i64::from(i))); } let v = last.expect("SMI is warm"); assert!( v > 50.0, "close-at-high series should drive SMI well above 0: {v}" ); } #[test] fn close_at_low_pushes_toward_minus_100() { let mut smi = Smi::classic(); let mut last = None; for i in 0..80 { let h = 100.0 - f64::from(i); let l = h - 2.0; last = smi.update(candle(h, l, l, i64::from(i))); } let v = last.expect("SMI is warm"); assert!( v < -50.0, "close-at-low series should drive SMI well below 0: {v}" ); } #[test] fn warmup_emits_first_value_at_warmup_period() { let mut smi = Smi::new(3, 2, 2).unwrap(); // period 3 + d 2 + d2 2 - 2 = 5. assert_eq!(smi.warmup_period(), 5); let mut got = None; for i in 0..5 { got = smi.update(candle(11.0, 9.0, 10.0, i)); } assert!(got.is_some()); } #[test] fn flat_close_yields_zero_displacement() { // Every close is exactly at the centre of the range -> displacement // is 0 every bar -> SMI converges to 0. let mut smi = Smi::classic(); let mut last = None; for i in 0..60 { // High and low straddle a constant close. last = smi.update(candle(11.0, 9.0, 10.0, i)); } let v = last.unwrap(); assert_relative_eq!(v, 0.0, epsilon = 1e-12); } #[test] fn batch_equals_streaming() { let candles: Vec = (0..80_i64) .map(|i| { let c = 100.0 + (i as f64 * 0.3).sin() * 8.0; candle(c + 1.0, c - 1.0, c, i) }) .collect(); let batch = Smi::classic().batch(&candles); let mut b = Smi::classic(); let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); assert_eq!(batch, streamed); } #[test] fn reset_clears_state() { let mut smi = Smi::classic(); for i in 0..40 { smi.update(candle(11.0, 9.0, 10.0, i)); } assert!(smi.is_ready()); smi.reset(); assert!(!smi.is_ready()); } #[test] fn zero_range_holds_previous_value() { // High == low on every bar -> instantaneous range is zero, the // EMA of (range / 2) settles to zero, so `r2 <= 0.0` after warmup // and the indicator must hold its previous value (None here, since // r2 was zero from the very first warm bar) rather than divide by // zero. let mut smi = Smi::new(3, 2, 2).unwrap(); // warmup_period = 3 + 2 + 2 - 2 = 5; feed warmup + 2 extra bars. for i in 0..7 { let v = smi.update(candle(10.0, 10.0, 10.0, i)); assert_eq!(v, None, "zero-range SMI must hold None, got {v:?}"); } } }