wickra_core/indicators/
r_squared.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedTrend;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
51pub struct RSquared {
52 period: usize,
53 window: VecDeque<f64>,
54 sum_x: f64,
55 denom: f64,
57 trend: ShiftedTrend,
58}
59
60impl RSquared {
61 pub fn new(period: usize) -> Result<Self> {
67 if period < 2 {
68 return Err(Error::InvalidPeriod {
69 message: "R² needs period >= 2",
70 });
71 }
72 if period > crate::error::MAX_PERIOD {
73 return Err(Error::InvalidPeriod {
74 message: crate::error::PERIOD_ABOVE_MAX,
75 });
76 }
77 let n = period as f64;
78 let sum_x = n * (n - 1.0) / 2.0;
79 let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
80 Ok(Self {
81 period,
82 window: VecDeque::with_capacity(period),
83 sum_x,
84 denom: n * sum_xx - sum_x * sum_x,
85 trend: ShiftedTrend::new(),
86 })
87 }
88
89 pub const fn period(&self) -> usize {
91 self.period
92 }
93}
94
95impl Indicator for RSquared {
96 type Input = f64;
97 type Output = f64;
98
99 #[inline]
100 fn update(&mut self, value: f64) -> Option<f64> {
101 if !value.is_finite() {
102 return None;
103 }
104 if self.window.len() == self.period {
105 let front = self.window.pop_front().expect("non-empty");
106 self.trend.slide(front);
107 }
108 let index = self.window.len();
109 self.window.push_back(value);
110 self.trend.push(value, index);
111 if self.trend.needs_reseed(self.period) {
112 self.trend.reseed(self.window.iter().copied());
113 }
114
115 if self.window.len() < self.period {
116 return None;
117 }
118 let n = self.period as f64;
119 let slope = (n * self.trend.sum_xy() - self.sum_x * self.trend.sum_y()) / self.denom;
120 let mean_y = self.trend.sum_y() / n;
124 let ss_total = (self.trend.sum_y_sq() - n * mean_y * mean_y).max(0.0);
125 let s_xx = self.denom / n;
126 let ss_explained = slope * slope * s_xx;
127 if ss_total <= 0.0 {
128 return Some(1.0);
130 }
131 Some((ss_explained / ss_total).clamp(0.0, 1.0))
132 }
133
134 fn reset(&mut self) {
135 self.window.clear();
136 self.trend.reset();
137 }
138
139 #[inline]
140 fn warmup_period(&self) -> usize {
141 self.period
142 }
143
144 #[inline]
145 fn is_ready(&self) -> bool {
146 self.window.len() == self.period
147 }
148
149 #[inline]
150 fn name(&self) -> &'static str {
151 "RSquared"
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::traits::BatchExt;
159 use approx::assert_relative_eq;
160
161 #[test]
162 fn rejects_period_below_two() {
163 assert!(RSquared::new(0).is_err());
164 assert!(RSquared::new(1).is_err());
165 assert!(RSquared::new(2).is_ok());
166 }
167
168 #[test]
169 fn accessors_and_metadata() {
170 let r = RSquared::new(14).unwrap();
171 assert_eq!(r.period(), 14);
172 assert_eq!(r.warmup_period(), 14);
173 assert_eq!(r.name(), "RSquared");
174 }
175
176 #[test]
177 fn perfect_line_is_one() {
178 let prices: Vec<f64> = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect();
179 let mut r = RSquared::new(10).unwrap();
180 for v in r.batch(&prices).into_iter().flatten() {
181 assert_relative_eq!(v, 1.0, epsilon = 1e-9);
182 }
183 }
184
185 #[test]
186 fn constant_series_is_one() {
187 let mut r = RSquared::new(5).unwrap();
189 for v in r.batch(&[42.0; 20]).into_iter().flatten() {
190 assert_relative_eq!(v, 1.0, epsilon = 1e-12);
191 }
192 }
193
194 #[test]
195 fn output_stays_in_zero_one_range() {
196 let prices: Vec<f64> = (0..120)
197 .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0 + (f64::from(i) * 0.07).cos() * 12.0)
198 .collect();
199 let mut r = RSquared::new(20).unwrap();
200 for v in r.batch(&prices).into_iter().flatten() {
201 assert!((0.0..=1.0).contains(&v), "R² out of range: {v}");
202 }
203 }
204
205 #[test]
206 fn reset_clears_state() {
207 let mut r = RSquared::new(5).unwrap();
208 r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
209 assert!(r.is_ready());
210 r.reset();
211 assert!(!r.is_ready());
212 assert_eq!(r.update(1.0), None);
213 }
214
215 #[test]
216 fn batch_equals_streaming() {
217 let prices: Vec<f64> = (0..60)
218 .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
219 .collect();
220 let batch = RSquared::new(14).unwrap().batch(&prices);
221 let mut b = RSquared::new(14).unwrap();
222 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
223 assert_eq!(batch, streamed);
224 }
225
226 fn centred_fit(window: &[f64]) -> (f64, f64, f64) {
234 let n = window.len() as f64;
235 let mean = window.iter().sum::<f64>() / n;
236 let mean_x = (n - 1.0) / 2.0;
237 let (mut sxy, mut sxx) = (0.0, 0.0);
238 for (i, &y) in window.iter().enumerate() {
239 let dx = i as f64 - mean_x;
240 sxy += dx * (y - mean);
241 sxx += dx * dx;
242 }
243 let slope = sxy / sxx;
244 let mut sse = 0.0;
245 for (i, &y) in window.iter().enumerate() {
246 let r = (y - mean) - slope * (i as f64 - mean_x);
247 sse += r * r;
248 }
249 (slope, mean, sse)
250 }
251
252 fn high_level_series(bars: usize) -> Vec<f64> {
256 (0..bars)
257 .map(|i| {
258 let t = i as f64;
259 1e8 + ((t * 0.11).sin() + 0.4 * (t * 0.37).cos())
260 })
261 .collect()
262 }
263
264 #[test]
271 fn coefficient_at_a_high_price_level_matches_a_centred_fit() {
272 const P: usize = 20;
273 let data = high_level_series(400);
274 let mut ind = RSquared::new(P).unwrap();
275 let mut compared = 0_usize;
276 let mut saw_imperfect_fit = false;
277 for (i, &v) in data.iter().enumerate() {
278 let Some(r2) = ind.update(v) else { continue };
279 let window = &data[i + 1 - P..=i];
280 let (_, mean, sse) = centred_fit(window);
281 let tss: f64 = window.iter().map(|y| (y - mean) * (y - mean)).sum();
282 let want = 1.0 - sse / tss;
283 if want < 0.99 {
284 saw_imperfect_fit = true;
285 }
286 compared += 1;
287 assert_relative_eq!(r2, want, max_relative = 1e-9);
288 }
289 assert_eq!(compared, data.len() - ind.warmup_period() + 1);
290 assert!(saw_imperfect_fit);
292 }
293}