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 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 pub fn classic() -> Self {
69 Self::new(12, 26, 9).expect("classic PPO periods are valid")
70 }
71
72 pub const fn periods(&self) -> (usize, usize, usize) {
74 let (fast, slow) = self.ppo.periods();
75 (fast, slow, self.signal_period)
76 }
77
78 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 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 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 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}