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::indicators::rolling_moments::ShiftedTrend;
7use crate::traits::Indicator;
8
9/// Linear Regression Intercept (`LINEARREG_INTERCEPT`): the intercept `a` of the
10/// rolling least-squares fit `y = a + b·x` over the last `period` inputs, indexed
11/// `x = 0, 1, …, period − 1`.
12///
13/// ```text
14/// b (slope)     = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²)
15/// a (intercept) = (Σy − b·Σx) / n
16/// ```
17///
18/// Where [`LinearRegression`](crate::LinearRegression) reports the fitted line at
19/// the most recent bar (`a + b·(period − 1)`), this reports its value at the
20/// *start* of the window (`x = 0`). Each update is O(1), maintaining the same
21/// closed-form sliding-window sums as `LinearRegression`.
22///
23/// # Example
24///
25/// ```
26/// use wickra_core::{Indicator, LinRegIntercept};
27///
28/// let mut indicator = LinRegIntercept::new(14).unwrap();
29/// let mut last = None;
30/// for i in 0..80 {
31///     last = indicator.update(f64::from(i));
32/// }
33/// assert!(last.is_some());
34/// ```
35#[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    /// 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        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    /// Configured period.
74    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        // An absolute price level, so the reference point comes back here.
105        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        // period 3 over [1, 2, 9]: fit y = 0 + 4x, intercept = 0.
156        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        // After sliding to window [2, 9, 4]... intercept stays finite and the
167        // fit is exact for a clean line [10, 12, 14]: y = 10 + 2x, intercept 10.
168        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}