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`] |
29//! | Live | [`LinRegState::push`] / `push_bars` / `from_history` |
30//! | Teaching | [`linear_regression_solution`] |
31//!
32//! Each push when warm is **O(period)** (recompute OLS on the ring). Fine for
33//! typical windows (20–200); not nanosecond-critical path.
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#[derive(Clone, Debug)]
119pub struct LinRegState {
120    params: LinRegParams,
121    ring: RingF64,
122    last: Option<LinRegBar>,
123    scratch: Vec<f64>,
124}
125
126impl LinRegState {
127    pub fn new(params: LinRegParams) -> FinanceResult<Self> {
128        let _ = ValidatedLinReg::new(params)?;
129        Ok(Self {
130            params,
131            ring: RingF64::with_capacity(params.period),
132            last: None,
133            scratch: Vec::with_capacity(params.period),
134        })
135    }
136
137    pub fn from_history(params: LinRegParams, series: &[f64]) -> FinanceResult<Self> {
138        let mut s = Self::new(params)?;
139        let _ = s.push_bars(series)?;
140        Ok(s)
141    }
142
143    pub fn params(&self) -> LinRegParams {
144        self.params
145    }
146
147    pub fn reset(&mut self) {
148        self.ring.clear();
149        self.last = None;
150        self.scratch.clear();
151    }
152
153    pub fn push(&mut self, y: f64) -> FinanceResult<Option<LinRegBar>> {
154        require_finite("series", y)?;
155        self.ring.push(y);
156        if !self.ring.is_full() {
157            self.last = None;
158            return Ok(None);
159        }
160        self.ring.copy_ordered(&mut self.scratch);
161        let bar = fit_ols(&self.scratch);
162        self.last = Some(bar);
163        Ok(Some(bar))
164    }
165
166    pub fn push_bars(&mut self, series: &[f64]) -> FinanceResult<Vec<Option<LinRegBar>>> {
167        let mut out = Vec::with_capacity(series.len());
168        for &y in series {
169            out.push(self.push(y)?);
170        }
171        Ok(out)
172    }
173
174    pub fn last(&self) -> Option<LinRegBar> {
175        self.last
176    }
177}
178
179#[derive(Clone, Debug)]
180pub struct LinRegSolution {
181    series: Vec<Option<LinRegBar>>,
182    y: Vec<f64>,
183    params: LinRegParams,
184    formula: String,
185}
186
187impl LinRegSolution {
188    pub fn series(&self) -> &[Option<LinRegBar>] {
189        &self.series
190    }
191    pub fn formula(&self) -> &str {
192        &self.formula
193    }
194    pub fn params(&self) -> LinRegParams {
195        self.params
196    }
197
198    pub fn print_table(&self) {
199        self.print_table_locale_opt(None, None);
200    }
201
202    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
203        self.print_table_locale_opt(Some(locale), Some(precision));
204    }
205
206    fn print_table_locale_opt(
207        &self,
208        locale: Option<&num_format::Locale>,
209        precision: Option<usize>,
210    ) {
211        let columns = columns_with_strings(&[
212            ("period", "i", true),
213            ("y", "f", true),
214            ("slope", "f", true),
215            ("angle_deg", "f", true),
216            ("r2", "f", true),
217        ]);
218        let data = self
219            .y
220            .iter()
221            .enumerate()
222            .map(|(i, y)| {
223                let (slope, ang, r2) = match self.series[i] {
224                    Some(b) => (
225                        b.slope.to_string(),
226                        b.angle_degrees.to_string(),
227                        b.r_squared.to_string(),
228                    ),
229                    None => ("n/a".to_string(), "n/a".to_string(), "n/a".to_string()),
230                };
231                vec![i.to_string(), y.to_string(), slope, ang, r2]
232            })
233            .collect();
234        print_table_locale_opt(&columns, data, locale, precision);
235    }
236}
237
238/// Batch rolling OLS. `series` is **your** choice of bar field (close, high, …).
239pub fn linear_regression(
240    series: &[f64],
241    params: LinRegParams,
242) -> FinanceResult<Vec<Option<LinRegBar>>> {
243    ValidatedLinReg::new(params)?.compute(series)
244}
245
246pub fn linear_regression_solution(
247    series: &[f64],
248    params: LinRegParams,
249) -> FinanceResult<LinRegSolution> {
250    let s = linear_regression(series, params)?;
251    Ok(LinRegSolution {
252        series: s,
253        y: series.to_vec(),
254        params,
255        formula: format!(
256            "OLS over last {}: y = intercept + slope*x, x=0..N-1; angle=atan(slope)",
257            params.period
258        ),
259    })
260}
261
262fn linear_regression_validated(
263    series: &[f64],
264    eng: ValidatedLinReg,
265) -> FinanceResult<Vec<Option<LinRegBar>>> {
266    validate_series("series", series)?;
267    let mut st = LinRegState::new(eng.params)?;
268    st.push_bars(series)
269}
270
271/// OLS on a full window `y[0..n)` with `x = 0..n-1`.
272fn fit_ols(y: &[f64]) -> LinRegBar {
273    let n = y.len() as f64;
274    let mut sum_x = 0.0;
275    let mut sum_y = 0.0;
276    let mut sum_xx = 0.0;
277    let mut sum_xy = 0.0;
278    for (i, &yi) in y.iter().enumerate() {
279        let x = i as f64;
280        sum_x += x;
281        sum_y += yi;
282        sum_xx += x * x;
283        sum_xy += x * yi;
284    }
285    let denom = n * sum_xx - sum_x * sum_x;
286    let slope = if denom.abs() < 1e-18 {
287        0.0
288    } else {
289        (n * sum_xy - sum_x * sum_y) / denom
290    };
291    let intercept = (sum_y - slope * sum_x) / n;
292
293    // R²
294    let mean_y = sum_y / n;
295    let mut ss_tot = 0.0;
296    let mut ss_res = 0.0;
297    for (i, &yi) in y.iter().enumerate() {
298        let fit = intercept + slope * (i as f64);
299        ss_tot += (yi - mean_y) * (yi - mean_y);
300        ss_res += (yi - fit) * (yi - fit);
301    }
302    let r_squared = if ss_tot <= 1e-18 {
303        1.0 // constant series → perfect "fit"
304    } else {
305        (1.0 - ss_res / ss_tot).clamp(0.0, 1.0)
306    };
307
308    let angle_radians = slope.atan();
309    let angle_degrees = angle_radians.to_degrees();
310    LinRegBar {
311        slope,
312        intercept,
313        r_squared,
314        angle_radians,
315        angle_degrees,
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn perfect_line_slope_one() {
325        let y = [1.0, 2.0, 3.0, 4.0, 5.0];
326        let s = linear_regression(&y, LinRegParams::new(5)).unwrap();
327        let b = s[4].unwrap();
328        assert!((b.slope - 1.0).abs() < 1e-12);
329        assert!((b.r_squared - 1.0).abs() < 1e-12);
330        assert!((b.intercept - 1.0).abs() < 1e-12);
331    }
332
333    #[test]
334    fn state_parity() {
335        let y: Vec<f64> = (0..40).map(|i| 100.0 + i as f64 * 0.25).collect();
336        let batch = linear_regression(&y, LinRegParams::period_20()).unwrap();
337        let st = LinRegState::from_history(LinRegParams::period_20(), &y).unwrap();
338        let b = batch.last().unwrap().unwrap();
339        let s = st.last().unwrap();
340        assert!((b.slope - s.slope).abs() < 1e-12);
341    }
342
343    #[test]
344    fn period_one_err() {
345        assert!(ValidatedLinReg::new(LinRegParams::new(1)).is_err());
346    }
347
348    #[test]
349    fn flat_series_zero_slope() {
350        let y = vec![42.0; 20];
351        let b = linear_regression(&y, LinRegParams::new(10))
352            .unwrap()
353            .last()
354            .unwrap()
355            .unwrap();
356        assert!(b.slope.abs() < 1e-12);
357        assert!((b.r_squared - 1.0).abs() < 1e-12);
358    }
359
360    #[test]
361    fn angle_matches_atan_slope() {
362        let y = [0.0, 1.0, 2.0, 3.0, 4.0];
363        let b = linear_regression(&y, LinRegParams::new(5))
364            .unwrap()
365            .last()
366            .unwrap()
367            .unwrap();
368        assert!((b.angle_radians - b.slope.atan()).abs() < 1e-15);
369        assert!((b.angle_degrees - b.angle_radians.to_degrees()).abs() < 1e-12);
370    }
371
372    #[test]
373    fn works_on_highs_not_only_closes() {
374        // Versatility: slope of highs series
375        let highs = [10.0, 11.0, 12.5, 12.0, 13.0, 14.0, 15.0];
376        let s = linear_regression(&highs, LinRegParams::new(5)).unwrap();
377        assert!(s[6].unwrap().slope > 0.0);
378    }
379
380    #[test]
381    fn empty_err() {
382        assert!(linear_regression(&[], LinRegParams::new(5)).is_err());
383    }
384}