Skip to main content

wickra_core/indicators/
linreg_channel.rs

1//! Linear Regression Channel — OLS endpoint ± k · stddev of residuals.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Linear Regression Channel output.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct LinRegChannelOutput {
11    /// Upper channel: regression endpoint plus `multiplier · stddev` of the
12    /// residuals.
13    pub upper: f64,
14    /// Middle line: OLS endpoint over the window.
15    pub middle: f64,
16    /// Lower channel: regression endpoint minus `multiplier · stddev` of the
17    /// residuals.
18    pub lower: f64,
19}
20
21/// Linear Regression Channel: rolling least-squares line with `±k·σ` bands
22/// sized by the residuals about the fitted line.
23///
24/// ```text
25/// fit y = a + b·x by OLS over the last `period` closes
26/// residual_i = y_i − (a + b · x_i)
27/// sigma      = sqrt( Σ residual_i² / period )      // population stddev
28/// middle     = a + b · (period − 1)                // endpoint of the line
29/// upper      = middle + multiplier · sigma
30/// lower      = middle − multiplier · sigma
31/// ```
32///
33/// Where [`BollingerBands`](crate::BollingerBands) measures dispersion about
34/// the *mean*, the `LinReg` Channel measures it about the *trend*: detrended
35/// residuals, so a steady drift up or down does not bias the band width. The
36/// resulting envelope tracks the trend without flaring on momentum bursts —
37/// breakouts are statistically meaningful in the direction of trend, not just
38/// in absolute price.
39///
40/// # Example
41///
42/// ```
43/// use wickra_core::{Indicator, LinRegChannel};
44///
45/// let mut indicator = LinRegChannel::new(20, 2.0).unwrap();
46/// let mut last = None;
47/// for i in 0..40 {
48///     last = indicator.update(100.0 + f64::from(i));
49/// }
50/// assert!(last.is_some());
51/// ```
52#[derive(Debug, Clone)]
53pub struct LinRegChannel {
54    period: usize,
55    multiplier: f64,
56    window: VecDeque<f64>,
57    sum_x: f64,
58    sum_xx: f64,
59}
60
61impl LinRegChannel {
62    /// # Errors
63    /// Returns [`Error::InvalidPeriod`] if `period < 2` and
64    /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
65    /// positive and finite.
66    pub fn new(period: usize, multiplier: f64) -> Result<Self> {
67        if period < 2 {
68            return Err(Error::InvalidPeriod {
69                message: "linear regression channel needs period >= 2",
70            });
71        }
72        if period > crate::error::MAX_PERIOD {
73            return Err(Error::InvalidPeriod {
74                message: crate::error::PERIOD_ABOVE_MAX,
75            });
76        }
77        if !multiplier.is_finite() || multiplier <= 0.0 {
78            return Err(Error::NonPositiveMultiplier);
79        }
80        let n = period as f64;
81        Ok(Self {
82            period,
83            multiplier,
84            window: VecDeque::with_capacity(period),
85            sum_x: n * (n - 1.0) / 2.0,
86            sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0,
87        })
88    }
89
90    /// Configured period.
91    pub const fn period(&self) -> usize {
92        self.period
93    }
94
95    /// Configured multiplier.
96    pub const fn multiplier(&self) -> f64 {
97        self.multiplier
98    }
99}
100
101impl Indicator for LinRegChannel {
102    type Input = f64;
103    type Output = LinRegChannelOutput;
104
105    fn update(&mut self, value: f64) -> Option<LinRegChannelOutput> {
106        if !value.is_finite() {
107            return None;
108        }
109        if self.window.len() == self.period {
110            self.window.pop_front();
111        }
112        self.window.push_back(value);
113        if self.window.len() < self.period {
114            return None;
115        }
116        // Recompute over the live window every bar. The OLS endpoint *could*
117        // be maintained incrementally (see `LinearRegression`) but the
118        // residual-stddev cannot be slid in closed form without storing each
119        // residual; recomputing both keeps the code simple and is O(period)
120        // per update — entirely acceptable for the periods used in practice.
121        let n = self.period as f64;
122        // The fit runs on deviations from the window mean. The slope is
123        // invariant under that shift, and the residuals -- which the channel
124        // width is built from -- stay on their own scale instead of being
125        // formed as a difference of two numbers the size of the price.
126        let mean = self.window.iter().sum::<f64>() / n;
127        let mut sum_y = 0.0;
128        let mut sum_xy = 0.0;
129        for (i, &y) in self.window.iter().enumerate() {
130            let x = i as f64;
131            let d = y - mean;
132            sum_y += d;
133            sum_xy += x * d;
134        }
135        let denom = n * self.sum_xx - self.sum_x * self.sum_x;
136        let slope = (n * sum_xy - self.sum_x * sum_y) / denom;
137        let intercept = (sum_y - slope * self.sum_x) / n;
138
139        // Residuals about the fitted line, on the same centred scale.
140        let mut sum_sq = 0.0;
141        for (i, &y) in self.window.iter().enumerate() {
142            let fitted = intercept + slope * (i as f64);
143            let r = (y - mean) - fitted;
144            sum_sq += r * r;
145        }
146        let sigma = (sum_sq / n).sqrt();
147        // An absolute price level, so the window mean comes back here.
148        let middle = mean + intercept + slope * (n - 1.0);
149        Some(LinRegChannelOutput {
150            upper: middle + self.multiplier * sigma,
151            middle,
152            lower: middle - self.multiplier * sigma,
153        })
154    }
155
156    fn reset(&mut self) {
157        self.window.clear();
158    }
159
160    #[inline]
161    fn warmup_period(&self) -> usize {
162        self.period
163    }
164
165    #[inline]
166    fn is_ready(&self) -> bool {
167        self.window.len() == self.period
168    }
169
170    #[inline]
171    fn name(&self) -> &'static str {
172        "LinRegChannel"
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::traits::BatchExt;
180    use approx::assert_relative_eq;
181
182    #[test]
183    fn rejects_period_below_two() {
184        assert!(LinRegChannel::new(0, 2.0).is_err());
185        assert!(LinRegChannel::new(1, 2.0).is_err());
186        assert!(LinRegChannel::new(2, 2.0).is_ok());
187    }
188
189    #[test]
190    fn rejects_non_positive_multiplier() {
191        assert!(matches!(
192            LinRegChannel::new(20, 0.0),
193            Err(Error::NonPositiveMultiplier)
194        ));
195        assert!(matches!(
196            LinRegChannel::new(20, -1.0),
197            Err(Error::NonPositiveMultiplier)
198        ));
199        assert!(matches!(
200            LinRegChannel::new(20, f64::NAN),
201            Err(Error::NonPositiveMultiplier)
202        ));
203    }
204
205    #[test]
206    fn accessors_and_metadata() {
207        let lc = LinRegChannel::new(20, 2.0).unwrap();
208        assert_eq!(lc.period(), 20);
209        assert_relative_eq!(lc.multiplier(), 2.0, epsilon = 1e-12);
210        assert_eq!(lc.warmup_period(), 20);
211        assert_eq!(lc.name(), "LinRegChannel");
212    }
213
214    #[test]
215    fn perfect_line_collapses_channel() {
216        // A perfectly linear series has zero residuals, so upper == middle == lower.
217        let prices: Vec<f64> = (0..40).map(|i| 2.0 * f64::from(i) + 5.0).collect();
218        let mut lc = LinRegChannel::new(10, 2.0).unwrap();
219        for o in lc.batch(&prices).into_iter().flatten() {
220            assert_relative_eq!(o.upper, o.middle, epsilon = 1e-9);
221            assert_relative_eq!(o.middle, o.lower, epsilon = 1e-9);
222        }
223    }
224
225    #[test]
226    fn constant_series_collapses_channel() {
227        let mut lc = LinRegChannel::new(8, 2.0).unwrap();
228        let out = lc.batch(&[42.0; 20]);
229        let v = out.iter().rev().flatten().next().unwrap();
230        assert_relative_eq!(v.middle, 42.0, epsilon = 1e-9);
231        assert_relative_eq!(v.upper, 42.0, epsilon = 1e-9);
232        assert_relative_eq!(v.lower, 42.0, epsilon = 1e-9);
233    }
234
235    #[test]
236    fn upper_above_middle_above_lower() {
237        let prices: Vec<f64> = (0..80)
238            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
239            .collect();
240        let mut lc = LinRegChannel::new(20, 2.0).unwrap();
241        for o in lc.batch(&prices).into_iter().flatten() {
242            assert!(o.upper >= o.middle);
243            assert!(o.middle >= o.lower);
244        }
245    }
246
247    #[test]
248    fn batch_equals_streaming() {
249        let prices: Vec<f64> = (0..60)
250            .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
251            .collect();
252        let mut a = LinRegChannel::new(14, 2.0).unwrap();
253        let mut b = LinRegChannel::new(14, 2.0).unwrap();
254        assert_eq!(
255            a.batch(&prices),
256            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
257        );
258    }
259
260    #[test]
261    fn reset_clears_state() {
262        let mut lc = LinRegChannel::new(5, 2.0).unwrap();
263        lc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
264        assert!(lc.is_ready());
265        lc.reset();
266        assert!(!lc.is_ready());
267        assert_eq!(lc.update(1.0), None);
268    }
269
270    /// Reference: period 3 over `[1, 2, 9]`. Fitted line `y = 0 + 4·x`,
271    /// endpoint at `x = 2` is `8`. Residuals: `1 − 0 = 1`, `2 − 4 = −2`,
272    /// `9 − 8 = 1`. Population variance = (1 + 4 + 1) / 3 = 2, sigma = sqrt(2).
273    /// With multiplier 2.0, upper = 8 + 2·sqrt(2), lower = 8 − 2·sqrt(2).
274    #[test]
275    fn reference_values() {
276        let mut lc = LinRegChannel::new(3, 2.0).unwrap();
277        let out = lc.batch(&[1.0, 2.0, 9.0]);
278        let v = out[2].unwrap();
279        let s2 = f64::sqrt(2.0);
280        assert_relative_eq!(v.middle, 8.0, epsilon = 1e-9);
281        assert_relative_eq!(v.upper, 8.0 + 2.0 * s2, epsilon = 1e-9);
282        assert_relative_eq!(v.lower, 8.0 - 2.0 * s2, epsilon = 1e-9);
283    }
284}