Skip to main content

egui_charts/studies/builtin/
lsma.rs

1//! Least Squares Moving Average (LSMA) indicator.
2//!
3//! Also known as the *Linear Regression Line* or *Moving Linear Regression*,
4//! LSMA fits an ordinary least-squares line through the last `N` closing
5//! prices and reports the endpoint value. An optional `offset` parameter
6//! projects the line forward (or backward) by a specified number of bars.
7//!
8//! # Formula
9//!
10//! For each window of `period` prices, compute the best-fit line
11//! `y = m*x + b` and evaluate it at the last position (+offset).
12//!
13//! # Example
14//!
15//! ```rust
16//! # let bars: Vec<egui_charts::model::Bar> = (0..60).map(|i| { let p = 100.0 + (i as f64 * 0.3).sin() * 5.0; egui_charts::model::Bar::new(chrono::Utc::now(), p, p + 1.0, p - 1.0, p + 0.5, 1_000.0) }).collect();
17//! use egui_charts::studies::{LSMA, Indicator};
18//!
19//! let mut lsma = LSMA::new(25);
20//! lsma.calculate(&bars);
21//! ```
22
23use crate::model::Bar;
24use crate::studies::palette;
25use crate::studies::stepper::update_window;
26use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
27use egui::Color32;
28
29/// Least Squares Moving Average (Linear Regression Line) indicator.
30///
31/// Fits a least-squares regression line to the most recent `period` prices
32/// and reports the end-point value. Overlay indicator.
33#[derive(Clone)]
34pub struct LSMA {
35    period: usize,
36    offset: i32, // Future projection offset (can be negative for past)
37    values: IndicatorValues,
38    color: Color32,
39    visible: bool,
40}
41
42impl LSMA {
43    /// Create a new LSMA indicator.
44    ///
45    /// # Arguments
46    /// * `period` -- Number of bars in the regression window (e.g. 25).
47    pub fn new(period: usize) -> Self {
48        Self {
49            period: period.max(1),
50            offset: 0,
51            values: IndicatorValues::new(),
52            color: palette::BLUE,
53            visible: true,
54        }
55    }
56
57    /// Set the forward (positive) or backward (negative) projection offset in bars.
58    pub fn with_offset(mut self, offset: i32) -> Self {
59        self.offset = offset;
60        self
61    }
62
63    /// Set a custom line colour (builder pattern).
64    pub fn with_color(mut self, color: Color32) -> Self {
65        self.color = color;
66        self
67    }
68
69    /// Compute the least-squares regression value for a price window,
70    /// evaluated at the last index plus `offset`.
71    fn linear_regression(prices: &[f64], offset: i32) -> f64 {
72        let n = prices.len() as f64;
73        if n == 0.0 {
74            return 0.0;
75        }
76
77        // Calculate sums for linear regression
78        // y = mx + b where x is the bar index
79        let mut sum_x = 0.0;
80        let mut sum_y = 0.0;
81        let mut sum_xy = 0.0;
82        let mut sum_xx = 0.0;
83
84        for (i, price) in prices.iter().enumerate() {
85            let x = i as f64;
86            sum_x += x;
87            sum_y += price;
88            sum_xy += x * price;
89            sum_xx += x * x;
90        }
91
92        // Calculate slope (m) and intercept (b)
93        let denominator = n * sum_xx - sum_x * sum_x;
94        if denominator.abs() < 1e-10 {
95            return sum_y / n; // Return avg if no slope
96        }
97
98        let m = (n * sum_xy - sum_x * sum_y) / denominator;
99        let b = (sum_y - m * sum_x) / n;
100
101        // Calculate value at the last point + offset
102        let x_target = (prices.len() - 1) as f64 + offset as f64;
103        m * x_target + b
104    }
105}
106
107/// Construct with the conventional default parameters.
108impl Default for LSMA {
109    fn default() -> Self {
110        Self::new(25)
111    }
112}
113
114impl LSMA {
115    fn compute(&self, data: &[Bar]) -> IndicatorValues {
116        let mut values = IndicatorValues::new();
117
118        for i in 0..data.len() {
119            if i < self.period - 1 {
120                values.push(IndicatorValue::None);
121            } else {
122                let prices: Vec<f64> = data[i + 1 - self.period..=i]
123                    .iter()
124                    .map(|bar| bar.close)
125                    .collect();
126
127                let lsma = Self::linear_regression(&prices, self.offset);
128                values.push(IndicatorValue::Single(lsma));
129            }
130        }
131        values
132    }
133}
134
135impl Indicator for LSMA {
136    fn source(&self) -> Option<crate::studies::StudySource> {
137        Some(crate::studies::StudySource::Price(
138            crate::model::PriceSource::Close,
139        ))
140    }
141
142    crate::studies::inputs::study_inputs!(self;
143        period: length("Period", 1, usize::MAX),
144        offset: integer("Offset", i32::MIN, i32::MAX)
145    ; {});
146
147    fn name(&self) -> &str {
148        "LSMA"
149    }
150
151    fn desc(&self) -> &str {
152        "Least Squares Moving Avg - Linear regression line"
153    }
154
155    fn calculate(&mut self, data: &[Bar]) {
156        self.update(data, 0);
157    }
158
159    fn update(&mut self, data: &[Bar], from: usize) {
160        let mut values = std::mem::take(&mut self.values);
161        update_window(
162            &mut values,
163            data,
164            from,
165            |_data, from| from.saturating_sub(self.period - 1),
166            |data| self.compute(data),
167        );
168        self.values = values;
169    }
170
171    fn is_incremental(&self) -> bool {
172        true
173    }
174
175    fn values(&self) -> &IndicatorValues {
176        &self.values
177    }
178
179    fn colors(&self) -> Vec<Color32> {
180        vec![self.color]
181    }
182
183    fn set_colors(&mut self, colors: Vec<Color32>) {
184        if !colors.is_empty() {
185            self.color = colors[0];
186        }
187    }
188
189    fn is_overlay(&self) -> bool {
190        true
191    }
192
193    fn is_visible(&self) -> bool {
194        self.visible
195    }
196
197    fn set_visible(&mut self, visible: bool) {
198        self.visible = visible;
199    }
200
201    fn clone_box(&self) -> Box<dyn Indicator> {
202        Box::new(self.clone())
203    }
204
205    fn line_names(&self) -> Vec<String> {
206        if self.offset != 0 {
207            vec![format!("LSMA({}, {})", self.period, self.offset)]
208        } else {
209            vec![format!("LSMA({})", self.period)]
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::studies::test_support::make_bar;
218
219    #[test]
220    fn regression_endpoint_and_offset_match_hand_values() {
221        for (closes, offset, expected) in [
222            ([100.0; 5], 0, 100.0),
223            ([100.0, 101.0, 102.0, 103.0, 104.0], 0, 104.0),
224            ([100.0, 101.0, 102.0, 103.0, 104.0], 1, 105.0),
225        ] {
226            let data: Vec<Bar> = closes.into_iter().map(make_bar).collect();
227            let mut lsma = LSMA::new(data.len()).with_offset(offset);
228            lsma.calculate(&data);
229            let got = lsma.values().value(0, data.len() - 1).unwrap();
230            assert!((got - expected).abs() < 1e-9, "{closes:?}, offset {offset}");
231        }
232    }
233
234    #[test]
235    fn test_lsma_calculation() {
236        let mut lsma = LSMA::new(3);
237
238        let data = vec![
239            make_bar(100.0),
240            make_bar(101.0),
241            make_bar(102.0),
242            make_bar(103.0),
243        ];
244
245        lsma.calculate(&data);
246
247        assert_eq!(lsma.values.len(), 4);
248        assert!(matches!(lsma.values.to_rows()[0], IndicatorValue::None));
249        assert!(matches!(lsma.values.to_rows()[1], IndicatorValue::None));
250
251        // Third value should be close to 102.0 (uptrend)
252        let IndicatorValue::Single(v) = lsma.values.to_rows()[2] else {
253            panic!("no value: {:?}", lsma.values.to_rows()[2]);
254        };
255        assert!((v - 102.0).abs() < 0.01);
256    }
257}