Skip to main content

finance_solution/stocks/ta/
linear_regression.rs

1//! # Rolling least-squares linear regression
2//!
3//! Fit \(y = \text{intercept} + \text{slope}\cdot x\) over the last **N** samples,
4//! where \(x = 0,1,\ldots,N-1\) (oldest → newest in the window).
5//!
6//! **Versatile:** pass **any** `f64` series — closes, highs, lows, typical price,
7//! volume, custom transforms. The crate does not force OHLCV; your engine picks
8//! the slice (e.g. highs for resistance slope, closes for trend slope).
9//!
10//! ---
11//!
12//! ## Trading perspective
13//!
14//! | Output | Habit |
15//! |--------|-------|
16//! | **slope** | Direction & steepness per bar (price units / bar) |
17//! | **angle_degrees** | `atan(slope)` in degrees — comparable trend “angle” when scale is fixed |
18//! | **r_squared** | How linear the window is (1 = perfect line) |
19//! | **intercept** | Fitted value at the oldest bar of the window |
20//!
21//! ---
22//!
23//! ## Engineering
24//!
25//! | Layer | API |
26//! |-------|-----|
27//! | Params | [`LinRegParams`] |
28//! | Batch | [`linear_regression`] (via [`LinRegState`]) |
29//! | Live | [`LinRegState::push`] / `push_bars` / `from_history` |
30//! | Teaching | [`linear_regression_solution`] |
31//!
32//! Each push when warm is **O(1)** (running `Σy`, `Σxy`, `Σy²`; fixed `x = 0..N-1`).
33//! First full window seeds those sums in O(N).
34//!
35//! ## Word problem
36//!
37//! > Closes 1,2,3,4,5 over five bars. Slope of the 5-bar regression?
38//!
39//! Expect: slope **1.0** (perfect line).
40//!
41//! ```
42//! use finance_solution::stocks::ta::{linear_regression, LinRegParams};
43//! let y = [1.0, 2.0, 3.0, 4.0, 5.0];
44//! let s = linear_regression(&y, LinRegParams::new(5)).unwrap();
45//! assert!((s[4].unwrap().slope - 1.0).abs() < 1e-12);
46//! assert!((s[4].unwrap().r_squared - 1.0).abs() < 1e-12);
47//! ```
48
49use crate::stocks::ta::common::validate_series;
50use crate::stocks::ta::ring::RingF64;
51use crate::util::error::{require_finite, FinanceResult};
52use crate::util::primitives::PeriodLength;
53use crate::{columns_with_strings, print_table_locale_opt};
54
55/// Rolling regression window length.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57pub struct LinRegParams {
58    pub period: usize,
59}
60
61impl LinRegParams {
62    pub const fn new(period: usize) -> Self {
63        Self { period }
64    }
65
66    /// Common short trend window.
67    pub const fn period_20() -> Self {
68        Self { period: 20 }
69    }
70
71    pub const fn period_50() -> Self {
72        Self { period: 50 }
73    }
74}
75
76/// One fitted window.
77#[derive(Clone, Copy, Debug, PartialEq)]
78pub struct LinRegBar {
79    /// \(\Delta y / \Delta x\) with \(x\) in bar-index units (0 = oldest in window).
80    pub slope: f64,
81    /// \(y\) at \(x = 0\) (oldest bar of the window).
82    pub intercept: f64,
83    /// Coefficient of determination in \([0, 1]\) (clamped); 1 = perfect fit.
84    pub r_squared: f64,
85    /// `atan(slope)` radians.
86    pub angle_radians: f64,
87    /// `atan(slope)` degrees.
88    pub angle_degrees: f64,
89}
90
91/// Validated pack.
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
93pub struct ValidatedLinReg {
94    params: LinRegParams,
95}
96
97impl ValidatedLinReg {
98    pub fn new(params: LinRegParams) -> FinanceResult<Self> {
99        PeriodLength::new(params.period)?;
100        if params.period < 2 {
101            return Err(crate::util::error::FinanceError::Unsolvable {
102                message: "linear regression period must be >= 2",
103            });
104        }
105        Ok(Self { params })
106    }
107
108    pub fn params(self) -> LinRegParams {
109        self.params
110    }
111
112    pub fn compute(self, series: &[f64]) -> FinanceResult<Vec<Option<LinRegBar>>> {
113        linear_regression_validated(series, self)
114    }
115}
116
117/// Incremental rolling regression on a caller-chosen series.
118///
119/// After warm-up each [`push`](Self::push) is **O(1)** (running `Σy`, `Σxy`, `Σy²`;
120/// `Σx` / `Σx²` are constant for fixed window length with `x = 0..N-1`).
121#[derive(Clone, Debug)]
122pub struct LinRegState {
123    params: LinRegParams,
124    ring: RingF64,
125    /// Set once the window is full.
126    sums: Option<LinRegSums>,
127    last: Option<LinRegBar>,
128}
129
130#[derive(Clone, Copy, Debug)]
131struct LinRegSums {
132    sum_y: f64,
133    sum_xy: f64,
134    sum_y2: f64,
135}
136
137impl LinRegState {
138    pub fn new(params: LinRegParams) -> FinanceResult<Self> {
139        let _ = ValidatedLinReg::new(params)?;
140        Ok(Self {
141            params,
142            ring: RingF64::with_capacity(params.period),
143            sums: None,
144            last: None,
145        })
146    }
147
148    pub fn from_history(params: LinRegParams, series: &[f64]) -> FinanceResult<Self> {
149        let mut s = Self::new(params)?;
150        let _ = s.push_bars(series)?;
151        Ok(s)
152    }
153
154    pub fn params(&self) -> LinRegParams {
155        self.params
156    }
157
158    pub fn reset(&mut self) {
159        self.ring.clear();
160        self.sums = None;
161        self.last = None;
162    }
163
164    pub fn push(&mut self, y: f64) -> FinanceResult<Option<LinRegBar>> {
165        require_finite("series", y)?;
166        let n = self.params.period;
167        let nf = n as f64;
168
169        if let Some(mut s) = self.sums {
170            let y_old = self.ring.oldest().unwrap();
171            let sum_y_old = s.sum_y;
172            // Slide with re-indexed x=0..n-1:
173            // sum_xy' = sum_xy - sum_y + y_old + (n-1)*y_new
174            s.sum_xy = s.sum_xy - sum_y_old + y_old + (nf - 1.0) * y;
175            s.sum_y = sum_y_old - y_old + y;
176            s.sum_y2 = s.sum_y2 - y_old * y_old + y * y;
177            let _ = self.ring.push(y);
178            self.sums = Some(s);
179            let bar = fit_ols_from_sums(n, s.sum_y, s.sum_xy, s.sum_y2);
180            self.last = Some(bar);
181            Ok(Some(bar))
182        } else {
183            let _ = self.ring.push(y);
184            if !self.ring.is_full() {
185                self.last = None;
186                return Ok(None);
187            }
188            let mut ordered = Vec::with_capacity(n);
189            self.ring.copy_ordered(&mut ordered);
190            let mut sum_y = 0.0;
191            let mut sum_xy = 0.0;
192            let mut sum_y2 = 0.0;
193            for (i, &yi) in ordered.iter().enumerate() {
194                let x = i as f64;
195                sum_y += yi;
196                sum_xy += x * yi;
197                sum_y2 += yi * yi;
198            }
199            self.sums = Some(LinRegSums {
200                sum_y,
201                sum_xy,
202                sum_y2,
203            });
204            let bar = fit_ols_from_sums(n, sum_y, sum_xy, sum_y2);
205            self.last = Some(bar);
206            Ok(Some(bar))
207        }
208    }
209
210    pub fn push_bars(&mut self, series: &[f64]) -> FinanceResult<Vec<Option<LinRegBar>>> {
211        let mut out = Vec::with_capacity(series.len());
212        for &y in series {
213            out.push(self.push(y)?);
214        }
215        Ok(out)
216    }
217
218    pub fn last(&self) -> Option<LinRegBar> {
219        self.last
220    }
221}
222
223#[derive(Clone, Debug)]
224pub struct LinRegSolution {
225    series: Vec<Option<LinRegBar>>,
226    y: Vec<f64>,
227    params: LinRegParams,
228    formula: String,
229}
230
231impl LinRegSolution {
232    pub fn series(&self) -> &[Option<LinRegBar>] {
233        &self.series
234    }
235    pub fn formula(&self) -> &str {
236        &self.formula
237    }
238    pub fn params(&self) -> LinRegParams {
239        self.params
240    }
241
242    pub fn print_table(&self) {
243        self.print_table_locale_opt(None, None);
244    }
245
246    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
247        self.print_table_locale_opt(Some(locale), Some(precision));
248    }
249
250    fn print_table_locale_opt(
251        &self,
252        locale: Option<&num_format::Locale>,
253        precision: Option<usize>,
254    ) {
255        let columns = columns_with_strings(&[
256            ("period", "i", true),
257            ("y", "f", true),
258            ("slope", "f", true),
259            ("angle_deg", "f", true),
260            ("r2", "f", true),
261        ]);
262        let data = self
263            .y
264            .iter()
265            .enumerate()
266            .map(|(i, y)| {
267                let (slope, ang, r2) = match self.series[i] {
268                    Some(b) => (
269                        b.slope.to_string(),
270                        b.angle_degrees.to_string(),
271                        b.r_squared.to_string(),
272                    ),
273                    None => ("n/a".to_string(), "n/a".to_string(), "n/a".to_string()),
274                };
275                vec![i.to_string(), y.to_string(), slope, ang, r2]
276            })
277            .collect();
278        print_table_locale_opt(&columns, data, locale, precision);
279    }
280}
281
282/// Batch rolling OLS. `series` is **your** choice of bar field (close, high, …).
283pub fn linear_regression(
284    series: &[f64],
285    params: LinRegParams,
286) -> FinanceResult<Vec<Option<LinRegBar>>> {
287    ValidatedLinReg::new(params)?.compute(series)
288}
289
290pub fn linear_regression_solution(
291    series: &[f64],
292    params: LinRegParams,
293) -> FinanceResult<LinRegSolution> {
294    let s = linear_regression(series, params)?;
295    Ok(LinRegSolution {
296        series: s,
297        y: series.to_vec(),
298        params,
299        formula: format!(
300            "OLS over last {}: y = intercept + slope*x, x=0..N-1; angle=atan(slope)",
301            params.period
302        ),
303    })
304}
305
306fn linear_regression_validated(
307    series: &[f64],
308    eng: ValidatedLinReg,
309) -> FinanceResult<Vec<Option<LinRegBar>>> {
310    validate_series("series", series)?;
311    let mut st = LinRegState::new(eng.params)?;
312    st.push_bars(series)
313}
314
315/// OLS from sufficient stats with `x = 0..n-1` (fixed for a given `n`).
316fn fit_ols_from_sums(n: usize, sum_y: f64, sum_xy: f64, sum_y2: f64) -> LinRegBar {
317    let nf = n as f64;
318    // sum_x = 0+1+...+(n-1) = (n-1)n/2
319    // sum_xx = (n-1)n(2n-1)/6
320    let sum_x = (nf - 1.0) * nf / 2.0;
321    let sum_xx = (nf - 1.0) * nf * (2.0 * nf - 1.0) / 6.0;
322    let denom = nf * sum_xx - sum_x * sum_x;
323    let slope = if denom.abs() < 1e-18 {
324        0.0
325    } else {
326        (nf * sum_xy - sum_x * sum_y) / denom
327    };
328    let intercept = (sum_y - slope * sum_x) / nf;
329
330    let mean_y = sum_y / nf;
331    let ss_tot = sum_y2 - nf * mean_y * mean_y;
332    // ss_res = Σ(y - a - b x)² expanded with sufficient stats
333    let ss_res = sum_y2
334        + nf * intercept * intercept
335        + slope * slope * sum_xx
336        + 2.0 * intercept * slope * sum_x
337        - 2.0 * intercept * sum_y
338        - 2.0 * slope * sum_xy;
339    let r_squared = if ss_tot <= 1e-18 {
340        1.0
341    } else {
342        (1.0 - ss_res / ss_tot).clamp(0.0, 1.0)
343    };
344
345    let angle_radians = slope.atan();
346    LinRegBar {
347        slope,
348        intercept,
349        r_squared,
350        angle_radians,
351        angle_degrees: angle_radians.to_degrees(),
352    }
353}
354
355/// OLS on a full window `y[0..n)` with `x = 0..n-1` (tests / cold path).
356#[cfg(test)]
357fn fit_ols(y: &[f64]) -> LinRegBar {
358    let mut sum_y = 0.0;
359    let mut sum_xy = 0.0;
360    let mut sum_y2 = 0.0;
361    for (i, &yi) in y.iter().enumerate() {
362        sum_y += yi;
363        sum_xy += (i as f64) * yi;
364        sum_y2 += yi * yi;
365    }
366    fit_ols_from_sums(y.len(), sum_y, sum_xy, sum_y2)
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn perfect_line_slope_one() {
375        let y = [1.0, 2.0, 3.0, 4.0, 5.0];
376        let s = linear_regression(&y, LinRegParams::new(5)).unwrap();
377        let b = s[4].unwrap();
378        assert!((b.slope - 1.0).abs() < 1e-12);
379        assert!((b.r_squared - 1.0).abs() < 1e-12);
380        assert!((b.intercept - 1.0).abs() < 1e-12);
381    }
382
383    #[test]
384    fn state_parity() {
385        let y: Vec<f64> = (0..40).map(|i| 100.0 + i as f64 * 0.25).collect();
386        let batch = linear_regression(&y, LinRegParams::period_20()).unwrap();
387        let st = LinRegState::from_history(LinRegParams::period_20(), &y).unwrap();
388        let b = batch.last().unwrap().unwrap();
389        let s = st.last().unwrap();
390        assert!((b.slope - s.slope).abs() < 1e-12);
391    }
392
393    #[test]
394    fn period_one_err() {
395        assert!(ValidatedLinReg::new(LinRegParams::new(1)).is_err());
396    }
397
398    #[test]
399    fn flat_series_zero_slope() {
400        let y = vec![42.0; 20];
401        let b = linear_regression(&y, LinRegParams::new(10))
402            .unwrap()
403            .last()
404            .unwrap()
405            .unwrap();
406        assert!(b.slope.abs() < 1e-12);
407        assert!((b.r_squared - 1.0).abs() < 1e-12);
408    }
409
410    #[test]
411    fn angle_matches_atan_slope() {
412        let y = [0.0, 1.0, 2.0, 3.0, 4.0];
413        let b = linear_regression(&y, LinRegParams::new(5))
414            .unwrap()
415            .last()
416            .unwrap()
417            .unwrap();
418        assert!((b.angle_radians - b.slope.atan()).abs() < 1e-15);
419        assert!((b.angle_degrees - b.angle_radians.to_degrees()).abs() < 1e-12);
420    }
421
422    #[test]
423    fn works_on_highs_not_only_closes() {
424        // Versatility: slope of highs series
425        let highs = [10.0, 11.0, 12.5, 12.0, 13.0, 14.0, 15.0];
426        let s = linear_regression(&highs, LinRegParams::new(5)).unwrap();
427        assert!(s[6].unwrap().slope > 0.0);
428    }
429
430    #[test]
431    fn empty_err() {
432        assert!(linear_regression(&[], LinRegParams::new(5)).is_err());
433    }
434}