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 {
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 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 let omega = 2.0 * PI / self.period as f64;
86 let leading = (bp - self.bp_history[0]) / omega;
87
88 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 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}