Skip to main content

wickra_core/indicators/
stoch_rsi.rs

1//! Stochastic RSI.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8use super::Rsi;
9
10/// Stochastic RSI — the Stochastic Oscillator formula applied to the RSI series
11/// instead of to price.
12///
13/// RSI itself rarely reaches its `[0, 100]` extremes, so it spends most of its
14/// life bunched in the middle of the range. `StochRSI` re-scales it: it reports
15/// where the *current* RSI sits within its own high/low range over the last
16/// `stoch_period` bars, which makes overbought/oversold turns far easier to
17/// see.
18///
19/// ```text
20/// StochRSI = 100 · (RSI − min(RSI, stoch_period)) / (max(RSI, …) − min(RSI, …))
21/// ```
22///
23/// The output is bounded in `[0, 100]`. A flat RSI window (zero range) is
24/// reported as the neutral `50.0`, matching the [`Stochastic`](crate::Stochastic)
25/// convention.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Indicator, StochRsi};
31///
32/// let mut indicator = StochRsi::new(14, 14).unwrap();
33/// let mut last = None;
34/// for i in 0..80 {
35///     last = indicator.update(100.0 + (f64::from(i) * 0.5).sin() * 10.0);
36/// }
37/// assert!(last.is_some());
38/// ```
39#[derive(Debug, Clone)]
40pub struct StochRsi {
41    rsi_period: usize,
42    stoch_period: usize,
43    rsi: Rsi,
44    /// Rolling window of the last `stoch_period` RSI values.
45    window: VecDeque<f64>,
46    last: Option<f64>,
47}
48
49impl StochRsi {
50    /// Construct a new `StochRSI` with the RSI period and the stochastic lookback.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`Error::PeriodZero`] if either period is `0`.
55    pub fn new(rsi_period: usize, stoch_period: usize) -> Result<Self> {
56        if rsi_period == 0 || stoch_period == 0 {
57            return Err(Error::PeriodZero);
58        }
59        Ok(Self {
60            rsi_period,
61            stoch_period,
62            rsi: Rsi::new(rsi_period)?,
63            window: VecDeque::with_capacity(stoch_period),
64            last: None,
65        })
66    }
67
68    /// The `(rsi_period, stoch_period)` pair.
69    pub const fn periods(&self) -> (usize, usize) {
70        (self.rsi_period, self.stoch_period)
71    }
72
73    /// Current value if available.
74    pub const fn value(&self) -> Option<f64> {
75        self.last
76    }
77}
78
79impl Indicator for StochRsi {
80    type Input = f64;
81    type Output = f64;
82
83    #[inline]
84    fn update(&mut self, input: f64) -> Option<f64> {
85        if !input.is_finite() {
86            // Non-finite input is ignored; state is left untouched.
87            return None;
88        }
89        let rsi_value = self.rsi.update(input)?;
90
91        if self.window.len() == self.stoch_period {
92            self.window.pop_front();
93        }
94        self.window.push_back(rsi_value);
95        if self.window.len() < self.stoch_period {
96            return None;
97        }
98
99        let max = self
100            .window
101            .iter()
102            .copied()
103            .fold(f64::NEG_INFINITY, f64::max);
104        let min = self.window.iter().copied().fold(f64::INFINITY, f64::min);
105        let range = max - min;
106        let stoch = if range == 0.0 {
107            // Flat RSI window: report the neutral midpoint.
108            50.0
109        } else {
110            100.0 * (rsi_value - min) / range
111        };
112        self.last = Some(stoch);
113        Some(stoch)
114    }
115
116    fn reset(&mut self) {
117        self.rsi.reset();
118        self.window.clear();
119        self.last = None;
120    }
121
122    #[inline]
123    fn warmup_period(&self) -> usize {
124        // RSI emits its first value at input `rsi_period + 1`; the stochastic
125        // window then needs `stoch_period` RSI values.
126        self.rsi_period + self.stoch_period
127    }
128
129    #[inline]
130    fn is_ready(&self) -> bool {
131        self.last.is_some()
132    }
133
134    #[inline]
135    fn name(&self) -> &'static str {
136        "StochRSI"
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::traits::BatchExt;
144    use approx::assert_relative_eq;
145
146    #[test]
147    fn new_rejects_zero_period() {
148        assert!(matches!(StochRsi::new(0, 14), Err(Error::PeriodZero)));
149        assert!(matches!(StochRsi::new(14, 0), Err(Error::PeriodZero)));
150    }
151
152    /// Cover the const accessors `periods` / `value` (69-76) and the
153    /// Indicator-impl `name` body (131-133). `warmup_period` is already
154    /// covered by `first_emission_at_warmup_period`.
155    #[test]
156    fn accessors_and_metadata() {
157        let mut sr = StochRsi::new(14, 14).unwrap();
158        assert_eq!(sr.periods(), (14, 14));
159        assert_eq!(sr.name(), "StochRSI");
160        assert_eq!(sr.value(), None);
161        for i in 1..=sr.warmup_period() {
162            sr.update(100.0 + f64::from(u32::try_from(i).unwrap()));
163        }
164        assert!(sr.value().is_some());
165    }
166
167    #[test]
168    fn first_emission_at_warmup_period() {
169        let mut sr = StochRsi::new(5, 4).unwrap();
170        assert_eq!(sr.warmup_period(), 9);
171        let prices: Vec<f64> = (1..=40)
172            .map(|i| 100.0 + (f64::from(i) * 0.6).sin() * 8.0)
173            .collect();
174        let out = sr.batch(&prices);
175        for v in out.iter().take(8) {
176            assert!(v.is_none());
177        }
178        assert!(out[8].is_some());
179    }
180
181    #[test]
182    fn flat_rsi_window_yields_50() {
183        // A constant price series gives a constant RSI (50.0), so the StochRSI
184        // window has zero range and reports the neutral midpoint.
185        let mut sr = StochRsi::new(5, 4).unwrap();
186        let out = sr.batch(&[100.0; 40]);
187        for v in out.iter().skip(9).flatten() {
188            assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
189        }
190    }
191
192    #[test]
193    fn pure_uptrend_yields_50() {
194        // A pure uptrend pins RSI at 100, so its window is again flat.
195        let mut sr = StochRsi::new(5, 4).unwrap();
196        let out = sr.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
197        for v in out.iter().skip(9).flatten() {
198            assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
199        }
200    }
201
202    #[test]
203    fn output_stays_within_0_100() {
204        let mut sr = StochRsi::new(14, 14).unwrap();
205        let prices: Vec<f64> = (1..=200)
206            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 15.0 + (f64::from(i) * 0.07).cos() * 6.0)
207            .collect();
208        for v in sr.batch(&prices).into_iter().flatten() {
209            assert!((0.0..=100.0).contains(&v), "StochRSI out of range: {v}");
210        }
211    }
212
213    #[test]
214    fn ignores_non_finite_input() {
215        let mut sr = StochRsi::new(5, 4).unwrap();
216        let prices: Vec<f64> = (1..=40)
217            .map(|i| 100.0 + (f64::from(i) * 0.6).sin() * 8.0)
218            .collect();
219        let out = sr.batch(&prices);
220        let last = *out.last().unwrap();
221        assert!(last.is_some());
222        assert_eq!(sr.update(f64::NAN), None);
223        assert_eq!(sr.update(f64::INFINITY), None);
224    }
225
226    #[test]
227    fn reset_clears_state() {
228        let mut sr = StochRsi::new(5, 4).unwrap();
229        sr.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
230        assert!(sr.is_ready());
231        sr.reset();
232        assert!(!sr.is_ready());
233        assert_eq!(sr.update(1.0), None);
234    }
235
236    #[test]
237    fn batch_equals_streaming() {
238        let prices: Vec<f64> = (1..=120)
239            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 12.0)
240            .collect();
241        let batch = StochRsi::new(14, 14).unwrap().batch(&prices);
242        let mut b = StochRsi::new(14, 14).unwrap();
243        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
244        assert_eq!(batch, streamed);
245    }
246}