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        if period > crate::error::MAX_PERIOD {
64            return Err(Error::InvalidPeriod {
65                message: crate::error::PERIOD_ABOVE_MAX,
66            });
67        }
68        Ok(Self {
69            period,
70            smoothing,
71            closes: VecDeque::with_capacity(period + 1),
72            prev_close: None,
73            segments: VecDeque::with_capacity(period),
74            segment_sum: 0.0,
75            ema: Ema::new(smoothing)?,
76        })
77    }
78
79    /// Configured `(period, smoothing)`.
80    pub const fn periods(&self) -> (usize, usize) {
81        (self.period, self.smoothing)
82    }
83}
84
85impl Indicator for PolarizedFractalEfficiency {
86    type Input = f64;
87    type Output = f64;
88
89    #[inline]
90    fn update(&mut self, close: f64) -> Option<f64> {
91        if !close.is_finite() {
92            return None;
93        }
94        if let Some(prev) = self.prev_close {
95            let diff = close - prev;
96            let segment = diff.mul_add(diff, 1.0).sqrt();
97            self.segment_sum += segment;
98            self.segments.push_back(segment);
99            if self.segments.len() > self.period {
100                self.segment_sum -= self.segments.pop_front().unwrap_or(0.0);
101            }
102        }
103        self.prev_close = Some(close);
104
105        self.closes.push_back(close);
106        if self.closes.len() > self.period + 1 {
107            self.closes.pop_front();
108        }
109        if self.closes.len() <= self.period {
110            return None;
111        }
112
113        let oldest = *self.closes.front().unwrap_or(&close);
114        let net = close - oldest;
115        let direction = if net > 0.0 {
116            1.0
117        } else if net < 0.0 {
118            -1.0
119        } else {
120            0.0
121        };
122        let span = self.period as f64;
123        let straight = net.mul_add(net, span * span).sqrt();
124        let raw = 100.0 * direction * straight / self.segment_sum;
125        self.ema.update(raw)
126    }
127
128    fn reset(&mut self) {
129        self.closes.clear();
130        self.prev_close = None;
131        self.segments.clear();
132        self.segment_sum = 0.0;
133        self.ema.reset();
134    }
135
136    #[inline]
137    fn warmup_period(&self) -> usize {
138        self.period + self.smoothing
139    }
140
141    #[inline]
142    fn is_ready(&self) -> bool {
143        self.ema.is_ready()
144    }
145
146    #[inline]
147    fn name(&self) -> &'static str {
148        "PolarizedFractalEfficiency"
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::traits::BatchExt;
156    use approx::assert_relative_eq;
157
158    #[test]
159    fn rejects_zero_period() {
160        assert!(matches!(
161            PolarizedFractalEfficiency::new(0, 5),
162            Err(Error::PeriodZero)
163        ));
164        assert!(matches!(
165            PolarizedFractalEfficiency::new(10, 0),
166            Err(Error::PeriodZero)
167        ));
168    }
169
170    #[test]
171    fn accessors_and_metadata() {
172        let pfe = PolarizedFractalEfficiency::new(10, 5).unwrap();
173        assert_eq!(pfe.periods(), (10, 5));
174        assert_eq!(pfe.warmup_period(), 15);
175        assert_eq!(pfe.name(), "PolarizedFractalEfficiency");
176        assert!(!pfe.is_ready());
177    }
178
179    #[test]
180    fn warmup_emits_after_period_plus_smoothing() {
181        let mut pfe = PolarizedFractalEfficiency::new(4, 2).unwrap();
182        // raw needs period+1 = 5 closes; EMA(2) needs 2 raws -> first value at
183        // input 6 (index 5).
184        let inputs: Vec<f64> = (0..10).map(f64::from).collect();
185        let out = pfe.batch(&inputs);
186        assert!(out[4].is_none());
187        assert!(out[5].is_some());
188    }
189
190    #[test]
191    fn perfect_uptrend_is_strongly_positive() {
192        // A straight ramp: every step is +1, the diagonal is maximally
193        // efficient, so PFE saturates near +100.
194        let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
195        let inputs: Vec<f64> = (0..30).map(f64::from).collect();
196        let last = pfe.batch(&inputs).last().unwrap().unwrap();
197        assert!(last > 99.0, "pfe {last} should be near +100");
198    }
199
200    #[test]
201    fn perfect_downtrend_is_strongly_negative() {
202        let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
203        let inputs: Vec<f64> = (0..30).map(|i| -f64::from(i)).collect();
204        let last = pfe.batch(&inputs).last().unwrap().unwrap();
205        assert!(last < -99.0, "pfe {last} should be near -100");
206    }
207
208    #[test]
209    fn flat_market_returns_zero() {
210        // No net move over the window -> direction 0 -> raw 0 -> PFE 0.
211        let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
212        let inputs = [10.0; 20];
213        let last = pfe.batch(&inputs).last().unwrap().unwrap();
214        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
215    }
216
217    #[test]
218    fn choppy_market_is_inefficient() {
219        // A sawtooth whip: the net move is tiny relative to the jagged path, so
220        // efficiency stays well below the +-100 saturation of a clean trend.
221        let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
222        let inputs: Vec<f64> = (0..40)
223            .map(|i| if i % 2 == 0 { 100.0 } else { 102.0 })
224            .collect();
225        let last = pfe.batch(&inputs).last().unwrap().unwrap();
226        assert!(
227            last.abs() < 60.0,
228            "choppy pfe {last} should be far from +-100"
229        );
230    }
231
232    #[test]
233    fn reset_clears_state() {
234        let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
235        let inputs: Vec<f64> = (0..30).map(f64::from).collect();
236        pfe.batch(&inputs);
237        assert!(pfe.is_ready());
238        pfe.reset();
239        assert!(!pfe.is_ready());
240        assert_eq!(pfe.periods(), (5, 3));
241    }
242
243    #[test]
244    fn batch_equals_streaming() {
245        let inputs: Vec<f64> = (0..80)
246            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
247            .collect();
248        let mut a = PolarizedFractalEfficiency::new(10, 5).unwrap();
249        let mut b = PolarizedFractalEfficiency::new(10, 5).unwrap();
250        assert_eq!(
251            a.batch(&inputs),
252            inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
253        );
254    }
255}