quantwave_core/indicators/
channel_cycle.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use std::f64::consts::PI;
4
5#[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 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); 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 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 let omega = 2.0 * PI / self.period as f64;
82 let leading = (bp - self.bp_history[0]) / omega;
83
84 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 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}