Skip to main content

wickra_core/indicators/
stochastic.rs

1//! Stochastic Oscillator (%K and %D).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::sma::Sma;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Stochastic Oscillator output.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct StochasticOutput {
13    /// Raw %K: `100 * (close - LL) / (HH - LL)` over the lookback.
14    pub k: f64,
15    /// %D: SMA of %K over the smoothing period.
16    pub d: f64,
17}
18
19/// Fast Stochastic Oscillator.
20///
21/// Maintains rolling highest-high and lowest-low over the lookback period via a
22/// monotonic deque, giving O(1) amortized updates. %D is an SMA of the %K series.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{Candle, Indicator, Stochastic};
28///
29/// let mut indicator = Stochastic::new(5, 3).unwrap();
30/// let mut last = None;
31/// for i in 0..80 {
32///     let base = 100.0 + f64::from(i);
33///     let candle =
34///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
35///     last = indicator.update(candle);
36/// }
37/// assert!(last.is_some());
38/// ```
39#[derive(Debug, Clone)]
40pub struct Stochastic {
41    k_period: usize,
42    d_period: usize,
43    candles: VecDeque<Candle>,
44    // Monotonic deques over candle indices in the rolling window.
45    hh_idx: VecDeque<usize>, // indices of candidates for highest high (front = current max)
46    ll_idx: VecDeque<usize>, // indices of candidates for lowest low (front = current min)
47    // Absolute count of candles ever ingested. Used so monotonic-deque indices stay unique.
48    count: usize,
49    d_sma: Sma,
50    last_k: Option<f64>,
51}
52
53impl Stochastic {
54    /// Construct a stochastic with %K lookback and %D smoothing periods.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`Error::PeriodZero`] if either period is zero.
59    pub fn new(k_period: usize, d_period: usize) -> Result<Self> {
60        if k_period == 0 || d_period == 0 {
61            return Err(Error::PeriodZero);
62        }
63        Ok(Self {
64            k_period,
65            d_period,
66            candles: VecDeque::with_capacity(k_period),
67            hh_idx: VecDeque::with_capacity(k_period),
68            ll_idx: VecDeque::with_capacity(k_period),
69            count: 0,
70            d_sma: Sma::new(d_period)?,
71            last_k: None,
72        })
73    }
74
75    /// Classic fast stochastic: `%K = 14`, `%D = 3`.
76    pub fn classic() -> Self {
77        Self::new(14, 3).expect("classic stochastic periods are valid")
78    }
79
80    /// Configured `(k_period, d_period)`.
81    pub const fn periods(&self) -> (usize, usize) {
82        (self.k_period, self.d_period)
83    }
84
85    fn push_window(&mut self, candle: Candle) {
86        let idx = self.count;
87        self.count += 1;
88        // Drop deque entries that are outside the window.
89        let oldest_keep_idx = idx.saturating_sub(self.k_period - 1);
90        while let Some(&front) = self.hh_idx.front() {
91            if front < oldest_keep_idx {
92                self.hh_idx.pop_front();
93            } else {
94                break;
95            }
96        }
97        while let Some(&front) = self.ll_idx.front() {
98            if front < oldest_keep_idx {
99                self.ll_idx.pop_front();
100            } else {
101                break;
102            }
103        }
104        // Maintain monotonic-decreasing deque for highs.
105        while let Some(&back) = self.hh_idx.back() {
106            let back_off = back - idx.saturating_sub(self.candles.len());
107            if self.candles[back_off].high <= candle.high {
108                self.hh_idx.pop_back();
109            } else {
110                break;
111            }
112        }
113        self.hh_idx.push_back(idx);
114        // Maintain monotonic-increasing deque for lows.
115        while let Some(&back) = self.ll_idx.back() {
116            let back_off = back - idx.saturating_sub(self.candles.len());
117            if self.candles[back_off].low >= candle.low {
118                self.ll_idx.pop_back();
119            } else {
120                break;
121            }
122        }
123        self.ll_idx.push_back(idx);
124
125        if self.candles.len() == self.k_period {
126            self.candles.pop_front();
127        }
128        self.candles.push_back(candle);
129    }
130
131    fn current_extremes(&self) -> (f64, f64) {
132        let base = self.count - self.candles.len();
133        let hi = self.candles[self.hh_idx[0] - base].high;
134        let lo = self.candles[self.ll_idx[0] - base].low;
135        (hi, lo)
136    }
137}
138
139impl Indicator for Stochastic {
140    type Input = Candle;
141    type Output = StochasticOutput;
142
143    #[inline]
144    fn update(&mut self, candle: Candle) -> Option<StochasticOutput> {
145        self.push_window(candle);
146        if self.candles.len() < self.k_period {
147            return None;
148        }
149        let (hh, ll) = self.current_extremes();
150        let range = hh - ll;
151        let k = if range == 0.0 {
152            // Flat range; convention: 50 (neutral, like RSI on flat input).
153            50.0
154        } else {
155            100.0 * (candle.close - ll) / range
156        };
157        self.last_k = Some(k);
158        let d = self.d_sma.update(k)?;
159        Some(StochasticOutput { k, d })
160    }
161
162    fn reset(&mut self) {
163        self.candles.clear();
164        self.hh_idx.clear();
165        self.ll_idx.clear();
166        self.count = 0;
167        self.d_sma.reset();
168        self.last_k = None;
169    }
170
171    #[inline]
172    fn warmup_period(&self) -> usize {
173        self.k_period + self.d_period - 1
174    }
175
176    #[inline]
177    fn is_ready(&self) -> bool {
178        self.d_sma.is_ready()
179    }
180
181    #[inline]
182    fn name(&self) -> &'static str {
183        "Stochastic"
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::traits::BatchExt;
191    use approx::assert_relative_eq;
192
193    fn c(h: f64, l: f64, cl: f64) -> Candle {
194        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
195    }
196
197    /// Naive %K computation for cross-checks.
198    fn naive_k(candles: &[Candle], k_period: usize) -> Vec<Option<f64>> {
199        candles
200            .iter()
201            .enumerate()
202            .map(|(i, _)| {
203                if i + 1 < k_period {
204                    None
205                } else {
206                    let w = &candles[i + 1 - k_period..=i];
207                    let hh = w.iter().map(|x| x.high).fold(f64::NEG_INFINITY, f64::max);
208                    let ll = w.iter().map(|x| x.low).fold(f64::INFINITY, f64::min);
209                    let range = hh - ll;
210                    let cl = candles[i].close;
211                    Some(if range == 0.0 {
212                        50.0
213                    } else {
214                        100.0 * (cl - ll) / range
215                    })
216                }
217            })
218            .collect()
219    }
220
221    #[test]
222    fn rejects_zero_periods() {
223        assert!(matches!(Stochastic::new(0, 3), Err(Error::PeriodZero)));
224        assert!(matches!(Stochastic::new(14, 0), Err(Error::PeriodZero)));
225    }
226
227    /// Cover the `Stochastic::classic()` convenience constructor plus the
228    /// `periods()` const accessor and the Indicator-impl `warmup_period`
229    /// / `name` methods. Existing tests called `Stochastic::new(_, _)`
230    /// directly and never asked for the configured periods, warmup
231    /// length, or name.
232    #[test]
233    fn classic_periods_and_metadata() {
234        let s = Stochastic::classic();
235        assert_eq!(s.periods(), (14, 3));
236        // Warmup for the classic config: k_period + d_period - 1 = 14 + 3 - 1 = 16.
237        assert_eq!(s.warmup_period(), 16);
238        assert_eq!(s.name(), "Stochastic");
239    }
240
241    #[test]
242    fn close_at_high_yields_k_100() {
243        let candles = vec![
244            c(10.0, 8.0, 9.0),
245            c(11.0, 9.0, 10.0),
246            c(12.0, 10.0, 12.0), // close == high == HH
247        ];
248        let mut s = Stochastic::new(3, 1).unwrap();
249        let out = s.batch(&candles);
250        assert_relative_eq!(out[2].unwrap().k, 100.0, epsilon = 1e-12);
251    }
252
253    #[test]
254    fn close_at_low_yields_k_0() {
255        let candles = vec![
256            c(10.0, 8.0, 9.0),
257            c(11.0, 9.0, 10.0),
258            c(12.0, 8.0, 8.0), // close == LL
259        ];
260        let mut s = Stochastic::new(3, 1).unwrap();
261        let out = s.batch(&candles);
262        assert_relative_eq!(out[2].unwrap().k, 0.0, epsilon = 1e-12);
263    }
264
265    #[test]
266    fn flat_range_yields_k_50() {
267        let candles: Vec<Candle> = (0..20).map(|_| c(10.0, 10.0, 10.0)).collect();
268        let mut s = Stochastic::new(14, 3).unwrap();
269        for o in s.batch(&candles).into_iter().flatten() {
270            assert_relative_eq!(o.k, 50.0, epsilon = 1e-12);
271            assert_relative_eq!(o.d, 50.0, epsilon = 1e-12);
272        }
273        // Cross-check: the naive_k test helper must agree on the flat-range
274        // convention. The k_matches_naive test only feeds oscillating prices,
275        // so the helper's flat-range branch was never exercised.
276        let ks = naive_k(&candles, 14);
277        for k in ks.into_iter().skip(13) {
278            assert_relative_eq!(k.expect("ready after 14 inputs"), 50.0, epsilon = 1e-12);
279        }
280    }
281
282    #[test]
283    fn k_matches_naive() {
284        let candles: Vec<Candle> = (0..60)
285            .map(|i| {
286                let mid = 50.0 + (f64::from(i) * 0.4).sin() * 10.0;
287                c(mid + 2.0, mid - 2.0, mid + (f64::from(i) * 0.7).cos())
288            })
289            .collect();
290        let mut s = Stochastic::new(14, 3).unwrap();
291        let out = s.batch(&candles);
292        let naive = naive_k(&candles, 14);
293        for (i, got) in out.iter().enumerate() {
294            if let Some(o) = got {
295                let n = naive[i].expect("naive ready");
296                assert_relative_eq!(o.k, n, epsilon = 1e-9);
297            }
298        }
299    }
300
301    #[test]
302    fn d_is_sma_of_k() {
303        let candles: Vec<Candle> = (0..60)
304            .map(|i| {
305                let mid = 50.0 + f64::from(i).sin() * 5.0;
306                c(mid + 1.5, mid - 1.5, mid)
307            })
308            .collect();
309        let mut s = Stochastic::new(14, 3).unwrap();
310        let out = s.batch(&candles);
311        // The naive %K series gives us the ground-truth values that %D should average.
312        let naive_ks = naive_k(&candles, 14);
313        // The first emitted %D corresponds to the SMA of the first three valid %K values
314        // (i.e. those at indices 13, 14, 15). At that point %D becomes ready, and the
315        // first `Some(_)` output appears at index 15.
316        let first_emit_idx = out
317            .iter()
318            .position(Option::is_some)
319            .expect("d eventually emits");
320        let first_d = out[first_emit_idx].unwrap().d;
321        let k_window = &naive_ks[first_emit_idx - 2..=first_emit_idx];
322        let want = k_window
323            .iter()
324            .map(|v| v.expect("naive K ready inside window"))
325            .sum::<f64>()
326            / 3.0;
327        assert_relative_eq!(first_d, want, epsilon = 1e-9);
328    }
329
330    #[test]
331    fn batch_equals_streaming() {
332        let candles: Vec<Candle> = (0..50)
333            .map(|i| {
334                let mid = 100.0 + f64::from(i) * 0.5;
335                c(mid + 2.0, mid - 2.0, mid)
336            })
337            .collect();
338        let mut a = Stochastic::new(14, 3).unwrap();
339        let mut b = Stochastic::new(14, 3).unwrap();
340        assert_eq!(
341            a.batch(&candles),
342            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
343        );
344    }
345
346    #[test]
347    fn reset_clears_state() {
348        let mut s = Stochastic::new(5, 3).unwrap();
349        let candles: Vec<Candle> = (0..10).map(|i| c(10.0 + f64::from(i), 5.0, 7.0)).collect();
350        s.batch(&candles);
351        assert!(s.is_ready());
352        s.reset();
353        assert!(!s.is_ready());
354        assert_eq!(s.update(candles[0]), None);
355    }
356}