Skip to main content

quantwave_core/indicators/
channel_cycle.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use std::f64::consts::PI;
4
5/// Channel Cycle Indicator
6///
7/// Based on John Ehlers' "Inferring Trading Strategies from Probability Distribution Functions".
8/// Detrends price using a channel normalization, then applies a bandpass filter to extract
9/// a near sine wave, and computes a leading cosine-like function.
10#[derive(Debug, Clone)]
11pub struct ChannelCycle {
12    period: usize,
13    alpha: f64,
14    beta: f64,
15    price_window: Vec<f64>,
16    bp_history: [f64; 2],
17    detrended_prev: [f64; 2],
18    count: usize,
19}
20
21impl ChannelCycle {
22    pub fn new(period: usize) -> Self {
23        // Standard Ehlers Bandpass for delta=0.1
24        let delta = 0.1;
25        let beta = (2.0 * PI / period as f64).cos();
26        let gamma = 1.0 / (4.0 * PI * delta / period as f64).cos();
27        let alpha = gamma - (gamma * gamma - 1.0).sqrt();
28
29        Self {
30            period,
31            alpha,
32            beta,
33            price_window: Vec::with_capacity(period),
34            bp_history: [0.0; 2],
35            detrended_prev: [0.0; 2],
36            count: 0,
37        }
38    }
39}
40
41impl Default for ChannelCycle {
42    fn default() -> Self {
43        Self::new(20)
44    }
45}
46
47impl Next<f64> for ChannelCycle {
48    type Output = (f64, f64); // (Sine, Leading)
49
50    fn next(&mut self, input: f64) -> Self::Output {
51        self.count += 1;
52        self.price_window.push(input);
53        if self.price_window.len() > self.period {
54            self.price_window.remove(0);
55        }
56
57        if self.price_window.len() < self.period {
58            return (0.0, 0.0);
59        }
60
61        let mut high = f64::MIN;
62        let mut low = f64::MAX;
63        for &p in &self.price_window {
64            if p > high { high = p; }
65            if p < low { low = p; }
66        }
67
68        let detrended = if high != low {
69            (input - low) / (high - low) - 0.5
70        } else {
71            0.0
72        };
73
74        // Bandpass Filter
75        let bp = 0.5 * (1.0 - self.alpha) * (detrended - self.detrended_prev[1])
76            + self.beta * (1.0 + self.alpha) * self.bp_history[0]
77            - self.alpha * self.bp_history[1];
78
79        // Leading Function: derivative corrected for angular frequency
80        // leading = (BP - BP[1]) / (2*PI/Period)
81        let omega = 2.0 * PI / self.period as f64;
82        let leading = (bp - self.bp_history[0]) / omega;
83
84        // Shift history
85        self.bp_history[1] = self.bp_history[0];
86        self.bp_history[0] = bp;
87        self.detrended_prev[1] = self.detrended_prev[0];
88        self.detrended_prev[0] = detrended;
89
90        (bp, leading)
91    }
92}
93
94pub const CHANNEL_CYCLE_METADATA: IndicatorMetadata = IndicatorMetadata {
95    name: "ChannelCycle",
96    description: "Extracts cyclic components and a leading function using channel-normalized bandpass filtering.",
97    params: &[
98        ParamDef {
99            name: "period",
100            default: "20",
101            description: "Channel and Bandpass period",
102        },
103    ],
104    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/InferringTradingStrategies.pdf",
105    formula_latex: r#"
106\[
107Detrended = \frac{Price - Low}{High - Low} - 0.5
108\]
109\[
110BP = \text{Bandpass}(Detrended, Period)
111\]
112\[
113Leading = \frac{BP - BP_{t-1}}{2\pi/Period}
114\]
115"#,
116    gold_standard_file: "channel_cycle.json",
117    category: "Ehlers DSP",
118};
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::traits::Next;
124    use proptest::prelude::*;
125
126    #[test]
127    fn test_channel_cycle_basic() {
128        let mut cc = ChannelCycle::new(20);
129        for i in 0..100 {
130            let (s, l) = cc.next(100.0 + (i as f64 * 0.1).sin());
131            assert!(!s.is_nan());
132            assert!(!l.is_nan());
133        }
134    }
135
136    proptest! {
137        #[test]
138        fn test_channel_cycle_parity(
139            inputs in prop::collection::vec(1.0..100.0, 100..200),
140        ) {
141            let period = 20;
142            let mut cc = ChannelCycle::new(period);
143            let streaming_results: Vec<(f64, f64)> = inputs.iter().map(|&x| cc.next(x)).collect();
144
145            // Batch implementation
146            let mut batch_results = Vec::with_capacity(inputs.len());
147            let delta = 0.1;
148            let beta = (2.0 * PI / period as f64).cos();
149            let gamma = 1.0 / (4.0 * PI * delta / period as f64).cos();
150            let alpha = gamma - (gamma * gamma - 1.0).sqrt();
151            let omega = 2.0 * PI / period as f64;
152
153            let mut detrended_vals = Vec::new();
154            let mut bp_vals = vec![0.0; inputs.len() + 2];
155            let mut d_vals = vec![0.0; inputs.len() + 2];
156
157            for (i, &input) in inputs.iter().enumerate() {
158                let start = if i >= period - 1 { i + 1 - period } else { 0 };
159                let window = &inputs[start..i + 1];
160                
161                if window.len() < period {
162                    batch_results.push((0.0, 0.0));
163                    detrended_vals.push(0.0);
164                    continue;
165                }
166
167                let mut high = f64::MIN;
168                let mut low = f64::MAX;
169                for &p in window {
170                    if p > high { high = p; }
171                    if p < low { low = p; }
172                }
173
174                let detrended = if high != low {
175                    (input - low) / (high - low) - 0.5
176                } else {
177                    0.0
178                };
179                detrended_vals.push(detrended);
180                
181                let idx = i + 2;
182                d_vals[idx] = detrended;
183                let bp = 0.5 * (1.0 - alpha) * (d_vals[idx] - d_vals[idx-2])
184                    + beta * (1.0 + alpha) * bp_vals[idx-1]
185                    - alpha * bp_vals[idx-2];
186                bp_vals[idx] = bp;
187
188                let leading = (bp - bp_vals[idx-1]) / omega;
189                batch_results.push((bp, leading));
190            }
191
192            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
193                approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-10);
194                approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-10);
195            }
196        }
197    }
198}