Skip to main content

quantwave_core/indicators/
bandpass.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use std::f64::consts::PI;
4
5/// BandPass Filter
6///
7/// Based on John Ehlers' "Empirical Mode Decomposition" and "Fourier Series Model".
8/// Isolates cyclic components within a specific frequency band.
9#[derive(Debug, Clone)]
10pub struct BandPass {
11    alpha: f64,
12    beta: f64,
13    price_prev1: f64,
14    price_prev2: f64,
15    bp_history: [f64; 2],
16    count: usize,
17}
18
19impl BandPass {
20    pub fn new(period: usize, bandwidth: f64) -> Self {
21        let beta = (2.0 * PI / period as f64).cos();
22        let gamma = 1.0 / (2.0 * PI * bandwidth / period as f64).cos();
23        let alpha = gamma - (gamma * gamma - 1.0).sqrt();
24
25        Self {
26            alpha,
27            beta,
28            price_prev1: 0.0,
29            price_prev2: 0.0,
30            bp_history: [0.0; 2],
31            count: 0,
32        }
33    }
34}
35
36impl Next<f64> for BandPass {
37    type Output = f64;
38
39    fn next(&mut self, input: f64) -> Self::Output {
40        self.count += 1;
41
42        // BP = .5*(1 - alpha)*(Price - Price[2]) + beta*(1 + alpha)*BP[1] - alpha*BP[2];
43        let bp = 0.5 * (1.0 - self.alpha) * (input - self.price_prev2)
44            + self.beta * (1.0 + self.alpha) * self.bp_history[0]
45            - self.alpha * self.bp_history[1];
46
47        self.bp_history[1] = self.bp_history[0];
48        self.bp_history[0] = bp;
49        self.price_prev2 = self.price_prev1;
50        self.price_prev1 = input;
51
52        bp
53    }
54}
55
56pub const BANDPASS_METADATA: IndicatorMetadata = IndicatorMetadata {
57    name: "BandPass",
58    description: "A bandpass filter that isolates cycle components around a center period.",
59    usage: "Apply to isolate a specific cycle period in price, filtering out both trend and noise. Use zero crossings of the filtered output as entry and exit signals.",
60    keywords: &["filter", "cycle", "ehlers", "dsp", "bandpass"],
61    ehlers_summary: "Ehlers presents the BandPass filter in Cybernetic Analysis as a second-order IIR filter centred on a target cycle period with tunable bandwidth. It simultaneously attenuates lower and higher frequencies, leaving only the desired cycle band in the output.",
62    params: &[
63        ParamDef {
64            name: "period",
65            default: "20",
66            description: "Center period of the passband",
67        },
68        ParamDef {
69            name: "bandwidth",
70            default: "0.1",
71            description: "Relative bandwidth (delta)",
72        },
73    ],
74    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/EmpiricalModeDecomposition.pdf",
75    formula_latex: r#"
76\[
77\beta = \cos(360/P), \gamma = 1/\cos(720\delta/P), \alpha = \gamma - \sqrt{\gamma^2 - 1}
78\]
79\[
80BP = 0.5(1 - \alpha)(Price - Price_{t-2}) + \beta(1 + \alpha)BP_{t-1} - \alpha BP_{t-2}
81\]
82"#,
83    gold_standard_file: "bandpass.json",
84    category: "Ehlers DSP",
85};
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::traits::Next;
91    use proptest::prelude::*;
92
93    #[test]
94    fn test_bandpass_basic() {
95        let mut bp = BandPass::new(20, 0.1);
96        for i in 0..50 {
97            let val = bp.next(100.0 + (i as f64 * 0.1).sin());
98            assert!(!val.is_nan());
99        }
100    }
101
102    proptest! {
103        #[test]
104        fn test_bandpass_parity(
105            inputs in prop::collection::vec(1.0..100.0, 50..100),
106        ) {
107            let period = 20;
108            let bandwidth = 0.1;
109            let mut bp_obj = BandPass::new(period, bandwidth);
110            let streaming_results: Vec<f64> = inputs.iter().map(|&x| bp_obj.next(x)).collect();
111
112            // Batch implementation
113            let mut batch_results = Vec::with_capacity(inputs.len());
114            let beta = (2.0 * PI / period as f64).cos();
115            let _gamma = 1.0 / (2.0 * PI * 2.0 * bandwidth / period as f64).cos(); // Ehlers uses 720*delta
116            // Wait, Ehlers uses 360/P for Cosine (degrees) which is 2*PI/P for cos (radians).
117            // Ehlers uses 720*delta/P for Cosine which is 4*PI*delta/P for cos.
118            // My alpha/beta in code:
119            // beta = cos(2*PI/P)
120            // gamma = 1/cos(4*PI*delta/P)
121            
122            let alpha = {
123                let g = 1.0 / (2.0 * PI * bandwidth / period as f64).cos();
124                g - (g * g - 1.0).sqrt()
125            };
126
127            let mut p_hist = vec![0.0; inputs.len() + 2];
128            let mut b_hist = vec![0.0; inputs.len() + 2];
129
130            for (i, &input) in inputs.iter().enumerate() {
131                let idx = i + 2;
132                p_hist[idx] = input;
133                let bp = 0.5 * (1.0 - alpha) * (p_hist[idx] - p_hist[idx-2])
134                    + beta * (1.0 + alpha) * b_hist[idx-1]
135                    - alpha * b_hist[idx-2];
136                b_hist[idx] = bp;
137                batch_results.push(bp);
138            }
139
140            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
141                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
142            }
143        }
144    }
145}