Skip to main content

quantwave_core/indicators/incremental/
stoch.rs

1//! Native O(1) Stochastic family — TA-Lib parity (STOCH, STOCHF, STOCHRSI).
2
3use crate::indicators::incremental::ma_stream::MaStream;
4use crate::indicators::incremental::rsi::RSI;
5use crate::indicators::ma_type::MaType;
6use crate::traits::Next;
7use crate::utils::RingBuffer;
8
9/// Rolling highest high / lowest low over `period` bars.
10#[derive(Debug, Clone)]
11struct HlWindow {
12    highs: RingBuffer<f64>,
13    lows: RingBuffer<f64>,
14    period: usize,
15}
16
17impl HlWindow {
18    fn new(period: usize) -> Self {
19        Self {
20            highs: RingBuffer::with_capacity(period),
21            lows: RingBuffer::with_capacity(period),
22            period,
23        }
24    }
25
26    fn push(&mut self, high: f64, low: f64) -> Option<(f64, f64, f64)> {
27        if self.highs.len() >= self.period {
28            let _ = self.highs.pop_front();
29            let _ = self.lows.pop_front();
30        }
31        self.highs.push_back(high);
32        self.lows.push_back(low);
33        if self.highs.len() < self.period {
34            return None;
35        }
36        let mut hh = f64::NEG_INFINITY;
37        let mut ll = f64::INFINITY;
38        for (&h, &l) in self.highs.iter().zip(self.lows.iter()) {
39            if h > hh {
40                hh = h;
41            }
42            if l < ll {
43                ll = l;
44            }
45        }
46        let range = hh - ll;
47        Some((hh, ll, range))
48    }
49}
50
51fn fastk_from_hlc(close: f64, ll: f64, range: f64) -> f64 {
52    if range > 0.0 {
53        100.0 * (close - ll) / range
54    } else {
55        50.0
56    }
57}
58
59/// Stochastic Oscillator (STOCH) — default SMA smoothing on %K and %D.
60#[derive(Debug, Clone)]
61#[allow(non_camel_case_types)]
62pub struct STOCH {
63    pub fastk_period: usize,
64    pub slowk_period: usize,
65    pub slowk_matype: MaType,
66    pub slowd_period: usize,
67    pub slowd_matype: MaType,
68    hl: HlWindow,
69    slowk_ma: MaStream,
70    slowd_ma: MaStream,
71    slowk_valid: Vec<f64>,
72    bar_index: usize,
73    out_start: usize,
74}
75
76impl STOCH {
77    pub fn new(
78        fastk_period: usize,
79        slowk_period: usize,
80        slowk_matype: MaType,
81        slowd_period: usize,
82        slowd_matype: MaType,
83    ) -> Self {
84        Self {
85            fastk_period,
86            slowk_period,
87            slowk_matype,
88            slowd_period,
89            slowd_matype,
90            hl: HlWindow::new(fastk_period),
91            slowk_ma: MaStream::new(slowk_period, slowk_matype),
92            slowd_ma: MaStream::new(slowd_period, slowd_matype),
93            slowk_valid: Vec::new(),
94            bar_index: 0,
95            out_start: fastk_period - 1 + slowk_period - 1 + slowd_period - 1,
96        }
97    }
98}
99
100impl Next<(f64, f64, f64)> for STOCH {
101    type Output = (f64, f64);
102
103    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
104        let i = self.bar_index;
105        self.bar_index += 1;
106
107        let Some((_, ll, range)) = self.hl.push(high, low) else {
108            return (f64::NAN, f64::NAN);
109        };
110        let fastk = fastk_from_hlc(close, ll, range);
111        let slowk_raw = self.slowk_ma.next(fastk);
112        if !slowk_raw.is_nan() {
113            self.slowk_valid.push(slowk_raw);
114        }
115        let slowd_raw = if slowk_raw.is_nan() {
116            f64::NAN
117        } else {
118            self.slowd_ma.next(slowk_raw)
119        };
120
121        if i < self.out_start {
122            return (f64::NAN, f64::NAN);
123        }
124
125        let k_skip = self.slowd_period - 1;
126        let j = i - self.out_start;
127        let idx = k_skip + j;
128        let slowk_out = self.slowk_valid.get(idx).copied().unwrap_or(f64::NAN);
129        let slowd_out = if slowd_raw.is_nan() {
130            f64::NAN
131        } else {
132            slowd_raw
133        };
134
135        (slowk_out, slowd_out)
136    }
137}
138
139/// Fast Stochastic (STOCHF).
140#[derive(Debug, Clone)]
141#[allow(non_camel_case_types)]
142pub struct STOCHF {
143    pub fastk_period: usize,
144    pub fastd_period: usize,
145    pub fastd_matype: MaType,
146    hl: HlWindow,
147    fastd_ma: MaStream,
148    fastk_values: Vec<f64>,
149    bar_index: usize,
150    out_start: usize,
151}
152
153impl STOCHF {
154    pub fn new(fastk_period: usize, fastd_period: usize, fastd_matype: MaType) -> Self {
155        Self {
156            fastk_period,
157            fastd_period,
158            fastd_matype,
159            hl: HlWindow::new(fastk_period),
160            fastd_ma: MaStream::new(fastd_period, fastd_matype),
161            fastk_values: Vec::new(),
162            bar_index: 0,
163            out_start: fastk_period - 1 + fastd_period - 1,
164        }
165    }
166}
167
168impl Next<(f64, f64, f64)> for STOCHF {
169    type Output = (f64, f64);
170
171    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
172        let i = self.bar_index;
173        self.bar_index += 1;
174
175        let Some((_, ll, range)) = self.hl.push(high, low) else {
176            return (f64::NAN, f64::NAN);
177        };
178        let fastk = fastk_from_hlc(close, ll, range);
179        self.fastk_values.push(fastk);
180
181        let fastd_raw = self.fastd_ma.next(fastk);
182
183        if i < self.out_start {
184            return (f64::NAN, f64::NAN);
185        }
186
187        let k_skip = self.fastd_period - 1;
188        let j = i - self.out_start;
189        let idx = k_skip + j;
190        let fastk_out = self.fastk_values.get(idx).copied().unwrap_or(f64::NAN);
191        let fastd_out = if fastd_raw.is_nan() {
192            f64::NAN
193        } else {
194            fastd_raw
195        };
196
197        (fastk_out, fastd_out)
198    }
199}
200
201/// Stochastic RSI (STOCHRSI).
202#[derive(Debug, Clone)]
203#[allow(non_camel_case_types)]
204pub struct STOCHRSI {
205    pub timeperiod: usize,
206    pub fastk_period: usize,
207    pub fastd_period: usize,
208    pub fastd_matype: MaType,
209    rsi: RSI,
210    rsi_valid: Vec<f64>,
211    fastd_ma: MaStream,
212    fastk_values: Vec<f64>,
213    bar_index: usize,
214    d_start: usize,
215}
216
217impl STOCHRSI {
218    pub fn new(
219        timeperiod: usize,
220        fastk_period: usize,
221        fastd_period: usize,
222        fastd_matype: MaType,
223    ) -> Self {
224        let d_start = timeperiod + fastk_period - 1 + fastd_period - 1;
225        Self {
226            timeperiod,
227            fastk_period,
228            fastd_period,
229            fastd_matype,
230            rsi: RSI::new(timeperiod),
231            rsi_valid: Vec::new(),
232            fastd_ma: MaStream::new(fastd_period, fastd_matype),
233            fastk_values: Vec::new(),
234            bar_index: 0,
235            d_start,
236        }
237    }
238}
239
240impl Next<f64> for STOCHRSI {
241    type Output = (f64, f64);
242
243    fn next(&mut self, input: f64) -> Self::Output {
244        let i = self.bar_index;
245        self.bar_index += 1;
246
247        let rsi_v = self.rsi.next(input);
248        if !rsi_v.is_nan() {
249            self.rsi_valid.push(rsi_v);
250        }
251
252        if self.rsi_valid.len() < self.fastk_period {
253            return (f64::NAN, f64::NAN);
254        }
255
256        let idx = self.rsi_valid.len() - 1;
257        let start = idx + 1 - self.fastk_period;
258        let mut hh = f64::NEG_INFINITY;
259        let mut ll = f64::INFINITY;
260        for j in start..=idx {
261            let v = self.rsi_valid[j];
262            if v > hh {
263                hh = v;
264            }
265            if v < ll {
266                ll = v;
267            }
268        }
269        let range = hh - ll;
270        let fastk = if range > 0.0 {
271            100.0 * (self.rsi_valid[idx] - ll) / range
272        } else {
273            50.0
274        };
275        self.fastk_values.push(fastk);
276
277        let fastd_raw = self.fastd_ma.next(fastk);
278
279        if i < self.d_start {
280            return (f64::NAN, f64::NAN);
281        }
282
283        let k_skip = self.fastd_period - 1;
284        let j = i - self.d_start;
285        let idx = k_skip + j;
286        let fastk_out = self.fastk_values.get(idx).copied().unwrap_or(f64::NAN);
287        let fastd_out = if fastd_raw.is_nan() {
288            f64::NAN
289        } else {
290            fastd_raw
291        };
292
293        (fastk_out, fastd_out)
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use proptest::prelude::*;
301
302    /// `MaType`s whose lookback is exactly `period - 1`.
303    ///
304    /// `talib_rs::momentum::stoch` (and `stochf` / `stochrsi`) slice the NaN
305    /// prefix off the smoothed %K with the hard-coded length `slowk_period - 1`
306    /// rather than the selected MA's real lookback. For SMA/EMA/WMA/TRIMA that
307    /// is the same number and the oracle is meaningful. For DEMA/TEMA/KAMA/
308    /// MAMA/T3 the batch keeps NaNs inside the "valid" slice and then smooths
309    /// them, so the oracle's own output is NaN-poisoned garbage — there is
310    /// nothing coherent to assert parity against. Our streaming path uses each
311    /// MA's true warmup instead. Tracked for an upstream fix; do not "fix" the
312    /// streaming side to reproduce the poisoning.
313    const STOCH_PARITY_MATYPES: [MaType; 4] =
314        [MaType::Sma, MaType::Ema, MaType::Wma, MaType::Trima];
315
316    fn hlc(
317        len: usize,
318        highs: &[f64],
319        lows: &[f64],
320        closes: &[f64],
321    ) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
322        let mut high = Vec::with_capacity(len);
323        let mut low = Vec::with_capacity(len);
324        let mut close = Vec::with_capacity(len);
325        for i in 0..len {
326            let (h, l, c): (f64, f64, f64) = (highs[i], lows[i], closes[i]);
327            high.push(h.max(l).max(c));
328            low.push(h.min(l).min(c));
329            close.push(c);
330        }
331        (high, low, close)
332    }
333
334    fn assert_pair_parity(streaming: &[(f64, f64)], b_k: &[f64], b_d: &[f64], label: &str) {
335        for (i, &(s_k, s_d)) in streaming.iter().enumerate() {
336            for (s, b, name) in [(s_k, b_k[i], "k"), (s_d, b_d[i], "d")] {
337                if s.is_nan() {
338                    assert!(
339                        b.is_nan(),
340                        "{label} bar {i} {name}: streaming NaN, batch {b}"
341                    );
342                } else {
343                    assert!(
344                        !b.is_nan(),
345                        "{label} bar {i} {name}: streaming {s}, batch NaN"
346                    );
347                    approx::assert_relative_eq!(s, b, epsilon = 1e-6);
348                }
349            }
350        }
351    }
352
353    proptest! {
354        #[test]
355        fn test_stoch_parity(
356            highs in prop::collection::vec(1.0..100.0, 1..100),
357            lows in prop::collection::vec(1.0..100.0, 1..100),
358            closes in prop::collection::vec(1.0..100.0, 1..100)
359        ) {
360            let len = highs.len().min(lows.len()).min(closes.len());
361            if len < 20 { return Ok(()); }
362            let (high, low, close) = hlc(len, &highs, &lows, &closes);
363
364            let fastk = 5;
365            let slowk = 3;
366            let slowk_ma = MaType::Sma;
367            let slowd = 3;
368            let slowd_ma = MaType::Sma;
369
370            let mut stoch = STOCH::new(fastk, slowk, slowk_ma, slowd, slowd_ma);
371            let streaming: Vec<(f64, f64)> = (0..len)
372                .map(|i| stoch.next((high[i], low[i], close[i])))
373                .collect();
374            let (b_k, b_d) = talib_rs::momentum::stoch(
375                &high, &low, &close, fastk, slowk, slowk_ma.into(), slowd, slowd_ma.into(),
376            )
377            .unwrap_or_else(|_| (vec![f64::NAN; len], vec![f64::NAN; len]));
378
379            assert_pair_parity(&streaming, &b_k, &b_d, "stoch/sma");
380        }
381
382        /// STOCH and STOCHF smooth through `MaStream`, so `slowk_matype` /
383        /// `fastd_matype` were silently ignored for everything except EMA.
384        #[test]
385        fn test_stoch_family_parity_matypes(
386            highs in prop::collection::vec(1.0..100.0, 60..120),
387            lows in prop::collection::vec(1.0..100.0, 60..120),
388            closes in prop::collection::vec(1.0..100.0, 60..120),
389            idx in 0usize..4,
390        ) {
391            let matype = STOCH_PARITY_MATYPES[idx];
392            let len = highs.len().min(lows.len()).min(closes.len());
393            let (high, low, close) = hlc(len, &highs, &lows, &closes);
394            let (fastk, slowk, slowd) = (5usize, 3usize, 3usize);
395
396            let mut stoch = STOCH::new(fastk, slowk, matype, slowd, matype);
397            let streaming: Vec<(f64, f64)> = (0..len)
398                .map(|i| stoch.next((high[i], low[i], close[i])))
399                .collect();
400            let (b_k, b_d) = talib_rs::momentum::stoch(
401                &high, &low, &close, fastk, slowk, matype.into(), slowd, matype.into(),
402            )
403            .unwrap_or_else(|_| (vec![f64::NAN; len], vec![f64::NAN; len]));
404            assert_pair_parity(&streaming, &b_k, &b_d, &format!("stoch/{matype}"));
405
406            let mut stochf = STOCHF::new(fastk, slowd, matype);
407            let streaming: Vec<(f64, f64)> = (0..len)
408                .map(|i| stochf.next((high[i], low[i], close[i])))
409                .collect();
410            let (b_k, b_d) = talib_rs::momentum::stochf(
411                &high, &low, &close, fastk, slowd, matype.into(),
412            )
413            .unwrap_or_else(|_| (vec![f64::NAN; len], vec![f64::NAN; len]));
414            assert_pair_parity(&streaming, &b_k, &b_d, &format!("stochf/{matype}"));
415        }
416    }
417
418    /// The `matype` argument must actually reach the smoothing stage.
419    #[test]
420    fn stoch_matype_changes_output() {
421        let bars: Vec<(f64, f64, f64)> = (0..200)
422            .map(|i| {
423                let c = 50.0 + 10.0 * (i as f64 * 0.3).sin();
424                (c + 1.0, c - 1.0, c)
425            })
426            .collect();
427        let run = |m: MaType| {
428            let mut s = STOCH::new(5, 3, m, 3, m);
429            bars.iter().map(|&b| s.next(b)).collect::<Vec<_>>()
430        };
431        let sma = run(MaType::Sma);
432        for matype in MaType::ALL {
433            if matype == MaType::Sma {
434                continue;
435            }
436            let got = run(matype);
437            let identical = got.iter().zip(sma.iter()).all(|(a, b)| {
438                ((a.0.is_nan() && b.0.is_nan()) || (a.0 - b.0).abs() < 1e-12)
439                    && ((a.1.is_nan() && b.1.is_nan()) || (a.1 - b.1).abs() < 1e-12)
440            });
441            assert!(!identical, "STOCH with {matype} still produces SMA values");
442        }
443    }
444}