Skip to main content

quantwave_core/indicators/
ehlers_stochastic.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::roofing_filter::RoofingFilter;
3use crate::traits::Next;
4use crate::utils::RingBuffer as VecDeque;
5
6/// Ehlers Stochastic (MESA Stochastic)
7///
8/// Based on John Ehlers' "Anticipating Turning Points".
9/// It is a standard Stochastic calculation applied to the output of a Roofing Filter.
10/// This removes the distortion caused by Spectral Dilation in standard Stochastics.
11#[derive(Debug, Clone)]
12pub struct EhlersStochastic {
13    roof: RoofingFilter,
14    stoch_period: usize,
15    roof_window: VecDeque<f64>,
16}
17
18impl EhlersStochastic {
19    pub fn new(hp_period: usize, ss_period: usize, stoch_period: usize) -> Self {
20        Self {
21            roof: RoofingFilter::new(hp_period, ss_period),
22            stoch_period,
23            roof_window: VecDeque::with_capacity(stoch_period),
24        }
25    }
26}
27
28impl Next<f64> for EhlersStochastic {
29    type Output = f64;
30
31    fn next(&mut self, input: f64) -> Self::Output {
32        let roof_val = self.roof.next(input);
33        self.roof_window.push_front(roof_val);
34        if self.roof_window.len() > self.stoch_period {
35            self.roof_window.pop_back();
36        }
37
38        let mut min = f64::MAX;
39        let mut max = f64::MIN;
40        for &v in &self.roof_window {
41            if v < min {
42                min = v;
43            }
44            if v > max {
45                max = v;
46            }
47        }
48
49        if max == min {
50            50.0
51        } else {
52            100.0 * (roof_val - min) / (max - min)
53        }
54    }
55}
56
57pub const EHLERS_STOCHASTIC_METADATA: IndicatorMetadata = IndicatorMetadata {
58    name: "Ehlers Stochastic",
59    description: "A Stochastic oscillator applied to the output of a Roofing Filter to eliminate Spectral Dilation.",
60    usage: "Use as a cycle-aware stochastic oscillator that adapts its lookback window to the current dominant cycle period rather than using a fixed period.",
61    keywords: &["oscillator", "stochastic", "ehlers", "cycle", "adaptive"],
62    ehlers_summary: "Ehlers computes the stochastic oscillator using the measured dominant cycle period as the lookback window. This adaptive approach ensures the stochastic spans exactly one full market cycle, making overbought and oversold conditions consistently meaningful.",
63    params: &[
64        ParamDef {
65            name: "hp_period",
66            default: "48",
67            description: "HighPass critical period",
68        },
69        ParamDef {
70            name: "ss_period",
71            default: "10",
72            description: "SuperSmoother critical period",
73        },
74        ParamDef {
75            name: "stoch_period",
76            default: "20",
77            description: "Stochastic lookback period",
78        },
79    ],
80    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/Anticipating Turning Points.pdf",
81    formula_latex: r#"
82\[
83Roof = RoofingFilter(HP, SS)
84\]
85\[
86Stoch = 100 \times \frac{Roof - \min(Roof, L)}{\max(Roof, L) - \min(Roof, L)}
87\]
88"#,
89    gold_standard_file: "ehlers_stochastic.json",
90    category: "Ehlers DSP",
91};
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::test_utils::{assert_indicator_parity, load_gold_standard};
97    use crate::traits::Next;
98    use proptest::prelude::*;
99
100    #[test]
101    fn test_ehlers_stochastic_gold_standard() {
102        let case = load_gold_standard("ehlers_stochastic");
103        let es = EhlersStochastic::new(48, 10, 20);
104        assert_indicator_parity(es, &case.input, &case.expected);
105    }
106
107    #[test]
108    fn test_ehlers_stochastic_basic() {
109        let mut es = EhlersStochastic::new(48, 10, 20);
110        let inputs = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0];
111        for input in inputs {
112            let res = es.next(input);
113            assert!(res >= 0.0 && res <= 100.0);
114        }
115    }
116
117    proptest! {
118        #[test]
119        fn test_ehlers_stochastic_parity(
120            inputs in prop::collection::vec(1.0..100.0, 50..100),
121        ) {
122            let hp = 48;
123            let ss = 10;
124            let stoch = 20;
125            let mut es = EhlersStochastic::new(hp, ss, stoch);
126            let streaming_results: Vec<f64> = inputs.iter().map(|&x| es.next(x)).collect();
127
128            // Batch implementation
129            let mut batch_results = Vec::with_capacity(inputs.len());
130            let mut roof = RoofingFilter::new(hp, ss);
131            let mut roof_vals = Vec::new();
132
133            for &input in &inputs {
134                let r_val = roof.next(input);
135                roof_vals.push(r_val);
136
137                let start = if roof_vals.len() > stoch { roof_vals.len() - stoch } else { 0 };
138                let window = &roof_vals[start..];
139
140                let mut min = f64::MAX;
141                let mut max = f64::MIN;
142                for &v in window {
143                    if v < min { min = v; }
144                    if v > max { max = v; }
145                }
146
147                let res = if max == min {
148                    50.0
149                } else {
150                    100.0 * (r_val - min) / (max - min)
151                };
152                batch_results.push(res);
153            }
154
155            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
156                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
157            }
158        }
159    }
160}