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