Skip to main content

kestrel_chartkit/indicator/
smi.rs

1use std::collections::{HashMap, VecDeque};
2
3use crate::model::Bar;
4
5use super::smoothing::Ema;
6use super::{Indicator, IndicatorOutput};
7
8/// Stochastic Momentum Index: where the close sits relative to the *midpoint* of the recent
9/// high-low range, double-smoothed.
10///
11/// Over the last `len` bars, `HH` is the highest high and `LL` the lowest low. Two series are
12/// formed and each smoothed twice with exponential averages of `smooth_1` and then `smooth_2`:
13///
14/// ```text
15/// distance = close - (HH + LL) / 2
16/// range    = HH - LL
17/// SMI      = 200 * smoothed(distance) / smoothed(range)
18/// ```
19///
20/// The factor 200 follows from the halved range in the denominator: an unsmoothed close at the
21/// high gives `distance = range/2` and therefore `+100`, at the low `-100`. Double smoothing can
22/// carry the published value slightly past those marks, which is left as it comes out rather than
23/// clipped.
24///
25/// This is not the Stochastic Oscillator, which measures against the *low* of the range and lives
26/// in `0..100`. It is also not Stochastic RSI, which runs the same idea over an RSI series, nor
27/// the SMI Ergodic/TSI family, which double-smooths price *changes* rather than the position in a
28/// range. Same three letters, different measurements.
29///
30/// Per-bar outputs, all index points in roughly `-100..=100`:
31/// - `value`: the SMI line.
32/// - `extra["signal"]`: `Ema(signal_len)` over the published SMI values, present only from the
33///   `signal_len`-th of them on.
34///
35/// A window whose high equals its low has no range to place the close in; the line is `0` there
36/// by convention rather than a division by zero.
37///
38/// Both smoothing stages run from the first full window on: the first is seeded with the first
39/// `distance` (respectively `range`) value, the second with the first output of the first, and
40/// each takes every value the stage before it produces — the second stage does not wait for the
41/// first to be published. Publication waits instead: the line appears once
42/// `smooth_1 + smooth_2 - 1` windows have passed through both stages, i.e. with the
43/// `len + smooth_1 + smooth_2 - 2`-th bar. The signal line only ever sees published values.
44/// [`Indicator::reset`] clears the window and all four averages.
45#[derive(Debug, Clone)]
46pub struct StochasticMomentumIndex {
47    len: usize,
48    smooth_1: usize,
49    smooth_2: usize,
50    signal_len: usize,
51    highs: VecDeque<f64>,
52    lows: VecDeque<f64>,
53    distance_1: Ema,
54    distance_2: Ema,
55    range_1: Ema,
56    range_2: Ema,
57    signal_ema: Ema,
58    observations: usize,
59    lines_published: usize,
60}
61
62impl StochasticMomentumIndex {
63    pub fn new(len: usize, smooth_1: usize, smooth_2: usize, signal_len: usize) -> Self {
64        let len = len.max(1);
65        let smooth_1 = smooth_1.max(1);
66        let smooth_2 = smooth_2.max(1);
67        let signal_len = signal_len.max(1);
68        Self {
69            len,
70            smooth_1,
71            smooth_2,
72            signal_len,
73            highs: VecDeque::with_capacity(len),
74            lows: VecDeque::with_capacity(len),
75            distance_1: Ema::new(smooth_1),
76            distance_2: Ema::new(smooth_2),
77            range_1: Ema::new(smooth_1),
78            range_2: Ema::new(smooth_2),
79            signal_ema: Ema::new(signal_len),
80            observations: 0,
81            lines_published: 0,
82        }
83    }
84
85    pub fn with_defaults() -> Self {
86        Self::new(10, 3, 3, 3)
87    }
88}
89
90impl Indicator for StochasticMomentumIndex {
91    fn name(&self) -> &str {
92        "smi"
93    }
94
95    fn warmup_period(&self) -> usize {
96        self.len + self.smooth_1 + self.smooth_2 - 2
97    }
98
99    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
100        self.highs.push_back(bar.high);
101        self.lows.push_back(bar.low);
102        if self.highs.len() > self.len {
103            self.highs.pop_front();
104            self.lows.pop_front();
105        }
106        if self.highs.len() < self.len {
107            return None;
108        }
109
110        let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
111        let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
112        let range = highest - lowest;
113        let distance = bar.close - (highest + lowest) / 2.0;
114
115        let smoothed_distance = self.distance_2.update(self.distance_1.update(distance)?)?;
116        let smoothed_range = self.range_2.update(self.range_1.update(range)?)?;
117
118        self.observations += 1;
119        if self.observations < self.smooth_1 + self.smooth_2 - 1 {
120            return None;
121        }
122
123        let line = if smoothed_range.abs() > 0.0 {
124            200.0 * smoothed_distance / smoothed_range
125        } else {
126            0.0
127        };
128        self.lines_published += 1;
129
130        let mut extra = HashMap::new();
131        let signal = self.signal_ema.update(line)?;
132        if self.lines_published >= self.signal_len {
133            extra.insert("signal".to_string(), signal);
134        }
135
136        Some(IndicatorOutput::with_extra(line, extra))
137    }
138
139    fn reset(&mut self) {
140        self.highs.clear();
141        self.lows.clear();
142        self.distance_1.reset();
143        self.distance_2.reset();
144        self.range_1.reset();
145        self.range_2.reset();
146        self.signal_ema.reset();
147        self.observations = 0;
148        self.lines_published = 0;
149    }
150}