quantwave_core/indicators/
ehlers_loops.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use std::f64::consts::PI;
4
5#[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 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 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 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 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 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 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 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); 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 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 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}