quantwave_core/indicators/
ehlers_stochastic.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::roofing_filter::RoofingFilter;
3use crate::traits::Next;
4use std::collections::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 { 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 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}