wickra_core/indicators/
linreg.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 LinearRegression {
52 period: usize,
53 window: VecDeque<f64>,
54 sum_x: f64,
56 denom: f64,
59 trend: ShiftedTrend,
61}
62
63impl LinearRegression {
64 pub fn new(period: usize) -> Result<Self> {
70 if period < 2 {
71 return Err(Error::InvalidPeriod {
72 message: "linear regression needs period >= 2",
73 });
74 }
75 if period > crate::error::MAX_PERIOD {
76 return Err(Error::InvalidPeriod {
77 message: crate::error::PERIOD_ABOVE_MAX,
78 });
79 }
80 let n = period as f64;
81 let sum_x = n * (n - 1.0) / 2.0;
83 let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
84 Ok(Self {
85 period,
86 window: VecDeque::with_capacity(period),
87 sum_x,
88 denom: n * sum_xx - sum_x * sum_x,
89 trend: ShiftedTrend::new(),
90 })
91 }
92
93 pub const fn period(&self) -> usize {
95 self.period
96 }
97}
98
99impl Indicator for LinearRegression {
100 type Input = f64;
101 type Output = f64;
102
103 #[inline]
104 fn update(&mut self, value: f64) -> Option<f64> {
105 if !value.is_finite() {
106 return None;
107 }
108 if self.window.len() == self.period {
109 let front = self.window.pop_front().expect("non-empty");
110 self.trend.slide(front);
111 }
112 let index = self.window.len();
113 self.window.push_back(value);
114 self.trend.push(value, index);
115 if self.trend.needs_reseed(self.period) {
116 self.trend.reseed(self.window.iter().copied());
117 }
118
119 if self.window.len() < self.period {
120 return None;
121 }
122 let n = self.period as f64;
123 let slope = (n * self.trend.sum_xy() - self.sum_x * self.trend.sum_y()) / self.denom;
124 let intercept = (self.trend.sum_y() - slope * self.sum_x) / n + self.trend.offset();
128 Some(intercept + slope * (n - 1.0))
129 }
130
131 fn reset(&mut self) {
132 self.window.clear();
133 self.trend.reset();
134 }
135
136 #[inline]
137 fn warmup_period(&self) -> usize {
138 self.period
139 }
140
141 #[inline]
142 fn is_ready(&self) -> bool {
143 self.window.len() == self.period
144 }
145
146 #[inline]
147 fn name(&self) -> &'static str {
148 "LinearRegression"
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::traits::BatchExt;
156 use approx::assert_relative_eq;
157
158 #[test]
159 fn reference_values() {
160 let mut lr = LinearRegression::new(3).unwrap();
162 let out = lr.batch(&[1.0, 2.0, 9.0]);
163 assert!(out[0].is_none());
164 assert!(out[1].is_none());
165 assert_relative_eq!(out[2].unwrap(), 8.0, epsilon = 1e-9);
166 }
167
168 #[test]
169 fn perfect_line_returns_current_value() {
170 let prices: Vec<f64> = (0..40).map(|i| 2.0 * f64::from(i) + 5.0).collect();
173 let mut lr = LinearRegression::new(10).unwrap();
174 for (i, v) in lr.batch(&prices).into_iter().enumerate() {
175 if let Some(v) = v {
176 assert_relative_eq!(v, 2.0 * i as f64 + 5.0, epsilon = 1e-6);
177 }
178 }
179 }
180
181 #[test]
182 fn constant_series_returns_the_constant() {
183 let mut lr = LinearRegression::new(8).unwrap();
184 for v in lr.batch(&[42.0; 20]).into_iter().flatten() {
185 assert_relative_eq!(v, 42.0, epsilon = 1e-9);
186 }
187 }
188
189 #[test]
190 fn first_value_on_period_th_input() {
191 let mut lr = LinearRegression::new(5).unwrap();
192 let out = lr.batch(&[1.0, 3.0, 2.0, 5.0, 4.0, 6.0]);
193 for (i, v) in out.iter().enumerate().take(4) {
194 assert!(v.is_none(), "index {i} must be None during warmup");
195 }
196 assert!(out[4].is_some(), "first value lands at index period - 1");
197 assert_eq!(lr.warmup_period(), 5);
198 }
199
200 #[test]
201 fn rejects_period_below_two() {
202 assert!(LinearRegression::new(0).is_err());
203 assert!(LinearRegression::new(1).is_err());
204 assert!(LinearRegression::new(2).is_ok());
205 }
206
207 #[test]
210 fn accessors_and_metadata() {
211 let lr = LinearRegression::new(14).unwrap();
212 assert_eq!(lr.period(), 14);
213 assert_eq!(lr.name(), "LinearRegression");
214 }
215
216 #[test]
217 fn reset_clears_state() {
218 let mut lr = LinearRegression::new(5).unwrap();
219 lr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
220 assert!(lr.is_ready());
221 lr.reset();
222 assert!(!lr.is_ready());
223 assert_eq!(lr.update(1.0), None);
224 }
225
226 #[test]
227 fn batch_equals_streaming() {
228 let prices: Vec<f64> = (0..60)
229 .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
230 .collect();
231 let mut a = LinearRegression::new(14).unwrap();
232 let mut b = LinearRegression::new(14).unwrap();
233 assert_eq!(
234 a.batch(&prices),
235 prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
236 );
237 }
238
239 #[test]
246 fn incremental_matches_naive_fit_bar_by_bar() {
247 fn naive_endpoint(window: &[f64]) -> f64 {
248 let n = window.len() as f64;
249 let mut sum_y = 0.0;
250 let mut sum_xy = 0.0;
251 let mut sum_x = 0.0;
252 let mut sum_xx = 0.0;
253 for (i, &y) in window.iter().enumerate() {
254 let x = i as f64;
255 sum_y += y;
256 sum_xy += x * y;
257 sum_x += x;
258 sum_xx += x * x;
259 }
260 let denom = n * sum_xx - sum_x * sum_x;
261 let slope = (n * sum_xy - sum_x * sum_y) / denom;
262 let intercept = (sum_y - slope * sum_x) / n;
263 intercept + slope * (n - 1.0)
264 }
265
266 fn check(prices: &[f64], period: usize) {
267 let mut lr = LinearRegression::new(period).unwrap();
268 for (t, p) in prices.iter().enumerate() {
269 let streaming = lr.update(*p);
270 if t + 1 >= period {
271 let lo = t + 1 - period;
272 let expected = naive_endpoint(&prices[lo..=t]);
273 let got = streaming.expect("warmed up");
274 assert!(
275 (got - expected).abs() < 1e-9,
276 "endpoint diverges at t={t}, period={period}: got={got}, expected={expected}",
277 );
278 }
279 }
280 }
281
282 let noisy_ramp: Vec<f64> = (0..120)
283 .map(|i| 100.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 3.0)
284 .collect();
285 check(&noisy_ramp, 5);
286 check(&noisy_ramp, 14);
287 check(&noisy_ramp, 30);
288
289 let mut step = vec![1.0; 30];
290 step.extend(std::iter::repeat_n(100.0, 30));
291 step.extend(std::iter::repeat_n(0.001, 30));
292 check(&step, 5);
293 check(&step, 14);
294
295 let constant = vec![42.0; 50];
296 check(&constant, 8);
297 check(&constant, 25);
298 }
299
300 fn centred_fit(window: &[f64]) -> (f64, f64, f64) {
308 let n = window.len() as f64;
309 let mean = window.iter().sum::<f64>() / n;
310 let mean_x = (n - 1.0) / 2.0;
311 let (mut sxy, mut sxx) = (0.0, 0.0);
312 for (i, &y) in window.iter().enumerate() {
313 let dx = i as f64 - mean_x;
314 sxy += dx * (y - mean);
315 sxx += dx * dx;
316 }
317 let slope = sxy / sxx;
318 let mut sse = 0.0;
319 for (i, &y) in window.iter().enumerate() {
320 let r = (y - mean) - slope * (i as f64 - mean_x);
321 sse += r * r;
322 }
323 (slope, mean, sse)
324 }
325
326 fn high_level_series(bars: usize) -> Vec<f64> {
330 (0..bars)
331 .map(|i| {
332 let t = i as f64;
333 1e8 + ((t * 0.11).sin() + 0.4 * (t * 0.37).cos())
334 })
335 .collect()
336 }
337
338 #[test]
344 fn endpoint_at_a_high_price_level_matches_a_centred_fit() {
345 const P: usize = 20;
346 let data = high_level_series(400);
347 let mut ind = LinearRegression::new(P).unwrap();
348 let mut compared = 0_usize;
349 for (i, &v) in data.iter().enumerate() {
350 let Some(endpoint) = ind.update(v) else {
351 continue;
352 };
353 let window = &data[i + 1 - P..=i];
354 let (slope, mean, _) = centred_fit(window);
355 let mean_x = (P as f64 - 1.0) / 2.0;
356 let want = mean + slope * (P as f64 - 1.0 - mean_x);
359 compared += 1;
360 assert_relative_eq!(endpoint, want, max_relative = 1e-14);
361 }
362 assert_eq!(compared, data.len() - ind.warmup_period() + 1);
363 }
364}