Skip to main content

kestrel_chartkit/indicator/
lsma.rs

1use super::{Indicator, IndicatorAlert, IndicatorOutput};
2use crate::model::Bar;
3use crate::stats::linear_regression;
4use std::collections::HashMap;
5use std::collections::VecDeque;
6
7/// Least Squares Moving Average: the endpoint of an ordinary least-squares fit through the last
8/// `period` closes.
9///
10/// The fit runs over the local window index `x = 0 ..= period - 1`, so `value` is the fitted
11/// price at `x = period - 1` — the current bar, never beyond it.
12///
13/// The same fit already carries more than that endpoint, and those numbers are published rather
14/// than recomputed by callers:
15/// - `extra["slope"]`: price change per bar of the fitted line, in price units per bar. Not an
16///   angle: a slope drawn as an angle depends on the axis scaling of whoever draws it.
17/// - `extra["intercept"]`: fitted price at the local window index 0, i.e. at the oldest bar of
18///   the current window — not at the start of the series.
19/// - `extra["r2"]`: share of the window's price variance explained by the line, in `0..=1`. A
20///   goodness of fit, not a probability that the move continues. A window without price variance
21///   has nothing left to explain; the convention here is `1`.
22///
23/// First output: with the `period`-th bar, and only when the fit is defined (a window of
24/// non-finite values yields none). [`Indicator::reset`] clears the window, so the next series
25/// starts deterministically.
26#[derive(Debug, Clone)]
27pub struct LsmaEngine {
28    period: usize,
29    window: VecDeque<f64>,
30}
31
32impl LsmaEngine {
33    pub fn new(period: usize) -> Self {
34        Self {
35            period: period.max(2),
36            window: VecDeque::with_capacity(period),
37        }
38    }
39}
40
41impl Indicator for LsmaEngine {
42    fn name(&self) -> &str {
43        "lsma"
44    }
45
46    fn warmup_period(&self) -> usize {
47        self.period
48    }
49
50    fn reset(&mut self) {
51        self.window.clear();
52    }
53
54    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
55        self.window.push_back(bar.close);
56        if self.window.len() > self.period {
57            self.window.pop_front();
58        }
59
60        if self.window.len() < self.period {
61            return None;
62        }
63
64        let slice: Vec<f64> = self.window.iter().copied().collect();
65        let fit = linear_regression(&slice)?;
66
67        // Endpoint prediction at x = N - 1
68        let lsma_val = fit.slope * (self.period - 1) as f64 + fit.intercept;
69
70        let extra = HashMap::from([
71            ("slope".to_string(), fit.slope),
72            ("intercept".to_string(), fit.intercept),
73            ("r2".to_string(), fit.r2),
74        ]);
75        Some(IndicatorOutput::with_extra(lsma_val, extra))
76    }
77
78    fn alerts(&self) -> Vec<IndicatorAlert> {
79        Vec::new()
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn test_lsma_linear_trend() {
89        let mut lsma = LsmaEngine::new(5);
90        for i in 0..10 {
91            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + (i as f64 * 2.0), 1000.0);
92            if let Some(out) = lsma.on_bar(&b) {
93                if i == 9 {
94                    assert!((out.value - 118.0).abs() < 1e-6);
95                }
96            }
97        }
98    }
99}