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 {
65                high = p;
66            }
67            if p < low {
68                low = p;
69            }
70        }
71
72        let detrended = if high != low {
73            (input - low) / (high - low) - 0.5
74        } else {
75            0.0
76        };
77
78        // Bandpass Filter
79        let bp = 0.5 * (1.0 - self.alpha) * (detrended - self.detrended_prev[1])
80            + self.beta * (1.0 + self.alpha) * self.bp_history[0]
81            - self.alpha * self.bp_history[1];
82
83        // Leading Function: derivative corrected for angular frequency
84        // leading = (BP - BP[1]) / (2*PI/Period)
85        let omega = 2.0 * PI / self.period as f64;
86        let leading = (bp - self.bp_history[0]) / omega;
87
88        // Shift history
89        self.bp_history[1] = self.bp_history[0];
90        self.bp_history[0] = bp;
91        self.detrended_prev[1] = self.detrended_prev[0];
92        self.detrended_prev[0] = detrended;
93
94        (bp, leading)
95    }
96}
97
98pub const CHANNEL_CYCLE_METADATA: IndicatorMetadata = IndicatorMetadata {
99    name: "ChannelCycle",
100    description: "Extracts cyclic components and a leading function using channel-normalized bandpass filtering.",
101    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.",
102    keywords: &["cycle", "ehlers", "dsp", "dominant-cycle"],
103    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.",
104    params: &[ParamDef {
105        name: "period",
106        default: "20",
107        description: "Channel and Bandpass period",
108    }],
109    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/InferringTradingStrategies.pdf",
110    formula_latex: r#"
111\[
112Detrended = \frac{Price - Low}{High - Low} - 0.5
113\]
114\[
115BP = \text{Bandpass}(Detrended, Period)
116\]
117\[
118Leading = \frac{BP - BP_{t-1}}{2\pi/Period}
119\]
120"#,
121    gold_standard_file: "channel_cycle.json",
122    category: "Ehlers DSP",
123};
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::traits::Next;
129    use proptest::prelude::*;
130
131    #[test]
132    fn test_channel_cycle_basic() {
133        let mut cc = ChannelCycle::new(20);
134        for i in 0..100 {
135            let (s, l) = cc.next(100.0 + (i as f64 * 0.1).sin());
136            assert!(!s.is_nan());
137            assert!(!l.is_nan());
138        }
139    }
140
141    proptest! {
142        #[test]
143        fn test_channel_cycle_parity(
144            inputs in prop::collection::vec(1.0..100.0, 100..200),
145        ) {
146            let period = 20;
147            let mut cc = ChannelCycle::new(period);
148            let streaming_results: Vec<(f64, f64)> = inputs.iter().map(|&x| cc.next(x)).collect();
149
150            // Batch implementation
151            let mut batch_results = Vec::with_capacity(inputs.len());
152            let delta = 0.1;
153            let beta = (2.0 * PI / period as f64).cos();
154            let gamma = 1.0 / (4.0 * PI * delta / period as f64).cos();
155            let alpha = gamma - (gamma * gamma - 1.0).sqrt();
156            let omega = 2.0 * PI / period as f64;
157
158            let mut detrended_vals = Vec::new();
159            let mut bp_vals = vec![0.0; inputs.len() + 2];
160            let mut d_vals = vec![0.0; inputs.len() + 2];
161
162            for (i, &input) in inputs.iter().enumerate() {
163                let start = if i >= period - 1 { i + 1 - period } else { 0 };
164                let window = &inputs[start..i + 1];
165
166                if window.len() < period {
167                    batch_results.push((0.0, 0.0));
168                    detrended_vals.push(0.0);
169                    continue;
170                }
171
172                let mut high = f64::MIN;
173                let mut low = f64::MAX;
174                for &p in window {
175                    if p > high { high = p; }
176                    if p < low { low = p; }
177                }
178
179                let detrended = if high != low {
180                    (input - low) / (high - low) - 0.5
181                } else {
182                    0.0
183                };
184                detrended_vals.push(detrended);
185
186                let idx = i + 2;
187                d_vals[idx] = detrended;
188                let bp = 0.5 * (1.0 - alpha) * (d_vals[idx] - d_vals[idx-2])
189                    + beta * (1.0 + alpha) * bp_vals[idx-1]
190                    - alpha * bp_vals[idx-2];
191                bp_vals[idx] = bp;
192
193                let leading = (bp - bp_vals[idx-1]) / omega;
194                batch_results.push((bp, leading));
195            }
196
197            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
198                approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-10);
199                approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-10);
200            }
201        }
202    }
203}