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