1use 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#[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 #[default]
76 Ratio,
77 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 pub period_k: usize,
94 pub period_d: usize,
96 pub slowing: usize,
98 pub ma_type: MovingAverageType,
100 pub d_method: StochasticsDMethod,
102 pub value_k: f64,
104 pub value_d: f64,
106 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 slowing_ma: Option<Box<dyn MovingAverage + Send + Sync>>,
115 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 if let Some(ref mut ma) = self.slowing_ma {
175 ma.reset();
176 }
177
178 if let Some(ref mut ma) = self.d_ma {
180 ma.reset();
181 }
182 }
183}
184
185impl Stochastics {
186 #[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, MovingAverageType::Exponential, StochasticsDMethod::Ratio, )
207 }
208
209 #[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 let slowing_ma = if slowing > 1 {
246 Some(MovingAverageFactory::create(ma_type, slowing))
247 } else {
248 None
249 };
250
251 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 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 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 if !self.initialized
300 && self.highs.len() == self.period_k
301 && self.lows.len() == self.period_k
302 {
303 if self.slowing_ma.is_none() && self.d_method == StochasticsDMethod::Ratio {
306 self.initialized = true;
307 }
308 }
309
310 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 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 #[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 let raw_k = 100.0 * ((close - k_min_low) / (k_max_high - k_min_low));
332
333 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, };
341 self.value_k = slowed_k;
342
343 self.value_d = match self.d_method {
345 StochasticsDMethod::Ratio => {
346 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 if let Some(ref mut ma) = self.d_ma {
358 ma.update_raw(slowed_k);
359 ma.value()
360 } else {
361 50.0 }
363 }
364 };
365
366 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, 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 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 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 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 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 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 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, MovingAverageType::Exponential,
624 StochasticsDMethod::Ratio,
625 );
626
627 for i in 1..=15 {
629 stoch.update_raw(f64::from(i), f64::from(i) - 0.1, f64::from(i));
630 }
631
632 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, MovingAverageType::Exponential,
644 StochasticsDMethod::MovingAverage, );
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 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, MovingAverageType::Exponential,
676 StochasticsDMethod::Ratio,
677 );
678
679 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 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, );
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 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 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 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 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, 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, 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 for _ in 0..10 {
801 stoch.update_raw(100.0, 100.0, 100.0);
802 }
803
804 assert!(stoch.value_k.is_finite());
806 assert!(stoch.value_d.is_finite());
807 }
808}