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 > crate::error::MAX_PERIOD {
59            return Err(Error::InvalidPeriod {
60                message: crate::error::PERIOD_ABOVE_MAX,
61            });
62        }
63        if period < 2 {
64            return Err(Error::InvalidPeriod {
65                message: "FRAMA period must be at least 2",
66            });
67        }
68        if period % 2 != 0 {
69            return Err(Error::InvalidPeriod {
70                message: "FRAMA period must be even",
71            });
72        }
73        Ok(Self {
74            period,
75            half: period / 2,
76            window: VecDeque::with_capacity(period),
77            current: None,
78        })
79    }
80
81    /// Configured period.
82    pub const fn period(&self) -> usize {
83        self.period
84    }
85}
86
87impl Indicator for Frama {
88    type Input = f64;
89    type Output = f64;
90
91    fn update(&mut self, input: f64) -> Option<f64> {
92        if !input.is_finite() {
93            return None;
94        }
95        if self.window.len() == self.period {
96            self.window.pop_front();
97        }
98        self.window.push_back(input);
99        if self.window.len() < self.period {
100            return None;
101        }
102
103        let half = self.half;
104        let mut h_first = f64::NEG_INFINITY;
105        let mut l_first = f64::INFINITY;
106        let mut h_second = f64::NEG_INFINITY;
107        let mut l_second = f64::INFINITY;
108        let mut h_whole = f64::NEG_INFINITY;
109        let mut l_whole = f64::INFINITY;
110        for (i, &p) in self.window.iter().enumerate() {
111            if p > h_whole {
112                h_whole = p;
113            }
114            if p < l_whole {
115                l_whole = p;
116            }
117            if i < half {
118                if p > h_first {
119                    h_first = p;
120                }
121                if p < l_first {
122                    l_first = p;
123                }
124            } else {
125                if p > h_second {
126                    h_second = p;
127                }
128                if p < l_second {
129                    l_second = p;
130                }
131            }
132        }
133
134        let half_f = half as f64;
135        let period_f = self.period as f64;
136        let n1 = (h_first - l_first) / half_f;
137        let n2 = (h_second - l_second) / half_f;
138        let n3 = (h_whole - l_whole) / period_f;
139
140        let alpha = if n1 > 0.0 && n2 > 0.0 && n3 > 0.0 {
141            let d = ((n1 + n2).ln() - n3.ln()) / 2.0_f64.ln();
142            (-4.6 * (d - 1.0)).exp().clamp(0.01, 1.0)
143        } else {
144            // Degenerate (perfectly flat half or whole window): use the slowest
145            // smoothing so the indicator coasts on its previous value.
146            0.01
147        };
148
149        let prev = self.current.unwrap_or(input);
150        let next = alpha * input + (1.0 - alpha) * prev;
151        self.current = Some(next);
152        Some(next)
153    }
154
155    fn reset(&mut self) {
156        self.window.clear();
157        self.current = None;
158    }
159
160    #[inline]
161    fn warmup_period(&self) -> usize {
162        self.period
163    }
164
165    #[inline]
166    fn is_ready(&self) -> bool {
167        self.current.is_some()
168    }
169
170    #[inline]
171    fn name(&self) -> &'static str {
172        "FRAMA"
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::traits::BatchExt;
180    use approx::assert_relative_eq;
181
182    #[test]
183    fn rejects_zero_period() {
184        assert!(matches!(Frama::new(0), Err(Error::PeriodZero)));
185    }
186
187    #[test]
188    fn rejects_invalid_period() {
189        assert!(matches!(Frama::new(1), Err(Error::InvalidPeriod { .. })));
190        assert!(matches!(Frama::new(3), Err(Error::InvalidPeriod { .. })));
191        assert!(matches!(Frama::new(15), Err(Error::InvalidPeriod { .. })));
192    }
193
194    #[test]
195    fn accessors_and_metadata() {
196        let frama = Frama::new(16).unwrap();
197        assert_eq!(frama.period(), 16);
198        assert_eq!(frama.warmup_period(), 16);
199        assert_eq!(frama.name(), "FRAMA");
200    }
201
202    #[test]
203    fn constant_series_yields_the_constant() {
204        // Flat input -> alpha clamps to 0.01 (degenerate ranges) and the
205        // EMA recurrence holds the seed value forever.
206        let mut frama = Frama::new(4).unwrap();
207        let out = frama.batch(&[42.0_f64; 30]);
208        for v in out.iter().skip(3).flatten() {
209            assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
210        }
211    }
212
213    #[test]
214    fn warmup_emits_first_value_at_period() {
215        let mut frama = Frama::new(4).unwrap();
216        assert_eq!(frama.update(1.0), None);
217        assert_eq!(frama.update(2.0), None);
218        assert_eq!(frama.update(3.0), None);
219        assert!(frama.update(4.0).is_some());
220    }
221
222    #[test]
223    fn pure_uptrend_alpha_close_to_one() {
224        // A strict monotonic uptrend has fractal dimension ~1, so alpha is
225        // pushed to 1.0 and FRAMA reduces to the latest price.
226        let mut frama = Frama::new(4).unwrap();
227        let prices: Vec<f64> = (1..=8).map(f64::from).collect();
228        let out = frama.batch(&prices);
229        let last = out.last().unwrap().unwrap();
230        assert!(
231            (last - 8.0).abs() < 0.05,
232            "FRAMA on a clean uptrend should hug the latest close: {last}"
233        );
234    }
235
236    #[test]
237    fn batch_equals_streaming() {
238        let prices: Vec<f64> = (1..=80)
239            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
240            .collect();
241        let mut a = Frama::new(8).unwrap();
242        let mut b = Frama::new(8).unwrap();
243        assert_eq!(
244            a.batch(&prices),
245            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
246        );
247    }
248
249    #[test]
250    fn reset_clears_state() {
251        let mut frama = Frama::new(4).unwrap();
252        frama.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
253        assert!(frama.is_ready());
254        frama.reset();
255        assert!(!frama.is_ready());
256        assert_eq!(frama.update(1.0), None);
257    }
258
259    #[test]
260    fn ignores_non_finite_input() {
261        let mut frama = Frama::new(4).unwrap();
262        frama.batch(&[1.0, 2.0, 3.0, 4.0]);
263        frama.update(5.0).unwrap();
264        assert_eq!(frama.update(f64::NAN), None);
265        assert_eq!(frama.update(f64::INFINITY), None);
266    }
267}