Skip to main content

quantwave_core/indicators/
correlation_cycle.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use std::collections::VecDeque;
4use std::f64::consts::PI;
5
6/// Correlation Cycle Indicator
7///
8/// Based on John Ehlers' "Correlation as a Cycle Indicator".
9/// Computes the correlation of price against a fixed-period Cosine and negative Sine wave
10/// to derive a phase angle. This angle is used to identify market cycles and trends.
11#[derive(Debug, Clone)]
12pub struct CorrelationCycle {
13    period: usize,
14    price_window: VecDeque<f64>,
15    cosine_wave: Vec<f64>,
16    sine_wave: Vec<f64>,
17    prev_angle: f64,
18    count: usize,
19}
20
21impl CorrelationCycle {
22    pub fn new(period: usize) -> Self {
23        let mut cosine_wave = Vec::with_capacity(period);
24        let mut sine_wave = Vec::with_capacity(period);
25        for n in 0..period {
26            let angle = 2.0 * PI * n as f64 / period as f64;
27            cosine_wave.push(angle.cos());
28            sine_wave.push(-angle.sin());
29        }
30
31        Self {
32            period,
33            price_window: VecDeque::with_capacity(period),
34            cosine_wave,
35            sine_wave,
36            prev_angle: 0.0,
37            count: 0,
38        }
39    }
40
41    fn pearson_correlation(n: usize, x: &VecDeque<f64>, y: &Vec<f64>) -> f64 {
42        let mut sx = 0.0;
43        let mut sy = 0.0;
44        let mut sxx = 0.0;
45        let mut syy = 0.0;
46        let mut sxy = 0.0;
47
48        for i in 0..n {
49            let xi = x[i];
50            let yi = y[i];
51            sx += xi;
52            sy += yi;
53            sxx += xi * xi;
54            syy += yi * yi;
55            sxy += xi * yi;
56        }
57
58        let nf = n as f64;
59        let num = nf * sxy - sx * sy;
60        let den = ((nf * sxx - sx * sx) * (nf * syy - sy * sy)).sqrt();
61
62        if den > 0.0 {
63            num / den
64        } else {
65            0.0
66        }
67    }
68}
69
70impl Default for CorrelationCycle {
71    fn default() -> Self {
72        Self::new(20)
73    }
74}
75
76impl Next<f64> for CorrelationCycle {
77    type Output = (f64, f64, f64); // (Real, Imag, Angle)
78
79    fn next(&mut self, input: f64) -> Self::Output {
80        self.count += 1;
81        self.price_window.push_front(input);
82        if self.price_window.len() > self.period {
83            self.price_window.pop_back();
84        }
85
86        if self.price_window.len() < self.period {
87            return (0.0, 0.0, 0.0);
88        }
89
90        let real = Self::pearson_correlation(self.period, &self.price_window, &self.cosine_wave);
91        let imag = Self::pearson_correlation(self.period, &self.price_window, &self.sine_wave);
92
93        // Angle in degrees: 90 + atan(Real / Imag)
94        // Ehlers resolve ambiguity: if Imag > 0 then Angle = Angle - 180
95        let mut angle = if imag != 0.0 {
96            (real / imag).atan().to_degrees() + 90.0
97        } else {
98            90.0
99        };
100
101        if imag > 0.0 {
102            angle -= 180.0;
103        }
104
105        // Do not allow rate change of angle to go negative
106        // If Angle[1] - Angle < 270 and Angle < Angle[1] Then Angle = Angle[1];
107        if self.count > self.period + 1 {
108            if self.prev_angle - angle < 270.0 && angle < self.prev_angle {
109                angle = self.prev_angle;
110            }
111        }
112
113        self.prev_angle = angle;
114
115        (real, imag, angle)
116    }
117}
118
119pub const CORRELATION_CYCLE_METADATA: IndicatorMetadata = IndicatorMetadata {
120    name: "CorrelationCycle",
121    description: "Determines cycle phase angle by correlating price with orthogonal sinusoids.",
122    params: &[
123        ParamDef {
124            name: "period",
125            default: "20",
126            description: "Correlation wavelength",
127        },
128    ],
129    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/CORRELATION%20AS%20A%20CYCLE%20INDICATOR.pdf",
130    formula_latex: r#"
131\[
132R = \text{Corr}(Price, \cos(2\pi n/P)), I = \text{Corr}(Price, -\sin(2\pi n/P))
133\]
134\[
135\text{Angle} = 90 + \arctan(R/I) \text{ (with quadrant resolution)}
136\]
137"#,
138    gold_standard_file: "correlation_cycle.json",
139    category: "Ehlers DSP",
140};
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::traits::Next;
146    use proptest::prelude::*;
147
148    #[test]
149    fn test_correlation_cycle_basic() {
150        let mut cc = CorrelationCycle::new(20);
151        for i in 0..100 {
152            let (r, im, a) = cc.next((2.0 * PI * i as f64 / 20.0).sin());
153            assert!(!r.is_nan());
154            assert!(!im.is_nan());
155            assert!(!a.is_nan());
156        }
157    }
158
159    proptest! {
160        #[test]
161        fn test_correlation_cycle_parity(
162            inputs in prop::collection::vec(1.0..100.0, 100..150),
163        ) {
164            let period = 20;
165            let mut cc = CorrelationCycle::new(period);
166            let streaming_results: Vec<(f64, f64, f64)> = inputs.iter().map(|&x| cc.next(x)).collect();
167
168            // Batch implementation
169            let mut batch_results = Vec::with_capacity(inputs.len());
170            let mut cos_w = Vec::new();
171            let mut sin_w = Vec::new();
172            for n in 0..period {
173                let ang = 2.0 * PI * n as f64 / period as f64;
174                cos_w.push(ang.cos());
175                sin_w.push(-ang.sin());
176            }
177
178            let mut prev_a = 0.0;
179            for i in 0..inputs.len() {
180                if i < period - 1 {
181                    batch_results.push((0.0, 0.0, 0.0));
182                    continue;
183                }
184                
185                let mut sx = 0.0;
186                let mut sy_c = 0.0;
187                let mut sy_s = 0.0;
188                let mut sxx = 0.0;
189                let mut syy_c = 0.0;
190                let mut syy_s = 0.0;
191                let mut sxy_c = 0.0;
192                let mut sxy_s = 0.0;
193
194                for j in 0..period {
195                    let xi = inputs[i - j];
196                    let yc = cos_w[j];
197                    let ys = sin_w[j];
198                    sx += xi;
199                    sy_c += yc;
200                    sy_s += ys;
201                    sxx += xi * xi;
202                    syy_c += yc * yc;
203                    syy_s += ys * ys;
204                    sxy_c += xi * yc;
205                    sxy_s += xi * ys;
206                }
207
208                let nf = period as f64;
209                let den_c = ((nf * sxx - sx * sx) * (nf * syy_c - sy_c * sy_c)).sqrt();
210                let real = if den_c > 0.0 { (nf * sxy_c - sx * sy_c) / den_c } else { 0.0 };
211                
212                let den_s = ((nf * sxx - sx * sx) * (nf * syy_s - sy_s * sy_s)).sqrt();
213                let imag = if den_s > 0.0 { (nf * sxy_s - sx * sy_s) / den_s } else { 0.0 };
214
215                let mut angle = if imag != 0.0 {
216                    (real / imag).atan().to_degrees() + 90.0
217                } else {
218                    90.0
219                };
220                if imag > 0.0 { angle -= 180.0; }
221
222                if i > period {
223                    if prev_a - angle < 270.0 && angle < prev_a {
224                        angle = prev_a;
225                    }
226                }
227                
228                prev_a = angle;
229                batch_results.push((real, imag, angle));
230            }
231
232            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
233                approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-10);
234                approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-10);
235                approx::assert_relative_eq!(s.2, b.2, epsilon = 1e-10);
236            }
237        }
238    }
239}