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