Skip to main content

wickra_core/indicators/
smi.rs

1//! Stochastic Momentum Index (SMI).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::ema::Ema;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// William Blau's Stochastic Momentum Index — a doubly-smoothed,
11/// `±100`-bounded oscillator built from the close's distance to the centre
12/// of the recent high-low range.
13///
14/// Over the lookback `period`, let `HH = max(high)`, `LL = min(low)`,
15/// `C = (HH + LL) / 2` and `R = HH - LL`. The raw displacement is
16/// `d_t = close_t - C_t`. Both `d` and `R` are smoothed twice with `EMA`s,
17/// then combined into the bounded reading:
18///
19/// ```text
20/// D_smoothed  = EMA(EMA(d, d_period), d2_period)
21/// HL_smoothed = EMA(EMA(R, d_period), d2_period)
22/// SMI         = 100 · D_smoothed / (HL_smoothed / 2)
23/// ```
24///
25/// Blau's recommended defaults are `(period = 5, d = 3, d2 = 3)`. Wickra
26/// publishes the SMI value only; the optional signal `EMA(SMI, k)` is left
27/// to the consumer via `Chain` / their own `Ema`.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Candle, Indicator, Smi};
33///
34/// let mut smi = Smi::new(5, 3, 3).unwrap();
35/// let mut last = None;
36/// for i in 0..40 {
37///     let p = 100.0 + f64::from(i);
38///     let candle = Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap();
39///     last = smi.update(candle);
40/// }
41/// assert!(last.is_some());
42/// ```
43#[derive(Debug, Clone)]
44pub struct Smi {
45    period: usize,
46    d_period: usize,
47    d2_period: usize,
48    highs: VecDeque<f64>,
49    lows: VecDeque<f64>,
50    ema_d1: Ema,
51    ema_d2: Ema,
52    ema_r1: Ema,
53    ema_r2: Ema,
54    current: Option<f64>,
55}
56
57impl Smi {
58    /// # Errors
59    /// Returns [`Error::PeriodZero`] if any period is zero.
60    pub fn new(period: usize, d_period: usize, d2_period: usize) -> Result<Self> {
61        if period == 0 || d_period == 0 || d2_period == 0 {
62            return Err(Error::PeriodZero);
63        }
64        Ok(Self {
65            period,
66            d_period,
67            d2_period,
68            highs: VecDeque::with_capacity(period),
69            lows: VecDeque::with_capacity(period),
70            ema_d1: Ema::new(d_period)?,
71            ema_d2: Ema::new(d2_period)?,
72            ema_r1: Ema::new(d_period)?,
73            ema_r2: Ema::new(d2_period)?,
74            current: None,
75        })
76    }
77
78    /// Blau's recommended defaults `(period = 5, d = 3, d2 = 3)`.
79    pub fn classic() -> Self {
80        Self::new(5, 3, 3).expect("classic SMI parameters are valid")
81    }
82
83    /// Configured `(period, d_period, d2_period)`.
84    pub const fn periods(&self) -> (usize, usize, usize) {
85        (self.period, self.d_period, self.d2_period)
86    }
87}
88
89impl Indicator for Smi {
90    type Input = Candle;
91    type Output = f64;
92
93    #[inline]
94    fn update(&mut self, candle: Candle) -> Option<f64> {
95        if self.highs.len() == self.period {
96            self.highs.pop_front();
97            self.lows.pop_front();
98        }
99        self.highs.push_back(candle.high);
100        self.lows.push_back(candle.low);
101        if self.highs.len() < self.period {
102            return None;
103        }
104        let hh = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
105        let ll = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
106        let center = f64::midpoint(hh, ll);
107        let displacement = candle.close - center;
108        let range = hh - ll;
109
110        // Feed every EMA on every candle so both stacks warm in parallel —
111        // gating the range stack behind the displacement stack would starve
112        // it by one input.
113        let d1 = self.ema_d1.update(displacement);
114        let r1 = self.ema_r1.update(range);
115        let d2 = d1.and_then(|x| self.ema_d2.update(x));
116        let r2 = r1.and_then(|x| self.ema_r2.update(x));
117        let (d2, r2) = (d2?, r2?);
118
119        if r2 <= 0.0 {
120            // Window where the smoothed range collapses to zero: the formula
121            // is undefined. Hold the previous reading rather than emit inf.
122            return self.current;
123        }
124        let value = 100.0 * d2 / (r2 / 2.0);
125        self.current = Some(value);
126        Some(value)
127    }
128
129    fn reset(&mut self) {
130        self.highs.clear();
131        self.lows.clear();
132        self.ema_d1.reset();
133        self.ema_d2.reset();
134        self.ema_r1.reset();
135        self.ema_r2.reset();
136        self.current = None;
137    }
138
139    #[inline]
140    fn warmup_period(&self) -> usize {
141        // The high-low window needs `period` candles; then both EMA stacks
142        // need `d_period + d2_period - 1` more values to fully warm up.
143        self.period + self.d_period + self.d2_period - 2
144    }
145
146    #[inline]
147    fn is_ready(&self) -> bool {
148        self.current.is_some()
149    }
150
151    #[inline]
152    fn name(&self) -> &'static str {
153        "SMI"
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::traits::BatchExt;
161    use approx::assert_relative_eq;
162
163    fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
164        Candle::new(close, high, low, close, 1.0, ts).unwrap()
165    }
166
167    #[test]
168    fn rejects_zero_period() {
169        assert!(matches!(Smi::new(0, 3, 3), Err(Error::PeriodZero)));
170        assert!(matches!(Smi::new(5, 0, 3), Err(Error::PeriodZero)));
171        assert!(matches!(Smi::new(5, 3, 0), Err(Error::PeriodZero)));
172    }
173
174    #[test]
175    fn accessors_and_metadata() {
176        let smi = Smi::new(5, 3, 3).unwrap();
177        assert_eq!(smi.periods(), (5, 3, 3));
178        assert_eq!(smi.warmup_period(), 9);
179        assert_eq!(smi.name(), "SMI");
180    }
181
182    #[test]
183    fn classic_factory() {
184        let smi = Smi::classic();
185        assert_eq!(smi.periods(), (5, 3, 3));
186    }
187
188    #[test]
189    fn close_at_high_pushes_toward_plus_100() {
190        // Every candle's close equals its high in a rising series: the
191        // displacement is at the top of the range every bar, so SMI sits in
192        // the strongly positive region. After enough double-smoothing it
193        // approaches the upper bound.
194        let mut smi = Smi::classic();
195        let mut last = None;
196        for i in 0..80 {
197            let h = 100.0 + f64::from(i);
198            let l = h - 2.0;
199            last = smi.update(candle(h, l, h, i64::from(i)));
200        }
201        let v = last.expect("SMI is warm");
202        assert!(
203            v > 50.0,
204            "close-at-high series should drive SMI well above 0: {v}"
205        );
206    }
207
208    #[test]
209    fn close_at_low_pushes_toward_minus_100() {
210        let mut smi = Smi::classic();
211        let mut last = None;
212        for i in 0..80 {
213            let h = 100.0 - f64::from(i);
214            let l = h - 2.0;
215            last = smi.update(candle(h, l, l, i64::from(i)));
216        }
217        let v = last.expect("SMI is warm");
218        assert!(
219            v < -50.0,
220            "close-at-low series should drive SMI well below 0: {v}"
221        );
222    }
223
224    #[test]
225    fn warmup_emits_first_value_at_warmup_period() {
226        let mut smi = Smi::new(3, 2, 2).unwrap();
227        // period 3 + d 2 + d2 2 - 2 = 5.
228        assert_eq!(smi.warmup_period(), 5);
229        let mut got = None;
230        for i in 0..5 {
231            got = smi.update(candle(11.0, 9.0, 10.0, i));
232        }
233        assert!(got.is_some());
234    }
235
236    #[test]
237    fn flat_close_yields_zero_displacement() {
238        // Every close is exactly at the centre of the range -> displacement
239        // is 0 every bar -> SMI converges to 0.
240        let mut smi = Smi::classic();
241        let mut last = None;
242        for i in 0..60 {
243            // High and low straddle a constant close.
244            last = smi.update(candle(11.0, 9.0, 10.0, i));
245        }
246        let v = last.unwrap();
247        assert_relative_eq!(v, 0.0, epsilon = 1e-12);
248    }
249
250    #[test]
251    fn batch_equals_streaming() {
252        let candles: Vec<Candle> = (0..80_i64)
253            .map(|i| {
254                let c = 100.0 + (i as f64 * 0.3).sin() * 8.0;
255                candle(c + 1.0, c - 1.0, c, i)
256            })
257            .collect();
258        let batch = Smi::classic().batch(&candles);
259        let mut b = Smi::classic();
260        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
261        assert_eq!(batch, streamed);
262    }
263
264    #[test]
265    fn reset_clears_state() {
266        let mut smi = Smi::classic();
267        for i in 0..40 {
268            smi.update(candle(11.0, 9.0, 10.0, i));
269        }
270        assert!(smi.is_ready());
271        smi.reset();
272        assert!(!smi.is_ready());
273    }
274
275    #[test]
276    fn zero_range_holds_previous_value() {
277        // High == low on every bar -> instantaneous range is zero, the
278        // EMA of (range / 2) settles to zero, so `r2 <= 0.0` after warmup
279        // and the indicator must hold its previous value (None here, since
280        // r2 was zero from the very first warm bar) rather than divide by
281        // zero.
282        let mut smi = Smi::new(3, 2, 2).unwrap();
283        // warmup_period = 3 + 2 + 2 - 2 = 5; feed warmup + 2 extra bars.
284        for i in 0..7 {
285            let v = smi.update(candle(10.0, 10.0, 10.0, i));
286            assert_eq!(v, None, "zero-range SMI must hold None, got {v:?}");
287        }
288    }
289}