Skip to main content

wickra_core/indicators/
linreg_intercept.rs

1//! Linear Regression Intercept (`LINEARREG_INTERCEPT`).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Linear Regression Intercept (`LINEARREG_INTERCEPT`): the intercept `a` of the
9/// rolling least-squares fit `y = a + b·x` over the last `period` inputs, indexed
10/// `x = 0, 1, …, period − 1`.
11///
12/// ```text
13/// b (slope)     = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²)
14/// a (intercept) = (Σy − b·Σx) / n
15/// ```
16///
17/// Where [`LinearRegression`](crate::LinearRegression) reports the fitted line at
18/// the most recent bar (`a + b·(period − 1)`), this reports its value at the
19/// *start* of the window (`x = 0`). Each update is O(1), maintaining the same
20/// closed-form sliding-window sums as `LinearRegression`.
21///
22/// # Example
23///
24/// ```
25/// use wickra_core::{Indicator, LinRegIntercept};
26///
27/// let mut indicator = LinRegIntercept::new(14).unwrap();
28/// let mut last = None;
29/// for i in 0..80 {
30///     last = indicator.update(f64::from(i));
31/// }
32/// assert!(last.is_some());
33/// ```
34#[derive(Debug, Clone)]
35pub struct LinRegIntercept {
36    period: usize,
37    window: VecDeque<f64>,
38    sum_x: f64,
39    denom: f64,
40    sum_y: f64,
41    sum_xy: f64,
42}
43
44impl LinRegIntercept {
45    /// Construct a new rolling linear-regression intercept over `period` inputs.
46    ///
47    /// # Errors
48    /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
49    /// undefined for fewer than two points.
50    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        let n = period as f64;
57        let sum_x = n * (n - 1.0) / 2.0;
58        let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
59        Ok(Self {
60            period,
61            window: VecDeque::with_capacity(period),
62            sum_x,
63            denom: n * sum_xx - sum_x * sum_x,
64            sum_y: 0.0,
65            sum_xy: 0.0,
66        })
67    }
68
69    /// Configured period.
70    pub const fn period(&self) -> usize {
71        self.period
72    }
73}
74
75impl Indicator for LinRegIntercept {
76    type Input = f64;
77    type Output = f64;
78
79    fn update(&mut self, value: f64) -> Option<f64> {
80        if !value.is_finite() {
81            return None;
82        }
83        if self.window.len() == self.period {
84            let y0 = self.window.pop_front().expect("non-empty");
85            self.sum_xy = self.sum_xy - self.sum_y + y0;
86            self.sum_y -= y0;
87        }
88        let k = self.window.len() as f64;
89        self.window.push_back(value);
90        self.sum_y += value;
91        self.sum_xy += k * value;
92
93        if self.window.len() < self.period {
94            return None;
95        }
96        let n = self.period as f64;
97        let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom;
98        let intercept = (self.sum_y - slope * self.sum_x) / n;
99        Some(intercept)
100    }
101
102    fn reset(&mut self) {
103        self.window.clear();
104        self.sum_y = 0.0;
105        self.sum_xy = 0.0;
106    }
107
108    fn warmup_period(&self) -> usize {
109        self.period
110    }
111
112    fn is_ready(&self) -> bool {
113        self.window.len() == self.period
114    }
115
116    fn name(&self) -> &'static str {
117        "LINEARREG_INTERCEPT"
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::traits::BatchExt;
125    use approx::assert_relative_eq;
126
127    #[test]
128    fn rejects_short_period() {
129        assert!(matches!(
130            LinRegIntercept::new(1),
131            Err(Error::InvalidPeriod { .. })
132        ));
133    }
134
135    #[test]
136    fn accessors_report_config() {
137        let lr = LinRegIntercept::new(5).unwrap();
138        assert_eq!(lr.period(), 5);
139        assert_eq!(lr.name(), "LINEARREG_INTERCEPT");
140        assert_eq!(lr.warmup_period(), 5);
141        assert!(!lr.is_ready());
142    }
143
144    #[test]
145    fn reference_value() {
146        // period 3 over [1, 2, 9]: fit y = 0 + 4x, intercept = 0.
147        let mut lr = LinRegIntercept::new(3).unwrap();
148        let out: Vec<Option<f64>> = lr.batch(&[1.0, 2.0, 9.0]);
149        assert!(out[0].is_none());
150        assert!(out[1].is_none());
151        assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-9);
152        assert!(lr.is_ready());
153    }
154
155    #[test]
156    fn slides_and_tracks_a_shifted_line() {
157        // After sliding to window [2, 9, 4]... intercept stays finite and the
158        // fit is exact for a clean line [10, 12, 14]: y = 10 + 2x, intercept 10.
159        let mut lr = LinRegIntercept::new(3).unwrap();
160        let out: Vec<Option<f64>> = lr.batch(&[1.0, 10.0, 12.0, 14.0]);
161        assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-9);
162    }
163
164    #[test]
165    fn reset_clears_state() {
166        let mut lr = LinRegIntercept::new(3).unwrap();
167        let _ = lr.batch(&[1.0, 2.0, 9.0]);
168        assert!(lr.is_ready());
169        lr.reset();
170        assert!(!lr.is_ready());
171        assert_eq!(lr.update(1.0), None);
172    }
173}