quantwave_core/indicators/
amfm.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use crate::utils::RingBuffer as 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(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#[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 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 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}