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                    && let Some(old) = envelope_hist.pop_front() {
222                        sum_env -= old;
223                    }
224                batch_results.push(sum_env / envelope_hist.len() as f64);
225            }
226
227            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
228                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
229            }
230        }
231
232        #[test]
233        fn test_fm_demodulator_parity(
234            closes in prop::collection::vec(1.0..100.0, 50..100),
235            opens in prop::collection::vec(1.0..100.0, 50..100),
236        ) {
237            let period = 30;
238            let mut fm = FMDemodulator::new(period);
239            let inputs: Vec<(f64, f64)> = closes.iter().zip(opens.iter()).map(|(&c, &o)| (c, o)).collect();
240            let streaming_results: Vec<f64> = inputs.iter().map(|&x| fm.next(x)).collect();
241
242            // Batch implementation
243            let mut batch_results = Vec::with_capacity(inputs.len());
244            let a1 = (-1.414 * PI / period as f64).exp();
245            let c2 = 2.0 * a1 * (1.414 * PI / period as f64).cos();
246            let c3 = -a1 * a1;
247            let c1 = 1.0 - c2 - c3;
248
249            let mut hl_prev = 0.0;
250            let mut ss_hist = [0.0; 2];
251
252            for (i, &input) in inputs.iter().enumerate() {
253                let deriv = input.0 - input.1;
254                let hl = (10.0 * deriv).clamp(-1.0, 1.0);
255
256                let res = if i + 1 < 3 {
257                    deriv
258                } else {
259                    c1 * (hl + hl_prev) / 2.0 + c2 * ss_hist[0] + c3 * ss_hist[1]
260                };
261
262                ss_hist[1] = ss_hist[0];
263                ss_hist[0] = res;
264                hl_prev = hl;
265                batch_results.push(res);
266            }
267
268            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
269                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
270            }
271        }
272    }
273}