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(f64::total_cmp);
46
47    let q = quantile.clamp(0.0, 1.0);
48    let max_idx = finite.len().saturating_sub(1);
49    let idx_f = q * max_idx as f64;
50    let raw_lower = idx_f.floor();
51    let raw_upper = idx_f.ceil();
52    let idx_lower = if raw_lower.is_finite() && raw_lower >= 0.0 {
53        (raw_lower as usize).min(max_idx)
54    } else {
55        0
56    };
57    let idx_upper = if raw_upper.is_finite() && raw_upper >= 0.0 {
58        (raw_upper as usize).min(max_idx)
59    } else {
60        0
61    };
62
63    let v_lower = finite.get(idx_lower).copied().unwrap_or(0.0);
64    let v_upper = finite.get(idx_upper).copied().unwrap_or(0.0);
65
66    if idx_lower == idx_upper {
67        v_lower
68    } else {
69        let weight = idx_f - idx_lower as f64;
70        v_lower * (1.0 - weight) + v_upper * weight
71    }
72}
73
74/// Returns the percentile rank (0.0..=100.0) of `val` within `slice`.
75pub fn percent_rank(slice: &[f64], val: f64) -> f64 {
76    let finite: Vec<f64> = slice.iter().copied().filter(|v| v.is_finite()).collect();
77    if finite.is_empty() || !val.is_finite() {
78        return 0.0;
79    }
80    let count_below = finite.iter().filter(|&&v| v <= val).count();
81    (count_below as f64 / finite.len() as f64) * 100.0
82}
83
84/// Computes Pearson correlation from finite, positionally aligned pairs.
85pub fn correlation(left: &[f64], right: &[f64]) -> Option<f64> {
86    let pairs: Vec<(f64, f64)> = left
87        .iter()
88        .copied()
89        .zip(right.iter().copied())
90        .filter(|(x, y)| x.is_finite() && y.is_finite())
91        .collect();
92    if pairs.len() < 2 {
93        return None;
94    }
95    let count = pairs.len() as f64;
96    let mean_x = pairs.iter().map(|(x, _)| x).sum::<f64>() / count;
97    let mean_y = pairs.iter().map(|(_, y)| y).sum::<f64>() / count;
98    let covariance = pairs
99        .iter()
100        .map(|(x, y)| (x - mean_x) * (y - mean_y))
101        .sum::<f64>();
102    let variance_x = pairs.iter().map(|(x, _)| (x - mean_x).powi(2)).sum::<f64>();
103    let variance_y = pairs.iter().map(|(_, y)| (y - mean_y).powi(2)).sum::<f64>();
104    let denominator = (variance_x * variance_y).sqrt();
105    (denominator > f64::EPSILON).then_some(covariance / denominator)
106}
107
108/// Result of a linear regression fit over a data slice.
109#[derive(Debug, Clone, Copy, PartialEq)]
110pub struct LinearRegressionResult {
111    pub slope: f64,
112    pub intercept: f64,
113    pub r2: f64,
114}
115
116/// Computes ordinary least squares (OLS) linear regression over a slice of values (where X is 0..N-1).
117pub fn linear_regression(slice: &[f64]) -> Option<LinearRegressionResult> {
118    let n = slice.len();
119    if n < 2 {
120        return None;
121    }
122
123    let mut sum_x = 0.0f64;
124    let mut sum_y = 0.0f64;
125    let mut sum_xy = 0.0f64;
126    let mut sum_xx = 0.0f64;
127    let mut valid_n = 0;
128
129    for (i, &y) in slice.iter().enumerate() {
130        if y.is_finite() {
131            let x = i as f64;
132            sum_x += x;
133            sum_y += y;
134            sum_xy += x * y;
135            sum_xx += x * x;
136            valid_n += 1;
137        }
138    }
139
140    if valid_n < 2 {
141        return None;
142    }
143
144    let fn_val = valid_n as f64;
145    let denom = fn_val * sum_xx - sum_x * sum_x;
146    if denom.abs() < 1e-12 {
147        return None;
148    }
149
150    let slope = (fn_val * sum_xy - sum_x * sum_y) / denom;
151    let intercept = (sum_y - slope * sum_x) / fn_val;
152
153    let y_mean = sum_y / fn_val;
154    let ss_tot: f64 = slice
155        .iter()
156        .filter(|v| v.is_finite())
157        .map(|&y| (y - y_mean).powi(2))
158        .sum();
159    let ss_res: f64 = slice
160        .iter()
161        .enumerate()
162        .filter(|(_, v)| v.is_finite())
163        .map(|(i, &y)| (y - (slope * i as f64 + intercept)).powi(2))
164        .sum();
165
166    let r2 = if ss_tot > 0.0 {
167        (1.0 - (ss_res / ss_tot)).clamp(0.0, 1.0)
168    } else {
169        1.0
170    };
171
172    Some(LinearRegressionResult {
173        slope,
174        intercept,
175        r2,
176    })
177}
178
179/// A binomial proportion with its confidence bounds, all in `0.0..=1.0`.
180#[derive(Debug, Clone, Copy, PartialEq)]
181pub struct ProportionInterval {
182    /// `successes / trials`.
183    pub estimate: f64,
184    pub lower: f64,
185    pub upper: f64,
186}
187
188/// Wilson score interval for `successes` out of `trials`.
189///
190/// With `p = successes / trials`, `n = trials` and the normal quantile `z`
191/// (1.959963984540054 for 95 %):
192///
193/// ```text
194/// centre     = (p + z²/(2n)) / (1 + z²/n)
195/// half_width = z · sqrt(p(1 − p)/n + z²/(4n²)) / (1 + z²/n)
196/// ```
197///
198/// Chosen over the Wald interval `p ± z·sqrt(p(1 − p)/n)` because Wald
199/// collapses to zero width at `p = 0` or `p = 1` and leaves `0..=1` for small
200/// `n` — exactly the samples where the uncertainty matters most. Bounds are
201/// clamped to `0..=1` against rounding.
202///
203/// `None` for `trials == 0`, `successes > trials`, or a `z` that is not a
204/// positive finite number.
205pub fn wilson_interval(successes: usize, trials: usize, z: f64) -> Option<ProportionInterval> {
206    if trials == 0 || successes > trials || !z.is_finite() || z <= 0.0 {
207        return None;
208    }
209    let n = trials as f64;
210    let p = successes as f64 / n;
211    let z2 = z * z;
212    let denominator = 1.0 + z2 / n;
213    let centre = (p + z2 / (2.0 * n)) / denominator;
214    let half_width = z * (p * (1.0 - p) / n + z2 / (4.0 * n * n)).sqrt() / denominator;
215    Some(ProportionInterval {
216        estimate: p,
217        lower: (centre - half_width).clamp(0.0, 1.0),
218        upper: (centre + half_width).clamp(0.0, 1.0),
219    })
220}
221
222/// Length of the longest unbroken run of consecutive elements for which
223/// `predicate` holds; `0` if it never does.
224///
225/// The longest losing streak of a trade list is
226/// `longest_run(&pnl, |p| *p < 0.0)`.
227pub fn longest_run<T>(values: &[T], mut predicate: impl FnMut(&T) -> bool) -> usize {
228    let mut longest = 0;
229    let mut current = 0;
230    for value in values {
231        if predicate(value) {
232            current += 1;
233            longest = longest.max(current);
234        } else {
235            current = 0;
236        }
237    }
238    longest
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_rolling_stats() {
247        let data = vec![10.0, 20.0, 30.0, 40.0, 50.0];
248        assert_eq!(rolling_sum(&data), 150.0);
249        assert_eq!(rolling_mean(&data), 30.0);
250        assert_eq!(rolling_median(&data), 30.0);
251        assert_eq!(percent_rank(&data, 30.0), 60.0);
252    }
253
254    #[test]
255    fn test_linear_regression() {
256        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
257        let res = linear_regression(&data).unwrap();
258        assert!((res.slope - 1.0).abs() < 1e-6);
259        assert!((res.intercept - 1.0).abs() < 1e-6);
260        assert!((res.r2 - 1.0).abs() < 1e-6);
261    }
262
263    #[test]
264    fn test_correlation() {
265        assert_eq!(correlation(&[1.0, 2.0, 3.0], &[2.0, 4.0, 6.0]), Some(1.0));
266        assert_eq!(correlation(&[1.0, 1.0], &[1.0, 2.0]), None);
267    }
268}