quantwave_core/indicators/
series_norm.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
8use crate::traits::Next;
9use crate::utils::RingBuffer as VecDeque;
10
11#[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#[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 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 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}