Skip to main content

wickra_core/indicators/
frama.rs

1//! Fractal Adaptive Moving Average (FRAMA).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Ehlers' Fractal Adaptive Moving Average.
9///
10/// FRAMA picks its smoothing constant from the fractal dimension `D` of the
11/// recent window: in a trending (low-`D`) market it follows price tightly, in
12/// a choppy (high-`D`) market it smooths heavily. The window of `period`
13/// closes is split into two equal halves; the fractal dimension comes from
14/// the price ranges of the halves vs. the whole window:
15///
16/// ```text
17/// N1 = (max(first half)  - min(first half))  / (period / 2)
18/// N2 = (max(second half) - min(second half)) / (period / 2)
19/// N3 = (max(window)      - min(window))      / period
20/// D  = (log(N1 + N2) - log(N3)) / log(2)
21/// alpha = exp(-4.6 * (D - 1))   clamped to [0.01, 1.0]
22/// ```
23///
24/// The output is an EMA-like recurrence
25/// `FRAMA_t = alpha * close_t + (1 - alpha) * FRAMA_{t - 1}`, seeded with the
26/// first close. `period` must be even and at least 2.
27///
28/// Reference: John F. Ehlers, *Fractal Adaptive Moving Average*, 2005.
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Frama, Indicator};
34///
35/// let mut frama = Frama::new(16).unwrap();
36/// let mut last = None;
37/// for i in 0..40 {
38///     last = frama.update(100.0 + f64::from(i));
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone)]
43pub struct Frama {
44    period: usize,
45    half: usize,
46    window: VecDeque<f64>,
47    current: Option<f64>,
48}
49
50impl Frama {
51    /// # Errors
52    /// - [`Error::PeriodZero`] if `period == 0`.
53    /// - [`Error::InvalidPeriod`] if `period` is odd or below 2.
54    pub fn new(period: usize) -> Result<Self> {
55        if period == 0 {
56            return Err(Error::PeriodZero);
57        }
58        if period < 2 {
59            return Err(Error::InvalidPeriod {
60                message: "FRAMA period must be at least 2",
61            });
62        }
63        if period % 2 != 0 {
64            return Err(Error::InvalidPeriod {
65                message: "FRAMA period must be even",
66            });
67        }
68        Ok(Self {
69            period,
70            half: period / 2,
71            window: VecDeque::with_capacity(period),
72            current: None,
73        })
74    }
75
76    /// Configured period.
77    pub const fn period(&self) -> usize {
78        self.period
79    }
80}
81
82impl Indicator for Frama {
83    type Input = f64;
84    type Output = f64;
85
86    fn update(&mut self, input: f64) -> Option<f64> {
87        if !input.is_finite() {
88            return self.current;
89        }
90        if self.window.len() == self.period {
91            self.window.pop_front();
92        }
93        self.window.push_back(input);
94        if self.window.len() < self.period {
95            return None;
96        }
97
98        let half = self.half;
99        let mut h_first = f64::NEG_INFINITY;
100        let mut l_first = f64::INFINITY;
101        let mut h_second = f64::NEG_INFINITY;
102        let mut l_second = f64::INFINITY;
103        let mut h_whole = f64::NEG_INFINITY;
104        let mut l_whole = f64::INFINITY;
105        for (i, &p) in self.window.iter().enumerate() {
106            if p > h_whole {
107                h_whole = p;
108            }
109            if p < l_whole {
110                l_whole = p;
111            }
112            if i < half {
113                if p > h_first {
114                    h_first = p;
115                }
116                if p < l_first {
117                    l_first = p;
118                }
119            } else {
120                if p > h_second {
121                    h_second = p;
122                }
123                if p < l_second {
124                    l_second = p;
125                }
126            }
127        }
128
129        let half_f = half as f64;
130        let period_f = self.period as f64;
131        let n1 = (h_first - l_first) / half_f;
132        let n2 = (h_second - l_second) / half_f;
133        let n3 = (h_whole - l_whole) / period_f;
134
135        let alpha = if n1 > 0.0 && n2 > 0.0 && n3 > 0.0 {
136            let d = ((n1 + n2).ln() - n3.ln()) / 2.0_f64.ln();
137            (-4.6 * (d - 1.0)).exp().clamp(0.01, 1.0)
138        } else {
139            // Degenerate (perfectly flat half or whole window): use the slowest
140            // smoothing so the indicator coasts on its previous value.
141            0.01
142        };
143
144        let prev = self.current.unwrap_or(input);
145        let next = alpha * input + (1.0 - alpha) * prev;
146        self.current = Some(next);
147        Some(next)
148    }
149
150    fn reset(&mut self) {
151        self.window.clear();
152        self.current = None;
153    }
154
155    fn warmup_period(&self) -> usize {
156        self.period
157    }
158
159    fn is_ready(&self) -> bool {
160        self.current.is_some()
161    }
162
163    fn name(&self) -> &'static str {
164        "FRAMA"
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::traits::BatchExt;
172    use approx::assert_relative_eq;
173
174    #[test]
175    fn rejects_zero_period() {
176        assert!(matches!(Frama::new(0), Err(Error::PeriodZero)));
177    }
178
179    #[test]
180    fn rejects_invalid_period() {
181        assert!(matches!(Frama::new(1), Err(Error::InvalidPeriod { .. })));
182        assert!(matches!(Frama::new(3), Err(Error::InvalidPeriod { .. })));
183        assert!(matches!(Frama::new(15), Err(Error::InvalidPeriod { .. })));
184    }
185
186    #[test]
187    fn accessors_and_metadata() {
188        let frama = Frama::new(16).unwrap();
189        assert_eq!(frama.period(), 16);
190        assert_eq!(frama.warmup_period(), 16);
191        assert_eq!(frama.name(), "FRAMA");
192    }
193
194    #[test]
195    fn constant_series_yields_the_constant() {
196        // Flat input -> alpha clamps to 0.01 (degenerate ranges) and the
197        // EMA recurrence holds the seed value forever.
198        let mut frama = Frama::new(4).unwrap();
199        let out = frama.batch(&[42.0_f64; 30]);
200        for v in out.iter().skip(3).flatten() {
201            assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
202        }
203    }
204
205    #[test]
206    fn warmup_emits_first_value_at_period() {
207        let mut frama = Frama::new(4).unwrap();
208        assert_eq!(frama.update(1.0), None);
209        assert_eq!(frama.update(2.0), None);
210        assert_eq!(frama.update(3.0), None);
211        assert!(frama.update(4.0).is_some());
212    }
213
214    #[test]
215    fn pure_uptrend_alpha_close_to_one() {
216        // A strict monotonic uptrend has fractal dimension ~1, so alpha is
217        // pushed to 1.0 and FRAMA reduces to the latest price.
218        let mut frama = Frama::new(4).unwrap();
219        let prices: Vec<f64> = (1..=8).map(f64::from).collect();
220        let out = frama.batch(&prices);
221        let last = out.last().unwrap().unwrap();
222        assert!(
223            (last - 8.0).abs() < 0.05,
224            "FRAMA on a clean uptrend should hug the latest close: {last}"
225        );
226    }
227
228    #[test]
229    fn batch_equals_streaming() {
230        let prices: Vec<f64> = (1..=80)
231            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
232            .collect();
233        let mut a = Frama::new(8).unwrap();
234        let mut b = Frama::new(8).unwrap();
235        assert_eq!(
236            a.batch(&prices),
237            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
238        );
239    }
240
241    #[test]
242    fn reset_clears_state() {
243        let mut frama = Frama::new(4).unwrap();
244        frama.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
245        assert!(frama.is_ready());
246        frama.reset();
247        assert!(!frama.is_ready());
248        assert_eq!(frama.update(1.0), None);
249    }
250
251    #[test]
252    fn ignores_non_finite_input() {
253        let mut frama = Frama::new(4).unwrap();
254        frama.batch(&[1.0, 2.0, 3.0, 4.0]);
255        let before = frama.update(5.0).unwrap();
256        assert_eq!(frama.update(f64::NAN), Some(before));
257        assert_eq!(frama.update(f64::INFINITY), Some(before));
258    }
259}