Skip to main content

quantwave_core/indicators/
series_norm.rs

1//! Rolling rank and z-score of a single series.
2//!
3//! These are the normalizations used to compare a level with its own history
4//! (a one-year window is the usual default). They do not know what the series
5//! is. A column of chain `gextotal` values is one caller.
6
7use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
8use crate::traits::Next;
9use crate::utils::RingBuffer as VecDeque;
10
11/// Fraction of the trailing window that is less than or equal to the current value.
12///
13/// The window includes the current bar. Output is NaN until `period` finite
14/// values have been seen, and NaN if the current value or any value in the
15/// window is NaN. A constant window ranks as 1.
16#[derive(Debug, Clone)]
17pub struct PercentRank {
18    period: usize,
19    buf: VecDeque<f64>,
20}
21
22impl PercentRank {
23    pub fn new(period: usize) -> Self {
24        let period = period.max(1);
25        Self {
26            period,
27            buf: VecDeque::with_capacity(period),
28        }
29    }
30}
31
32impl Next<f64> for PercentRank {
33    type Output = f64;
34
35    fn next(&mut self, x: f64) -> Self::Output {
36        self.buf.push_back(x);
37        if self.buf.len() > self.period {
38            self.buf.pop_front();
39        }
40        if self.buf.len() < self.period || self.buf.iter().any(|v| v.is_nan()) {
41            return f64::NAN;
42        }
43        let n = self.buf.len() as f64;
44        let le = self.buf.iter().filter(|v| **v <= x).count() as f64;
45        le / n
46    }
47}
48
49/// Rolling z-score, sample standard deviation (divide by n − 1).
50///
51/// NaN until `period` finite values are in the window, when `period < 2`,
52/// or when the sample standard deviation is 0.
53#[derive(Debug, Clone)]
54pub struct Zscore {
55    period: usize,
56    buf: VecDeque<f64>,
57}
58
59impl Zscore {
60    pub fn new(period: usize) -> Self {
61        let period = period.max(2);
62        Self {
63            period,
64            buf: VecDeque::with_capacity(period),
65        }
66    }
67}
68
69impl Next<f64> for Zscore {
70    type Output = f64;
71
72    fn next(&mut self, x: f64) -> Self::Output {
73        self.buf.push_back(x);
74        if self.buf.len() > self.period {
75            self.buf.pop_front();
76        }
77        if self.buf.len() < self.period || self.buf.iter().any(|v| v.is_nan()) {
78            return f64::NAN;
79        }
80        let n = self.buf.len() as f64;
81        let mean = self.buf.iter().sum::<f64>() / n;
82        let mut acc = 0.0;
83        for v in &self.buf {
84            let d = v - mean;
85            acc += d * d;
86        }
87        let std = (acc / (n - 1.0)).sqrt();
88        if std == 0.0 {
89            f64::NAN
90        } else {
91            (x - mean) / std
92        }
93    }
94}
95
96pub const PERCENT_RANK_METADATA: IndicatorMetadata = IndicatorMetadata {
97    name: "Percent Rank",
98    description: "Where the current value sits inside its own trailing window, as the fraction of window values less than or equal to it.",
99    usage: "Rank a series against its own history before comparing years. A daily chain gextotal column uses period 252. The rank is not a signal by itself; compare it with a threshold in the caller.",
100    keywords: &["statistics", "rank", "percentile", "normalization"],
101    ehlers_summary: "Not an Ehlers filter. Empirical percent rank of a trailing window, including the current observation.",
102    params: &[ParamDef {
103        name: "period",
104        default: "252",
105        description: "Trailing window length, in bars. 252 is one trading year of daily data.",
106    }],
107    formula_source: "Standard trailing percent rank. Window and 0.90 / 0.10 thresholds follow the gamma-exposure normalization in David Bergstrom, Build Alpha, Gamma Exposure (Sep 2026).",
108    formula_latex: r"\[\mathrm{percent\_rank}_t = \frac{\#\{x_i \le x_t : i \in [t-n+1, t]\}}{n}\]",
109    gold_standard_file: "",
110    category: "Statistics",
111};
112
113pub const ZSCORE_METADATA: IndicatorMetadata = IndicatorMetadata {
114    name: "Rolling Z-Score",
115    description: "How many sample standard deviations the current value is from its trailing mean.",
116    usage: "Normalize a drifting series. A daily chain gextotal column uses period 252. Zero variance returns NaN.",
117    keywords: &["statistics", "zscore", "normalization"],
118    ehlers_summary: "Not an Ehlers filter. Rolling z-score with the sample standard deviation (n − 1).",
119    params: &[ParamDef {
120        name: "period",
121        default: "252",
122        description: "Trailing window length, in bars. Must be at least 2.",
123    }],
124    formula_source: "Standard rolling z-score. The 252-bar window and ±2 thresholds follow David Bergstrom, Build Alpha, Gamma Exposure (Sep 2026).",
125    formula_latex: r"\[z_t = (x_t - \mu_n) / s_n,\quad s_n^2 = \sum (x_i - \mu_n)^2 / (n - 1)\]",
126    gold_standard_file: "",
127    category: "Statistics",
128};
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use approx::assert_relative_eq;
134    use proptest::prelude::*;
135
136    fn replay<I: Next<f64, Output = f64>>(mut ind: I, xs: &[f64]) -> Vec<f64> {
137        xs.iter().copied().map(|x| ind.next(x)).collect()
138    }
139
140    #[test]
141    fn percent_rank_known_window() {
142        let out = replay(PercentRank::new(5), &[1.0, 2.0, 3.0, 4.0, 5.0]);
143        assert!(out[..4].iter().all(|v| v.is_nan()));
144        assert_relative_eq!(out[4], 1.0);
145        let out = replay(PercentRank::new(5), &[1.0, 2.0, 3.0, 4.0, 3.0]);
146        // window 1,2,3,4,3 — values <= 3: 1,2,3,3 → 4/5
147        assert_relative_eq!(out[4], 0.8);
148    }
149
150    #[test]
151    fn percent_rank_constant_is_one() {
152        let out = replay(PercentRank::new(3), &[2.0, 2.0, 2.0]);
153        assert_relative_eq!(out[2], 1.0);
154    }
155
156    #[test]
157    fn zscore_known_window() {
158        let out = replay(Zscore::new(3), &[1.0, 2.0, 3.0]);
159        assert!(out[0].is_nan() && out[1].is_nan());
160        // mean 2, sample std 1, z of 3 is 1
161        assert_relative_eq!(out[2], 1.0);
162    }
163
164    #[test]
165    fn zscore_zero_variance_is_nan() {
166        let out = replay(Zscore::new(3), &[4.0, 4.0, 4.0]);
167        assert!(out[2].is_nan());
168    }
169
170    #[test]
171    fn nan_in_window_poisons_output() {
172        let out = replay(PercentRank::new(3), &[1.0, f64::NAN, 3.0, 4.0, 5.0]);
173        assert!(out[2].is_nan());
174        assert!(out[3].is_nan());
175        assert!(out[4].is_finite());
176    }
177
178    proptest! {
179        #[test]
180        fn test_percent_rank_parity(xs in prop::collection::vec(-1000.0..1000.0f64, 1..40), period in 2usize..12) {
181            let once = replay(PercentRank::new(period), &xs);
182            let twice = replay(PercentRank::new(period), &xs);
183            prop_assert_eq!(once.len(), twice.len());
184            for (a, b) in once.iter().zip(twice.iter()) {
185                prop_assert_eq!(a.is_nan(), b.is_nan());
186                if a.is_finite() {
187                    prop_assert!((a - b).abs() < 1e-12);
188                }
189            }
190        }
191
192        #[test]
193        fn test_zscore_parity(xs in prop::collection::vec(-1000.0..1000.0f64, 2..40), period in 2usize..12) {
194            let once = replay(Zscore::new(period), &xs);
195            let twice = replay(Zscore::new(period), &xs);
196            for (a, b) in once.iter().zip(twice.iter()) {
197                prop_assert_eq!(a.is_nan(), b.is_nan());
198                if a.is_finite() && b.is_finite() {
199                    prop_assert!((a - b).abs() < 1e-9);
200                }
201            }
202        }
203    }
204
205    #[test]
206    fn streaming_matches_replay() {
207        let xs = [1.0, 3.0, 2.0, 8.0, 5.0, 5.0, 0.5, 4.0];
208        let once = replay(PercentRank::new(4), &xs);
209        let twice = replay(PercentRank::new(4), &xs);
210        for (a, b) in once.iter().zip(twice.iter()) {
211            assert_eq!(a.is_nan(), b.is_nan());
212            if a.is_finite() {
213                assert_relative_eq!(a, b);
214            }
215        }
216        let once = replay(Zscore::new(4), &xs);
217        let twice = replay(Zscore::new(4), &xs);
218        for (a, b) in once.iter().zip(twice.iter()) {
219            assert_eq!(a.is_nan(), b.is_nan());
220            if a.is_finite() {
221                assert_relative_eq!(a, b);
222            }
223        }
224    }
225}