Skip to main content

quantwave_core/indicators/
ehlers_loops.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use std::f64::consts::PI;
4
5/// Ehlers Loops
6///
7/// Based on John Ehlers' "Ehlers Loops" (2022).
8/// This indicator combines a 2-pole Butterworth Highpass filter and a SuperSmoother filter
9/// to normalize price and volume data into "RMS" units (Standard Deviations).
10/// Plotting PriceRMS vs VolRMS in a scatter plot creates "Ehlers Loops".
11#[derive(Debug, Clone)]
12pub struct EhlersLoops {
13    price_filter: NormalizedRoofing,
14    volume_filter: NormalizedRoofing,
15}
16
17#[derive(Debug, Clone)]
18struct NormalizedRoofing {
19    hpc1: f64,
20    hpc2: f64,
21    hpc3: f64,
22    ssc1: f64,
23    ssc2: f64,
24    ssc3: f64,
25    rms_alpha: f64,
26
27    input_history: [f64; 2],
28    hp_history: [f64; 2],
29    ss_history: [f64; 2],
30    ms: f64,
31    count: usize,
32}
33
34impl NormalizedRoofing {
35    fn new(lp_period: usize, hp_period: usize, rms_alpha: f64) -> Self {
36        let hp_period_f = hp_period as f64;
37        let lp_period_f = lp_period as f64;
38
39        // 2 Pole Butterworth Highpass Filter coefficients
40        let hpa1 = (-1.414 * PI / hp_period_f).exp();
41        let hpb1 = 2.0 * hpa1 * (1.414 * PI / hp_period_f).cos();
42        let hpc2 = hpb1;
43        let hpc3 = -hpa1 * hpa1;
44        let hpc1 = (1.0 + hpc2 - hpc3) / 4.0;
45
46        // 2 Pole Super Smoother Filter coefficients
47        let ssa1 = (-1.414 * PI / lp_period_f).exp();
48        let ssb1 = 2.0 * ssa1 * (1.414 * PI / lp_period_f).cos();
49        let ssc2 = ssb1;
50        let ssc3 = -ssa1 * ssa1;
51        let ssc1 = 1.0 - ssc2 - ssc3;
52
53        Self {
54            hpc1,
55            hpc2,
56            hpc3,
57            ssc1,
58            ssc2,
59            ssc3,
60            rms_alpha,
61            input_history: [0.0; 2],
62            hp_history: [0.0; 2],
63            ss_history: [0.0; 2],
64            ms: 0.0,
65            count: 0,
66        }
67    }
68
69    fn next(&mut self, input: f64) -> f64 {
70        self.count += 1;
71
72        // HP = hpc1 * ( Close - 2 * Close[1] + Close[2] ) + hpc2 * HP[1] + hpc3 * HP[2];
73        let hp = if self.count < 3 {
74            0.0
75        } else {
76            self.hpc1 * (input - 2.0 * self.input_history[0] + self.input_history[1])
77                + self.hpc2 * self.hp_history[0]
78                + self.hpc3 * self.hp_history[1]
79        };
80
81        // SS = ssc1 * ( HP + HP[1] ) / 2 + ssc2 * SS[1] + ssc3 * SS[2];
82        let ss = if self.count < 3 {
83            0.0
84        } else {
85            self.ssc1 * (hp + self.hp_history[0]) / 2.0
86                + self.ssc2 * self.ss_history[0]
87                + self.ssc3 * self.ss_history[1]
88        };
89
90        // Scale in terms of Standard Deviations using Fast RMS (EMA of squares)
91        if self.count == 1 {
92            self.ms = ss * ss;
93        } else {
94            self.ms = self.rms_alpha * (ss * ss) + (1.0 - self.rms_alpha) * self.ms;
95        }
96
97        let res = if self.ms > 0.0 {
98            ss / self.ms.sqrt()
99        } else {
100            0.0
101        };
102
103        // Update history
104        self.hp_history[1] = self.hp_history[0];
105        self.hp_history[0] = hp;
106        self.input_history[1] = self.input_history[0];
107        self.input_history[0] = input;
108        self.ss_history[1] = self.ss_history[0];
109        self.ss_history[0] = ss;
110
111        res
112    }
113}
114
115impl EhlersLoops {
116    pub fn new(lp_period: usize, hp_period: usize) -> Self {
117        // The default alpha for RMS in the paper is 0.0242
118        Self::with_rms_alpha(lp_period, hp_period, 0.0242)
119    }
120
121    pub fn with_rms_alpha(lp_period: usize, hp_period: usize, rms_alpha: f64) -> Self {
122        Self {
123            price_filter: NormalizedRoofing::new(lp_period, hp_period, rms_alpha),
124            volume_filter: NormalizedRoofing::new(lp_period, hp_period, rms_alpha),
125        }
126    }
127}
128
129impl Next<(f64, f64)> for EhlersLoops {
130    type Output = (f64, f64); // (PriceRMS, VolRMS)
131
132    fn next(&mut self, (price, volume): (f64, f64)) -> Self::Output {
133        (
134            self.price_filter.next(price),
135            self.volume_filter.next(volume),
136        )
137    }
138}
139
140pub const EHLERS_LOOPS_METADATA: IndicatorMetadata = IndicatorMetadata {
141    name: "Ehlers Loops",
142    description: "Converts price and volume into normalized standard deviation units for scatter plot analysis.",
143    usage: "Use to visualize cycle dynamics in phase-space by plotting the indicator value against its derivative. Loop patterns reveal cycle turns before they appear in the price chart.",
144    keywords: &["cycle", "phase", "ehlers", "dsp", "visualization"],
145    ehlers_summary: "Ehlers describes phase-space loops in Cybernetic Analysis as a powerful visualization technique where an indicator is plotted against its first derivative. In cycle mode the path traces elliptical loops; in trend mode the path collapses to a line, enabling visual market mode identification.",
146    params: &[
147        ParamDef {
148            name: "lp_period",
149            default: "20",
150            description: "Low-pass filter period (SuperSmoother)",
151        },
152        ParamDef {
153            name: "hp_period",
154            default: "125",
155            description: "High-pass filter period (Butterworth)",
156        },
157    ],
158    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/traderstipsreference/TRADERS’%20TIPS%20-%20JUNE%202022.html",
159    formula_latex: r#"
160\[
161HP = c_1 (Price - 2 Price_{t-1} + Price_{t-2}) + c_2 HP_{t-1} + c_3 HP_{t-2}
162\]
163\[
164SS = s_1 \frac{HP + HP_{t-1}}{2} + s_2 SS_{t-1} + s_3 SS_{t-2}
165\]
166\[
167MS = \alpha SS^2 + (1 - \alpha) MS_{t-1}
168\]
169\[
170RMS = \frac{SS}{\sqrt{MS}}
171\]
172"#,
173    gold_standard_file: "ehlers_loops.json",
174    category: "Ehlers DSP",
175};
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::traits::Next;
181    use crate::test_utils::{load_gold_standard_loops, assert_indicator_parity_loops};
182    use proptest::prelude::*;
183
184    #[test]
185    fn test_ehlers_loops_gold_standard() {
186        let case = load_gold_standard_loops("ehlers_loops");
187        let el = EhlersLoops::new(20, 125);
188        assert_indicator_parity_loops(el, &case.input, &case.expected);
189    }
190
191    #[test]
192    fn test_ehlers_loops_basic() {
193        let mut el = EhlersLoops::new(20, 125);
194        let inputs = vec![(100.0, 1000.0), (101.0, 1100.0), (102.0, 1200.0)];
195        for input in inputs {
196            let (p_rms, v_rms) = el.next(input);
197            assert!(!p_rms.is_nan());
198            assert!(!v_rms.is_nan());
199        }
200    }
201
202    proptest! {
203        #[test]
204        fn test_ehlers_loops_parity(
205            prices in prop::collection::vec(1.0..100.0, 100..200),
206            volumes in prop::collection::vec(100.0..1000.0, 100..200),
207        ) {
208            let lp_period = 20;
209            let hp_period = 125;
210            let rms_alpha = 0.0242;
211            let mut el = EhlersLoops::with_rms_alpha(lp_period, hp_period, rms_alpha);
212            
213            let min_len = prices.len().min(volumes.len());
214            let inputs: Vec<(f64, f64)> = prices[..min_len].iter().cloned().zip(volumes[..min_len].iter().cloned()).collect();
215            let streaming_results: Vec<(f64, f64)> = inputs.iter().map(|&x| el.next(x)).collect();
216
217            // Batch implementation for Price
218            let mut price_results = Vec::with_capacity(inputs.len());
219            let hp_period_f = hp_period as f64;
220            let lp_period_f = lp_period as f64;
221
222            let hpa1 = (-1.414 * PI / hp_period_f).exp();
223            let hpb1 = 2.0 * hpa1 * (1.414 * PI / hp_period_f).cos();
224            let hpc2 = hpb1;
225            let hpc3 = -hpa1 * hpa1;
226            let hpc1 = (1.0 + hpc2 - hpc3) / 4.0;
227
228            let ssa1 = (-1.414 * PI / lp_period_f).exp();
229            let ssb1 = 2.0 * ssa1 * (1.414 * PI / lp_period_f).cos();
230            let ssc2 = ssb1;
231            let ssc3 = -ssa1 * ssa1;
232            let ssc1 = 1.0 - ssc2 - ssc3;
233
234            let mut p_input_hist = [0.0; 2];
235            let mut p_hp_hist = [0.0; 2];
236            let mut p_ss_hist = [0.0; 2];
237            let mut p_ms = 0.0;
238
239            for (i, &(p_input, _)) in inputs.iter().enumerate() {
240                let bar = i + 1;
241                let hp = if bar < 3 { 0.0 } else {
242                    hpc1 * (p_input - 2.0 * p_input_hist[0] + p_input_hist[1]) + hpc2 * p_hp_hist[0] + hpc3 * p_hp_hist[1]
243                };
244                let ss = if bar < 3 { 0.0 } else {
245                    ssc1 * (hp + p_hp_hist[0]) / 2.0 + ssc2 * p_ss_hist[0] + ssc3 * p_ss_hist[1]
246                };
247                if bar == 1 { p_ms = ss * ss; } else { p_ms = rms_alpha * ss * ss + (1.0 - rms_alpha) * p_ms; }
248                let res = if p_ms > 0.0 { ss / p_ms.sqrt() } else { 0.0 };
249                
250                p_hp_hist[1] = p_hp_hist[0]; p_hp_hist[0] = hp;
251                p_input_hist[1] = p_input_hist[0]; p_input_hist[0] = p_input;
252                p_ss_hist[1] = p_ss_hist[0]; p_ss_hist[0] = ss;
253                price_results.push(res);
254            }
255
256            // Batch implementation for Volume
257            let mut vol_results = Vec::with_capacity(inputs.len());
258            let mut v_input_hist = [0.0; 2];
259            let mut v_hp_hist = [0.0; 2];
260            let mut v_ss_hist = [0.0; 2];
261            let mut v_ms = 0.0;
262
263            for (i, &(_, v_input)) in inputs.iter().enumerate() {
264                let bar = i + 1;
265                let hp = if bar < 3 { 0.0 } else {
266                    hpc1 * (v_input - 2.0 * v_input_hist[0] + v_input_hist[1]) + hpc2 * v_hp_hist[0] + hpc3 * v_hp_hist[1]
267                };
268                let ss = if bar < 3 { 0.0 } else {
269                    ssc1 * (hp + v_hp_hist[0]) / 2.0 + ssc2 * v_ss_hist[0] + ssc3 * v_ss_hist[1]
270                };
271                if bar == 1 { v_ms = ss * ss; } else { v_ms = rms_alpha * ss * ss + (1.0 - rms_alpha) * v_ms; }
272                let res = if v_ms > 0.0 { ss / v_ms.sqrt() } else { 0.0 };
273                
274                v_hp_hist[1] = v_hp_hist[0]; v_hp_hist[0] = hp;
275                v_input_hist[1] = v_input_hist[0]; v_input_hist[0] = v_input;
276                v_ss_hist[1] = v_ss_hist[0]; v_ss_hist[0] = ss;
277                vol_results.push(res);
278            }
279
280            for (_i, (s, bp, bv)) in streaming_results.iter().zip(price_results.iter().zip(vol_results.iter())).map(|(s, (bp, bv))| (s, bp, bv)).enumerate() {
281                approx::assert_relative_eq!(s.0, *bp, epsilon = 1e-10);
282                approx::assert_relative_eq!(s.1, *bv, epsilon = 1e-10);
283            }
284        }
285    }
286}