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