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        Ok(Self {
60            ppo: Ppo::new(fast, slow)?,
61            signal_ema: Ema::new(signal)?,
62            signal_period: signal,
63            current: None,
64        })
65    }
66
67    /// Default `(12, 26, 9)` configuration.
68    pub fn classic() -> Self {
69        Self::new(12, 26, 9).expect("classic PPO periods are valid")
70    }
71
72    /// Configured periods as `(fast, slow, signal)`.
73    pub const fn periods(&self) -> (usize, usize, usize) {
74        let (fast, slow) = self.ppo.periods();
75        (fast, slow, self.signal_period)
76    }
77
78    /// Current value if available.
79    pub const fn value(&self) -> Option<f64> {
80        self.current
81    }
82}
83
84impl Indicator for PpoHistogram {
85    type Input = f64;
86    type Output = f64;
87
88    fn update(&mut self, input: f64) -> Option<f64> {
89        // Guard before touching either stage so a non-finite input never
90        // advances the signal EMA on a stale, re-fed PPO value.
91        if !input.is_finite() {
92            return self.current;
93        }
94        let ppo = self.ppo.update(input)?;
95        let signal = self.signal_ema.update(ppo)?;
96        let histogram = ppo - signal;
97        self.current = Some(histogram);
98        Some(histogram)
99    }
100
101    fn reset(&mut self) {
102        self.ppo.reset();
103        self.signal_ema.reset();
104        self.current = None;
105    }
106
107    fn warmup_period(&self) -> usize {
108        // Slow EMA seeds the PPO, then the signal EMA needs `signal − 1` more.
109        self.ppo.warmup_period() + self.signal_period - 1
110    }
111
112    fn is_ready(&self) -> bool {
113        self.current.is_some()
114    }
115
116    fn name(&self) -> &'static str {
117        "PpoHistogram"
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::traits::BatchExt;
125    use approx::assert_relative_eq;
126
127    #[test]
128    fn rejects_invalid_periods() {
129        assert!(matches!(
130            PpoHistogram::new(0, 26, 9),
131            Err(Error::PeriodZero)
132        ));
133        assert!(matches!(
134            PpoHistogram::new(12, 0, 9),
135            Err(Error::PeriodZero)
136        ));
137        assert!(matches!(
138            PpoHistogram::new(12, 26, 0),
139            Err(Error::PeriodZero)
140        ));
141        assert!(matches!(
142            PpoHistogram::new(26, 12, 9),
143            Err(Error::InvalidPeriod { .. })
144        ));
145    }
146
147    #[test]
148    fn accessors_and_metadata() {
149        let osc = PpoHistogram::classic();
150        assert_eq!(osc.periods(), (12, 26, 9));
151        assert_eq!(osc.name(), "PpoHistogram");
152        assert_eq!(osc.warmup_period(), 26 + 9 - 1);
153        assert_eq!(osc.value(), None);
154        assert!(!osc.is_ready());
155    }
156
157    #[test]
158    fn equals_ppo_minus_signal_ema() {
159        // The histogram must equal PPO minus an EMA(signal) composed by hand.
160        let prices: Vec<f64> = (1..=120)
161            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 6.0)
162            .collect();
163        let got = PpoHistogram::new(12, 26, 9).unwrap().batch(&prices);
164
165        let mut ppo = Ppo::new(12, 26).unwrap();
166        let mut sig = Ema::new(9).unwrap();
167        let mut expected = Vec::with_capacity(prices.len());
168        for p in &prices {
169            let out = ppo
170                .update(*p)
171                .and_then(|line| sig.update(line).map(|signal| line - signal));
172            expected.push(out);
173        }
174        assert_eq!(got, expected);
175    }
176
177    #[test]
178    fn warmup_emits_first_value_at_warmup_period() {
179        let mut osc = PpoHistogram::new(3, 6, 3).unwrap();
180        let warmup = osc.warmup_period();
181        assert_eq!(warmup, 6 + 3 - 1);
182        for i in 1..warmup {
183            assert!(osc.update(100.0 + i as f64).is_none());
184        }
185        assert!(osc.update(100.0 + warmup as f64).is_some());
186        assert!(osc.is_ready());
187    }
188
189    #[test]
190    fn constant_series_converges_to_zero() {
191        let mut osc = PpoHistogram::classic();
192        let out = osc.batch(&[100.0_f64; 200]);
193        let last = out.iter().rev().flatten().next().expect("emits a value");
194        assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
195    }
196
197    #[test]
198    fn ignores_non_finite_input() {
199        let mut osc = PpoHistogram::new(3, 6, 3).unwrap();
200        let out = osc.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
201        let before = *out.last().unwrap();
202        assert!(before.is_some());
203        assert_eq!(osc.update(f64::NAN), before);
204        assert_eq!(osc.update(f64::INFINITY), before);
205        assert_eq!(osc.value(), before);
206    }
207
208    #[test]
209    fn batch_equals_streaming() {
210        let prices: Vec<f64> = (1..=100)
211            .map(|i| 100.0 + (f64::from(i) * 0.4).cos() * 10.0)
212            .collect();
213        let mut a = PpoHistogram::classic();
214        let mut b = PpoHistogram::classic();
215        assert_eq!(
216            a.batch(&prices),
217            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
218        );
219    }
220
221    #[test]
222    fn reset_clears_state() {
223        let mut osc = PpoHistogram::classic();
224        osc.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
225        assert!(osc.is_ready());
226        osc.reset();
227        assert!(!osc.is_ready());
228        assert_eq!(osc.update(1.0), None);
229    }
230}