Skip to main content

nautilus_indicators/average/
lr.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::fmt::{Debug, Display};
17
18use nautilus_core::correctness::FAILED;
19use nautilus_model::data::Bar;
20
21use crate::{
22    indicator::Indicator,
23    support::{MAX_PERIOD, RollingOls},
24};
25
26/// Linear regression over a rolling price window.
27#[repr(C)]
28#[derive(Debug)]
29#[cfg_attr(
30    feature = "python",
31    pyo3::pyclass(module = "nautilus_trader.indicators")
32)]
33#[cfg_attr(
34    feature = "python",
35    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
36)]
37pub struct LinearRegression {
38    pub period: usize,
39    pub slope: f64,
40    pub intercept: f64,
41    pub degree: f64,
42    pub cfo: f64,
43    pub r2: f64,
44    pub value: f64,
45    pub initialized: bool,
46    has_inputs: bool,
47    ols: RollingOls,
48}
49
50impl Display for LinearRegression {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "{}({})", self.name(), self.period)
53    }
54}
55
56impl Indicator for LinearRegression {
57    fn name(&self) -> String {
58        stringify!(LinearRegression).into()
59    }
60
61    fn has_inputs(&self) -> bool {
62        self.has_inputs
63    }
64
65    fn initialized(&self) -> bool {
66        self.initialized
67    }
68
69    fn handle_bar(&mut self, bar: &Bar) {
70        self.update_raw(bar.close.into());
71    }
72
73    fn reset(&mut self) {
74        self.slope = 0.0;
75        self.intercept = 0.0;
76        self.degree = 0.0;
77        self.cfo = 0.0;
78        self.r2 = 0.0;
79        self.value = 0.0;
80        self.ols.reset();
81        self.has_inputs = false;
82        self.initialized = false;
83    }
84}
85
86impl LinearRegression {
87    /// Creates a new [`LinearRegression`] instance.
88    ///
89    /// # Panics
90    ///
91    /// Panics if:
92    /// - `period` is less than two.
93    /// - `period` exceeds `MAX_PERIOD` (16,777,216).
94    #[must_use]
95    pub fn new(period: usize) -> Self {
96        Self::new_checked(period).expect(FAILED)
97    }
98
99    pub(crate) fn new_checked(period: usize) -> anyhow::Result<Self> {
100        anyhow::ensure!(period > 0, "LinearRegression: period must be > 0");
101        anyhow::ensure!(period >= 2, "LinearRegression: period must be >= 2");
102        anyhow::ensure!(
103            period <= MAX_PERIOD,
104            "LinearRegression: period exceeds MAX_PERIOD ({MAX_PERIOD})"
105        );
106        Ok(Self {
107            period,
108            slope: 0.0,
109            intercept: 0.0,
110            degree: 0.0,
111            cfo: 0.0,
112            r2: 0.0,
113            value: 0.0,
114            initialized: false,
115            has_inputs: false,
116            ols: RollingOls::new(period),
117        })
118    }
119
120    /// Updates the linear regression with a new data point.
121    pub fn update_raw(&mut self, close: f64) {
122        if !close.is_finite() {
123            return;
124        }
125        self.has_inputs = true;
126
127        if !self.ols.push(close) {
128            return;
129        }
130
131        let n = self.period as f64;
132        self.slope = self.ols.slope();
133        self.intercept = self.ols.intercept(self.slope);
134        self.value = self.intercept + self.slope * (n - 1.0);
135        self.degree = self.slope.atan().to_degrees();
136        self.cfo = if close == 0.0 {
137            f64::NAN
138        } else {
139            100.0 * (self.value - close) / close
140        };
141        let mean = self.ols.sum_y() / n;
142        let sst = (self.ols.sum_y_sq() - n * mean * mean).max(0.0);
143        // Only a flat window has no variance to explain; any absolute threshold
144        // would also reject valid fits on finely priced instruments.
145        self.r2 = if sst == 0.0 {
146            f64::NAN
147        } else {
148            (self.slope * self.slope * self.ols.denom() / n / sst).clamp(0.0, 1.0)
149        };
150        self.initialized = true;
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use nautilus_model::data::Bar;
157    use rstest::rstest;
158
159    use super::*;
160    use crate::{
161        average::lr::LinearRegression,
162        indicator::Indicator,
163        stubs::{bar_ethusdt_binance_minute_bid, indicator_lr_10},
164    };
165
166    #[rstest]
167    fn test_psl_initialized(indicator_lr_10: LinearRegression) {
168        let display_str = format!("{indicator_lr_10}");
169        assert_eq!(display_str, "LinearRegression(10)");
170        assert_eq!(indicator_lr_10.period, 10);
171        assert!(!indicator_lr_10.initialized);
172        assert!(!indicator_lr_10.has_inputs);
173    }
174
175    #[rstest]
176    #[case(vec![4.0, 5.0, 9.0, 10.0], 3.7, 10.3)]
177    #[case(vec![4.0, 5.0, 9.0, 10.0, 12.0], 5.7, 12.3)]
178    fn zero_based_fit_matches_expected_outputs(
179        #[case] prices: Vec<f64>,
180        #[case] expected_intercept: f64,
181        #[case] expected_endpoint: f64,
182    ) {
183        let mut regression = LinearRegression::new(4);
184
185        for price in prices {
186            regression.update_raw(price);
187        }
188
189        for (actual, expected) in [
190            (regression.intercept, expected_intercept),
191            (regression.value, expected_endpoint),
192            (regression.slope, 2.2),
193            (regression.r2, 121.0 / 130.0),
194            (regression.degree, 2.2_f64.atan().to_degrees()),
195        ] {
196            assert!(
197                (actual - expected).abs() <= 1.0e-12,
198                "actual {actual}, expected {expected}"
199            );
200        }
201    }
202
203    #[rstest]
204    #[should_panic(expected = "LinearRegression: period must be > 0")]
205    fn test_new_with_zero_period_panics() {
206        let _ = LinearRegression::new(0);
207    }
208
209    #[rstest]
210    fn test_value_with_one_input(mut indicator_lr_10: LinearRegression) {
211        indicator_lr_10.update_raw(1.0);
212        assert_eq!(indicator_lr_10.value, 0.0);
213    }
214
215    #[rstest]
216    fn test_value_with_three_inputs(mut indicator_lr_10: LinearRegression) {
217        indicator_lr_10.update_raw(1.0);
218        indicator_lr_10.update_raw(2.0);
219        indicator_lr_10.update_raw(3.0);
220        assert_eq!(indicator_lr_10.value, 0.0);
221    }
222
223    #[rstest]
224    fn test_initialized_with_required_input(mut indicator_lr_10: LinearRegression) {
225        for i in 1..10 {
226            indicator_lr_10.update_raw(f64::from(i));
227        }
228        assert!(!indicator_lr_10.initialized);
229        indicator_lr_10.update_raw(10.0);
230        assert!(indicator_lr_10.initialized);
231    }
232
233    #[rstest]
234    fn test_handle_bar(mut indicator_lr_10: LinearRegression, bar_ethusdt_binance_minute_bid: Bar) {
235        indicator_lr_10.handle_bar(&bar_ethusdt_binance_minute_bid);
236        assert_eq!(indicator_lr_10.value, 0.0);
237        assert!(indicator_lr_10.has_inputs);
238        assert!(!indicator_lr_10.initialized);
239    }
240
241    #[rstest]
242    fn test_reset(mut indicator_lr_10: LinearRegression) {
243        indicator_lr_10.update_raw(1.0);
244        indicator_lr_10.reset();
245        assert_eq!(indicator_lr_10.value, 0.0);
246        assert_eq!(indicator_lr_10.period, 10);
247        assert_eq!(indicator_lr_10.slope, 0.0);
248        assert_eq!(indicator_lr_10.intercept, 0.0);
249        assert_eq!(indicator_lr_10.degree, 0.0);
250        assert_eq!(indicator_lr_10.cfo, 0.0);
251        assert_eq!(indicator_lr_10.r2, 0.0);
252        assert!(!indicator_lr_10.has_inputs);
253        assert!(!indicator_lr_10.initialized);
254    }
255
256    #[rstest]
257    fn test_fit_uses_latest_period() {
258        let mut lr = LinearRegression::new(5);
259        for i in 1..=100 {
260            lr.update_raw(f64::from(i));
261            if i >= 5 {
262                assert_eq!(lr.value, f64::from(i));
263                assert_eq!(lr.slope, 1.0);
264                assert_eq!(lr.intercept, f64::from(i - 4));
265            }
266        }
267    }
268
269    #[rstest]
270    fn test_oldest_element_evicted() {
271        let mut lr = LinearRegression::new(4);
272        for v in 1..=5 {
273            lr.update_raw(f64::from(v));
274        }
275        assert_eq!(lr.intercept, 2.0);
276        assert_eq!(lr.value, 5.0);
277    }
278
279    #[rstest]
280    fn test_recent_elements_preserved() {
281        let mut lr = LinearRegression::new(5);
282        for v in 0..5 {
283            lr.update_raw(f64::from(v));
284        }
285        lr.update_raw(99.0);
286        assert_eq!(lr.slope, 19.8);
287        assert_eq!(lr.intercept, -17.8);
288        assert_eq!(lr.value, 61.400000000000006);
289    }
290
291    #[rstest]
292    fn test_multiple_evictions() {
293        let mut lr = LinearRegression::new(2);
294        lr.update_raw(10.0);
295        lr.update_raw(20.0);
296        lr.update_raw(30.0);
297        lr.update_raw(40.0);
298        assert_eq!(lr.intercept, 30.0);
299        assert_eq!(lr.value, 40.0);
300    }
301
302    #[rstest]
303    fn test_value_stable_after_eviction() {
304        let mut lr = LinearRegression::new(3);
305        lr.update_raw(1.0);
306        lr.update_raw(2.0);
307        lr.update_raw(3.0);
308        let before = lr.value;
309        lr.update_raw(4.0);
310        let after = lr.value;
311        assert!(after.is_finite());
312        assert_ne!(before, after);
313    }
314
315    #[rstest]
316    fn test_value_with_ten_inputs(mut indicator_lr_10: LinearRegression) {
317        indicator_lr_10.update_raw(1.00000);
318        indicator_lr_10.update_raw(1.00010);
319        indicator_lr_10.update_raw(1.00030);
320        indicator_lr_10.update_raw(1.00040);
321        indicator_lr_10.update_raw(1.00050);
322        indicator_lr_10.update_raw(1.00060);
323        indicator_lr_10.update_raw(1.00050);
324        indicator_lr_10.update_raw(1.00040);
325        indicator_lr_10.update_raw(1.00030);
326        indicator_lr_10.update_raw(1.00010);
327        indicator_lr_10.update_raw(1.00000);
328
329        assert!((indicator_lr_10.value - 1.000_232_727_272_727_6).abs() < 1e-12);
330    }
331
332    #[rstest]
333    fn r2_nan_for_constant_series() {
334        let mut lr = LinearRegression::new(5);
335        for _ in 0..5 {
336            lr.update_raw(42.0);
337        }
338        assert!(lr.initialized);
339        assert!(
340            lr.r2.is_nan(),
341            "R² should be NaN for a constant-value input series"
342        );
343    }
344
345    #[rstest]
346    fn r2_survives_tiny_spread_far_from_previous_offset() {
347        // The window [100, 100 + 2^-24] sits about 10 from the reference point the
348        // first reseed left, where `sum_y_sq - n * mean^2` cancels its variance
349        // unless the sums reseed onto the window.
350        let tiny = 2.0_f64.powi(-24);
351        let mut lr = LinearRegression::new(2);
352        for value in [110.0, 100.0, 100.0 + tiny] {
353            lr.update_raw(value);
354        }
355        assert_eq!(lr.slope, tiny);
356        assert_eq!(lr.intercept, 100.0);
357        assert_eq!(lr.value, 100.0 + tiny);
358        assert_eq!(lr.r2, 1.0);
359    }
360
361    #[rstest]
362    fn cfo_nan_when_last_price_zero() {
363        let mut lr = LinearRegression::new(3);
364        lr.update_raw(1.0);
365        lr.update_raw(2.0);
366        lr.update_raw(0.0);
367        assert!(lr.initialized);
368        assert!(
369            lr.cfo.is_nan(),
370            "CFO should be NaN when the most-recent price equals zero"
371        );
372    }
373
374    #[rstest]
375    fn positive_slope_and_degree_for_uptrend() {
376        let mut lr = LinearRegression::new(4);
377        for v in 1..=4 {
378            lr.update_raw(f64::from(v));
379        }
380        assert!(lr.slope > 0.0, "slope expected positive for up-trend");
381        assert!(lr.degree > 0.0, "degree expected positive for up-trend");
382    }
383
384    #[rstest]
385    fn negative_slope_and_degree_for_downtrend() {
386        let mut lr = LinearRegression::new(4);
387        for v in (1..=4).rev() {
388            lr.update_raw(f64::from(v));
389        }
390        assert!(lr.slope < 0.0, "slope expected negative for down-trend");
391        assert!(lr.degree < 0.0, "degree expected negative for down-trend");
392    }
393
394    #[rstest]
395    fn not_initialized_until_enough_samples() {
396        let mut lr = LinearRegression::new(6);
397        for v in 0..5 {
398            lr.update_raw(f64::from(v));
399        }
400        assert!(
401            !lr.initialized,
402            "indicator should remain uninitialised with fewer than `period` inputs"
403        );
404    }
405
406    #[rstest]
407    #[case(128)]
408    #[case(1_024)]
409    #[case(16_384)]
410    fn large_period_initialization_and_window_size(#[case] period: usize) {
411        let mut lr = LinearRegression::new(period);
412        for v in 0..period {
413            lr.update_raw(v as f64);
414        }
415        assert!(
416            lr.initialized,
417            "indicator should initialize after exactly `period` samples"
418        );
419        assert_eq!(lr.period, period);
420    }
421
422    #[rstest]
423    fn checked_period_bounds() {
424        assert!(LinearRegression::new_checked(0).is_err());
425        assert!(LinearRegression::new_checked(1).is_err());
426        assert!(LinearRegression::new_checked(MAX_PERIOD + 1).is_err());
427        assert!(LinearRegression::new_checked(2).is_ok());
428    }
429
430    #[rstest]
431    fn period_and_fit_after_updates() {
432        let mut lr = LinearRegression::new(5);
433        for i in 0..20 {
434            lr.update_raw(f64::from(i));
435        }
436        assert_eq!(lr.period, 5);
437        assert_eq!(lr.slope, 1.0);
438        assert_eq!(lr.intercept, 15.0);
439        assert_eq!(lr.value, 19.0);
440    }
441
442    #[rstest]
443    fn period_and_fit_after_reset() {
444        let mut lr = LinearRegression::new(8);
445        for i in 0..20 {
446            lr.update_raw(f64::from(i));
447        }
448        lr.reset();
449        for i in 0..8 {
450            lr.update_raw(f64::from(i) * 2.0);
451        }
452        assert_eq!(lr.period, 8);
453        assert_eq!(lr.slope, 2.0);
454        assert_eq!(lr.intercept, 0.0);
455        assert_eq!(lr.value, 14.0);
456    }
457
458    const EPS: f64 = 1e-12;
459
460    #[rstest]
461    #[should_panic]
462    fn new_zero_period_panics() {
463        let _ = LinearRegression::new(0);
464    }
465
466    #[rstest]
467    #[should_panic]
468    fn new_period_exceeds_max_panics() {
469        let _ = LinearRegression::new(MAX_PERIOD + 1);
470    }
471
472    #[rstest(
473        period, value,
474        case(8, 5.0),
475        case(16, -std::f64::consts::PI)
476    )]
477    fn constant_non_zero_series(period: usize, value: f64) {
478        let mut lr = LinearRegression::new(period);
479
480        for _ in 0..period {
481            lr.update_raw(value);
482        }
483
484        assert!(lr.initialized());
485        assert!(lr.slope.abs() < EPS);
486        assert!((lr.intercept - value).abs() < EPS);
487        assert!(lr.degree.abs() < EPS);
488        assert!(lr.r2.is_nan());
489        assert!((lr.cfo).abs() < EPS);
490        assert!((lr.value - value).abs() < EPS);
491    }
492
493    #[rstest(period, case(4), case(32))]
494    fn constant_zero_series_cfo_nan(period: usize) {
495        let mut lr = LinearRegression::new(period);
496
497        for _ in 0..period {
498            lr.update_raw(0.0);
499        }
500
501        assert!(lr.initialized());
502        assert!(lr.cfo.is_nan());
503    }
504
505    #[rstest(period, case(6), case(13))]
506    fn reset_clears_state_but_keeps_period(period: usize) {
507        let mut lr = LinearRegression::new(period);
508
509        for i in 1..=period {
510            lr.update_raw(i as f64);
511        }
512
513        lr.reset();
514
515        assert!(!lr.initialized());
516        assert!(!lr.has_inputs());
517
518        assert!(lr.slope.abs() < EPS);
519        assert!(lr.intercept.abs() < EPS);
520        assert!(lr.degree.abs() < EPS);
521        assert!(lr.cfo.abs() < EPS);
522        assert!(lr.r2.abs() < EPS);
523        assert!(lr.value.abs() < EPS);
524
525        assert_eq!(lr.period, period);
526    }
527
528    #[rstest(period, case(5), case(31))]
529    fn perfect_linear_series(period: usize) {
530        const A: f64 = 2.0;
531        const B: f64 = -3.0;
532        let mut lr = LinearRegression::new(period);
533
534        for x in 1..=period {
535            lr.update_raw(A.mul_add(x as f64, B));
536        }
537
538        assert!(lr.initialized());
539        assert!((lr.slope - A).abs() < EPS);
540        assert!((lr.intercept - (A + B)).abs() < EPS);
541        assert!((lr.r2 - 1.0).abs() < EPS);
542        assert!((lr.degree.to_radians().tan() - A).abs() < EPS);
543    }
544
545    #[rstest]
546    fn sliding_window_keeps_last_period() {
547        const P: usize = 4;
548        let mut lr = LinearRegression::new(P);
549        for i in 1..=P {
550            lr.update_raw(i as f64);
551        }
552        let slope_first_window = lr.slope;
553
554        lr.update_raw(-100.0);
555        assert!(lr.slope < slope_first_window);
556        assert_eq!(lr.intercept, 23.0);
557        assert_eq!(lr.slope, -30.5);
558        assert_eq!(lr.value, -68.5);
559    }
560
561    #[rstest]
562    fn r2_between_zero_and_one() {
563        const P: usize = 32;
564        let mut lr = LinearRegression::new(P);
565        for x in 1..=P {
566            let noise = if x.is_multiple_of(2) { 0.5 } else { -0.5 };
567            lr.update_raw(3.0f64.mul_add(x as f64, noise));
568        }
569        assert!(lr.r2 > 0.0 && lr.r2 < 1.0);
570    }
571
572    #[rstest]
573    fn reset_before_initialized() {
574        let mut lr = LinearRegression::new(10);
575        lr.update_raw(1.0);
576        lr.reset();
577
578        assert!(!lr.initialized());
579        assert!(!lr.has_inputs());
580        assert_eq!(lr.value, 0.0);
581    }
582    #[rstest]
583    #[case(0.0)]
584    #[case(1e12)]
585    fn shifted_fit_matches_centered_oracle_after_eviction_and_reset(#[case] offset: f64) {
586        let mut lr = LinearRegression::new(3);
587        for _ in 0..2 {
588            lr.reset();
589            let values: Vec<f64> = (0..400).map(|i| offset + f64::from((i * 7) % 13)).collect();
590            for (i, &value) in values.iter().enumerate() {
591                lr.update_raw(value);
592                assert_eq!(lr.initialized, i >= 2);
593                if i < 2 {
594                    continue;
595                }
596                let window = &values[i - 2..=i];
597                let deviations = [window[0] - offset, window[1] - offset, window[2] - offset];
598                let mean = deviations.iter().sum::<f64>() / 3.0;
599                let slope = (deviations[2] - deviations[0]) / 2.0;
600                let intercept = offset + (mean - slope);
601                let endpoint = intercept + 2.0 * slope;
602                let sst = deviations
603                    .iter()
604                    .map(|value| (value - mean).powi(2))
605                    .sum::<f64>();
606                let r2 = 2.0 * slope * slope / sst;
607                assert!((lr.slope - slope).abs() < 1e-12);
608                assert!((lr.intercept - intercept).abs() < 1e-12);
609                assert!((lr.value - endpoint).abs() < 1e-12);
610                assert!((lr.degree - slope.atan().to_degrees()).abs() < 1e-12);
611                if value == 0.0 {
612                    assert!(lr.cfo.is_nan());
613                } else {
614                    assert!((lr.cfo - 100.0 * (endpoint - value) / value).abs() < 1e-12);
615                }
616                assert!((lr.r2 - r2).abs() < 1e-12);
617            }
618        }
619    }
620}