1use 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
31const FLAT_WINDOW_VALUE: f64 = 50.0;
33
34#[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,
80 #[default]
83 MovingAverage,
84}
85
86#[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 pub period_k: usize,
103 pub period_d: usize,
105 pub slowing: usize,
107 pub ma_type: MovingAverageType,
109 pub d_method: StochasticsDMethod,
111 pub value_k: f64,
113 pub value_d: f64,
115 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 slowing_ma: Option<Box<dyn MovingAverage + Send + Sync>>,
124 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 if let Some(ref mut ma) = self.slowing_ma {
184 ma.reset();
185 }
186
187 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 #[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 #[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 let slowing_ma = if slowing > 1 {
271 Some(MovingAverageFactory::create(ma_type, slowing))
272 } else {
273 None
274 };
275
276 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 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 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 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 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 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 let value_d = match self.d_method {
373 StochasticsDMethod::Ratio => {
374 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 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 }
397 }
398 };
399
400 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, 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 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 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 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 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 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 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, MovingAverageType::Exponential,
679 StochasticsDMethod::Ratio,
680 );
681
682 for i in 1..=15 {
684 stoch.update_raw(f64::from(i), f64::from(i) - 0.1, f64::from(i));
685 }
686
687 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, MovingAverageType::Exponential,
699 StochasticsDMethod::MovingAverage, );
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 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, MovingAverageType::Exponential,
731 StochasticsDMethod::Ratio,
732 );
733
734 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 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, );
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 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 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 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 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, 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, 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 for _ in 0..10 {
880 stoch.update_raw(100.0, 100.0, 100.0);
881 }
882
883 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}