Skip to main content

wickra_core/indicators/
ppo_histogram.rs

1//! Percentage Price Oscillator Histogram.
2
3use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::indicators::ppo::Ppo;
6use crate::traits::Indicator;
7
8/// PPO Histogram — the `ppo − signal` bar of the Percentage Price Oscillator.
9///
10/// ```text
11/// ppo       = 100 · (EMA_fast − EMA_slow) / EMA_slow
12/// signal    = EMA(ppo, signal_period)
13/// histogram = ppo − signal
14/// ```
15///
16/// [`Ppo`](crate::Ppo) itself only emits the percentage line; this indicator
17/// adds the classic 9-period signal EMA on top and reports the resulting
18/// zero-centered histogram. Because PPO is scale-free (the EMA gap is divided
19/// by the slow EMA), the histogram is **comparable across instruments** — a
20/// PPO histogram of `0.4` means the same relative momentum on any asset, unlike
21/// the price-unit [`MacdHistogram`](crate::MacdHistogram).
22///
23/// With Appel's defaults `fast = 12`, `slow = 26`, `signal = 9`, the first
24/// value lands after `slow + signal − 1` inputs — the point at which the slow
25/// EMA and then the signal EMA are both seeded.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Indicator, PpoHistogram};
31///
32/// let mut indicator = PpoHistogram::new(12, 26, 9).unwrap();
33/// let mut last = None;
34/// for i in 0..80 {
35///     last = indicator.update(100.0 + f64::from(i));
36/// }
37/// assert!(last.is_some());
38/// ```
39#[derive(Debug, Clone)]
40pub struct PpoHistogram {
41    ppo: Ppo,
42    signal_ema: Ema,
43    signal_period: usize,
44    current: Option<f64>,
45}
46
47impl PpoHistogram {
48    /// Construct a PPO histogram with the `fast`/`slow` EMA periods and the
49    /// `signal` EMA period.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`Error::PeriodZero`] if any period is `0`, or
54    /// [`Error::InvalidPeriod`] if `fast >= slow`.
55    pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
56        if signal == 0 {
57            return Err(Error::PeriodZero);
58        }
59        if signal > crate::error::MAX_PERIOD {
60            return Err(Error::InvalidPeriod {
61                message: crate::error::PERIOD_ABOVE_MAX,
62            });
63        }
64        Ok(Self {
65            ppo: Ppo::new(fast, slow)?,
66            signal_ema: Ema::new(signal)?,
67            signal_period: signal,
68            current: None,
69        })
70    }
71
72    /// Default `(12, 26, 9)` configuration.
73    pub fn classic() -> Self {
74        Self::new(12, 26, 9).expect("classic PPO periods are valid")
75    }
76
77    /// Configured periods as `(fast, slow, signal)`.
78    pub const fn periods(&self) -> (usize, usize, usize) {
79        let (fast, slow) = self.ppo.periods();
80        (fast, slow, self.signal_period)
81    }
82
83    /// Current value if available.
84    pub const fn value(&self) -> Option<f64> {
85        self.current
86    }
87}
88
89impl Indicator for PpoHistogram {
90    type Input = f64;
91    type Output = f64;
92
93    #[inline]
94    fn update(&mut self, input: f64) -> Option<f64> {
95        // Guard before touching either stage so a non-finite input never
96        // advances the signal EMA on a stale, re-fed PPO value.
97        if !input.is_finite() {
98            return None;
99        }
100        let ppo = self.ppo.update(input)?;
101        let signal = self.signal_ema.update(ppo)?;
102        let histogram = ppo - signal;
103        self.current = Some(histogram);
104        Some(histogram)
105    }
106
107    fn reset(&mut self) {
108        self.ppo.reset();
109        self.signal_ema.reset();
110        self.current = None;
111    }
112
113    #[inline]
114    fn warmup_period(&self) -> usize {
115        // Slow EMA seeds the PPO, then the signal EMA needs `signal − 1` more.
116        self.ppo.warmup_period() + self.signal_period - 1
117    }
118
119    #[inline]
120    fn is_ready(&self) -> bool {
121        self.current.is_some()
122    }
123
124    #[inline]
125    fn name(&self) -> &'static str {
126        "PpoHistogram"
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::traits::BatchExt;
134    use approx::assert_relative_eq;
135
136    #[test]
137    fn rejects_invalid_periods() {
138        assert!(matches!(
139            PpoHistogram::new(0, 26, 9),
140            Err(Error::PeriodZero)
141        ));
142        assert!(matches!(
143            PpoHistogram::new(12, 0, 9),
144            Err(Error::PeriodZero)
145        ));
146        assert!(matches!(
147            PpoHistogram::new(12, 26, 0),
148            Err(Error::PeriodZero)
149        ));
150        assert!(matches!(
151            PpoHistogram::new(26, 12, 9),
152            Err(Error::InvalidPeriod { .. })
153        ));
154    }
155
156    #[test]
157    fn accessors_and_metadata() {
158        let osc = PpoHistogram::classic();
159        assert_eq!(osc.periods(), (12, 26, 9));
160        assert_eq!(osc.name(), "PpoHistogram");
161        assert_eq!(osc.warmup_period(), 26 + 9 - 1);
162        assert_eq!(osc.value(), None);
163        assert!(!osc.is_ready());
164    }
165
166    #[test]
167    fn equals_ppo_minus_signal_ema() {
168        // The histogram must equal PPO minus an EMA(signal) composed by hand.
169        let prices: Vec<f64> = (1..=120)
170            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 6.0)
171            .collect();
172        let got = PpoHistogram::new(12, 26, 9).unwrap().batch(&prices);
173
174        let mut ppo = Ppo::new(12, 26).unwrap();
175        let mut sig = Ema::new(9).unwrap();
176        let mut expected = Vec::with_capacity(prices.len());
177        for p in &prices {
178            let out = ppo
179                .update(*p)
180                .and_then(|line| sig.update(line).map(|signal| line - signal));
181            expected.push(out);
182        }
183        assert_eq!(got, expected);
184    }
185
186    #[test]
187    fn warmup_emits_first_value_at_warmup_period() {
188        let mut osc = PpoHistogram::new(3, 6, 3).unwrap();
189        let warmup = osc.warmup_period();
190        assert_eq!(warmup, 6 + 3 - 1);
191        for i in 1..warmup {
192            assert!(osc.update(100.0 + i as f64).is_none());
193        }
194        assert!(osc.update(100.0 + warmup as f64).is_some());
195        assert!(osc.is_ready());
196    }
197
198    #[test]
199    fn constant_series_converges_to_zero() {
200        let mut osc = PpoHistogram::classic();
201        let out = osc.batch(&[100.0_f64; 200]);
202        let last = out.iter().rev().flatten().next().expect("emits a value");
203        assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
204    }
205
206    #[test]
207    fn ignores_non_finite_input() {
208        let mut osc = PpoHistogram::new(3, 6, 3).unwrap();
209        let out = osc.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
210        let before = *out.last().unwrap();
211        assert!(before.is_some());
212        assert_eq!(osc.update(f64::NAN), None);
213        assert_eq!(osc.update(f64::INFINITY), None);
214        assert_eq!(osc.value(), before);
215    }
216
217    #[test]
218    fn batch_equals_streaming() {
219        let prices: Vec<f64> = (1..=100)
220            .map(|i| 100.0 + (f64::from(i) * 0.4).cos() * 10.0)
221            .collect();
222        let mut a = PpoHistogram::classic();
223        let mut b = PpoHistogram::classic();
224        assert_eq!(
225            a.batch(&prices),
226            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
227        );
228    }
229
230    #[test]
231    fn reset_clears_state() {
232        let mut osc = PpoHistogram::classic();
233        osc.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
234        assert!(osc.is_ready());
235        osc.reset();
236        assert!(!osc.is_ready());
237        assert_eq!(osc.update(1.0), None);
238    }
239}