Skip to main content

wickra_core/indicators/
ppo.rs

1//! Percentage Price Oscillator.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6use super::Ema;
7
8/// Percentage Price Oscillator — MACD expressed as a percentage.
9///
10/// PPO is the gap between a fast and a slow EMA, divided by the slow EMA and
11/// scaled to a percentage:
12///
13/// ```text
14/// PPO = 100 · (EMA_fast − EMA_slow) / EMA_slow
15/// ```
16///
17/// Dividing by the slow EMA makes PPO **scale-free**: a `PPO` of `1.5` means
18/// "the fast EMA is 1.5 % above the slow EMA" on any instrument, so PPO
19/// readings *are* comparable across assets — unlike the raw price-unit
20/// [`MacdIndicator`](crate::MacdIndicator). The classic PPO **signal line** is
21/// a 9-period EMA of this PPO line; compose it with [`Chain`](crate::Chain)
22/// and an [`Ema`] if you need it.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{Indicator, Ppo};
28///
29/// let mut indicator = Ppo::new(12, 26).unwrap();
30/// let mut last = None;
31/// for i in 0..80 {
32///     last = indicator.update(100.0 + f64::from(i));
33/// }
34/// assert!(last.is_some());
35/// ```
36#[derive(Debug, Clone)]
37pub struct Ppo {
38    fast: usize,
39    slow: usize,
40    ema_fast: Ema,
41    ema_slow: Ema,
42    current: Option<f64>,
43}
44
45impl Ppo {
46    /// Construct a new PPO with the `fast` and `slow` EMA periods.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`Error::PeriodZero`] if either period is `0`, or
51    /// [`Error::InvalidPeriod`] if `fast >= slow`.
52    pub fn new(fast: usize, slow: usize) -> Result<Self> {
53        if fast == 0 || slow == 0 {
54            return Err(Error::PeriodZero);
55        }
56        if fast >= slow {
57            return Err(Error::InvalidPeriod {
58                message: "PPO fast period must be < slow period",
59            });
60        }
61        Ok(Self {
62            fast,
63            slow,
64            ema_fast: Ema::new(fast)?,
65            ema_slow: Ema::new(slow)?,
66            current: None,
67        })
68    }
69
70    /// The `(fast, slow)` periods.
71    pub const fn periods(&self) -> (usize, usize) {
72        (self.fast, self.slow)
73    }
74
75    /// Current value if available.
76    pub const fn value(&self) -> Option<f64> {
77        self.current
78    }
79}
80
81impl Indicator for Ppo {
82    type Input = f64;
83    type Output = f64;
84
85    #[inline]
86    fn update(&mut self, input: f64) -> Option<f64> {
87        if !input.is_finite() {
88            // Non-finite input is ignored; the EMAs are not advanced.
89            return None;
90        }
91        let fast = self.ema_fast.update(input);
92        let slow = self.ema_slow.update(input);
93        match (fast, slow) {
94            (Some(f), Some(s)) => {
95                let ppo = if s == 0.0 {
96                    // Undefined ratio against a zero slow EMA: report flat.
97                    0.0
98                } else {
99                    100.0 * (f - s) / s
100                };
101                self.current = Some(ppo);
102                Some(ppo)
103            }
104            _ => None,
105        }
106    }
107
108    fn reset(&mut self) {
109        self.ema_fast.reset();
110        self.ema_slow.reset();
111        self.current = None;
112    }
113
114    #[inline]
115    fn warmup_period(&self) -> usize {
116        // The slow EMA is the last to seed.
117        self.slow
118    }
119
120    #[inline]
121    fn is_ready(&self) -> bool {
122        self.current.is_some()
123    }
124
125    #[inline]
126    fn name(&self) -> &'static str {
127        "PPO"
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::traits::BatchExt;
135    use approx::assert_relative_eq;
136
137    #[test]
138    fn new_rejects_zero_period() {
139        assert!(matches!(Ppo::new(0, 26), Err(Error::PeriodZero)));
140        assert!(matches!(Ppo::new(12, 0), Err(Error::PeriodZero)));
141    }
142
143    #[test]
144    fn new_rejects_fast_not_less_than_slow() {
145        assert!(matches!(Ppo::new(26, 12), Err(Error::InvalidPeriod { .. })));
146        assert!(matches!(Ppo::new(12, 12), Err(Error::InvalidPeriod { .. })));
147    }
148
149    /// Cover the const accessors `periods` / `value` (lines 71-78) and the
150    /// Indicator-impl `name` body (122-124). `warmup_period` is already
151    /// covered by `first_emission_at_warmup_period`.
152    #[test]
153    fn accessors_and_metadata() {
154        let mut ppo = Ppo::new(12, 26).unwrap();
155        assert_eq!(ppo.periods(), (12, 26));
156        assert_eq!(ppo.name(), "PPO");
157        assert_eq!(ppo.value(), None);
158        for i in 1..=26 {
159            ppo.update(f64::from(i));
160        }
161        assert!(ppo.value().is_some());
162    }
163
164    /// Cover the `s == 0.0` defensive branch (line 96). PPO divides by
165    /// the slow EMA; existing tests use prices ≈ 100, so the slow EMA
166    /// is never 0. Feed a stream of zeros — both EMAs converge to 0.0
167    /// and the indicator must emit exactly 0.0 (flat-momentum fallback)
168    /// rather than NaN.
169    #[test]
170    fn zero_slow_ema_yields_zero_ppo() {
171        let mut ppo = Ppo::new(3, 6).unwrap();
172        let out = ppo.batch(&[0.0_f64; 20]);
173        let last = out.into_iter().flatten().last().expect("emits");
174        assert_eq!(last, 0.0);
175    }
176
177    #[test]
178    fn first_emission_at_warmup_period() {
179        let mut ppo = Ppo::new(3, 6).unwrap();
180        assert_eq!(ppo.warmup_period(), 6);
181        let out = ppo.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
182        for v in out.iter().take(5) {
183            assert!(v.is_none());
184        }
185        assert!(out[5].is_some());
186    }
187
188    #[test]
189    fn constant_series_yields_zero() {
190        // Both EMAs converge to the constant, so their gap is zero.
191        let mut ppo = Ppo::new(3, 6).unwrap();
192        let out = ppo.batch(&[100.0; 60]);
193        for v in out.iter().skip(5).flatten() {
194            assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
195        }
196    }
197
198    #[test]
199    fn uptrend_is_positive() {
200        // In a rising series the fast EMA leads the slow EMA, so PPO > 0.
201        let mut ppo = Ppo::new(5, 12).unwrap();
202        let out = ppo.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
203        let last = out.iter().rev().flatten().next().unwrap();
204        assert!(*last > 0.0, "uptrend PPO should be positive, got {last}");
205    }
206
207    #[test]
208    fn ignores_non_finite_input() {
209        let mut ppo = Ppo::new(3, 6).unwrap();
210        let out = ppo.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
211        let last = *out.last().unwrap();
212        assert!(last.is_some());
213        assert_eq!(ppo.update(f64::NAN), None);
214        assert_eq!(ppo.update(f64::INFINITY), None);
215    }
216
217    #[test]
218    fn reset_clears_state() {
219        let mut ppo = Ppo::new(3, 6).unwrap();
220        ppo.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
221        assert!(ppo.is_ready());
222        ppo.reset();
223        assert!(!ppo.is_ready());
224        assert_eq!(ppo.update(1.0), None);
225    }
226
227    #[test]
228    fn batch_equals_streaming() {
229        let prices: Vec<f64> = (1..=120)
230            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
231            .collect();
232        let batch = Ppo::new(12, 26).unwrap().batch(&prices);
233        let mut b = Ppo::new(12, 26).unwrap();
234        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
235        assert_eq!(batch, streamed);
236    }
237}