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    fn update(&mut self, input: f64) -> Option<f64> {
84        if !input.is_finite() {
85            // Non-finite input is ignored; state is left untouched.
86            return self.last;
87        }
88        let rsi_value = self.rsi.update(input)?;
89
90        if self.window.len() == self.stoch_period {
91            self.window.pop_front();
92        }
93        self.window.push_back(rsi_value);
94        if self.window.len() < self.stoch_period {
95            return None;
96        }
97
98        let max = self
99            .window
100            .iter()
101            .copied()
102            .fold(f64::NEG_INFINITY, f64::max);
103        let min = self.window.iter().copied().fold(f64::INFINITY, f64::min);
104        let range = max - min;
105        let stoch = if range == 0.0 {
106            // Flat RSI window: report the neutral midpoint.
107            50.0
108        } else {
109            100.0 * (rsi_value - min) / range
110        };
111        self.last = Some(stoch);
112        Some(stoch)
113    }
114
115    fn reset(&mut self) {
116        self.rsi.reset();
117        self.window.clear();
118        self.last = None;
119    }
120
121    fn warmup_period(&self) -> usize {
122        // RSI emits its first value at input `rsi_period + 1`; the stochastic
123        // window then needs `stoch_period` RSI values.
124        self.rsi_period + self.stoch_period
125    }
126
127    fn is_ready(&self) -> bool {
128        self.last.is_some()
129    }
130
131    fn name(&self) -> &'static str {
132        "StochRSI"
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::traits::BatchExt;
140    use approx::assert_relative_eq;
141
142    #[test]
143    fn new_rejects_zero_period() {
144        assert!(matches!(StochRsi::new(0, 14), Err(Error::PeriodZero)));
145        assert!(matches!(StochRsi::new(14, 0), Err(Error::PeriodZero)));
146    }
147
148    /// Cover the const accessors `periods` / `value` (69-76) and the
149    /// Indicator-impl `name` body (131-133). `warmup_period` is already
150    /// covered by `first_emission_at_warmup_period`.
151    #[test]
152    fn accessors_and_metadata() {
153        let mut sr = StochRsi::new(14, 14).unwrap();
154        assert_eq!(sr.periods(), (14, 14));
155        assert_eq!(sr.name(), "StochRSI");
156        assert_eq!(sr.value(), None);
157        for i in 1..=sr.warmup_period() {
158            sr.update(100.0 + f64::from(u32::try_from(i).unwrap()));
159        }
160        assert!(sr.value().is_some());
161    }
162
163    #[test]
164    fn first_emission_at_warmup_period() {
165        let mut sr = StochRsi::new(5, 4).unwrap();
166        assert_eq!(sr.warmup_period(), 9);
167        let prices: Vec<f64> = (1..=40)
168            .map(|i| 100.0 + (f64::from(i) * 0.6).sin() * 8.0)
169            .collect();
170        let out = sr.batch(&prices);
171        for v in out.iter().take(8) {
172            assert!(v.is_none());
173        }
174        assert!(out[8].is_some());
175    }
176
177    #[test]
178    fn flat_rsi_window_yields_50() {
179        // A constant price series gives a constant RSI (50.0), so the StochRSI
180        // window has zero range and reports the neutral midpoint.
181        let mut sr = StochRsi::new(5, 4).unwrap();
182        let out = sr.batch(&[100.0; 40]);
183        for v in out.iter().skip(9).flatten() {
184            assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
185        }
186    }
187
188    #[test]
189    fn pure_uptrend_yields_50() {
190        // A pure uptrend pins RSI at 100, so its window is again flat.
191        let mut sr = StochRsi::new(5, 4).unwrap();
192        let out = sr.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
193        for v in out.iter().skip(9).flatten() {
194            assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
195        }
196    }
197
198    #[test]
199    fn output_stays_within_0_100() {
200        let mut sr = StochRsi::new(14, 14).unwrap();
201        let prices: Vec<f64> = (1..=200)
202            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 15.0 + (f64::from(i) * 0.07).cos() * 6.0)
203            .collect();
204        for v in sr.batch(&prices).into_iter().flatten() {
205            assert!((0.0..=100.0).contains(&v), "StochRSI out of range: {v}");
206        }
207    }
208
209    #[test]
210    fn ignores_non_finite_input() {
211        let mut sr = StochRsi::new(5, 4).unwrap();
212        let prices: Vec<f64> = (1..=40)
213            .map(|i| 100.0 + (f64::from(i) * 0.6).sin() * 8.0)
214            .collect();
215        let out = sr.batch(&prices);
216        let last = *out.last().unwrap();
217        assert!(last.is_some());
218        assert_eq!(sr.update(f64::NAN), last);
219        assert_eq!(sr.update(f64::INFINITY), last);
220    }
221
222    #[test]
223    fn reset_clears_state() {
224        let mut sr = StochRsi::new(5, 4).unwrap();
225        sr.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
226        assert!(sr.is_ready());
227        sr.reset();
228        assert!(!sr.is_ready());
229        assert_eq!(sr.update(1.0), None);
230    }
231
232    #[test]
233    fn batch_equals_streaming() {
234        let prices: Vec<f64> = (1..=120)
235            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 12.0)
236            .collect();
237        let batch = StochRsi::new(14, 14).unwrap().batch(&prices);
238        let mut b = StochRsi::new(14, 14).unwrap();
239        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
240        assert_eq!(batch, streamed);
241    }
242}