wickra_core/indicators/
linreg_intercept.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)]
36pub struct LinRegIntercept {
37 period: usize,
38 window: VecDeque<f64>,
39 sum_x: f64,
40 denom: f64,
41 trend: ShiftedTrend,
42}
43
44impl LinRegIntercept {
45 pub fn new(period: usize) -> Result<Self> {
51 if period < 2 {
52 return Err(Error::InvalidPeriod {
53 message: "linear regression intercept needs period >= 2",
54 });
55 }
56 if period > crate::error::MAX_PERIOD {
57 return Err(Error::InvalidPeriod {
58 message: crate::error::PERIOD_ABOVE_MAX,
59 });
60 }
61 let n = period as f64;
62 let sum_x = n * (n - 1.0) / 2.0;
63 let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
64 Ok(Self {
65 period,
66 window: VecDeque::with_capacity(period),
67 sum_x,
68 denom: n * sum_xx - sum_x * sum_x,
69 trend: ShiftedTrend::new(),
70 })
71 }
72
73 pub const fn period(&self) -> usize {
75 self.period
76 }
77}
78
79impl Indicator for LinRegIntercept {
80 type Input = f64;
81 type Output = f64;
82
83 #[inline]
84 fn update(&mut self, value: f64) -> Option<f64> {
85 if !value.is_finite() {
86 return None;
87 }
88 if self.window.len() == self.period {
89 let front = self.window.pop_front().expect("non-empty");
90 self.trend.slide(front);
91 }
92 let index = self.window.len();
93 self.window.push_back(value);
94 self.trend.push(value, index);
95 if self.trend.needs_reseed(self.period) {
96 self.trend.reseed(self.window.iter().copied());
97 }
98
99 if self.window.len() < self.period {
100 return None;
101 }
102 let n = self.period as f64;
103 let slope = (n * self.trend.sum_xy() - self.sum_x * self.trend.sum_y()) / self.denom;
104 let intercept = (self.trend.sum_y() - slope * self.sum_x) / n + self.trend.offset();
106 Some(intercept)
107 }
108
109 fn reset(&mut self) {
110 self.window.clear();
111 self.trend.reset();
112 }
113
114 #[inline]
115 fn warmup_period(&self) -> usize {
116 self.period
117 }
118
119 #[inline]
120 fn is_ready(&self) -> bool {
121 self.window.len() == self.period
122 }
123
124 #[inline]
125 fn name(&self) -> &'static str {
126 "LINEARREG_INTERCEPT"
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::traits::BatchExt;
134 use approx::assert_relative_eq;
135
136 #[test]
137 fn rejects_short_period() {
138 assert!(matches!(
139 LinRegIntercept::new(1),
140 Err(Error::InvalidPeriod { .. })
141 ));
142 }
143
144 #[test]
145 fn accessors_report_config() {
146 let lr = LinRegIntercept::new(5).unwrap();
147 assert_eq!(lr.period(), 5);
148 assert_eq!(lr.name(), "LINEARREG_INTERCEPT");
149 assert_eq!(lr.warmup_period(), 5);
150 assert!(!lr.is_ready());
151 }
152
153 #[test]
154 fn reference_value() {
155 let mut lr = LinRegIntercept::new(3).unwrap();
157 let out: Vec<Option<f64>> = lr.batch(&[1.0, 2.0, 9.0]);
158 assert!(out[0].is_none());
159 assert!(out[1].is_none());
160 assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-9);
161 assert!(lr.is_ready());
162 }
163
164 #[test]
165 fn slides_and_tracks_a_shifted_line() {
166 let mut lr = LinRegIntercept::new(3).unwrap();
169 let out: Vec<Option<f64>> = lr.batch(&[1.0, 10.0, 12.0, 14.0]);
170 assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-9);
171 }
172
173 #[test]
174 fn reset_clears_state() {
175 let mut lr = LinRegIntercept::new(3).unwrap();
176 let _ = lr.batch(&[1.0, 2.0, 9.0]);
177 assert!(lr.is_ready());
178 lr.reset();
179 assert!(!lr.is_ready());
180 assert_eq!(lr.update(1.0), None);
181 }
182}