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 std::collections::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 { min = v; }
42            if v > max { max = v; }
43        }
44        
45        if max == min {
46            50.0
47        } else {
48            100.0 * (roof_val - min) / (max - min)
49        }
50    }
51}
52
53pub const EHLERS_STOCHASTIC_METADATA: IndicatorMetadata = IndicatorMetadata {
54    name: "Ehlers Stochastic",
55    description: "A Stochastic oscillator applied to the output of a Roofing Filter to eliminate Spectral Dilation.",
56    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.",
57    keywords: &["oscillator", "stochastic", "ehlers", "cycle", "adaptive"],
58    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.",
59    params: &[
60        ParamDef { name: "hp_period", default: "48", description: "HighPass critical period" },
61        ParamDef { name: "ss_period", default: "10", description: "SuperSmoother critical period" },
62        ParamDef { name: "stoch_period", default: "20", description: "Stochastic lookback period" },
63    ],
64    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/Anticipating Turning Points.pdf",
65    formula_latex: r#"
66\[
67Roof = RoofingFilter(HP, SS)
68\]
69\[
70Stoch = 100 \times \frac{Roof - \min(Roof, L)}{\max(Roof, L) - \min(Roof, L)}
71\]
72"#,
73    gold_standard_file: "ehlers_stochastic.json",
74    category: "Ehlers DSP",
75};
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use crate::traits::Next;
81    use crate::test_utils::{load_gold_standard, assert_indicator_parity};
82    use proptest::prelude::*;
83
84    #[test]
85    fn test_ehlers_stochastic_gold_standard() {
86        let case = load_gold_standard("ehlers_stochastic");
87        let es = EhlersStochastic::new(48, 10, 20);
88        assert_indicator_parity(es, &case.input, &case.expected);
89    }
90
91    #[test]
92    fn test_ehlers_stochastic_basic() {
93        let mut es = EhlersStochastic::new(48, 10, 20);
94        let inputs = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0];
95        for input in inputs {
96            let res = es.next(input);
97            assert!(res >= 0.0 && res <= 100.0);
98        }
99    }
100
101    proptest! {
102        #[test]
103        fn test_ehlers_stochastic_parity(
104            inputs in prop::collection::vec(1.0..100.0, 50..100),
105        ) {
106            let hp = 48;
107            let ss = 10;
108            let stoch = 20;
109            let mut es = EhlersStochastic::new(hp, ss, stoch);
110            let streaming_results: Vec<f64> = inputs.iter().map(|&x| es.next(x)).collect();
111            
112            // Batch implementation
113            let mut batch_results = Vec::with_capacity(inputs.len());
114            let mut roof = RoofingFilter::new(hp, ss);
115            let mut roof_vals = Vec::new();
116            
117            for &input in &inputs {
118                let r_val = roof.next(input);
119                roof_vals.push(r_val);
120                
121                let start = if roof_vals.len() > stoch { roof_vals.len() - stoch } else { 0 };
122                let window = &roof_vals[start..];
123                
124                let mut min = f64::MAX;
125                let mut max = f64::MIN;
126                for &v in window {
127                    if v < min { min = v; }
128                    if v > max { max = v; }
129                }
130                
131                let res = if max == min {
132                    50.0
133                } else {
134                    100.0 * (r_val - min) / (max - min)
135                };
136                batch_results.push(res);
137            }
138            
139            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
140                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
141            }
142        }
143    }
144}