Skip to main content

quantwave_core/indicators/
amfm.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use crate::utils::RingBuffer as VecDeque;
4use std::f64::consts::PI;
5
6/// AM Detector (Volatility)
7///
8/// Based on John Ehlers' "A Technical Description of Market Data for Traders".
9/// It recovers market volatility by detecting the envelope of the amplitude-modulated
10/// whitened price spectrum.
11#[derive(Debug, Clone)]
12pub struct AMDetector {
13    highest_len: usize,
14    avg_len: usize,
15    deriv_history: VecDeque<f64>,
16    envelope_history: VecDeque<f64>,
17    sum_envelope: f64,
18}
19
20impl AMDetector {
21    pub fn new(highest_len: usize, avg_len: usize) -> Self {
22        Self {
23            highest_len,
24            avg_len,
25            deriv_history: VecDeque::with_capacity(highest_len),
26            envelope_history: VecDeque::with_capacity(20),
27            sum_envelope: 0.0,
28        }
29    }
30}
31
32impl Next<(f64, f64)> for AMDetector {
33    type Output = f64;
34
35    fn next(&mut self, (close, open): (f64, f64)) -> Self::Output {
36        let deriv = (close - open).abs();
37        self.deriv_history.push_front(deriv);
38        if self.deriv_history.len() > self.highest_len {
39            self.deriv_history.pop_back();
40        }
41
42        let envelope = self.deriv_history.iter().fold(f64::MIN, |a, &b| a.max(b));
43
44        self.envelope_history.push_back(envelope);
45        self.sum_envelope += envelope;
46        if self.envelope_history.len() > self.avg_len
47            && let Some(old) = self.envelope_history.pop_front()
48        {
49            self.sum_envelope -= old;
50        }
51
52        self.sum_envelope / self.envelope_history.len() as f64
53    }
54}
55
56/// FM Demodulator (Timing)
57///
58/// Based on John Ehlers' "A Technical Description of Market Data for Traders".
59/// It extracts market timing information by demodulating the frequency-modulated
60/// price spectrum using a hard limiter and a SuperSmoother filter.
61#[derive(Debug, Clone)]
62pub struct FMDemodulator {
63    _period: usize,
64    c1: f64,
65    c2: f64,
66    c3: f64,
67    hl_prev: f64,
68    ss_history: [f64; 2],
69    count: usize,
70}
71
72impl FMDemodulator {
73    pub fn new(period: usize) -> Self {
74        let a1 = (-1.414 * PI / period as f64).exp();
75        let c2 = 2.0 * a1 * (1.414 * PI / period as f64).cos();
76        let c3 = -a1 * a1;
77        let c1 = 1.0 - c2 - c3;
78
79        Self {
80            _period: period,
81            c1,
82            c2,
83            c3,
84            hl_prev: 0.0,
85            ss_history: [0.0; 2],
86            count: 0,
87        }
88    }
89}
90
91impl Next<(f64, f64)> for FMDemodulator {
92    type Output = f64;
93
94    fn next(&mut self, (close, open): (f64, f64)) -> Self::Output {
95        self.count += 1;
96        let deriv = close - open;
97        let hl = (10.0 * deriv).clamp(-1.0, 1.0);
98
99        let ss = if self.count < 3 {
100            deriv
101        } else {
102            self.c1 * (hl + self.hl_prev) / 2.0
103                + self.c2 * self.ss_history[0]
104                + self.c3 * self.ss_history[1]
105        };
106
107        self.ss_history[1] = self.ss_history[0];
108        self.ss_history[0] = ss;
109        self.hl_prev = hl;
110
111        ss
112    }
113}
114
115pub const AM_DETECTOR_METADATA: IndicatorMetadata = IndicatorMetadata {
116    name: "AM Detector",
117    description: "Recovers market volatility from the amplitude-modulated whitened price spectrum.",
118    usage: "Use to extract the instantaneous amplitude and frequency of market cycles. The AM output measures cycle energy for position sizing; the FM output tracks cycle period for adaptive indicator tuning.",
119    keywords: &["cycle", "ehlers", "dsp", "amplitude", "frequency"],
120    ehlers_summary: "Ehlers adapts AM and FM demodulation techniques from radio engineering in Cycle Analytics for Traders to extract cycle amplitude and instantaneous frequency from market data. The amplitude envelope measures how energetic the current cycle is, while FM reveals whether the cycle period is expanding or contracting.",
121    params: &[
122        ParamDef {
123            name: "highest_len",
124            default: "4",
125            description: "Envelope lookback length",
126        },
127        ParamDef {
128            name: "avg_len",
129            default: "8",
130            description: "Smoothing length",
131        },
132    ],
133    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/AMFM.pdf",
134    formula_latex: r#"
135\[
136Deriv = |Close - Open|, Envel = \max(Deriv, 4), Volatil = \text{Avg}(Envel, 8)
137\]
138"#,
139    gold_standard_file: "am_detector.json",
140    category: "Ehlers DSP",
141};
142
143pub const FM_DEMODULATOR_METADATA: IndicatorMetadata = IndicatorMetadata {
144    name: "FM Demodulator",
145    description: "Extracts market timing information by demodulating the frequency-modulated price spectrum.",
146    usage: "Use to extract the instantaneous amplitude and frequency of market cycles. The AM output measures cycle energy for position sizing; the FM output tracks cycle period for adaptive indicator tuning.",
147    keywords: &["cycle", "ehlers", "dsp", "amplitude", "frequency"],
148    ehlers_summary: "Ehlers adapts AM and FM demodulation techniques from radio engineering in Cycle Analytics for Traders to extract cycle amplitude and instantaneous frequency from market data. The amplitude envelope measures how energetic the current cycle is, while FM reveals whether the cycle period is expanding or contracting.",
149    params: &[ParamDef {
150        name: "period",
151        default: "30",
152        description: "SuperSmoother period",
153    }],
154    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/AMFM.pdf",
155    formula_latex: r#"
156\[
157Deriv = Close - Open, HL = \text{Clip}(10 \times Deriv, -1, 1)
158\]
159\[
160SS = c_1 \frac{HL + HL_{t-1}}{2} + c_2 SS_{t-1} + c_3 SS_{t-2}
161\]
162"#,
163    gold_standard_file: "fm_demodulator.json",
164    category: "Ehlers DSP",
165};
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::traits::Next;
171    use proptest::prelude::*;
172
173    #[test]
174    fn test_am_detector_basic() {
175        let mut am = AMDetector::new(4, 8);
176        let inputs = vec![(10.0, 9.0), (11.0, 10.0), (12.0, 11.0)];
177        for input in inputs {
178            let res = am.next(input);
179            assert!(res >= 0.0);
180        }
181    }
182
183    #[test]
184    fn test_fm_demodulator_basic() {
185        let mut fm = FMDemodulator::new(30);
186        let inputs = vec![(10.0, 9.0), (11.0, 10.0), (12.0, 11.0)];
187        for input in inputs {
188            let res = fm.next(input);
189            assert!(!res.is_nan());
190        }
191    }
192
193    proptest! {
194        #[test]
195        fn test_am_detector_parity(
196            closes in prop::collection::vec(1.0..100.0, 50..100),
197            opens in prop::collection::vec(1.0..100.0, 50..100),
198        ) {
199            let h_len = 4;
200            let a_len = 8;
201            let mut am = AMDetector::new(h_len, a_len);
202            let inputs: Vec<(f64, f64)> = closes.iter().zip(opens.iter()).map(|(&c, &o)| (c, o)).collect();
203            let streaming_results: Vec<f64> = inputs.iter().map(|&x| am.next(x)).collect();
204
205            // Batch implementation
206            let mut batch_results = Vec::with_capacity(inputs.len());
207            let mut envelope_hist = VecDeque::with_capacity(20);
208            let mut sum_env = 0.0;
209
210            for i in 0..inputs.len() {
211                let start = if i >= h_len { i + 1 - h_len } else { 0 };
212                let mut max_deriv = f64::MIN;
213                for j in start..=i {
214                    let deriv = (inputs[j].0 - inputs[j].1).abs();
215                    if deriv > max_deriv { max_deriv = deriv; }
216                }
217
218                envelope_hist.push_back(max_deriv);
219                sum_env += max_deriv;
220                if envelope_hist.len() > a_len {
221                    if let Some(old) = envelope_hist.pop_front() {
222                        sum_env -= old;
223                    }
224                }
225                batch_results.push(sum_env / envelope_hist.len() as f64);
226            }
227
228            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
229                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
230            }
231        }
232
233        #[test]
234        fn test_fm_demodulator_parity(
235            closes in prop::collection::vec(1.0..100.0, 50..100),
236            opens in prop::collection::vec(1.0..100.0, 50..100),
237        ) {
238            let period = 30;
239            let mut fm = FMDemodulator::new(period);
240            let inputs: Vec<(f64, f64)> = closes.iter().zip(opens.iter()).map(|(&c, &o)| (c, o)).collect();
241            let streaming_results: Vec<f64> = inputs.iter().map(|&x| fm.next(x)).collect();
242
243            // Batch implementation
244            let mut batch_results = Vec::with_capacity(inputs.len());
245            let a1 = (-1.414 * PI / period as f64).exp();
246            let c2 = 2.0 * a1 * (1.414 * PI / period as f64).cos();
247            let c3 = -a1 * a1;
248            let c1 = 1.0 - c2 - c3;
249
250            let mut hl_prev = 0.0;
251            let mut ss_hist = [0.0; 2];
252
253            for (i, &input) in inputs.iter().enumerate() {
254                let deriv = input.0 - input.1;
255                let mut hl = 10.0 * deriv;
256                if hl > 1.0 { hl = 1.0; }
257                if hl < -1.0 { hl = -1.0; }
258
259                let res = if i + 1 < 3 {
260                    deriv
261                } else {
262                    c1 * (hl + hl_prev) / 2.0 + c2 * ss_hist[0] + c3 * ss_hist[1]
263                };
264
265                ss_hist[1] = ss_hist[0];
266                ss_hist[0] = res;
267                hl_prev = hl;
268                batch_results.push(res);
269            }
270
271            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
272                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
273            }
274        }
275    }
276}