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