quantwave_core/indicators/
amfm.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use std::collections::VecDeque;
4use std::f64::consts::PI;
5
6#[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#[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 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 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}