Skip to main content

quantwave_core/indicators/
autotune.rs

1use crate::indicators::high_pass::HighPass;
2use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
3use crate::traits::Next;
4use std::collections::VecDeque;
5use std::f64::consts::PI;
6
7/// AutoTune Filter
8///
9/// Based on John Ehlers' "The AutoTune Filter" (2025).
10/// This indicator dynamically tunes a BandPass filter by identifying the Dominant Cycle
11/// using a rolling autocorrelation of high-pass filtered data.
12#[derive(Debug, Clone)]
13pub struct AutoTuneFilter {
14    window: usize,
15    bandwidth: f64,
16    highpass: HighPass,
17    filt_history: VecDeque<f64>,
18    dc_prev: f64,
19    price_prev: [f64; 2],
20    bp_history: [f64; 2],
21    count: usize,
22}
23
24impl AutoTuneFilter {
25    pub fn new(window: usize, bandwidth: f64) -> Self {
26        Self {
27            window,
28            bandwidth,
29            highpass: HighPass::new(window),
30            filt_history: VecDeque::with_capacity(2 * window),
31            dc_prev: window as f64, // Initial guess
32            price_prev: [0.0; 2],
33            bp_history: [0.0; 2],
34            count: 0,
35        }
36    }
37}
38
39impl Next<f64> for AutoTuneFilter {
40    type Output = f64;
41
42    fn next(&mut self, input: f64) -> Self::Output {
43        self.count += 1;
44        let filt = self.highpass.next(input);
45        self.filt_history.push_front(filt);
46        if self.filt_history.len() > 2 * self.window {
47            self.filt_history.pop_back();
48        }
49
50        if self.filt_history.len() < 2 * self.window {
51            self.price_prev[1] = self.price_prev[0];
52            self.price_prev[0] = input;
53            return 0.0;
54        }
55
56        let mut dc = self.dc_prev;
57        let mut min_corr = 1.0;
58        let window_f = self.window as f64;
59
60        // Find minimum correlation and Dominant Cycle
61        for lag in 1..=self.window {
62            let mut sx = 0.0;
63            let mut sy = 0.0;
64            let mut sxx = 0.0;
65            let mut sxy = 0.0;
66            let mut syy = 0.0;
67
68            for j in 0..self.window {
69                let x = self.filt_history[j];
70                let y = self.filt_history[lag + j];
71                sx += x;
72                sy += y;
73                sxx += x * x;
74                sxy += x * y;
75                syy += y * y;
76            }
77
78            let div1 = window_f * sxx - sx * sx;
79            let div2 = window_f * syy - sy * sy;
80
81            if div1 > 0.0 && div2 > 0.0 {
82                let corr = (window_f * sxy - sx * sy) / (div1 * div2).sqrt();
83                if corr < min_corr {
84                    min_corr = corr;
85                    dc = 2.0 * lag as f64;
86                }
87            }
88        }
89
90        // Limit the rate of change of the Dominant Cycle
91        if dc > self.dc_prev + 2.0 {
92            dc = self.dc_prev + 2.0;
93        }
94        if dc < self.dc_prev - 2.0 {
95            dc = self.dc_prev - 2.0;
96        }
97        if dc < 2.0 {
98            dc = 2.0;
99        }
100        self.dc_prev = dc;
101
102        // Bandpass Filter tuned to the Dominant Cycle
103        let l1 = (2.0 * PI / dc).cos();
104        let g1 = (2.0 * PI * self.bandwidth / dc).cos();
105        // Prevent division by zero if g1 is somehow 0, though cos is 0 only at PI/2 + kPI
106        let s1 = if g1.abs() > 1e-10 {
107            let gamma_inv = 1.0 / g1;
108            gamma_inv - (gamma_inv * gamma_inv - 1.0).max(0.0).sqrt()
109        } else {
110            1.0
111        };
112
113        let bp = 0.5 * (1.0 - s1) * (input - self.price_prev[1])
114            + l1 * (1.0 + s1) * self.bp_history[0]
115            - s1 * self.bp_history[1];
116
117        self.bp_history[1] = self.bp_history[0];
118        self.bp_history[0] = bp;
119        self.price_prev[1] = self.price_prev[0];
120        self.price_prev[0] = input;
121
122        bp
123    }
124}
125
126pub const AUTOTUNE_FILTER_METADATA: IndicatorMetadata = IndicatorMetadata {
127    name: "AutoTune Filter",
128    description: "An adaptive BandPass filter that dynamically tunes itself to the market's dominant cycle.",
129    usage: "Use to isolate the cyclical component of price while automatically adapting to changes in cycle length. Zero crossings of the output or its rate of change can be used as trading signals.",
130    keywords: &["adaptive", "filter", "cycle", "ehlers", "dsp", "autotune"],
131    ehlers_summary: "The AutoTune filter provides a bridge between the time domain and frequency domain by using a rolling autocorrelation function to measure the Dominant Cycle in real time. By dynamically tuning a Bandpass filter to twice the lag at which autocorrelation is minimized, it maintains consistent performance and avoids the destructive phase shifts typical of fixed-tuned filters.",
132    params: &[
133        ParamDef {
134            name: "window",
135            default: "20",
136            description: "Window length for autocorrelation and HighPass filter",
137        },
138        ParamDef {
139            name: "bandwidth",
140            default: "0.25",
141            description: "Bandwidth of the tuned BandPass filter",
142        },
143    ],
144    formula_source: "references/Ehlers Papers/The AutoTune Filter.pdf",
145    formula_latex: r#"
146\[
147R(lag) = \frac{n \sum X_i Y_i - \sum X_i \sum Y_i}{\sqrt{(n \sum X_i^2 - (\sum X_i)^2)(n \sum Y_i^2 - (\sum Y_i)^2)}}
148\]
149\[
150DC = 2 \times \text{argmin}_{lag} R(lag)
151\]
152\[
153BP = \text{BandPass}(Price, DC, BW)
154\]
155"#,
156    gold_standard_file: "autotune_filter.json",
157    category: "Ehlers DSP",
158};
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::traits::Next;
164    use proptest::prelude::*;
165
166    #[test]
167    fn test_autotune_basic() {
168        let mut at = AutoTuneFilter::new(20, 0.25);
169        // Feed some data to warm up the 2*window buffer
170        for _ in 0..40 {
171            at.next(100.0);
172        }
173        for i in 0..100 {
174            // Sine wave with period 20
175            let val = at.next(100.0 + (i as f64 * 2.0 * PI / 20.0).sin());
176            assert!(!val.is_nan());
177        }
178    }
179
180    #[test]
181    fn test_autotune_dc_tracking() {
182        let window = 20;
183        let mut at = AutoTuneFilter::new(window, 0.25);
184        
185        // Sine wave with period 10
186        let period = 10.0;
187        for i in 0..100 {
188            let _ = at.next(100.0 + (i as f64 * 2.0 * PI / period).sin());
189        }
190        
191        // After warming up, dc_prev should be close to the period (10.0)
192        // Note: dc is twice the lag of minimum correlation. 
193        // For a sine wave, min correlation is at half-period lag.
194        // So lag = 5, dc = 10.
195        assert!(at.dc_prev >= 8.0 && at.dc_prev <= 12.0, "DC was {}", at.dc_prev);
196    }
197
198    proptest! {
199        #[test]
200        fn test_autotune_parity(
201            inputs in prop::collection::vec(90.0..110.0, 60..100),
202        ) {
203            let window = 20;
204            let bandwidth = 0.25;
205            let mut at_obj = AutoTuneFilter::new(window, bandwidth);
206            let streaming_results: Vec<f64> = inputs.iter().map(|&x| at_obj.next(x)).collect();
207
208            // Batch-like verification of the state machine
209            let mut hp = HighPass::new(window);
210            let mut filt_hist = VecDeque::new();
211            let mut dc_prev = window as f64;
212            let mut price_prev = [0.0; 2];
213            let mut bp_hist = [0.0; 2];
214            let mut expected = Vec::with_capacity(inputs.len());
215
216            for (i, &input) in inputs.iter().enumerate() {
217                let filt = hp.next(input);
218                filt_hist.push_front(filt);
219                if filt_hist.len() > 2 * window {
220                    filt_hist.pop_back();
221                }
222
223                if filt_hist.len() < 2 * window {
224                    price_prev[1] = price_prev[0];
225                    price_prev[0] = input;
226                    expected.push(0.0);
227                    continue;
228                }
229
230                let mut dc = dc_prev;
231                let mut min_corr = 1.0;
232                for lag in 1..=window {
233                    let mut sx = 0.0; let mut sy = 0.0;
234                    let mut sxx = 0.0; let mut sxy = 0.0; let mut syy = 0.0;
235                    for j in 0..window {
236                        let x = filt_hist[j];
237                        let y = filt_hist[lag+j];
238                        sx += x; sy += y;
239                        sxx += x*x; sxy += x*y; syy += y*y;
240                    }
241                    let div1 = (window as f64) * sxx - sx*sx;
242                    let div2 = (window as f64) * syy - sy*sy;
243                    if div1 > 0.0 && div2 > 0.0 {
244                        let corr = ((window as f64) * sxy - sx*sy) / (div1*div2).sqrt();
245                        if corr < min_corr {
246                            min_corr = corr;
247                            dc = 2.0 * lag as f64;
248                        }
249                    }
250                }
251
252                if dc > dc_prev + 2.0 { dc = dc_prev + 2.0; }
253                if dc < dc_prev - 2.0 { dc = dc_prev - 2.0; }
254                if dc < 2.0 { dc = 2.0; }
255                dc_prev = dc;
256
257                let l1 = (2.0 * PI / dc).cos();
258                let g1 = (2.0 * PI * bandwidth / dc).cos();
259                let s1 = 1.0/g1 - (1.0/(g1*g1) - 1.0).max(0.0).sqrt();
260
261                let bp = 0.5 * (1.0 - s1) * (input - price_prev[1])
262                    + l1 * (1.0 + s1) * bp_hist[0]
263                    - s1 * bp_hist[1];
264
265                bp_hist[1] = bp_hist[0];
266                bp_hist[0] = bp;
267                price_prev[1] = price_prev[0];
268                price_prev[0] = input;
269                expected.push(bp);
270            }
271
272            for (s, e) in streaming_results.iter().zip(expected.iter()) {
273                approx::assert_relative_eq!(s, e, epsilon = 1e-10);
274            }
275        }
276    }
277}