Skip to main content

wickra_core/indicators/
rolling_percentile_rank.rs

1//! Rolling Percentile Rank of the latest value within its trailing window.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Percentile rank of the most-recent value within the last `period` values,
9/// in `[0, 100]`.
10///
11/// ```text
12/// rank = 100 · (#below + 0.5 · #equal) / period
13/// ```
14///
15/// where `#below` counts window values strictly less than the current value and
16/// `#equal` counts those equal to it (including the current value itself). This
17/// is the "mean" method of `percentileofscore`: ties are split symmetrically,
18/// so a flat window scores exactly `50`, the strict window maximum scores just
19/// under `100`, and the strict minimum just over `0`.
20///
21/// Percentile rank turns any series into a bounded, self-normalising oscillator:
22/// "where does today sit relative to its own recent history" — high readings
23/// mark stretched extremes, mid readings mark the typical range. It is the
24/// scale-free cousin of the z-score that makes no distributional assumption.
25///
26/// Each `update` is O(period): one linear pass tallies the comparisons.
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Indicator, RollingPercentileRank};
32///
33/// let mut indicator = RollingPercentileRank::new(20).unwrap();
34/// let mut last = None;
35/// for i in 0..40 {
36///     last = indicator.update(100.0 + f64::from(i));
37/// }
38/// // A strictly rising series puts the newest value near the top.
39/// assert!(last.unwrap() > 90.0);
40/// ```
41#[derive(Debug, Clone)]
42pub struct RollingPercentileRank {
43    period: usize,
44    window: VecDeque<f64>,
45}
46
47impl RollingPercentileRank {
48    /// Construct a new rolling percentile rank with the given period.
49    ///
50    /// # Errors
51    /// Returns [`Error::PeriodZero`] if `period == 0`.
52    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    /// Configured period.
68    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        // All values equal: #below = 0, #equal = period → 0.5 → 50.
148        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        // Window [1,2,3,4,5], current = 5: #below = 4, #equal = 1.
157        // (4 + 0.5) / 5 * 100 = 90.
158        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        // Window [5,4,3,2,1], current = 1: #below = 0, #equal = 1.
166        // (0 + 0.5) / 5 * 100 = 10.
167        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}