use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
use crate::traits::Next;
use crate::utils::RingBuffer as VecDeque;
#[derive(Debug, Clone)]
pub struct PercentRank {
period: usize,
buf: VecDeque<f64>,
}
impl PercentRank {
pub fn new(period: usize) -> Self {
let period = period.max(1);
Self {
period,
buf: VecDeque::with_capacity(period),
}
}
}
impl Next<f64> for PercentRank {
type Output = f64;
fn next(&mut self, x: f64) -> Self::Output {
self.buf.push_back(x);
if self.buf.len() > self.period {
self.buf.pop_front();
}
if self.buf.len() < self.period || self.buf.iter().any(|v| v.is_nan()) {
return f64::NAN;
}
let n = self.buf.len() as f64;
let le = self.buf.iter().filter(|v| **v <= x).count() as f64;
le / n
}
}
#[derive(Debug, Clone)]
pub struct Zscore {
period: usize,
buf: VecDeque<f64>,
}
impl Zscore {
pub fn new(period: usize) -> Self {
let period = period.max(2);
Self {
period,
buf: VecDeque::with_capacity(period),
}
}
}
impl Next<f64> for Zscore {
type Output = f64;
fn next(&mut self, x: f64) -> Self::Output {
self.buf.push_back(x);
if self.buf.len() > self.period {
self.buf.pop_front();
}
if self.buf.len() < self.period || self.buf.iter().any(|v| v.is_nan()) {
return f64::NAN;
}
let n = self.buf.len() as f64;
let mean = self.buf.iter().sum::<f64>() / n;
let mut acc = 0.0;
for v in &self.buf {
let d = v - mean;
acc += d * d;
}
let std = (acc / (n - 1.0)).sqrt();
if std == 0.0 {
f64::NAN
} else {
(x - mean) / std
}
}
}
pub const PERCENT_RANK_METADATA: IndicatorMetadata = IndicatorMetadata {
name: "Percent Rank",
description: "Where the current value sits inside its own trailing window, as the fraction of window values less than or equal to it.",
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.",
keywords: &["statistics", "rank", "percentile", "normalization"],
ehlers_summary: "Not an Ehlers filter. Empirical percent rank of a trailing window, including the current observation.",
params: &[ParamDef {
name: "period",
default: "252",
description: "Trailing window length, in bars. 252 is one trading year of daily data.",
}],
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).",
formula_latex: r"\[\mathrm{percent\_rank}_t = \frac{\#\{x_i \le x_t : i \in [t-n+1, t]\}}{n}\]",
gold_standard_file: "",
category: "Statistics",
};
pub const ZSCORE_METADATA: IndicatorMetadata = IndicatorMetadata {
name: "Rolling Z-Score",
description: "How many sample standard deviations the current value is from its trailing mean.",
usage: "Normalize a drifting series. A daily chain gextotal column uses period 252. Zero variance returns NaN.",
keywords: &["statistics", "zscore", "normalization"],
ehlers_summary: "Not an Ehlers filter. Rolling z-score with the sample standard deviation (n − 1).",
params: &[ParamDef {
name: "period",
default: "252",
description: "Trailing window length, in bars. Must be at least 2.",
}],
formula_source: "Standard rolling z-score. The 252-bar window and ±2 thresholds follow David Bergstrom, Build Alpha, Gamma Exposure (Sep 2026).",
formula_latex: r"\[z_t = (x_t - \mu_n) / s_n,\quad s_n^2 = \sum (x_i - \mu_n)^2 / (n - 1)\]",
gold_standard_file: "",
category: "Statistics",
};
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use proptest::prelude::*;
fn replay<I: Next<f64, Output = f64>>(mut ind: I, xs: &[f64]) -> Vec<f64> {
xs.iter().copied().map(|x| ind.next(x)).collect()
}
#[test]
fn percent_rank_known_window() {
let out = replay(PercentRank::new(5), &[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(out[..4].iter().all(|v| v.is_nan()));
assert_relative_eq!(out[4], 1.0);
let out = replay(PercentRank::new(5), &[1.0, 2.0, 3.0, 4.0, 3.0]);
assert_relative_eq!(out[4], 0.8);
}
#[test]
fn percent_rank_constant_is_one() {
let out = replay(PercentRank::new(3), &[2.0, 2.0, 2.0]);
assert_relative_eq!(out[2], 1.0);
}
#[test]
fn zscore_known_window() {
let out = replay(Zscore::new(3), &[1.0, 2.0, 3.0]);
assert!(out[0].is_nan() && out[1].is_nan());
assert_relative_eq!(out[2], 1.0);
}
#[test]
fn zscore_zero_variance_is_nan() {
let out = replay(Zscore::new(3), &[4.0, 4.0, 4.0]);
assert!(out[2].is_nan());
}
#[test]
fn nan_in_window_poisons_output() {
let out = replay(PercentRank::new(3), &[1.0, f64::NAN, 3.0, 4.0, 5.0]);
assert!(out[2].is_nan());
assert!(out[3].is_nan());
assert!(out[4].is_finite());
}
proptest! {
#[test]
fn test_percent_rank_parity(xs in prop::collection::vec(-1000.0..1000.0f64, 1..40), period in 2usize..12) {
let once = replay(PercentRank::new(period), &xs);
let twice = replay(PercentRank::new(period), &xs);
prop_assert_eq!(once.len(), twice.len());
for (a, b) in once.iter().zip(twice.iter()) {
prop_assert_eq!(a.is_nan(), b.is_nan());
if a.is_finite() {
prop_assert!((a - b).abs() < 1e-12);
}
}
}
#[test]
fn test_zscore_parity(xs in prop::collection::vec(-1000.0..1000.0f64, 2..40), period in 2usize..12) {
let once = replay(Zscore::new(period), &xs);
let twice = replay(Zscore::new(period), &xs);
for (a, b) in once.iter().zip(twice.iter()) {
prop_assert_eq!(a.is_nan(), b.is_nan());
if a.is_finite() && b.is_finite() {
prop_assert!((a - b).abs() < 1e-9);
}
}
}
}
#[test]
fn streaming_matches_replay() {
let xs = [1.0, 3.0, 2.0, 8.0, 5.0, 5.0, 0.5, 4.0];
let once = replay(PercentRank::new(4), &xs);
let twice = replay(PercentRank::new(4), &xs);
for (a, b) in once.iter().zip(twice.iter()) {
assert_eq!(a.is_nan(), b.is_nan());
if a.is_finite() {
assert_relative_eq!(a, b);
}
}
let once = replay(Zscore::new(4), &xs);
let twice = replay(Zscore::new(4), &xs);
for (a, b) in once.iter().zip(twice.iter()) {
assert_eq!(a.is_nan(), b.is_nan());
if a.is_finite() {
assert_relative_eq!(a, b);
}
}
}
}