Skip to main content

kestrel_chartkit/
stats.rs

1//! Rolling statistics primitives for streaming series calculations.
2
3/// Returns the sum of all finite values in `slice`.
4pub fn rolling_sum(slice: &[f64]) -> f64 {
5    slice.iter().copied().filter(|v| v.is_finite()).sum()
6}
7
8/// Returns the arithmetic mean of all finite values in `slice`.
9pub fn rolling_mean(slice: &[f64]) -> f64 {
10    let finite: Vec<f64> = slice.iter().copied().filter(|v| v.is_finite()).collect();
11    if finite.is_empty() {
12        0.0
13    } else {
14        finite.iter().sum::<f64>() / finite.len() as f64
15    }
16}
17
18/// Returns the population variance of finite values in `slice`.
19pub fn rolling_variance(slice: &[f64]) -> f64 {
20    let finite: Vec<f64> = slice.iter().copied().filter(|v| v.is_finite()).collect();
21    if finite.len() < 2 {
22        return 0.0;
23    }
24    let mean = finite.iter().sum::<f64>() / finite.len() as f64;
25    let var_sum: f64 = finite.iter().map(|v| (v - mean).powi(2)).sum();
26    var_sum / finite.len() as f64
27}
28
29/// Returns the standard deviation of finite values in `slice`.
30pub fn rolling_stddev(slice: &[f64]) -> f64 {
31    rolling_variance(slice).sqrt()
32}
33
34/// Returns the median of finite values in `slice`.
35pub fn rolling_median(slice: &[f64]) -> f64 {
36    rolling_quantile(slice, 0.5)
37}
38
39/// Returns the quantile (0.0..=1.0) of finite values in `slice`.
40pub fn rolling_quantile(slice: &[f64], quantile: f64) -> f64 {
41    let mut finite: Vec<f64> = slice.iter().copied().filter(|v| v.is_finite()).collect();
42    if finite.is_empty() {
43        return 0.0;
44    }
45    finite.sort_by(|a, b| a.partial_cmp(b).unwrap());
46
47    let q = quantile.clamp(0.0, 1.0);
48    let idx_f = q * (finite.len() - 1) as f64;
49    let idx_lower = idx_f.floor() as usize;
50    let idx_upper = idx_f.ceil() as usize;
51
52    if idx_lower == idx_upper {
53        finite[idx_lower]
54    } else {
55        let weight = idx_f - idx_lower as f64;
56        finite[idx_lower] * (1.0 - weight) + finite[idx_upper] * weight
57    }
58}
59
60/// Returns the percentile rank (0.0..=100.0) of `val` within `slice`.
61pub fn percent_rank(slice: &[f64], val: f64) -> f64 {
62    let finite: Vec<f64> = slice.iter().copied().filter(|v| v.is_finite()).collect();
63    if finite.is_empty() || !val.is_finite() {
64        return 0.0;
65    }
66    let count_below = finite.iter().filter(|&&v| v <= val).count();
67    (count_below as f64 / finite.len() as f64) * 100.0
68}
69
70/// Computes Pearson correlation from finite, positionally aligned pairs.
71pub fn correlation(left: &[f64], right: &[f64]) -> Option<f64> {
72    let pairs: Vec<(f64, f64)> = left
73        .iter()
74        .copied()
75        .zip(right.iter().copied())
76        .filter(|(x, y)| x.is_finite() && y.is_finite())
77        .collect();
78    if pairs.len() < 2 {
79        return None;
80    }
81    let count = pairs.len() as f64;
82    let mean_x = pairs.iter().map(|(x, _)| x).sum::<f64>() / count;
83    let mean_y = pairs.iter().map(|(_, y)| y).sum::<f64>() / count;
84    let covariance = pairs
85        .iter()
86        .map(|(x, y)| (x - mean_x) * (y - mean_y))
87        .sum::<f64>();
88    let variance_x = pairs.iter().map(|(x, _)| (x - mean_x).powi(2)).sum::<f64>();
89    let variance_y = pairs.iter().map(|(_, y)| (y - mean_y).powi(2)).sum::<f64>();
90    let denominator = (variance_x * variance_y).sqrt();
91    (denominator > f64::EPSILON).then_some(covariance / denominator)
92}
93
94/// Result of a linear regression fit over a data slice.
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub struct LinearRegressionResult {
97    pub slope: f64,
98    pub intercept: f64,
99    pub r2: f64,
100}
101
102/// Computes ordinary least squares (OLS) linear regression over a slice of values (where X is 0..N-1).
103pub fn linear_regression(slice: &[f64]) -> Option<LinearRegressionResult> {
104    let n = slice.len();
105    if n < 2 {
106        return None;
107    }
108
109    let mut sum_x = 0.0f64;
110    let mut sum_y = 0.0f64;
111    let mut sum_xy = 0.0f64;
112    let mut sum_xx = 0.0f64;
113    let mut valid_n = 0;
114
115    for (i, &y) in slice.iter().enumerate() {
116        if y.is_finite() {
117            let x = i as f64;
118            sum_x += x;
119            sum_y += y;
120            sum_xy += x * y;
121            sum_xx += x * x;
122            valid_n += 1;
123        }
124    }
125
126    if valid_n < 2 {
127        return None;
128    }
129
130    let fn_val = valid_n as f64;
131    let denom = fn_val * sum_xx - sum_x * sum_x;
132    if denom.abs() < 1e-12 {
133        return None;
134    }
135
136    let slope = (fn_val * sum_xy - sum_x * sum_y) / denom;
137    let intercept = (sum_y - slope * sum_x) / fn_val;
138
139    let y_mean = sum_y / fn_val;
140    let ss_tot: f64 = slice
141        .iter()
142        .filter(|v| v.is_finite())
143        .map(|&y| (y - y_mean).powi(2))
144        .sum();
145    let ss_res: f64 = slice
146        .iter()
147        .enumerate()
148        .filter(|(_, v)| v.is_finite())
149        .map(|(i, &y)| (y - (slope * i as f64 + intercept)).powi(2))
150        .sum();
151
152    let r2 = if ss_tot > 0.0 {
153        (1.0 - (ss_res / ss_tot)).clamp(0.0, 1.0)
154    } else {
155        1.0
156    };
157
158    Some(LinearRegressionResult {
159        slope,
160        intercept,
161        r2,
162    })
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_rolling_stats() {
171        let data = vec![10.0, 20.0, 30.0, 40.0, 50.0];
172        assert_eq!(rolling_sum(&data), 150.0);
173        assert_eq!(rolling_mean(&data), 30.0);
174        assert_eq!(rolling_median(&data), 30.0);
175        assert_eq!(percent_rank(&data, 30.0), 60.0);
176    }
177
178    #[test]
179    fn test_linear_regression() {
180        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
181        let res = linear_regression(&data).unwrap();
182        assert!((res.slope - 1.0).abs() < 1e-6);
183        assert!((res.intercept - 1.0).abs() < 1e-6);
184        assert!((res.r2 - 1.0).abs() < 1e-6);
185    }
186
187    #[test]
188    fn test_correlation() {
189        assert_eq!(correlation(&[1.0, 2.0, 3.0], &[2.0, 4.0, 6.0]), Some(1.0));
190        assert_eq!(correlation(&[1.0, 1.0], &[1.0, 2.0]), None);
191    }
192}