Skip to main content

nautilus_indicators/momentum/
stochastics.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::fmt::{Debug, Display};
17
18use arraydeque::{ArrayDeque, Wrapping};
19use nautilus_model::data::Bar;
20use strum::{AsRefStr, Display as StrumDisplay, EnumIter, EnumString, FromRepr};
21
22use crate::{
23    average::{MovingAverageFactory, MovingAverageType},
24    indicator::{Indicator, MovingAverage},
25};
26
27const MAX_PERIOD: usize = 1_024;
28
29/// Method for calculating %D in the Stochastics indicator.
30///
31/// The %D line is the smoothed version of %K and can provide trading signals.
32/// Two calculation methods are supported:
33///
34/// - **Ratio**: Original Nautilus method using `100 * SUM(close-LL) / SUM(HH-LL)` over `period_d`.
35///   This is range-weighted and has less lag than MA-based methods.
36/// - **`MovingAverage`**: Uses MA of slowed %K values, compatible with
37///   cTrader/MetaTrader/TradingView implementations.
38#[repr(C)]
39#[derive(
40    Copy,
41    Clone,
42    Debug,
43    Default,
44    Hash,
45    PartialEq,
46    Eq,
47    PartialOrd,
48    Ord,
49    AsRefStr,
50    FromRepr,
51    EnumIter,
52    EnumString,
53    StrumDisplay,
54)]
55#[strum(ascii_case_insensitive)]
56#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
57#[cfg_attr(
58    feature = "python",
59    pyo3::pyclass(
60        frozen,
61        eq,
62        eq_int,
63        hash,
64        module = "nautilus_trader.core.nautilus_pyo3.indicators",
65        from_py_object,
66    )
67)]
68#[cfg_attr(
69    feature = "python",
70    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.indicators")
71)]
72pub enum StochasticsDMethod {
73    /// Ratio: Nautilus original method: `100 * SUM(close-LL) / SUM(HH-LL)` over `period_d`.
74    /// This is range-weighted and has less lag than MA-based methods.
75    #[default]
76    Ratio,
77    /// MA method: `MA(slowed_k, period_d, ma_type)`.
78    /// This produces values compatible with cTrader/MetaTrader/TradingView implementations.
79    MovingAverage,
80}
81
82#[repr(C)]
83#[cfg_attr(
84    feature = "python",
85    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.indicators")
86)]
87#[cfg_attr(
88    feature = "python",
89    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
90)]
91pub struct Stochastics {
92    /// The lookback period for %K calculation (highest high / lowest low).
93    pub period_k: usize,
94    /// The smoothing period for %D calculation.
95    pub period_d: usize,
96    /// The slowing period for %K smoothing (1 = no slowing (Nautilus original).
97    pub slowing: usize,
98    /// The moving average type used for slowing and MA-based %D.
99    pub ma_type: MovingAverageType,
100    /// The method for calculating %D (Ratio = Nautilus original method, `MovingAverage` = MA Smoothed).
101    pub d_method: StochasticsDMethod,
102    /// The current %K value (slowed if slowing > 1).
103    pub value_k: f64,
104    /// The current %D value.
105    pub value_d: f64,
106    /// Whether the indicator has received sufficient inputs to produce valid values.
107    pub initialized: bool,
108    has_inputs: bool,
109    highs: ArrayDeque<f64, MAX_PERIOD, Wrapping>,
110    lows: ArrayDeque<f64, MAX_PERIOD, Wrapping>,
111    c_sub_1: ArrayDeque<f64, MAX_PERIOD, Wrapping>,
112    h_sub_l: ArrayDeque<f64, MAX_PERIOD, Wrapping>,
113    /// Moving average for %K slowing (None when slowing == 1).
114    slowing_ma: Option<Box<dyn MovingAverage + Send + Sync>>,
115    /// Moving average for %D when `d_method` == `MovingAverage`.
116    d_ma: Option<Box<dyn MovingAverage + Send + Sync>>,
117}
118
119impl Debug for Stochastics {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        f.debug_struct(stringify!(Stochastics))
122            .field("period_k", &self.period_k)
123            .field("period_d", &self.period_d)
124            .field("slowing", &self.slowing)
125            .field("ma_type", &self.ma_type)
126            .field("d_method", &self.d_method)
127            .field("value_k", &self.value_k)
128            .field("value_d", &self.value_d)
129            .field("initialized", &self.initialized)
130            .field("has_inputs", &self.has_inputs)
131            .field(
132                "slowing_ma",
133                &self.slowing_ma.as_ref().map(|_| "MovingAverage"),
134            )
135            .field("d_ma", &self.d_ma.as_ref().map(|_| "MovingAverage"))
136            .finish_non_exhaustive()
137    }
138}
139
140impl Display for Stochastics {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        write!(f, "{}({},{})", self.name(), self.period_k, self.period_d)
143    }
144}
145
146impl Indicator for Stochastics {
147    fn name(&self) -> String {
148        stringify!(Stochastics).to_string()
149    }
150
151    fn has_inputs(&self) -> bool {
152        self.has_inputs
153    }
154
155    fn initialized(&self) -> bool {
156        self.initialized
157    }
158
159    fn handle_bar(&mut self, bar: &Bar) {
160        self.update_raw((&bar.high).into(), (&bar.low).into(), (&bar.close).into());
161    }
162
163    fn reset(&mut self) {
164        self.highs.clear();
165        self.lows.clear();
166        self.c_sub_1.clear();
167        self.h_sub_l.clear();
168        self.value_k = 0.0;
169        self.value_d = 0.0;
170        self.has_inputs = false;
171        self.initialized = false;
172
173        // Reset slowing MA if present
174        if let Some(ref mut ma) = self.slowing_ma {
175            ma.reset();
176        }
177
178        // Reset %D MA if present
179        if let Some(ref mut ma) = self.d_ma {
180            ma.reset();
181        }
182    }
183}
184
185impl Stochastics {
186    /// Creates a new [`Stochastics`] instance with default parameters.
187    ///
188    /// This is the backward-compatible constructor that produces identical output
189    /// to the original Nautilus implementation, setting the following to:
190    /// - `slowing = 1` (no slowing applied to %K)
191    /// - `ma_type = Exponential` (unused when slowing = 1 or with Ratio method)
192    /// - `d_method = Ratio` (Nautilus native %D calculation)
193    ///
194    /// # Panics
195    ///
196    /// This function panics if:
197    /// - `period_k` or `period_d` is less than 1 or greater than `MAX_PERIOD`.
198    #[must_use]
199    pub fn new(period_k: usize, period_d: usize) -> Self {
200        Self::new_with_params(
201            period_k,
202            period_d,
203            1,                              // slowing = 1 (no slowing)
204            MovingAverageType::Exponential, // ma_type (unused)
205            StochasticsDMethod::Ratio,      // d_method = Ratio
206        )
207    }
208
209    /// Creates a new [`Stochastics`] instance with full parameter control.
210    ///
211    /// # Parameters
212    ///
213    /// - `period_k`: The lookback period for %K (highest high / lowest low).
214    /// - `period_d`: The smoothing period for %D.
215    /// - `slowing`: MA smoothing period for raw %K (1 = no slowing, > 1 = smoothed).
216    /// - `ma_type`: MA type for slowing and MA-based %D (EMA, SMA, Wilder, etc.).
217    /// - `d_method`: %D calculation method (Ratio = Nautilus original, `MovingAverage` = MA smoothed).
218    ///
219    /// # Panics
220    ///
221    /// This function panics if:
222    /// - `period_k`, `period_d`, or `slowing` is less than 1 or greater than `MAX_PERIOD`.
223    #[must_use]
224    pub fn new_with_params(
225        period_k: usize,
226        period_d: usize,
227        slowing: usize,
228        ma_type: MovingAverageType,
229        d_method: StochasticsDMethod,
230    ) -> Self {
231        assert!(
232            period_k > 0 && period_k <= MAX_PERIOD,
233            "Stochastics: period_k {period_k} exceeds bounds (1..={MAX_PERIOD})"
234        );
235        assert!(
236            period_d > 0 && period_d <= MAX_PERIOD,
237            "Stochastics: period_d {period_d} exceeds bounds (1..={MAX_PERIOD})"
238        );
239        assert!(
240            slowing > 0 && slowing <= MAX_PERIOD,
241            "Stochastics: slowing {slowing} exceeds bounds (1..={MAX_PERIOD})"
242        );
243
244        // Create slowing MA only if slowing > 1
245        let slowing_ma = if slowing > 1 {
246            Some(MovingAverageFactory::create(ma_type, slowing))
247        } else {
248            None
249        };
250
251        // Create %D MA only if d_method == MovingAverage
252        let d_ma = match d_method {
253            StochasticsDMethod::MovingAverage => {
254                Some(MovingAverageFactory::create(ma_type, period_d))
255            }
256            StochasticsDMethod::Ratio => None,
257        };
258
259        Self {
260            period_k,
261            period_d,
262            slowing,
263            ma_type,
264            d_method,
265            has_inputs: false,
266            initialized: false,
267            value_k: 0.0,
268            value_d: 0.0,
269            highs: ArrayDeque::new(),
270            lows: ArrayDeque::new(),
271            h_sub_l: ArrayDeque::new(),
272            c_sub_1: ArrayDeque::new(),
273            slowing_ma,
274            d_ma,
275        }
276    }
277
278    /// Updates the indicator with raw price values.
279    ///
280    /// # Parameters
281    ///
282    /// - `high`: The high price for the period.
283    /// - `low`: The low price for the period.
284    /// - `close`: The close price for the period.
285    pub fn update_raw(&mut self, high: f64, low: f64, close: f64) {
286        if !self.has_inputs {
287            self.has_inputs = true;
288        }
289
290        // Maintain high/low deques for period_k lookback
291        if self.highs.len() == self.period_k {
292            self.highs.pop_front();
293            self.lows.pop_front();
294        }
295        let _ = self.highs.push_back(high);
296        let _ = self.lows.push_back(low);
297
298        // Check initialization for period_k (matches original behavior)
299        if !self.initialized
300            && self.highs.len() == self.period_k
301            && self.lows.len() == self.period_k
302        {
303            // Original behavior: set initialized when period_k is filled
304            // (for backward compat with d_method=Ratio, slowing=1)
305            if self.slowing_ma.is_none() && self.d_method == StochasticsDMethod::Ratio {
306                self.initialized = true;
307            }
308        }
309
310        // Calculate highest high and lowest low over period_k
311        let k_max_high = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
312        let k_min_low = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
313
314        // For Ratio method, always update the deques (matches original behavior)
315        if self.d_method == StochasticsDMethod::Ratio {
316            if self.c_sub_1.len() == self.period_d {
317                self.c_sub_1.pop_front();
318                self.h_sub_l.pop_front();
319            }
320            let _ = self.c_sub_1.push_back(close - k_min_low);
321            let _ = self.h_sub_l.push_back(k_max_high - k_min_low);
322        }
323
324        // Handle division by zero (flat market)
325        #[expect(clippy::float_cmp, reason = "guards divide-by-zero on flat market")]
326        if k_max_high == k_min_low {
327            return;
328        }
329
330        // Calculate raw %K
331        let raw_k = 100.0 * ((close - k_min_low) / (k_max_high - k_min_low));
332
333        // Apply slowing if configured (slowing > 1)
334        let slowed_k = match &mut self.slowing_ma {
335            Some(ma) => {
336                ma.update_raw(raw_k);
337                ma.value()
338            }
339            None => raw_k, // No slowing when slowing == 1
340        };
341        self.value_k = slowed_k;
342
343        // Calculate %D based on d_method
344        self.value_d = match self.d_method {
345            StochasticsDMethod::Ratio => {
346                // Nautilus original: 100 * SUM(close-LL) / SUM(HH-LL) over period_d
347                // Deques already updated above
348                let sum_h_sub_l: f64 = self.h_sub_l.iter().sum();
349                if sum_h_sub_l == 0.0 {
350                    0.0
351                } else {
352                    100.0 * (self.c_sub_1.iter().sum::<f64>() / sum_h_sub_l)
353                }
354            }
355            StochasticsDMethod::MovingAverage => {
356                // cTrader-like: MA(slowed_k, period_d, ma_type)
357                if let Some(ref mut ma) = self.d_ma {
358                    ma.update_raw(slowed_k);
359                    ma.value()
360                } else {
361                    50.0 // Fallback (shouldn't happen)
362                }
363            }
364        };
365
366        // Update initialization state for new parameter combinations
367        // For slowing > 1, we need additional warmup for the slowing MA
368        // For d_method == MovingAverage, we need additional warmup for the %D MA
369        if !self.initialized {
370            let base_ready = self.highs.len() == self.period_k;
371            let slowing_ready = match &self.slowing_ma {
372                Some(ma) => ma.initialized(),
373                None => true,
374            };
375            let d_ready = match self.d_method {
376                StochasticsDMethod::Ratio => true, // Already handled above for backward compat
377                StochasticsDMethod::MovingAverage => match &self.d_ma {
378                    Some(ma) => ma.initialized(),
379                    None => true,
380                },
381            };
382
383            if base_ready && slowing_ready && d_ready {
384                self.initialized = true;
385            }
386        }
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use nautilus_model::data::Bar;
393    use rstest::rstest;
394
395    use crate::{
396        average::MovingAverageType,
397        indicator::Indicator,
398        momentum::stochastics::{Stochastics, StochasticsDMethod},
399        stubs::{bar_ethusdt_binance_minute_bid, stochastics_10},
400    };
401
402    #[rstest]
403    fn test_stochastics_initialized(stochastics_10: Stochastics) {
404        let display_str = format!("{stochastics_10}");
405        assert_eq!(display_str, "Stochastics(10,10)");
406        assert_eq!(stochastics_10.period_d, 10);
407        assert_eq!(stochastics_10.period_k, 10);
408        assert!(!stochastics_10.initialized);
409        assert!(!stochastics_10.has_inputs);
410    }
411
412    #[rstest]
413    fn test_value_with_one_input(mut stochastics_10: Stochastics) {
414        stochastics_10.update_raw(1.0, 1.0, 1.0);
415        assert_eq!(stochastics_10.value_d, 0.0);
416        assert_eq!(stochastics_10.value_k, 0.0);
417    }
418
419    #[rstest]
420    fn test_value_with_three_inputs(mut stochastics_10: Stochastics) {
421        stochastics_10.update_raw(1.0, 1.0, 1.0);
422        stochastics_10.update_raw(2.0, 2.0, 2.0);
423        stochastics_10.update_raw(3.0, 3.0, 3.0);
424        assert_eq!(stochastics_10.value_d, 100.0);
425        assert_eq!(stochastics_10.value_k, 100.0);
426    }
427
428    #[rstest]
429    fn test_value_with_ten_inputs(mut stochastics_10: Stochastics) {
430        let high_values = [
431            1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0,
432        ];
433        let low_values = [
434            0.9, 1.9, 2.9, 3.9, 4.9, 5.9, 6.9, 7.9, 8.9, 9.9, 10.1, 10.2, 10.3, 11.1, 11.4,
435        ];
436        let close_values = [
437            1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0,
438        ];
439
440        for i in 0..15 {
441            stochastics_10.update_raw(high_values[i], low_values[i], close_values[i]);
442        }
443
444        assert!(stochastics_10.initialized());
445        assert_eq!(stochastics_10.value_d, 100.0);
446        assert_eq!(stochastics_10.value_k, 100.0);
447    }
448
449    #[rstest]
450    fn test_initialized_with_required_input(mut stochastics_10: Stochastics) {
451        for i in 1..10 {
452            stochastics_10.update_raw(f64::from(i), f64::from(i), f64::from(i));
453        }
454        assert!(!stochastics_10.initialized);
455        stochastics_10.update_raw(10.0, 12.0, 14.0);
456        assert!(stochastics_10.initialized);
457    }
458
459    #[rstest]
460    fn test_handle_bar(mut stochastics_10: Stochastics, bar_ethusdt_binance_minute_bid: Bar) {
461        stochastics_10.handle_bar(&bar_ethusdt_binance_minute_bid);
462        assert_eq!(stochastics_10.value_d, 49.090_909_090_909_09);
463        assert_eq!(stochastics_10.value_k, 49.090_909_090_909_09);
464        assert!(stochastics_10.has_inputs);
465        assert!(!stochastics_10.initialized);
466    }
467
468    #[rstest]
469    fn test_reset(mut stochastics_10: Stochastics) {
470        stochastics_10.update_raw(1.0, 1.0, 1.0);
471        assert_eq!(stochastics_10.c_sub_1.len(), 1);
472        assert_eq!(stochastics_10.h_sub_l.len(), 1);
473
474        stochastics_10.reset();
475        assert_eq!(stochastics_10.value_d, 0.0);
476        assert_eq!(stochastics_10.value_k, 0.0);
477        assert_eq!(stochastics_10.h_sub_l.len(), 0);
478        assert_eq!(stochastics_10.c_sub_1.len(), 0);
479        assert!(!stochastics_10.has_inputs);
480        assert!(!stochastics_10.initialized);
481    }
482
483    #[rstest]
484    fn test_new_defaults_slowing_1_ratio() {
485        let stoch = Stochastics::new(10, 3);
486        assert_eq!(stoch.period_k, 10);
487        assert_eq!(stoch.period_d, 3);
488        assert_eq!(stoch.slowing, 1);
489        assert_eq!(stoch.ma_type, MovingAverageType::Exponential);
490        assert_eq!(stoch.d_method, StochasticsDMethod::Ratio);
491        assert!(
492            stoch.slowing_ma.is_none(),
493            "slowing_ma should be None when slowing == 1"
494        );
495        assert!(
496            stoch.d_ma.is_none(),
497            "d_ma should be None when d_method == Ratio"
498        );
499    }
500
501    #[rstest]
502    fn test_new_with_params_accepts_all_params() {
503        let stoch = Stochastics::new_with_params(
504            11,
505            3,
506            3,
507            MovingAverageType::Exponential,
508            StochasticsDMethod::MovingAverage,
509        );
510        assert_eq!(stoch.period_k, 11);
511        assert_eq!(stoch.period_d, 3);
512        assert_eq!(stoch.slowing, 3);
513        assert_eq!(stoch.ma_type, MovingAverageType::Exponential);
514        assert_eq!(stoch.d_method, StochasticsDMethod::MovingAverage);
515        assert!(
516            stoch.slowing_ma.is_some(),
517            "slowing_ma should exist when slowing > 1"
518        );
519        assert!(
520            stoch.d_ma.is_some(),
521            "d_ma should exist when d_method == MovingAverage"
522        );
523    }
524
525    #[rstest]
526    fn test_backward_compatibility_identical_output() {
527        // Create both old-style and new-style with explicit defaults
528        let mut stoch_old = Stochastics::new(10, 10);
529        let mut stoch_new = Stochastics::new_with_params(
530            10,
531            10,
532            1,
533            MovingAverageType::Exponential,
534            StochasticsDMethod::Ratio,
535        );
536
537        // Feed identical data to both
538        let high_values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
539        let low_values = [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5];
540        let close_values = [0.8, 1.8, 2.8, 3.8, 4.8, 5.8, 6.8, 7.8, 8.8, 9.8];
541
542        for i in 0..10 {
543            stoch_old.update_raw(high_values[i], low_values[i], close_values[i]);
544            stoch_new.update_raw(high_values[i], low_values[i], close_values[i]);
545        }
546
547        // Output should be bit-for-bit identical
548        assert_eq!(stoch_old.value_k, stoch_new.value_k, "value_k mismatch");
549        assert_eq!(stoch_old.value_d, stoch_new.value_d, "value_d mismatch");
550        assert_eq!(stoch_old.initialized, stoch_new.initialized);
551    }
552
553    #[rstest]
554    fn test_slowing_3_smoothes_k() {
555        let mut stoch_no_slowing = Stochastics::new(5, 3);
556        let mut stoch_with_slowing = Stochastics::new_with_params(
557            5,
558            3,
559            3,
560            MovingAverageType::Exponential,
561            StochasticsDMethod::Ratio,
562        );
563
564        // Generate varying data to show smoothing effect
565        let data = [
566            (10.0, 5.0, 8.0),
567            (12.0, 6.0, 7.0),
568            (11.0, 4.0, 9.0),
569            (13.0, 7.0, 8.0),
570            (14.0, 8.0, 10.0),
571            (12.0, 6.0, 7.0),
572            (15.0, 9.0, 14.0),
573            (16.0, 10.0, 11.0),
574        ];
575
576        for (high, low, close) in data {
577            stoch_no_slowing.update_raw(high, low, close);
578            stoch_with_slowing.update_raw(high, low, close);
579        }
580
581        // With slowing, %K should be smoother (different from raw)
582        // We can't assert exact values without knowing the expected behavior,
583        // but we can verify they differ when slowing is applied
584        assert!(
585            (stoch_no_slowing.value_k - stoch_with_slowing.value_k).abs() > 0.01,
586            "Slowing should produce different %K values"
587        );
588    }
589
590    #[rstest]
591    #[case(MovingAverageType::Simple)]
592    #[case(MovingAverageType::Exponential)]
593    #[case(MovingAverageType::Wilder)]
594    #[case(MovingAverageType::Hull)]
595    fn test_slowing_with_different_ma_types(#[case] ma_type: MovingAverageType) {
596        let mut stoch = Stochastics::new_with_params(5, 3, 3, ma_type, StochasticsDMethod::Ratio);
597
598        // Feed data and verify it produces valid output
599        for i in 1..=10 {
600            stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
601        }
602
603        assert!(
604            stoch.value_k.is_finite(),
605            "value_k should be finite with {ma_type:?}"
606        );
607        assert!(
608            stoch.value_d.is_finite(),
609            "value_d should be finite with {ma_type:?}"
610        );
611        assert!(
612            stoch.value_k >= 0.0 && stoch.value_k <= 100.0,
613            "value_k out of range with {ma_type:?}"
614        );
615    }
616
617    #[rstest]
618    fn test_d_method_ratio_preserves_nautilus_behavior() {
619        let mut stoch = Stochastics::new_with_params(
620            10,
621            3,
622            1, // No slowing
623            MovingAverageType::Exponential,
624            StochasticsDMethod::Ratio,
625        );
626
627        // Same data as original test
628        for i in 1..=15 {
629            stoch.update_raw(f64::from(i), f64::from(i) - 0.1, f64::from(i));
630        }
631
632        // Should produce same ratio-based %D as original
633        assert!(stoch.initialized);
634        assert!(stoch.value_d > 0.0);
635    }
636
637    #[rstest]
638    fn test_d_method_ma_produces_smoothed_k() {
639        let mut stoch = Stochastics::new_with_params(
640            5,
641            3,
642            3, // With slowing
643            MovingAverageType::Exponential,
644            StochasticsDMethod::MovingAverage, // MA-based %D
645        );
646
647        let data = [
648            (10.0, 5.0, 8.0),
649            (12.0, 6.0, 7.0),
650            (11.0, 4.0, 9.0),
651            (13.0, 7.0, 8.0),
652            (14.0, 8.0, 10.0),
653            (12.0, 6.0, 7.0),
654            (15.0, 9.0, 14.0),
655            (16.0, 10.0, 11.0),
656            (14.0, 8.0, 12.0),
657            (13.0, 7.0, 10.0),
658        ];
659
660        for (high, low, close) in data {
661            stoch.update_raw(high, low, close);
662        }
663
664        // %D should be smoothed version of %K
665        assert!(stoch.value_d.is_finite());
666        assert!(stoch.value_d >= 0.0 && stoch.value_d <= 100.0);
667    }
668
669    #[rstest]
670    fn test_warmup_period_with_slowing() {
671        let mut stoch = Stochastics::new_with_params(
672            5,
673            3,
674            3, // slowing = 3 means we need period_k + slowing inputs for slowing MA
675            MovingAverageType::Exponential,
676            StochasticsDMethod::Ratio,
677        );
678
679        // With period_k=5, slowing=3, period_d=3:
680        // - Need 5 bars for period_k
681        // - Need 3 more for slowing MA to initialize
682        // - Need 3 for period_d ratio
683        // Exact warmup depends on MA implementation
684
685        for i in 1..=4 {
686            stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
687            assert!(!stoch.initialized, "Should not be initialized at bar {i}");
688        }
689
690        // After enough bars, should initialize
691        for i in 5..=15 {
692            stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
693        }
694
695        assert!(
696            stoch.initialized,
697            "Should be initialized after sufficient bars"
698        );
699    }
700
701    #[rstest]
702    fn test_warmup_period_with_ma_d_method() {
703        let mut stoch = Stochastics::new_with_params(
704            5,
705            3,
706            3,
707            MovingAverageType::Exponential,
708            StochasticsDMethod::MovingAverage, // MA %D needs its own warmup
709        );
710
711        for i in 1..=4 {
712            stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
713        }
714        assert!(!stoch.initialized);
715
716        // Keep feeding until initialized
717        for i in 5..=20 {
718            stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
719        }
720
721        assert!(
722            stoch.initialized,
723            "Should be initialized after sufficient bars"
724        );
725    }
726
727    #[rstest]
728    fn test_reset_clears_slowing_ma_state() {
729        let mut stoch = Stochastics::new_with_params(
730            5,
731            3,
732            3,
733            MovingAverageType::Exponential,
734            StochasticsDMethod::MovingAverage,
735        );
736
737        // Feed some data
738        for i in 1..=10 {
739            stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
740        }
741
742        assert!(stoch.has_inputs);
743
744        // Reset
745        stoch.reset();
746
747        assert!(!stoch.has_inputs);
748        assert!(!stoch.initialized);
749        assert_eq!(stoch.value_k, 0.0);
750        assert_eq!(stoch.value_d, 0.0);
751        assert_eq!(stoch.highs.len(), 0);
752        assert_eq!(stoch.lows.len(), 0);
753
754        // After reset, should be able to use again
755        for i in 1..=10 {
756            stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
757        }
758        assert!(stoch.value_k > 0.0);
759    }
760
761    #[rstest]
762    fn test_slowing_1_bypasses_ma() {
763        let stoch = Stochastics::new_with_params(
764            10,
765            3,
766            1, // slowing = 1 means no MA
767            MovingAverageType::Exponential,
768            StochasticsDMethod::Ratio,
769        );
770
771        assert!(
772            stoch.slowing_ma.is_none(),
773            "slowing = 1 should not create MA"
774        );
775    }
776
777    #[rstest]
778    #[should_panic(expected = "slowing")]
779    fn test_slowing_0_panics() {
780        let _ = Stochastics::new_with_params(
781            10,
782            3,
783            0, // Invalid
784            MovingAverageType::Exponential,
785            StochasticsDMethod::Ratio,
786        );
787    }
788
789    #[rstest]
790    fn test_division_by_zero_protection() {
791        let mut stoch = Stochastics::new_with_params(
792            5,
793            3,
794            3,
795            MovingAverageType::Exponential,
796            StochasticsDMethod::MovingAverage,
797        );
798
799        // Flat market: high == low == close
800        for _ in 0..10 {
801            stoch.update_raw(100.0, 100.0, 100.0);
802        }
803
804        // Should not panic, values should be 0 or previous
805        assert!(stoch.value_k.is_finite());
806        assert!(stoch.value_d.is_finite());
807    }
808}