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    fn update(&mut self, input: f64) -> Option<f64> {
86        if !input.is_finite() {
87            // Non-finite input is ignored; the EMAs are not advanced.
88            return self.current;
89        }
90        let fast = self.ema_fast.update(input);
91        let slow = self.ema_slow.update(input);
92        match (fast, slow) {
93            (Some(f), Some(s)) => {
94                let ppo = if s == 0.0 {
95                    // Undefined ratio against a zero slow EMA: report flat.
96                    0.0
97                } else {
98                    100.0 * (f - s) / s
99                };
100                self.current = Some(ppo);
101                Some(ppo)
102            }
103            _ => None,
104        }
105    }
106
107    fn reset(&mut self) {
108        self.ema_fast.reset();
109        self.ema_slow.reset();
110        self.current = None;
111    }
112
113    fn warmup_period(&self) -> usize {
114        // The slow EMA is the last to seed.
115        self.slow
116    }
117
118    fn is_ready(&self) -> bool {
119        self.current.is_some()
120    }
121
122    fn name(&self) -> &'static str {
123        "PPO"
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::traits::BatchExt;
131    use approx::assert_relative_eq;
132
133    #[test]
134    fn new_rejects_zero_period() {
135        assert!(matches!(Ppo::new(0, 26), Err(Error::PeriodZero)));
136        assert!(matches!(Ppo::new(12, 0), Err(Error::PeriodZero)));
137    }
138
139    #[test]
140    fn new_rejects_fast_not_less_than_slow() {
141        assert!(matches!(Ppo::new(26, 12), Err(Error::InvalidPeriod { .. })));
142        assert!(matches!(Ppo::new(12, 12), Err(Error::InvalidPeriod { .. })));
143    }
144
145    /// Cover the const accessors `periods` / `value` (lines 71-78) and the
146    /// Indicator-impl `name` body (122-124). `warmup_period` is already
147    /// covered by `first_emission_at_warmup_period`.
148    #[test]
149    fn accessors_and_metadata() {
150        let mut ppo = Ppo::new(12, 26).unwrap();
151        assert_eq!(ppo.periods(), (12, 26));
152        assert_eq!(ppo.name(), "PPO");
153        assert_eq!(ppo.value(), None);
154        for i in 1..=26 {
155            ppo.update(f64::from(i));
156        }
157        assert!(ppo.value().is_some());
158    }
159
160    /// Cover the `s == 0.0` defensive branch (line 96). PPO divides by
161    /// the slow EMA; existing tests use prices ≈ 100, so the slow EMA
162    /// is never 0. Feed a stream of zeros — both EMAs converge to 0.0
163    /// and the indicator must emit exactly 0.0 (flat-momentum fallback)
164    /// rather than NaN.
165    #[test]
166    fn zero_slow_ema_yields_zero_ppo() {
167        let mut ppo = Ppo::new(3, 6).unwrap();
168        let out = ppo.batch(&[0.0_f64; 20]);
169        let last = out.into_iter().flatten().last().expect("emits");
170        assert_eq!(last, 0.0);
171    }
172
173    #[test]
174    fn first_emission_at_warmup_period() {
175        let mut ppo = Ppo::new(3, 6).unwrap();
176        assert_eq!(ppo.warmup_period(), 6);
177        let out = ppo.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
178        for v in out.iter().take(5) {
179            assert!(v.is_none());
180        }
181        assert!(out[5].is_some());
182    }
183
184    #[test]
185    fn constant_series_yields_zero() {
186        // Both EMAs converge to the constant, so their gap is zero.
187        let mut ppo = Ppo::new(3, 6).unwrap();
188        let out = ppo.batch(&[100.0; 60]);
189        for v in out.iter().skip(5).flatten() {
190            assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
191        }
192    }
193
194    #[test]
195    fn uptrend_is_positive() {
196        // In a rising series the fast EMA leads the slow EMA, so PPO > 0.
197        let mut ppo = Ppo::new(5, 12).unwrap();
198        let out = ppo.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
199        let last = out.iter().rev().flatten().next().unwrap();
200        assert!(*last > 0.0, "uptrend PPO should be positive, got {last}");
201    }
202
203    #[test]
204    fn ignores_non_finite_input() {
205        let mut ppo = Ppo::new(3, 6).unwrap();
206        let out = ppo.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
207        let last = *out.last().unwrap();
208        assert!(last.is_some());
209        assert_eq!(ppo.update(f64::NAN), last);
210        assert_eq!(ppo.update(f64::INFINITY), last);
211    }
212
213    #[test]
214    fn reset_clears_state() {
215        let mut ppo = Ppo::new(3, 6).unwrap();
216        ppo.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
217        assert!(ppo.is_ready());
218        ppo.reset();
219        assert!(!ppo.is_ready());
220        assert_eq!(ppo.update(1.0), None);
221    }
222
223    #[test]
224    fn batch_equals_streaming() {
225        let prices: Vec<f64> = (1..=120)
226            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
227            .collect();
228        let batch = Ppo::new(12, 26).unwrap().batch(&prices);
229        let mut b = Ppo::new(12, 26).unwrap();
230        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
231        assert_eq!(batch, streamed);
232    }
233}