wickra_core/indicators/
pain_index.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
27pub struct PainIndex {
28 period: usize,
29 window: VecDeque<f64>,
30}
31
32impl PainIndex {
33 pub fn new(period: usize) -> Result<Self> {
38 if period == 0 {
39 return Err(Error::PeriodZero);
40 }
41 if period > crate::error::MAX_PERIOD {
42 return Err(Error::InvalidPeriod {
43 message: crate::error::PERIOD_ABOVE_MAX,
44 });
45 }
46 Ok(Self {
47 period,
48 window: VecDeque::with_capacity(period),
49 })
50 }
51
52 pub const fn period(&self) -> usize {
54 self.period
55 }
56}
57
58impl Indicator for PainIndex {
59 type Input = f64;
60 type Output = f64;
61
62 #[inline]
63 fn update(&mut self, input: f64) -> Option<f64> {
64 if !input.is_finite() {
65 return None;
66 }
67 if self.window.len() == self.period {
68 self.window.pop_front();
69 }
70 self.window.push_back(input);
71 if self.window.len() < self.period {
72 return None;
73 }
74 let mut peak = f64::NEG_INFINITY;
75 let mut sum_dd = 0.0_f64;
76 for &v in &self.window {
77 if v > peak {
78 peak = v;
79 }
80 if peak > 0.0 {
81 sum_dd += (peak - v) / peak;
82 }
83 }
84 Some(sum_dd / self.period as f64)
85 }
86
87 fn reset(&mut self) {
88 self.window.clear();
89 }
90
91 #[inline]
92 fn warmup_period(&self) -> usize {
93 self.period
94 }
95
96 #[inline]
97 fn is_ready(&self) -> bool {
98 self.window.len() == self.period
99 }
100
101 #[inline]
102 fn name(&self) -> &'static str {
103 "PainIndex"
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use crate::traits::BatchExt;
111 use approx::assert_relative_eq;
112
113 #[test]
114 fn rejects_zero_period() {
115 assert!(matches!(PainIndex::new(0), Err(Error::PeriodZero)));
116 }
117
118 #[test]
119 fn accessors_and_metadata() {
120 let p = PainIndex::new(10).unwrap();
121 assert_eq!(p.period(), 10);
122 assert_eq!(p.name(), "PainIndex");
123 assert_eq!(p.warmup_period(), 10);
124 }
125
126 #[test]
127 fn pure_uptrend_yields_zero() {
128 let mut p = PainIndex::new(5).unwrap();
129 let out = p.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
130 for v in out.into_iter().flatten() {
131 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
132 }
133 }
134
135 #[test]
136 fn reference_value() {
137 let mut p = PainIndex::new(3).unwrap();
140 let out = p.batch(&[100.0, 120.0, 90.0]);
141 assert_relative_eq!(out[2].unwrap(), 0.25 / 3.0, epsilon = 1e-12);
142 }
143
144 #[test]
145 fn ignores_non_finite_input() {
146 let mut p = PainIndex::new(3).unwrap();
147 assert_eq!(p.update(f64::NAN), None);
148 assert_eq!(p.update(f64::INFINITY), None);
149 }
150
151 #[test]
152 fn reset_clears_state() {
153 let mut p = PainIndex::new(3).unwrap();
154 p.batch(&[100.0, 90.0, 110.0]);
155 assert!(p.is_ready());
156 p.reset();
157 assert!(!p.is_ready());
158 assert_eq!(p.update(100.0), None);
159 }
160
161 #[test]
162 fn batch_equals_streaming() {
163 let prices: Vec<f64> = (0..40)
164 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
165 .collect();
166 let batch = PainIndex::new(10).unwrap().batch(&prices);
167 let mut s = PainIndex::new(10).unwrap();
168 let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
169 assert_eq!(batch, streamed);
170 }
171
172 #[test]
173 fn non_positive_peak_yields_zero() {
174 let mut p = PainIndex::new(3).unwrap();
175 let out = p.batch(&[0.0_f64; 6]);
176 for v in out.into_iter().flatten() {
177 assert_eq!(v, 0.0);
178 }
179 }
180}