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.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.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 testing::assert_approx_equal,
401 };
402
403 #[rstest]
404 fn test_stochastics_initialized(stochastics_10: Stochastics) {
405 let display_str = format!("{stochastics_10}");
406 assert_eq!(display_str, "Stochastics(10,10)");
407 assert_eq!(stochastics_10.period_d, 10);
408 assert_eq!(stochastics_10.period_k, 10);
409 assert!(!stochastics_10.initialized);
410 assert!(!stochastics_10.has_inputs);
411 }
412
413 #[rstest]
414 fn test_value_with_one_input(mut stochastics_10: Stochastics) {
415 stochastics_10.update_raw(1.0, 1.0, 1.0);
416 assert_eq!(stochastics_10.value_d, 0.0);
417 assert_eq!(stochastics_10.value_k, 0.0);
418 }
419
420 #[rstest]
421 fn test_value_with_three_inputs(mut stochastics_10: Stochastics) {
422 stochastics_10.update_raw(1.0, 1.0, 1.0);
423 stochastics_10.update_raw(2.0, 2.0, 2.0);
424 stochastics_10.update_raw(3.0, 3.0, 3.0);
425 assert_eq!(stochastics_10.value_d, 100.0);
426 assert_eq!(stochastics_10.value_k, 100.0);
427 }
428
429 #[rstest]
430 fn test_value_with_ten_inputs(mut stochastics_10: Stochastics) {
431 let high_values = [
432 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,
433 ];
434 let low_values = [
435 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,
436 ];
437 let close_values = [
438 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,
439 ];
440
441 for i in 0..15 {
442 stochastics_10.update_raw(high_values[i], low_values[i], close_values[i]);
443 }
444
445 assert!(stochastics_10.initialized());
446 assert_eq!(stochastics_10.value_d, 100.0);
447 assert_eq!(stochastics_10.value_k, 100.0);
448 }
449
450 #[rstest]
451 fn test_initialized_with_required_input(mut stochastics_10: Stochastics) {
452 for i in 1..10 {
453 stochastics_10.update_raw(f64::from(i), f64::from(i), f64::from(i));
454 }
455 assert!(!stochastics_10.initialized);
456 stochastics_10.update_raw(10.0, 12.0, 14.0);
457 assert!(stochastics_10.initialized);
458 }
459
460 #[rstest]
461 fn test_handle_bar(mut stochastics_10: Stochastics, bar_ethusdt_binance_minute_bid: Bar) {
462 stochastics_10.handle_bar(&bar_ethusdt_binance_minute_bid);
463 assert_approx_equal(stochastics_10.value_d, 49.0909090909);
464 assert_approx_equal(stochastics_10.value_k, 49.0909090909);
465 assert!(stochastics_10.has_inputs);
466 assert!(!stochastics_10.initialized);
467 }
468
469 #[rstest]
470 fn test_reset(mut stochastics_10: Stochastics) {
471 stochastics_10.update_raw(1.0, 1.0, 1.0);
472 assert_eq!(stochastics_10.c_sub_1.len(), 1);
473 assert_eq!(stochastics_10.h_sub_l.len(), 1);
474
475 stochastics_10.reset();
476 assert_eq!(stochastics_10.value_d, 0.0);
477 assert_eq!(stochastics_10.value_k, 0.0);
478 assert_eq!(stochastics_10.h_sub_l.len(), 0);
479 assert_eq!(stochastics_10.c_sub_1.len(), 0);
480 assert!(!stochastics_10.has_inputs);
481 assert!(!stochastics_10.initialized);
482 }
483
484 #[rstest]
485 fn test_new_defaults_slowing_1_ratio() {
486 let stoch = Stochastics::new(10, 3);
487 assert_eq!(stoch.period_k, 10);
488 assert_eq!(stoch.period_d, 3);
489 assert_eq!(stoch.slowing, 1);
490 assert_eq!(stoch.ma_type, MovingAverageType::Exponential);
491 assert_eq!(stoch.d_method, StochasticsDMethod::Ratio);
492 assert!(
493 stoch.slowing_ma.is_none(),
494 "slowing_ma should be None when slowing == 1"
495 );
496 assert!(
497 stoch.d_ma.is_none(),
498 "d_ma should be None when d_method == Ratio"
499 );
500 }
501
502 #[rstest]
503 fn test_new_with_params_accepts_all_params() {
504 let stoch = Stochastics::new_with_params(
505 11,
506 3,
507 3,
508 MovingAverageType::Exponential,
509 StochasticsDMethod::MovingAverage,
510 );
511 assert_eq!(stoch.period_k, 11);
512 assert_eq!(stoch.period_d, 3);
513 assert_eq!(stoch.slowing, 3);
514 assert_eq!(stoch.ma_type, MovingAverageType::Exponential);
515 assert_eq!(stoch.d_method, StochasticsDMethod::MovingAverage);
516 assert!(
517 stoch.slowing_ma.is_some(),
518 "slowing_ma should exist when slowing > 1"
519 );
520 assert!(
521 stoch.d_ma.is_some(),
522 "d_ma should exist when d_method == MovingAverage"
523 );
524 }
525
526 #[rstest]
527 fn test_backward_compatibility_identical_output() {
528 let mut stoch_old = Stochastics::new(10, 10);
530 let mut stoch_new = Stochastics::new_with_params(
531 10,
532 10,
533 1,
534 MovingAverageType::Exponential,
535 StochasticsDMethod::Ratio,
536 );
537
538 let high_values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
540 let low_values = [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5];
541 let close_values = [0.8, 1.8, 2.8, 3.8, 4.8, 5.8, 6.8, 7.8, 8.8, 9.8];
542
543 for i in 0..10 {
544 stoch_old.update_raw(high_values[i], low_values[i], close_values[i]);
545 stoch_new.update_raw(high_values[i], low_values[i], close_values[i]);
546 }
547
548 assert_eq!(stoch_old.value_k, stoch_new.value_k, "value_k mismatch");
550 assert_eq!(stoch_old.value_d, stoch_new.value_d, "value_d mismatch");
551 assert_eq!(stoch_old.initialized, stoch_new.initialized);
552 }
553
554 #[rstest]
555 fn test_slowing_3_smoothes_k() {
556 let mut stoch_no_slowing = Stochastics::new(5, 3);
557 let mut stoch_with_slowing = Stochastics::new_with_params(
558 5,
559 3,
560 3,
561 MovingAverageType::Exponential,
562 StochasticsDMethod::Ratio,
563 );
564
565 let data = [
567 (10.0, 5.0, 8.0),
568 (12.0, 6.0, 7.0),
569 (11.0, 4.0, 9.0),
570 (13.0, 7.0, 8.0),
571 (14.0, 8.0, 10.0),
572 (12.0, 6.0, 7.0),
573 (15.0, 9.0, 14.0),
574 (16.0, 10.0, 11.0),
575 ];
576
577 for (high, low, close) in data {
578 stoch_no_slowing.update_raw(high, low, close);
579 stoch_with_slowing.update_raw(high, low, close);
580 }
581
582 assert!(
586 (stoch_no_slowing.value_k - stoch_with_slowing.value_k).abs() > 0.01,
587 "Slowing should produce different %K values"
588 );
589 }
590
591 #[rstest]
592 #[case(MovingAverageType::Simple)]
593 #[case(MovingAverageType::Exponential)]
594 #[case(MovingAverageType::Wilder)]
595 #[case(MovingAverageType::Hull)]
596 fn test_slowing_with_different_ma_types(#[case] ma_type: MovingAverageType) {
597 let mut stoch = Stochastics::new_with_params(5, 3, 3, ma_type, StochasticsDMethod::Ratio);
598
599 for i in 1..=10 {
601 stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
602 }
603
604 assert!(
605 stoch.value_k.is_finite(),
606 "value_k should be finite with {ma_type:?}"
607 );
608 assert!(
609 stoch.value_d.is_finite(),
610 "value_d should be finite with {ma_type:?}"
611 );
612 assert!(
613 stoch.value_k >= 0.0 && stoch.value_k <= 100.0,
614 "value_k out of range with {ma_type:?}"
615 );
616 }
617
618 #[rstest]
619 fn test_d_method_ratio_preserves_nautilus_behavior() {
620 let mut stoch = Stochastics::new_with_params(
621 10,
622 3,
623 1, MovingAverageType::Exponential,
625 StochasticsDMethod::Ratio,
626 );
627
628 for i in 1..=15 {
630 stoch.update_raw(f64::from(i), f64::from(i) - 0.1, f64::from(i));
631 }
632
633 assert!(stoch.initialized);
635 assert!(stoch.value_d > 0.0);
636 }
637
638 #[rstest]
639 fn test_d_method_ma_produces_smoothed_k() {
640 let mut stoch = Stochastics::new_with_params(
641 5,
642 3,
643 3, MovingAverageType::Exponential,
645 StochasticsDMethod::MovingAverage, );
647
648 let data = [
649 (10.0, 5.0, 8.0),
650 (12.0, 6.0, 7.0),
651 (11.0, 4.0, 9.0),
652 (13.0, 7.0, 8.0),
653 (14.0, 8.0, 10.0),
654 (12.0, 6.0, 7.0),
655 (15.0, 9.0, 14.0),
656 (16.0, 10.0, 11.0),
657 (14.0, 8.0, 12.0),
658 (13.0, 7.0, 10.0),
659 ];
660
661 for (high, low, close) in data {
662 stoch.update_raw(high, low, close);
663 }
664
665 assert!(stoch.value_d.is_finite());
667 assert!(stoch.value_d >= 0.0 && stoch.value_d <= 100.0);
668 }
669
670 #[rstest]
671 fn test_warmup_period_with_slowing() {
672 let mut stoch = Stochastics::new_with_params(
673 5,
674 3,
675 3, MovingAverageType::Exponential,
677 StochasticsDMethod::Ratio,
678 );
679
680 for i in 1..=4 {
687 stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
688 assert!(!stoch.initialized, "Should not be initialized at bar {i}");
689 }
690
691 for i in 5..=15 {
693 stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
694 }
695
696 assert!(
697 stoch.initialized,
698 "Should be initialized after sufficient bars"
699 );
700 }
701
702 #[rstest]
703 fn test_warmup_period_with_ma_d_method() {
704 let mut stoch = Stochastics::new_with_params(
705 5,
706 3,
707 3,
708 MovingAverageType::Exponential,
709 StochasticsDMethod::MovingAverage, );
711
712 for i in 1..=4 {
713 stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
714 }
715 assert!(!stoch.initialized);
716
717 for i in 5..=20 {
719 stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
720 }
721
722 assert!(
723 stoch.initialized,
724 "Should be initialized after sufficient bars"
725 );
726 }
727
728 #[rstest]
729 fn test_reset_clears_slowing_ma_state() {
730 let mut stoch = Stochastics::new_with_params(
731 5,
732 3,
733 3,
734 MovingAverageType::Exponential,
735 StochasticsDMethod::MovingAverage,
736 );
737
738 for i in 1..=10 {
740 stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
741 }
742
743 assert!(stoch.has_inputs);
744
745 stoch.reset();
747
748 assert!(!stoch.has_inputs);
749 assert!(!stoch.initialized);
750 assert_eq!(stoch.value_k, 0.0);
751 assert_eq!(stoch.value_d, 0.0);
752 assert_eq!(stoch.highs.len(), 0);
753 assert_eq!(stoch.lows.len(), 0);
754
755 for i in 1..=10 {
757 stoch.update_raw(f64::from(i) + 5.0, f64::from(i), f64::from(i) + 2.0);
758 }
759 assert!(stoch.value_k > 0.0);
760 }
761
762 #[rstest]
763 fn test_slowing_1_bypasses_ma() {
764 let stoch = Stochastics::new_with_params(
765 10,
766 3,
767 1, MovingAverageType::Exponential,
769 StochasticsDMethod::Ratio,
770 );
771
772 assert!(
773 stoch.slowing_ma.is_none(),
774 "slowing = 1 should not create MA"
775 );
776 }
777
778 #[rstest]
779 #[should_panic(expected = "slowing")]
780 fn test_slowing_0_panics() {
781 let _ = Stochastics::new_with_params(
782 10,
783 3,
784 0, MovingAverageType::Exponential,
786 StochasticsDMethod::Ratio,
787 );
788 }
789
790 #[rstest]
791 fn test_division_by_zero_protection() {
792 let mut stoch = Stochastics::new_with_params(
793 5,
794 3,
795 3,
796 MovingAverageType::Exponential,
797 StochasticsDMethod::MovingAverage,
798 );
799
800 for _ in 0..10 {
802 stoch.update_raw(100.0, 100.0, 100.0);
803 }
804
805 assert!(stoch.value_k.is_finite());
807 assert!(stoch.value_d.is_finite());
808 }
809}