Skip to main content

quantwave_core/indicators/
frama.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use std::collections::VecDeque;
4
5/// Fractal Adaptive Moving Average (FRAMA)
6/// As described by John Ehlers.
7///
8/// The FRAMA uses the fractal dimension of prices to dynamically adapt its smoothing
9/// constant (alpha). It rapidly follows major changes in price and slows down when
10/// prices are in congestion.
11///
12/// The `length` parameter specifies the period `N`. If an odd length is provided,
13/// it will be automatically converted to an even number (by adding 1) because the
14/// fractal dimension calculation requires splitting the period into two equal halves.
15#[derive(Debug, Clone)]
16pub struct FRAMA {
17    length: usize,
18    half_length: usize,
19    high_history: VecDeque<f64>,
20    low_history: VecDeque<f64>,
21    filt: f64,
22    initialized: bool,
23}
24
25impl FRAMA {
26    pub fn new(mut length: usize) -> Self {
27        // Ehlers notes that N must be an even number.
28        if !length.is_multiple_of(2) {
29            length += 1;
30        }
31        let half_length = length / 2;
32
33        Self {
34            length,
35            half_length,
36            high_history: VecDeque::with_capacity(length),
37            low_history: VecDeque::with_capacity(length),
38            filt: 0.0,
39            initialized: false,
40        }
41    }
42}
43
44impl Next<(f64, f64, f64)> for FRAMA {
45    type Output = f64; // The filtered value (FRAMA)
46
47    fn next(&mut self, (high, low, price): (f64, f64, f64)) -> Self::Output {
48        if self.high_history.len() == self.length {
49            self.high_history.pop_back();
50            self.low_history.pop_back();
51        }
52
53        self.high_history.push_front(high);
54        self.low_history.push_front(low);
55
56        // Not enough data to compute the fractal dimension
57        if self.high_history.len() < self.length {
58            self.filt = price;
59            return self.filt;
60        }
61
62        // Calculate Highest High and Lowest Low over different periods
63        let mut hh1 = f64::MIN;
64        let mut ll1 = f64::MAX;
65        for i in 0..self.half_length {
66            hh1 = hh1.max(self.high_history[i]);
67            ll1 = ll1.min(self.low_history[i]);
68        }
69        let n1 = (hh1 - ll1) / (self.half_length as f64);
70
71        let mut hh2 = f64::MIN;
72        let mut ll2 = f64::MAX;
73        for i in self.half_length..self.length {
74            hh2 = hh2.max(self.high_history[i]);
75            ll2 = ll2.min(self.low_history[i]);
76        }
77        let n2 = (hh2 - ll2) / (self.half_length as f64);
78
79        let mut hh3 = f64::MIN;
80        let mut ll3 = f64::MAX;
81        for i in 0..self.length {
82            hh3 = hh3.max(self.high_history[i]);
83            ll3 = ll3.min(self.low_history[i]);
84        }
85        let n3 = (hh3 - ll3) / (self.length as f64);
86
87        let mut dimen = 1.0;
88        if n1 > 0.0 && n2 > 0.0 && n3 > 0.0 {
89            dimen = ((n1 + n2).ln() - n3.ln()) / std::f64::consts::LN_2;
90        }
91
92        let mut alpha = (-4.6 * (dimen - 1.0)).exp();
93        alpha = alpha.clamp(0.01, 1.0);
94
95        if !self.initialized {
96            self.filt = price;
97            self.initialized = true;
98        } else {
99            self.filt = alpha * price + (1.0 - alpha) * self.filt;
100        }
101
102        self.filt
103    }
104}
105
106pub const FRAMA_METADATA: IndicatorMetadata = IndicatorMetadata {
107    name: "Fractal Adaptive Moving Average",
108    description: "An adaptive moving average that uses the fractal dimension of prices to dynamically change its smoothing constant.",
109    usage: "Use as an adaptive moving average that slows dramatically during consolidation and speeds up during trending phases. Outperforms fixed-period MAs in ranging markets by avoiding false crossovers.",
110    keywords: &["moving-average", "adaptive", "fractal", "smoothing"],
111    ehlers_summary: "The Fractal Adaptive Moving Average uses the fractal dimension of recent price action to adapt its smoothing constant. During trending markets the fractal dimension approaches 1 (a line) producing a fast-reacting EMA; during ranging markets the dimension approaches 2 (a plane) slowing the average dramatically to filter chop.",
112    params: &[ParamDef {
113        name: "length",
114        default: "16",
115        description: "Length (must be an even number; odd values will be incremented by 1).",
116    }],
117    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/Ehlers%20Papers/implemented/FRAMA.pdf",
118    formula_latex: r#"
119\[
120D = \frac{\log(N_1 + N_2) - \log(N_3)}{\log(2)}
121\]
122\[
123\alpha = \exp(-4.6 (D - 1))
124\]
125\[
126\text{FRAMA}_t = \alpha P_t + (1 - \alpha) \text{FRAMA}_{t-1}
127\]
128"#,
129    gold_standard_file: "frama.json",
130    category: "Ehlers DSP",
131};
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use proptest::prelude::*;
137
138    fn frama_batch(data: &[(f64, f64, f64)], length: usize) -> Vec<f64> {
139        let mut indicator = FRAMA::new(length);
140        data.iter().map(|&x| indicator.next(x)).collect()
141    }
142
143    proptest! {
144        #[test]
145        fn test_frama_parity(input in prop::collection::vec((0.1..100.0, 0.1..100.0, 0.1..100.0), 1..100)) {
146            // Adjust input so High >= Low
147            let mut adj_input = Vec::with_capacity(input.len());
148            for (h, l, p) in input {
149                let h_f64: f64 = h;
150                let l_f64: f64 = l;
151                let high = h_f64.max(l_f64);
152                let low = h_f64.min(l_f64);
153                adj_input.push((high, low, p));
154            }
155
156            let length = 16;
157            let mut streaming_ind = FRAMA::new(length);
158            let streaming_results: Vec<f64> = adj_input.iter().map(|&x| streaming_ind.next(x)).collect();
159            let batch_results = frama_batch(&adj_input, length);
160
161            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
162                approx::assert_relative_eq!(*s, *b, epsilon = 1e-6);
163            }
164        }
165    }
166}