Skip to main content

quantwave_core/indicators/
fourier_transform.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use crate::indicators::high_pass::HighPass;
4use std::collections::VecDeque;
5use std::f64::consts::PI;
6
7/// Fourier Dominant Cycle
8///
9/// Based on John Ehlers' "Fourier Transform for Traders".
10/// Estimates the dominant cycle period using a Discrete Fourier Transform (DFT)
11/// with a Kay & Demeure resolution-enhancing transformation, followed by a
12/// center-of-gravity calculation.
13#[derive(Debug, Clone)]
14pub struct FourierDominantCycle {
15    window_len: usize,
16    hp: HighPass,
17    hp_history: [f64; 6],
18    cleaned_window: VecDeque<f64>,
19    count: usize,
20}
21
22impl FourierDominantCycle {
23    pub fn new(window_len: usize) -> Self {
24        Self {
25            window_len,
26            hp: HighPass::new(40),
27            hp_history: [0.0; 6],
28            cleaned_window: VecDeque::with_capacity(window_len),
29            count: 0,
30        }
31    }
32}
33
34impl Default for FourierDominantCycle {
35    fn default() -> Self {
36        Self::new(50)
37    }
38}
39
40impl Next<f64> for FourierDominantCycle {
41    type Output = f64;
42
43    fn next(&mut self, input: f64) -> Self::Output {
44        self.count += 1;
45        let hp_val = self.hp.next(input);
46
47        // FIR Smoothing: CleanedData = (HP + 2*HP[1] + 3*HP[2] + 3*HP[3] + 2*HP[4] + HP[5])/12
48        let cleaned = (hp_val 
49            + 2.0 * self.hp_history[0] 
50            + 3.0 * self.hp_history[1] 
51            + 3.0 * self.hp_history[2] 
52            + 2.0 * self.hp_history[3] 
53            + self.hp_history[4]) / 12.0;
54
55        // Shift HP history
56        for i in (1..6).rev() {
57            self.hp_history[i] = self.hp_history[i-1];
58        }
59        self.hp_history[0] = hp_val;
60
61        self.cleaned_window.push_front(cleaned);
62        if self.cleaned_window.len() > self.window_len {
63            self.cleaned_window.pop_back();
64        }
65
66        if self.cleaned_window.len() < self.window_len {
67            return 0.0;
68        }
69
70        // DFT
71        let mut pwr = vec![0.0; 51]; // Periods 8 to 50
72        for period_idx in 8..=50 {
73            let period = period_idx as f64;
74            let mut cos_part = 0.0;
75            let mut sin_part = 0.0;
76            for n in 0..self.window_len {
77                let angle = 2.0 * PI * n as f64 / period;
78                cos_part += self.cleaned_window[n] * angle.cos();
79                sin_part += self.cleaned_window[n] * angle.sin();
80            }
81            pwr[period_idx] = cos_part * cos_part + sin_part * sin_part;
82        }
83
84        // Max Power
85        let mut max_pwr = 0.0;
86        for &p in &pwr[8..=50] {
87            if p > max_pwr { max_pwr = p; }
88        }
89
90        // dB and CG
91        let mut num = 0.0;
92        let mut denom = 0.0;
93        for period_idx in 8..=50 {
94            let p = pwr[period_idx];
95            let db = if max_pwr > 0.0 && p > 0.0 {
96                let val = 0.01 / (1.0 - 0.99 * p / max_pwr);
97                -10.0 * val.log10()
98            } else {
99                20.0
100            }.min(20.0);
101
102            if db < 3.0 {
103                num += period_idx as f64 * (3.0 - db);
104                denom += 3.0 - db;
105            }
106        }
107
108        if denom != 0.0 {
109            num / denom
110        } else {
111            0.0
112        }
113    }
114}
115
116pub const FOURIER_DOMINANT_CYCLE_METADATA: IndicatorMetadata = IndicatorMetadata {
117    name: "FourierDominantCycle",
118    description: "Dominant cycle period estimation using resolution-enhanced DFT and center of gravity.",
119    params: &[
120        ParamDef {
121            name: "window_len",
122            default: "50",
123            description: "DFT window length",
124        },
125    ],
126    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/FourierTransformForTraders.pdf",
127    formula_latex: r#"
128\[
129HP = \text{HighPass}(Price, 40)
130\]
131\[
132Cleaned = \frac{HP + 2HP_{t-1} + 3HP_{t-2} + 3HP_{t-3} + 2HP_{t-4} + HP_{t-5}}{12}
133\]
134\[
135Pwr(P) = \left(\sum_{n=0}^{W-1} Cleaned_{t-n} \cos\left(\frac{2\pi n}{P}\right)\right)^2 + \left(\sum_{n=0}^{W-1} Cleaned_{t-n} \sin\left(\frac{2\pi n}{P}\right)\right)^2
136\]
137\[
138DB(P) = \min\left(20, -10 \log_{10}\left(\frac{0.01}{1 - 0.99 \frac{Pwr(P)}{\max(Pwr)}}\right)\right)
139\]
140\[
141DC = \frac{\sum_{P=8}^{50} P \cdot (3 - DB(P)) \text{ where } DB(P) < 3}{\sum (3 - DB(P))}
142\]
143"#,
144    gold_standard_file: "fourier_dominant_cycle.json",
145    category: "Ehlers DSP",
146};
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::traits::Next;
152    use proptest::prelude::*;
153
154    #[test]
155    fn test_fourier_dc_basic() {
156        let mut fdc = FourierDominantCycle::new(50);
157        for i in 0..200 {
158            // Sine wave with period 20
159            let val = fdc.next((2.0 * PI * i as f64 / 20.0).sin());
160            if i > 150 {
161                // Should be around 20
162                assert!(val > 15.0 && val < 25.0);
163            }
164        }
165    }
166
167    proptest! {
168        #[test]
169        fn test_fourier_dc_parity(
170            inputs in prop::collection::vec(1.0..100.0, 100..150),
171        ) {
172            let window_len = 50;
173            let mut fdc = FourierDominantCycle::new(window_len);
174            let streaming_results: Vec<f64> = inputs.iter().map(|&x| fdc.next(x)).collect();
175
176            // Batch implementation
177            let mut batch_results = Vec::with_capacity(inputs.len());
178            let mut hp = HighPass::new(40);
179            let hp_vals: Vec<f64> = inputs.iter().map(|&x| hp.next(x)).collect();
180            let mut cleaned_vals = Vec::new();
181
182            for i in 0..hp_vals.len() {
183                let c = (hp_vals[i] 
184                    + 2.0 * (if i > 0 { hp_vals[i-1] } else { 0.0 })
185                    + 3.0 * (if i > 1 { hp_vals[i-2] } else { 0.0 })
186                    + 3.0 * (if i > 2 { hp_vals[i-3] } else { 0.0 })
187                    + 2.0 * (if i > 3 { hp_vals[i-4] } else { 0.0 })
188                    + (if i > 4 { hp_vals[i-5] } else { 0.0 })) / 12.0;
189                cleaned_vals.push(c);
190
191                if i < window_len + 5 { // account for HP and FIR delay
192                    batch_results.push(0.0);
193                    continue;
194                }
195
196                let mut pwr = vec![0.0; 51];
197                for period_idx in 8..=50 {
198                    let period = period_idx as f64;
199                    let mut cos_part = 0.0;
200                    let mut sin_part = 0.0;
201                    for n in 0..window_len {
202                        let angle = 2.0 * PI * n as f64 / period;
203                        cos_part += cleaned_vals[i-n] * angle.cos();
204                        sin_part += cleaned_vals[i-n] * angle.sin();
205                    }
206                    pwr[period_idx] = cos_part * cos_part + sin_part * sin_part;
207                }
208
209                let mut max_p = 0.0;
210                for &p in &pwr[8..=50] {
211                    if p > max_p { max_p = p; }
212                }
213
214                let mut num = 0.0;
215                let mut den = 0.0;
216                for period_idx in 8..=50 {
217                    let p = pwr[period_idx];
218                    let db = if max_p > 0.0 && p > 0.0 {
219                        let val = 0.01 / (1.0 - 0.99 * p / max_p);
220                        -10.0 * val.log10()
221                    } else {
222                        20.0
223                    }.min(20.0);
224
225                    if db < 3.0 {
226                        num += period_idx as f64 * (3.0 - db);
227                        den += 3.0 - db;
228                    }
229                }
230
231                batch_results.push(if den != 0.0 { num / den } else { 0.0 });
232            }
233
234            // Note: streaming uses front-pushing window, batch uses indexing.
235            // There might be minor startup differences due to history initialization.
236            for i in 60..inputs.len() {
237                approx::assert_relative_eq!(streaming_results[i], batch_results[i], epsilon = 1e-10);
238            }
239        }
240    }
241}