nautilus_indicators/average/
lr.rs1use 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#[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 #[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 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 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 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}