Skip to main content

quantwave_core/indicators/
amfm.rs

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