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    usage: "Use to estimate the dominant cycle period from the width of price channels. Useful as a simpler alternative to Hilbert Transform cycle measurement when computational resources are limited.",
98    keywords: &["cycle", "ehlers", "dsp", "dominant-cycle"],
99    ehlers_summary: "Ehlers estimates the dominant cycle period by tracking successive peaks and troughs of price. The distance between turning points approximates half the cycle period, and smoothing this measurement across recent bars gives a stable period estimate for use in adaptive indicators.",
100    params: &[
101        ParamDef {
102            name: "period",
103            default: "20",
104            description: "Channel and Bandpass period",
105        },
106    ],
107    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/InferringTradingStrategies.pdf",
108    formula_latex: r#"
109\[
110Detrended = \frac{Price - Low}{High - Low} - 0.5
111\]
112\[
113BP = \text{Bandpass}(Detrended, Period)
114\]
115\[
116Leading = \frac{BP - BP_{t-1}}{2\pi/Period}
117\]
118"#,
119    gold_standard_file: "channel_cycle.json",
120    category: "Ehlers DSP",
121};
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::traits::Next;
127    use proptest::prelude::*;
128
129    #[test]
130    fn test_channel_cycle_basic() {
131        let mut cc = ChannelCycle::new(20);
132        for i in 0..100 {
133            let (s, l) = cc.next(100.0 + (i as f64 * 0.1).sin());
134            assert!(!s.is_nan());
135            assert!(!l.is_nan());
136        }
137    }
138
139    proptest! {
140        #[test]
141        fn test_channel_cycle_parity(
142            inputs in prop::collection::vec(1.0..100.0, 100..200),
143        ) {
144            let period = 20;
145            let mut cc = ChannelCycle::new(period);
146            let streaming_results: Vec<(f64, f64)> = inputs.iter().map(|&x| cc.next(x)).collect();
147
148            // Batch implementation
149            let mut batch_results = Vec::with_capacity(inputs.len());
150            let delta = 0.1;
151            let beta = (2.0 * PI / period as f64).cos();
152            let gamma = 1.0 / (4.0 * PI * delta / period as f64).cos();
153            let alpha = gamma - (gamma * gamma - 1.0).sqrt();
154            let omega = 2.0 * PI / period as f64;
155
156            let mut detrended_vals = Vec::new();
157            let mut bp_vals = vec![0.0; inputs.len() + 2];
158            let mut d_vals = vec![0.0; inputs.len() + 2];
159
160            for (i, &input) in inputs.iter().enumerate() {
161                let start = if i >= period - 1 { i + 1 - period } else { 0 };
162                let window = &inputs[start..i + 1];
163                
164                if window.len() < period {
165                    batch_results.push((0.0, 0.0));
166                    detrended_vals.push(0.0);
167                    continue;
168                }
169
170                let mut high = f64::MIN;
171                let mut low = f64::MAX;
172                for &p in window {
173                    if p > high { high = p; }
174                    if p < low { low = p; }
175                }
176
177                let detrended = if high != low {
178                    (input - low) / (high - low) - 0.5
179                } else {
180                    0.0
181                };
182                detrended_vals.push(detrended);
183                
184                let idx = i + 2;
185                d_vals[idx] = detrended;
186                let bp = 0.5 * (1.0 - alpha) * (d_vals[idx] - d_vals[idx-2])
187                    + beta * (1.0 + alpha) * bp_vals[idx-1]
188                    - alpha * bp_vals[idx-2];
189                bp_vals[idx] = bp;
190
191                let leading = (bp - bp_vals[idx-1]) / omega;
192                batch_results.push((bp, leading));
193            }
194
195            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
196                approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-10);
197                approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-10);
198            }
199        }
200    }
201}