quantwave_core/indicators/
ehlers_stochastic.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::roofing_filter::RoofingFilter;
3use crate::traits::Next;
4use crate::utils::RingBuffer as VecDeque;
5
6#[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 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}