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 crate::utils::RingBuffer as 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!(
196            at.dc_prev >= 8.0 && at.dc_prev <= 12.0,
197            "DC was {}",
198            at.dc_prev
199        );
200    }
201
202    proptest! {
203        #[test]
204        fn test_autotune_parity(
205            inputs in prop::collection::vec(90.0..110.0, 60..100),
206        ) {
207            let window = 20;
208            let bandwidth = 0.25;
209            let mut at_obj = AutoTuneFilter::new(window, bandwidth);
210            let streaming_results: Vec<f64> = inputs.iter().map(|&x| at_obj.next(x)).collect();
211
212            // Batch-like verification of the state machine
213            let mut hp = HighPass::new(window);
214            let mut filt_hist = VecDeque::with_capacity(2 * window);
215            let mut dc_prev = window as f64;
216            let mut price_prev = [0.0; 2];
217            let mut bp_hist = [0.0; 2];
218            let mut expected = Vec::with_capacity(inputs.len());
219
220            for (i, &input) in inputs.iter().enumerate() {
221                let filt = hp.next(input);
222                filt_hist.push_front(filt);
223                if filt_hist.len() > 2 * window {
224                    filt_hist.pop_back();
225                }
226
227                if filt_hist.len() < 2 * window {
228                    price_prev[1] = price_prev[0];
229                    price_prev[0] = input;
230                    expected.push(0.0);
231                    continue;
232                }
233
234                let mut dc = dc_prev;
235                let mut min_corr = 1.0;
236                for lag in 1..=window {
237                    let mut sx = 0.0; let mut sy = 0.0;
238                    let mut sxx = 0.0; let mut sxy = 0.0; let mut syy = 0.0;
239                    for j in 0..window {
240                        let x = filt_hist[j];
241                        let y = filt_hist[lag+j];
242                        sx += x; sy += y;
243                        sxx += x*x; sxy += x*y; syy += y*y;
244                    }
245                    let div1 = (window as f64) * sxx - sx*sx;
246                    let div2 = (window as f64) * syy - sy*sy;
247                    if div1 > 0.0 && div2 > 0.0 {
248                        let corr = ((window as f64) * sxy - sx*sy) / (div1*div2).sqrt();
249                        if corr < min_corr {
250                            min_corr = corr;
251                            dc = 2.0 * lag as f64;
252                        }
253                    }
254                }
255
256                if dc > dc_prev + 2.0 { dc = dc_prev + 2.0; }
257                if dc < dc_prev - 2.0 { dc = dc_prev - 2.0; }
258                if dc < 2.0 { dc = 2.0; }
259                dc_prev = dc;
260
261                let l1 = (2.0 * PI / dc).cos();
262                let g1 = (2.0 * PI * bandwidth / dc).cos();
263                let s1 = 1.0/g1 - (1.0/(g1*g1) - 1.0).max(0.0).sqrt();
264
265                let bp = 0.5 * (1.0 - s1) * (input - price_prev[1])
266                    + l1 * (1.0 + s1) * bp_hist[0]
267                    - s1 * bp_hist[1];
268
269                bp_hist[1] = bp_hist[0];
270                bp_hist[0] = bp;
271                price_prev[1] = price_prev[0];
272                price_prev[0] = input;
273                expected.push(bp);
274            }
275
276            for (s, e) in streaming_results.iter().zip(expected.iter()) {
277                approx::assert_relative_eq!(s, e, epsilon = 1e-10);
278            }
279        }
280    }
281}