wickra_core/indicators/
ppo_histogram.rs1use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::indicators::ppo::Ppo;
6use crate::traits::Indicator;
7
8#[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 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 pub fn classic() -> Self {
74 Self::new(12, 26, 9).expect("classic PPO periods are valid")
75 }
76
77 pub const fn periods(&self) -> (usize, usize, usize) {
79 let (fast, slow) = self.ppo.periods();
80 (fast, slow, self.signal_period)
81 }
82
83 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 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 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 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}