wickra_core/indicators/
rolling_percentile_rank.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
42pub struct RollingPercentileRank {
43 period: usize,
44 window: VecDeque<f64>,
45}
46
47impl RollingPercentileRank {
48 pub fn new(period: usize) -> Result<Self> {
53 if period == 0 {
54 return Err(Error::PeriodZero);
55 }
56 if period > crate::error::MAX_PERIOD {
57 return Err(Error::InvalidPeriod {
58 message: crate::error::PERIOD_ABOVE_MAX,
59 });
60 }
61 Ok(Self {
62 period,
63 window: VecDeque::with_capacity(period),
64 })
65 }
66
67 pub const fn period(&self) -> usize {
69 self.period
70 }
71}
72
73impl Indicator for RollingPercentileRank {
74 type Input = f64;
75 type Output = f64;
76
77 #[inline]
78 fn update(&mut self, value: f64) -> Option<f64> {
79 if !value.is_finite() {
80 return None;
81 }
82 if self.window.len() == self.period {
83 self.window.pop_front();
84 }
85 self.window.push_back(value);
86 if self.window.len() < self.period {
87 return None;
88 }
89 let mut below = 0_usize;
90 let mut equal = 0_usize;
91 for &x in &self.window {
92 if x < value {
93 below += 1;
94 } else if x == value {
95 equal += 1;
96 }
97 }
98 let score = (below as f64 + 0.5 * equal as f64) / self.period as f64 * 100.0;
99 Some(score)
100 }
101
102 fn reset(&mut self) {
103 self.window.clear();
104 }
105
106 #[inline]
107 fn warmup_period(&self) -> usize {
108 self.period
109 }
110
111 #[inline]
112 fn is_ready(&self) -> bool {
113 self.window.len() == self.period
114 }
115
116 #[inline]
117 fn name(&self) -> &'static str {
118 "RollingPercentileRank"
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use crate::traits::BatchExt;
126 use approx::assert_relative_eq;
127
128 #[test]
129 fn rejects_zero_period() {
130 assert!(matches!(
131 RollingPercentileRank::new(0),
132 Err(Error::PeriodZero)
133 ));
134 }
135
136 #[test]
137 fn accessors_and_metadata() {
138 let pr = RollingPercentileRank::new(14).unwrap();
139 assert_eq!(pr.period(), 14);
140 assert_eq!(pr.warmup_period(), 14);
141 assert_eq!(pr.name(), "RollingPercentileRank");
142 assert!(!pr.is_ready());
143 }
144
145 #[test]
146 fn flat_window_scores_fifty() {
147 let mut pr = RollingPercentileRank::new(10).unwrap();
149 for v in pr.batch(&[7.0; 20]).into_iter().flatten() {
150 assert_relative_eq!(v, 50.0, epsilon = 1e-12);
151 }
152 }
153
154 #[test]
155 fn current_is_strict_maximum() {
156 let mut pr = RollingPercentileRank::new(5).unwrap();
159 let out = pr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
160 assert_relative_eq!(out[4].unwrap(), 90.0, epsilon = 1e-12);
161 }
162
163 #[test]
164 fn current_is_strict_minimum() {
165 let mut pr = RollingPercentileRank::new(5).unwrap();
168 let out = pr.batch(&[5.0, 4.0, 3.0, 2.0, 1.0]);
169 assert_relative_eq!(out[4].unwrap(), 10.0, epsilon = 1e-12);
170 }
171
172 #[test]
173 fn output_within_bounds() {
174 let mut pr = RollingPercentileRank::new(20).unwrap();
175 let prices: Vec<f64> = (1..=200)
176 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
177 .collect();
178 for v in pr.batch(&prices).into_iter().flatten() {
179 assert!((0.0..=100.0).contains(&v), "out of bounds: {v}");
180 }
181 }
182
183 #[test]
184 fn reset_clears_state() {
185 let mut pr = RollingPercentileRank::new(5).unwrap();
186 pr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
187 assert!(pr.is_ready());
188 pr.reset();
189 assert!(!pr.is_ready());
190 assert_eq!(pr.update(1.0), None);
191 }
192
193 #[test]
194 fn batch_equals_streaming() {
195 let prices: Vec<f64> = (0..60)
196 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
197 .collect();
198 let batch = RollingPercentileRank::new(14).unwrap().batch(&prices);
199 let mut b = RollingPercentileRank::new(14).unwrap();
200 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
201 assert_eq!(batch, streamed);
202 }
203}