Skip to main content

wickra_core/indicators/
polarized_fractal_efficiency.rs

1//! Polarized Fractal Efficiency (PFE).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::ema::Ema;
7use crate::traits::Indicator;
8
9/// Polarized Fractal Efficiency: how efficiently price travelled over the last
10/// `period` bars, signed by direction and smoothed by an EMA.
11///
12/// ```text
13/// straight  = sqrt((C_t - C_{t-n})^2 + n^2)            (direct distance over n bars)
14/// path      = Σ_{i=1..n} sqrt((C_{t-i+1} - C_{t-i})^2 + 1)   (sum of single-bar steps)
15/// raw       = 100 * sign(C_t - C_{t-n}) * straight / path
16/// PFE       = EMA(raw, smoothing)
17/// ```
18///
19/// The ratio `straight / path` is the fractal efficiency: it is `1` when price
20/// moved in a perfectly straight line and falls toward `0` as the path becomes
21/// jagged. Polarizing it by the sign of the net move pushes the reading to
22/// `+100` for an efficient up-move and `-100` for an efficient down-move, with
23/// choppy markets oscillating near zero. Because each single-bar step and the
24/// `n`-bar diagonal both carry the bar count on the x-axis (`+1` and `+n^2`),
25/// the path length is always `>= n`, so the denominator can never be zero.
26///
27/// Reference: Hans Hannula, *Stocks & Commodities*, 1994.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Indicator, PolarizedFractalEfficiency};
33///
34/// let mut indicator = PolarizedFractalEfficiency::new(10, 5).unwrap();
35/// let mut last = None;
36/// for i in 0..40 {
37///     last = indicator.update(100.0 + f64::from(i));
38/// }
39/// assert!(last.is_some());
40/// ```
41#[derive(Debug, Clone)]
42pub struct PolarizedFractalEfficiency {
43    period: usize,
44    smoothing: usize,
45    closes: VecDeque<f64>,
46    prev_close: Option<f64>,
47    segments: VecDeque<f64>,
48    segment_sum: f64,
49    ema: Ema,
50}
51
52impl PolarizedFractalEfficiency {
53    /// Construct a PFE with the fractal lookback `period` and the EMA
54    /// `smoothing` period.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`Error::PeriodZero`] if `period == 0` or `smoothing == 0`.
59    pub fn new(period: usize, smoothing: usize) -> Result<Self> {
60        if period == 0 {
61            return Err(Error::PeriodZero);
62        }
63        Ok(Self {
64            period,
65            smoothing,
66            closes: VecDeque::with_capacity(period + 1),
67            prev_close: None,
68            segments: VecDeque::with_capacity(period),
69            segment_sum: 0.0,
70            ema: Ema::new(smoothing)?,
71        })
72    }
73
74    /// Configured `(period, smoothing)`.
75    pub const fn periods(&self) -> (usize, usize) {
76        (self.period, self.smoothing)
77    }
78}
79
80impl Indicator for PolarizedFractalEfficiency {
81    type Input = f64;
82    type Output = f64;
83
84    fn update(&mut self, close: f64) -> Option<f64> {
85        if !close.is_finite() {
86            return None;
87        }
88        if let Some(prev) = self.prev_close {
89            let diff = close - prev;
90            let segment = diff.mul_add(diff, 1.0).sqrt();
91            self.segment_sum += segment;
92            self.segments.push_back(segment);
93            if self.segments.len() > self.period {
94                self.segment_sum -= self.segments.pop_front().unwrap_or(0.0);
95            }
96        }
97        self.prev_close = Some(close);
98
99        self.closes.push_back(close);
100        if self.closes.len() > self.period + 1 {
101            self.closes.pop_front();
102        }
103        if self.closes.len() <= self.period {
104            return None;
105        }
106
107        let oldest = *self.closes.front().unwrap_or(&close);
108        let net = close - oldest;
109        let direction = if net > 0.0 {
110            1.0
111        } else if net < 0.0 {
112            -1.0
113        } else {
114            0.0
115        };
116        let span = self.period as f64;
117        let straight = net.mul_add(net, span * span).sqrt();
118        let raw = 100.0 * direction * straight / self.segment_sum;
119        self.ema.update(raw)
120    }
121
122    fn reset(&mut self) {
123        self.closes.clear();
124        self.prev_close = None;
125        self.segments.clear();
126        self.segment_sum = 0.0;
127        self.ema.reset();
128    }
129
130    fn warmup_period(&self) -> usize {
131        self.period + self.smoothing
132    }
133
134    fn is_ready(&self) -> bool {
135        self.ema.is_ready()
136    }
137
138    fn name(&self) -> &'static str {
139        "PolarizedFractalEfficiency"
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::traits::BatchExt;
147    use approx::assert_relative_eq;
148
149    #[test]
150    fn rejects_zero_period() {
151        assert!(matches!(
152            PolarizedFractalEfficiency::new(0, 5),
153            Err(Error::PeriodZero)
154        ));
155        assert!(matches!(
156            PolarizedFractalEfficiency::new(10, 0),
157            Err(Error::PeriodZero)
158        ));
159    }
160
161    #[test]
162    fn accessors_and_metadata() {
163        let pfe = PolarizedFractalEfficiency::new(10, 5).unwrap();
164        assert_eq!(pfe.periods(), (10, 5));
165        assert_eq!(pfe.warmup_period(), 15);
166        assert_eq!(pfe.name(), "PolarizedFractalEfficiency");
167        assert!(!pfe.is_ready());
168    }
169
170    #[test]
171    fn warmup_emits_after_period_plus_smoothing() {
172        let mut pfe = PolarizedFractalEfficiency::new(4, 2).unwrap();
173        // raw needs period+1 = 5 closes; EMA(2) needs 2 raws -> first value at
174        // input 6 (index 5).
175        let inputs: Vec<f64> = (0..10).map(f64::from).collect();
176        let out = pfe.batch(&inputs);
177        assert!(out[4].is_none());
178        assert!(out[5].is_some());
179    }
180
181    #[test]
182    fn perfect_uptrend_is_strongly_positive() {
183        // A straight ramp: every step is +1, the diagonal is maximally
184        // efficient, so PFE saturates near +100.
185        let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
186        let inputs: Vec<f64> = (0..30).map(f64::from).collect();
187        let last = pfe.batch(&inputs).last().unwrap().unwrap();
188        assert!(last > 99.0, "pfe {last} should be near +100");
189    }
190
191    #[test]
192    fn perfect_downtrend_is_strongly_negative() {
193        let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
194        let inputs: Vec<f64> = (0..30).map(|i| -f64::from(i)).collect();
195        let last = pfe.batch(&inputs).last().unwrap().unwrap();
196        assert!(last < -99.0, "pfe {last} should be near -100");
197    }
198
199    #[test]
200    fn flat_market_returns_zero() {
201        // No net move over the window -> direction 0 -> raw 0 -> PFE 0.
202        let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
203        let inputs = [10.0; 20];
204        let last = pfe.batch(&inputs).last().unwrap().unwrap();
205        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
206    }
207
208    #[test]
209    fn choppy_market_is_inefficient() {
210        // A sawtooth whip: the net move is tiny relative to the jagged path, so
211        // efficiency stays well below the +-100 saturation of a clean trend.
212        let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
213        let inputs: Vec<f64> = (0..40)
214            .map(|i| if i % 2 == 0 { 100.0 } else { 102.0 })
215            .collect();
216        let last = pfe.batch(&inputs).last().unwrap().unwrap();
217        assert!(
218            last.abs() < 60.0,
219            "choppy pfe {last} should be far from +-100"
220        );
221    }
222
223    #[test]
224    fn reset_clears_state() {
225        let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
226        let inputs: Vec<f64> = (0..30).map(f64::from).collect();
227        pfe.batch(&inputs);
228        assert!(pfe.is_ready());
229        pfe.reset();
230        assert!(!pfe.is_ready());
231        assert_eq!(pfe.periods(), (5, 3));
232    }
233
234    #[test]
235    fn batch_equals_streaming() {
236        let inputs: Vec<f64> = (0..80)
237            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
238            .collect();
239        let mut a = PolarizedFractalEfficiency::new(10, 5).unwrap();
240        let mut b = PolarizedFractalEfficiency::new(10, 5).unwrap();
241        assert_eq!(
242            a.batch(&inputs),
243            inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
244        );
245    }
246}