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    params: &[
144        ParamDef {
145            name: "lp_period",
146            default: "20",
147            description: "Low-pass filter period (SuperSmoother)",
148        },
149        ParamDef {
150            name: "hp_period",
151            default: "125",
152            description: "High-pass filter period (Butterworth)",
153        },
154    ],
155    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/traderstipsreference/TRADERS’%20TIPS%20-%20JUNE%202022.html",
156    formula_latex: r#"
157\[
158HP = c_1 (Price - 2 Price_{t-1} + Price_{t-2}) + c_2 HP_{t-1} + c_3 HP_{t-2}
159\]
160\[
161SS = s_1 \frac{HP + HP_{t-1}}{2} + s_2 SS_{t-1} + s_3 SS_{t-2}
162\]
163\[
164MS = \alpha SS^2 + (1 - \alpha) MS_{t-1}
165\]
166\[
167RMS = \frac{SS}{\sqrt{MS}}
168\]
169"#,
170    gold_standard_file: "ehlers_loops.json",
171    category: "Ehlers DSP",
172};
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::traits::Next;
178    use crate::test_utils::{load_gold_standard_loops, assert_indicator_parity_loops};
179    use proptest::prelude::*;
180
181    #[test]
182    fn test_ehlers_loops_gold_standard() {
183        let case = load_gold_standard_loops("ehlers_loops");
184        let el = EhlersLoops::new(20, 125);
185        assert_indicator_parity_loops(el, &case.input, &case.expected);
186    }
187
188    #[test]
189    fn test_ehlers_loops_basic() {
190        let mut el = EhlersLoops::new(20, 125);
191        let inputs = vec![(100.0, 1000.0), (101.0, 1100.0), (102.0, 1200.0)];
192        for input in inputs {
193            let (p_rms, v_rms) = el.next(input);
194            assert!(!p_rms.is_nan());
195            assert!(!v_rms.is_nan());
196        }
197    }
198
199    proptest! {
200        #[test]
201        fn test_ehlers_loops_parity(
202            prices in prop::collection::vec(1.0..100.0, 100..200),
203            volumes in prop::collection::vec(100.0..1000.0, 100..200),
204        ) {
205            let lp_period = 20;
206            let hp_period = 125;
207            let rms_alpha = 0.0242;
208            let mut el = EhlersLoops::with_rms_alpha(lp_period, hp_period, rms_alpha);
209            
210            let min_len = prices.len().min(volumes.len());
211            let inputs: Vec<(f64, f64)> = prices[..min_len].iter().cloned().zip(volumes[..min_len].iter().cloned()).collect();
212            let streaming_results: Vec<(f64, f64)> = inputs.iter().map(|&x| el.next(x)).collect();
213
214            // Batch implementation for Price
215            let mut price_results = Vec::with_capacity(inputs.len());
216            let hp_period_f = hp_period as f64;
217            let lp_period_f = lp_period as f64;
218
219            let hpa1 = (-1.414 * PI / hp_period_f).exp();
220            let hpb1 = 2.0 * hpa1 * (1.414 * PI / hp_period_f).cos();
221            let hpc2 = hpb1;
222            let hpc3 = -hpa1 * hpa1;
223            let hpc1 = (1.0 + hpc2 - hpc3) / 4.0;
224
225            let ssa1 = (-1.414 * PI / lp_period_f).exp();
226            let ssb1 = 2.0 * ssa1 * (1.414 * PI / lp_period_f).cos();
227            let ssc2 = ssb1;
228            let ssc3 = -ssa1 * ssa1;
229            let ssc1 = 1.0 - ssc2 - ssc3;
230
231            let mut p_input_hist = [0.0; 2];
232            let mut p_hp_hist = [0.0; 2];
233            let mut p_ss_hist = [0.0; 2];
234            let mut p_ms = 0.0;
235
236            for (i, &(p_input, _)) in inputs.iter().enumerate() {
237                let bar = i + 1;
238                let hp = if bar < 3 { 0.0 } else {
239                    hpc1 * (p_input - 2.0 * p_input_hist[0] + p_input_hist[1]) + hpc2 * p_hp_hist[0] + hpc3 * p_hp_hist[1]
240                };
241                let ss = if bar < 3 { 0.0 } else {
242                    ssc1 * (hp + p_hp_hist[0]) / 2.0 + ssc2 * p_ss_hist[0] + ssc3 * p_ss_hist[1]
243                };
244                if bar == 1 { p_ms = ss * ss; } else { p_ms = rms_alpha * ss * ss + (1.0 - rms_alpha) * p_ms; }
245                let res = if p_ms > 0.0 { ss / p_ms.sqrt() } else { 0.0 };
246                
247                p_hp_hist[1] = p_hp_hist[0]; p_hp_hist[0] = hp;
248                p_input_hist[1] = p_input_hist[0]; p_input_hist[0] = p_input;
249                p_ss_hist[1] = p_ss_hist[0]; p_ss_hist[0] = ss;
250                price_results.push(res);
251            }
252
253            // Batch implementation for Volume
254            let mut vol_results = Vec::with_capacity(inputs.len());
255            let mut v_input_hist = [0.0; 2];
256            let mut v_hp_hist = [0.0; 2];
257            let mut v_ss_hist = [0.0; 2];
258            let mut v_ms = 0.0;
259
260            for (i, &(_, v_input)) in inputs.iter().enumerate() {
261                let bar = i + 1;
262                let hp = if bar < 3 { 0.0 } else {
263                    hpc1 * (v_input - 2.0 * v_input_hist[0] + v_input_hist[1]) + hpc2 * v_hp_hist[0] + hpc3 * v_hp_hist[1]
264                };
265                let ss = if bar < 3 { 0.0 } else {
266                    ssc1 * (hp + v_hp_hist[0]) / 2.0 + ssc2 * v_ss_hist[0] + ssc3 * v_ss_hist[1]
267                };
268                if bar == 1 { v_ms = ss * ss; } else { v_ms = rms_alpha * ss * ss + (1.0 - rms_alpha) * v_ms; }
269                let res = if v_ms > 0.0 { ss / v_ms.sqrt() } else { 0.0 };
270                
271                v_hp_hist[1] = v_hp_hist[0]; v_hp_hist[0] = hp;
272                v_input_hist[1] = v_input_hist[0]; v_input_hist[0] = v_input;
273                v_ss_hist[1] = v_ss_hist[0]; v_ss_hist[0] = ss;
274                vol_results.push(res);
275            }
276
277            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() {
278                approx::assert_relative_eq!(s.0, *bp, epsilon = 1e-10);
279                approx::assert_relative_eq!(s.1, *bv, epsilon = 1e-10);
280            }
281        }
282    }
283}