qs_backtest/evaluation/
stats.rs1use super::{BootstrapConfig, ConfidenceInterval, MetricValue};
2
3pub(crate) fn mean(values: &[f64]) -> Option<f64> {
4 (!values.is_empty()).then(|| values.iter().sum::<f64>() / values.len() as f64)
5}
6
7pub(crate) fn median(values: &[f64]) -> Option<f64> {
8 if values.is_empty() {
9 return None;
10 }
11
12 let mut sorted = values.to_vec();
13 sorted.sort_by(f64::total_cmp);
14 let midpoint = sorted.len() / 2;
15 Some(if sorted.len().is_multiple_of(2) {
16 (sorted[midpoint - 1] + sorted[midpoint]) / 2.0
17 } else {
18 sorted[midpoint]
19 })
20}
21
22pub(crate) fn sample_standard_deviation(values: &[f64]) -> Option<f64> {
23 if values.len() < 2 {
24 return None;
25 }
26
27 let average = mean(values)?;
28 let variance = values
29 .iter()
30 .map(|value| (value - average).powi(2))
31 .sum::<f64>()
32 / (values.len() - 1) as f64;
33 Some(variance.sqrt())
34}
35
36pub fn wilson_interval(
41 successes: usize,
42 trials: usize,
43 confidence_level: f64,
44) -> MetricValue<ConfidenceInterval> {
45 if successes > trials {
46 return MetricValue::invalid_input("successes cannot exceed trials");
47 }
48 if !confidence_level.is_finite() || !(0.0..1.0).contains(&confidence_level) {
49 return MetricValue::invalid_input("confidence_level must be finite and between 0 and 1");
50 }
51 if trials == 0 {
52 return MetricValue::insufficient_data("at least one trial is required");
53 }
54
55 let n = trials as f64;
56 let estimate = successes as f64 / n;
57 let z = inverse_standard_normal(0.5 + confidence_level / 2.0);
58 let z_squared = z * z;
59 let denominator = 1.0 + z_squared / n;
60 let center = (estimate + z_squared / (2.0 * n)) / denominator;
61 let margin =
62 z * ((estimate * (1.0 - estimate) / n) + z_squared / (4.0 * n * n)).sqrt() / denominator;
63
64 MetricValue::available(ConfidenceInterval {
65 estimate,
66 lower: (center - margin).max(0.0),
67 upper: (center + margin).min(1.0),
68 confidence_level,
69 })
70}
71
72pub fn bootstrap_mean_confidence(
78 values: &[f64],
79 config: BootstrapConfig,
80) -> MetricValue<ConfidenceInterval> {
81 if values.iter().any(|value| !value.is_finite()) {
82 return MetricValue::invalid_input("bootstrap values must all be finite");
83 }
84 if !config.confidence_level.is_finite() || !(0.0..1.0).contains(&config.confidence_level) {
85 return MetricValue::invalid_input("confidence_level must be finite and between 0 and 1");
86 }
87 if config.samples == 0 {
88 return MetricValue::invalid_input("bootstrap samples must be greater than zero");
89 }
90 if values.len() < config.minimum_sample_size {
91 return MetricValue::insufficient_data(format!(
92 "at least {} observations are required for bootstrap confidence",
93 config.minimum_sample_size
94 ));
95 }
96 if values.is_empty() {
97 return MetricValue::insufficient_data("at least one observation is required");
98 }
99
100 let estimate = mean(values).expect("non-empty values checked above");
101 if !estimate.is_finite() {
102 return MetricValue::invalid_input("bootstrap mean exceeds the finite f64 range");
103 }
104
105 let mut rng = SplitMix64::new(config.seed);
106 let mut bootstrap_means = Vec::with_capacity(config.samples);
107 for _ in 0..config.samples {
108 let mut total = 0.0;
109 for _ in values {
110 total += values[rng.index(values.len())];
111 }
112 let bootstrap_mean = total / values.len() as f64;
113 if !bootstrap_mean.is_finite() {
114 return MetricValue::invalid_input(
115 "a bootstrap sample mean exceeds the finite f64 range",
116 );
117 }
118 bootstrap_means.push(bootstrap_mean);
119 }
120 bootstrap_means.sort_by(f64::total_cmp);
121
122 let alpha = 1.0 - config.confidence_level;
123 let lower = quantile_sorted(&bootstrap_means, alpha / 2.0);
124 let upper = quantile_sorted(&bootstrap_means, 1.0 - alpha / 2.0);
125
126 MetricValue::available(ConfidenceInterval {
127 estimate,
128 lower,
129 upper,
130 confidence_level: config.confidence_level,
131 })
132}
133
134pub(crate) fn quantile_sorted(sorted: &[f64], probability: f64) -> f64 {
135 debug_assert!(!sorted.is_empty());
136 let index = probability * (sorted.len() - 1) as f64;
137 let lower = index.floor() as usize;
138 let upper = index.ceil() as usize;
139 if lower == upper {
140 sorted[lower]
141 } else {
142 let weight = index - lower as f64;
143 sorted[lower] * (1.0 - weight) + sorted[upper] * weight
144 }
145}
146
147fn inverse_standard_normal(probability: f64) -> f64 {
150 const A: [f64; 6] = [
151 -3.969_683_028_665_376e1,
152 2.209_460_984_245_205e2,
153 -2.759_285_104_469_687e2,
154 1.383_577_518_672_69e2,
155 -3.066_479_806_614_716e1,
156 2.506_628_277_459_239,
157 ];
158 const B: [f64; 5] = [
159 -5.447_609_879_822_406e1,
160 1.615_858_368_580_409e2,
161 -1.556_989_798_598_866e2,
162 6.680_131_188_771_972e1,
163 -1.328_068_155_288_572e1,
164 ];
165 const C: [f64; 6] = [
166 -7.784_894_002_430_293e-3,
167 -3.223_964_580_411_365e-1,
168 -2.400_758_277_161_838,
169 -2.549_732_539_343_734,
170 4.374_664_141_464_968,
171 2.938_163_982_698_783,
172 ];
173 const D: [f64; 4] = [
174 7.784_695_709_041_462e-3,
175 3.224_671_290_700_398e-1,
176 2.445_134_137_142_996,
177 3.754_408_661_907_416,
178 ];
179 const LOW: f64 = 0.024_25;
180 const HIGH: f64 = 1.0 - LOW;
181
182 if probability < LOW {
183 let q = (-2.0 * probability.ln()).sqrt();
184 (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
185 / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
186 } else if probability <= HIGH {
187 let q = probability - 0.5;
188 let r = q * q;
189 (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q
190 / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0)
191 } else {
192 let q = (-2.0 * (1.0 - probability).ln()).sqrt();
193 -(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
194 / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
195 }
196}
197
198struct SplitMix64 {
199 state: u64,
200}
201
202impl SplitMix64 {
203 fn new(seed: u64) -> Self {
204 Self { state: seed }
205 }
206
207 fn next_u64(&mut self) -> u64 {
208 self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
209 let mut value = self.state;
210 value = (value ^ (value >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
211 value = (value ^ (value >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
212 value ^ (value >> 31)
213 }
214
215 fn index(&mut self, upper_bound: usize) -> usize {
216 ((self.next_u64() as u128 * upper_bound as u128) >> 64) as usize
217 }
218}