Skip to content

Commit 7cc4600

Browse files
authored
Change VecDeque for fixed-capacity ArrayDeque in LinearRegression (#2667)
1 parent 1b9cd70 commit 7cc4600

File tree

1 file changed

+153
-20
lines changed
  • crates/indicators/src/average

1 file changed

+153
-20
lines changed

crates/indicators/src/average/lr.rs

Lines changed: 153 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// https://nautechsystems.io
44
//
55
// 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.
6+
// You may not use this file except in compliance with the License.
77
// You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
88
//
99
// Unless required by applicable law or agreed to in writing, software
@@ -13,15 +13,15 @@
1313
// limitations under the License.
1414
// -------------------------------------------------------------------------------------------------
1515

16-
use std::{
17-
collections::VecDeque,
18-
fmt::{Debug, Display},
19-
};
16+
use std::fmt::{Debug, Display};
2017

18+
use arraydeque::{ArrayDeque, Wrapping};
2119
use nautilus_model::data::Bar;
2220

2321
use crate::indicator::Indicator;
2422

23+
const MAX_PERIOD: usize = 16_384;
24+
2525
#[repr(C)]
2626
#[derive(Debug)]
2727
#[cfg_attr(
@@ -38,7 +38,7 @@ pub struct LinearRegression {
3838
pub value: f64,
3939
pub initialized: bool,
4040
has_inputs: bool,
41-
inputs: VecDeque<f64>,
41+
inputs: ArrayDeque<f64, MAX_PERIOD, Wrapping>,
4242
x_sum: f64,
4343
x_mul_sum: f64,
4444
divisor: f64,
@@ -54,9 +54,11 @@ impl Indicator for LinearRegression {
5454
fn name(&self) -> String {
5555
stringify!(LinearRegression).into()
5656
}
57+
5758
fn has_inputs(&self) -> bool {
5859
self.has_inputs
5960
}
61+
6062
fn initialized(&self) -> bool {
6163
self.initialized
6264
}
@@ -83,13 +85,18 @@ impl LinearRegression {
8385
///
8486
/// # Panics
8587
///
86-
/// Panics if `period` is not positive (> 0).
88+
/// * Panics if `period` is zero.
89+
/// * Panics if `period` exceeds [`MAX_PERIOD`].
8790
#[must_use]
8891
pub fn new(period: usize) -> Self {
8992
assert!(
9093
period > 0,
9194
"LinearRegression: period must be > 0 (received {period})"
9295
);
96+
assert!(
97+
period <= MAX_PERIOD,
98+
"LinearRegression: period {period} exceeds MAX_PERIOD ({MAX_PERIOD})"
99+
);
93100

94101
let n = period as f64;
95102
let x_sum = 0.5 * n * (n + 1.0);
@@ -104,9 +111,9 @@ impl LinearRegression {
104111
cfo: 0.0,
105112
r2: 0.0,
106113
value: 0.0,
107-
inputs: VecDeque::with_capacity(period),
108-
has_inputs: false,
109114
initialized: false,
115+
has_inputs: false,
116+
inputs: ArrayDeque::new(),
110117
x_sum,
111118
x_mul_sum,
112119
divisor,
@@ -116,13 +123,13 @@ impl LinearRegression {
116123
/// Updates the linear regression with a new data point.
117124
///
118125
/// # Panics
119-
///
120-
/// Panics if there is insufficient data to compute the regression (empty history).
126+
/// Panics if called with an empty window – this is protected against by the logic
127+
/// that returns early until enough samples have been collected.
121128
pub fn update_raw(&mut self, close: f64) {
122129
if self.inputs.len() == self.period {
123-
self.inputs.pop_front();
130+
let _ = self.inputs.pop_front();
124131
}
125-
self.inputs.push_back(close);
132+
let _ = self.inputs.push_back(close);
126133

127134
self.has_inputs = true;
128135
if self.inputs.len() < self.period {
@@ -135,9 +142,7 @@ impl LinearRegression {
135142
let x_mul_sum = self.x_mul_sum;
136143
let divisor = self.divisor;
137144

138-
let mut y_sum = 0.0;
139-
let mut xy_sum = 0.0;
140-
145+
let (mut y_sum, mut xy_sum) = (0.0, 0.0);
141146
for (i, &y) in self.inputs.iter().enumerate() {
142147
let x = (i + 1) as f64;
143148
y_sum += y;
@@ -147,10 +152,7 @@ impl LinearRegression {
147152
self.slope = n.mul_add(xy_sum, -(x_sum * y_sum)) / divisor;
148153
self.intercept = y_sum.mul_add(x_mul_sum, -(x_sum * xy_sum)) / divisor;
149154

150-
let mut sse = 0.0;
151-
let mut y_last = 0.0;
152-
let mut e_last = 0.0;
153-
155+
let (mut sse, mut y_last, mut e_last) = (0.0, 0.0, 0.0);
154156
for (i, &y) in self.inputs.iter().enumerate() {
155157
let x = (i + 1) as f64;
156158
let y_hat = self.slope.mul_add(x, self.intercept);
@@ -194,6 +196,7 @@ mod tests {
194196
use nautilus_model::data::Bar;
195197
use rstest::rstest;
196198

199+
use super::*;
197200
use crate::{
198201
average::lr::LinearRegression,
199202
indicator::Indicator,
@@ -470,4 +473,134 @@ mod tests {
470473
assert_eq!(lr.x_mul_sum, x_mul_sum, "x_mul_sum must survive reset()");
471474
assert_eq!(lr.divisor, divisor, "divisor must survive reset()");
472475
}
476+
477+
const EPS: f64 = 1e-12;
478+
479+
#[rstest]
480+
#[should_panic]
481+
fn new_zero_period_panics() {
482+
let _ = LinearRegression::new(0);
483+
}
484+
485+
#[rstest]
486+
#[should_panic]
487+
fn new_period_exceeds_max_panics() {
488+
let _ = LinearRegression::new(MAX_PERIOD + 1);
489+
}
490+
491+
#[rstest(
492+
period, value,
493+
case(8, 5.0),
494+
case(16, -3.1415)
495+
)]
496+
fn constant_non_zero_series(period: usize, value: f64) {
497+
let mut lr = LinearRegression::new(period);
498+
499+
for _ in 0..period {
500+
lr.update_raw(value);
501+
}
502+
503+
assert!(lr.initialized());
504+
assert!(lr.slope.abs() < EPS);
505+
assert!((lr.intercept - value).abs() < EPS);
506+
assert_eq!(lr.degree, 0.0);
507+
assert!(lr.r2.is_nan());
508+
assert!((lr.cfo).abs() < EPS);
509+
assert!((lr.value - value).abs() < EPS);
510+
}
511+
512+
#[rstest(period, case(4), case(32))]
513+
fn constant_zero_series_cfo_nan(period: usize) {
514+
let mut lr = LinearRegression::new(period);
515+
516+
for _ in 0..period {
517+
lr.update_raw(0.0);
518+
}
519+
520+
assert!(lr.initialized());
521+
assert!(lr.cfo.is_nan());
522+
}
523+
524+
#[rstest(period, case(6), case(13))]
525+
fn reset_clears_state_but_keeps_constants(period: usize) {
526+
let mut lr = LinearRegression::new(period);
527+
528+
for i in 1..=period {
529+
lr.update_raw(i as f64);
530+
}
531+
532+
let x_sum_before = lr.x_sum;
533+
let x_mul_sum_before = lr.x_mul_sum;
534+
let divisor_before = lr.divisor;
535+
536+
lr.reset();
537+
538+
assert!(!lr.initialized());
539+
assert!(!lr.has_inputs());
540+
541+
assert!(lr.slope.abs() < EPS);
542+
assert!(lr.intercept.abs() < EPS);
543+
assert!(lr.degree.abs() < EPS);
544+
assert!(lr.cfo.abs() < EPS);
545+
assert!(lr.r2.abs() < EPS);
546+
assert!(lr.value.abs() < EPS);
547+
548+
assert_eq!(lr.x_sum, x_sum_before);
549+
assert_eq!(lr.x_mul_sum, x_mul_sum_before);
550+
assert_eq!(lr.divisor, divisor_before);
551+
}
552+
553+
#[rstest(period, case(5), case(31))]
554+
fn perfect_linear_series(period: usize) {
555+
const A: f64 = 2.0;
556+
const B: f64 = -3.0;
557+
let mut lr = LinearRegression::new(period);
558+
559+
for x in 1..=period {
560+
lr.update_raw(A * x as f64 + B);
561+
}
562+
563+
assert!(lr.initialized());
564+
assert!((lr.slope - A).abs() < EPS);
565+
assert!((lr.intercept - B).abs() < EPS);
566+
assert!((lr.r2 - 1.0).abs() < EPS);
567+
assert!((lr.degree.to_radians().tan() - A).abs() < EPS);
568+
}
569+
570+
#[rstest]
571+
fn sliding_window_keeps_last_period() {
572+
const P: usize = 4;
573+
let mut lr = LinearRegression::new(P);
574+
for i in 1..=P {
575+
lr.update_raw(i as f64);
576+
}
577+
let slope_first_window = lr.slope;
578+
579+
lr.update_raw(-100.0);
580+
assert!(lr.slope < slope_first_window);
581+
assert_eq!(lr.inputs.len(), P);
582+
assert_eq!(lr.inputs.front(), Some(&2.0));
583+
}
584+
585+
#[rstest]
586+
fn r2_between_zero_and_one() {
587+
const P: usize = 32;
588+
let mut lr = LinearRegression::new(P);
589+
for x in 1..=P {
590+
let noise = if x % 2 == 0 { 0.5 } else { -0.5 };
591+
lr.update_raw(3.0 * x as f64 + noise);
592+
}
593+
assert!(lr.r2 > 0.0 && lr.r2 < 1.0);
594+
}
595+
596+
#[rstest]
597+
fn reset_before_initialized() {
598+
let mut lr = LinearRegression::new(10);
599+
lr.update_raw(1.0);
600+
lr.reset();
601+
602+
assert!(!lr.initialized());
603+
assert!(!lr.has_inputs());
604+
assert_eq!(lr.inputs.len(), 0);
605+
}
473606
}

0 commit comments

Comments
 (0)