1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7#[cfg(feature = "python")]
8use pyo3::types::PyDict;
9
10#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
11use serde::{Deserialize, Serialize};
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use wasm_bindgen::prelude::*;
14
15use crate::indicators::moving_averages::linreg::{
16 linreg_with_kernel, LinRegInput, LinRegParams, LinRegStream,
17};
18use crate::utilities::data_loader::{source_type, Candles};
19use crate::utilities::enums::Kernel;
20use crate::utilities::helpers::{
21 alloc_with_nan_prefix, detect_best_batch_kernel, init_matrix_prefixes, make_uninit_matrix,
22};
23#[cfg(feature = "python")]
24use crate::utilities::kernel_validation::validate_kernel;
25
26#[cfg(not(target_arch = "wasm32"))]
27use rayon::prelude::*;
28use std::collections::VecDeque;
29use std::convert::AsRef;
30use std::mem::{ManuallyDrop, MaybeUninit};
31use thiserror::Error;
32
33impl<'a> AsRef<[f64]> for LinearRegressionIntensityInput<'a> {
34 #[inline(always)]
35 fn as_ref(&self) -> &[f64] {
36 match &self.data {
37 LinearRegressionIntensityData::Slice(slice) => slice,
38 LinearRegressionIntensityData::Candles { candles, source } => {
39 source_type(candles, source)
40 }
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
46pub enum LinearRegressionIntensityData<'a> {
47 Candles {
48 candles: &'a Candles,
49 source: &'a str,
50 },
51 Slice(&'a [f64]),
52}
53
54#[derive(Debug, Clone)]
55pub struct LinearRegressionIntensityOutput {
56 pub values: Vec<f64>,
57}
58
59#[derive(Debug, Clone)]
60#[cfg_attr(
61 all(target_arch = "wasm32", feature = "wasm"),
62 derive(Serialize, Deserialize)
63)]
64pub struct LinearRegressionIntensityParams {
65 pub lookback_period: Option<usize>,
66 pub range_tolerance: Option<f64>,
67 pub linreg_length: Option<usize>,
68}
69
70impl Default for LinearRegressionIntensityParams {
71 fn default() -> Self {
72 Self {
73 lookback_period: Some(12),
74 range_tolerance: Some(90.0),
75 linreg_length: Some(90),
76 }
77 }
78}
79
80#[derive(Debug, Clone)]
81pub struct LinearRegressionIntensityInput<'a> {
82 pub data: LinearRegressionIntensityData<'a>,
83 pub params: LinearRegressionIntensityParams,
84}
85
86impl<'a> LinearRegressionIntensityInput<'a> {
87 #[inline]
88 pub fn from_candles(
89 candles: &'a Candles,
90 source: &'a str,
91 params: LinearRegressionIntensityParams,
92 ) -> Self {
93 Self {
94 data: LinearRegressionIntensityData::Candles { candles, source },
95 params,
96 }
97 }
98
99 #[inline]
100 pub fn from_slice(slice: &'a [f64], params: LinearRegressionIntensityParams) -> Self {
101 Self {
102 data: LinearRegressionIntensityData::Slice(slice),
103 params,
104 }
105 }
106
107 #[inline]
108 pub fn with_default_candles(candles: &'a Candles) -> Self {
109 Self::from_candles(candles, "close", LinearRegressionIntensityParams::default())
110 }
111
112 #[inline]
113 pub fn get_lookback_period(&self) -> usize {
114 self.params.lookback_period.unwrap_or(12)
115 }
116
117 #[inline]
118 pub fn get_range_tolerance(&self) -> f64 {
119 self.params.range_tolerance.unwrap_or(90.0)
120 }
121
122 #[inline]
123 pub fn get_linreg_length(&self) -> usize {
124 self.params.linreg_length.unwrap_or(90)
125 }
126}
127
128#[derive(Copy, Clone, Debug)]
129pub struct LinearRegressionIntensityBuilder {
130 lookback_period: Option<usize>,
131 range_tolerance: Option<f64>,
132 linreg_length: Option<usize>,
133 kernel: Kernel,
134}
135
136impl Default for LinearRegressionIntensityBuilder {
137 fn default() -> Self {
138 Self {
139 lookback_period: None,
140 range_tolerance: None,
141 linreg_length: None,
142 kernel: Kernel::Auto,
143 }
144 }
145}
146
147impl LinearRegressionIntensityBuilder {
148 #[inline(always)]
149 pub fn new() -> Self {
150 Self::default()
151 }
152
153 #[inline(always)]
154 pub fn lookback_period(mut self, value: usize) -> Self {
155 self.lookback_period = Some(value);
156 self
157 }
158
159 #[inline(always)]
160 pub fn range_tolerance(mut self, value: f64) -> Self {
161 self.range_tolerance = Some(value);
162 self
163 }
164
165 #[inline(always)]
166 pub fn linreg_length(mut self, value: usize) -> Self {
167 self.linreg_length = Some(value);
168 self
169 }
170
171 #[inline(always)]
172 pub fn kernel(mut self, value: Kernel) -> Self {
173 self.kernel = value;
174 self
175 }
176
177 #[inline(always)]
178 pub fn apply(
179 self,
180 candles: &Candles,
181 ) -> Result<LinearRegressionIntensityOutput, LinearRegressionIntensityError> {
182 self.apply_source(candles, "close")
183 }
184
185 #[inline(always)]
186 pub fn apply_source(
187 self,
188 candles: &Candles,
189 source: &str,
190 ) -> Result<LinearRegressionIntensityOutput, LinearRegressionIntensityError> {
191 let input = LinearRegressionIntensityInput::from_candles(
192 candles,
193 source,
194 LinearRegressionIntensityParams {
195 lookback_period: self.lookback_period,
196 range_tolerance: self.range_tolerance,
197 linreg_length: self.linreg_length,
198 },
199 );
200 linear_regression_intensity_with_kernel(&input, self.kernel)
201 }
202
203 #[inline(always)]
204 pub fn apply_slice(
205 self,
206 data: &[f64],
207 ) -> Result<LinearRegressionIntensityOutput, LinearRegressionIntensityError> {
208 let input = LinearRegressionIntensityInput::from_slice(
209 data,
210 LinearRegressionIntensityParams {
211 lookback_period: self.lookback_period,
212 range_tolerance: self.range_tolerance,
213 linreg_length: self.linreg_length,
214 },
215 );
216 linear_regression_intensity_with_kernel(&input, self.kernel)
217 }
218
219 #[inline(always)]
220 pub fn into_stream(
221 self,
222 ) -> Result<LinearRegressionIntensityStream, LinearRegressionIntensityError> {
223 LinearRegressionIntensityStream::try_new(LinearRegressionIntensityParams {
224 lookback_period: self.lookback_period,
225 range_tolerance: self.range_tolerance,
226 linreg_length: self.linreg_length,
227 })
228 }
229}
230
231#[derive(Debug, Error)]
232pub enum LinearRegressionIntensityError {
233 #[error("linear_regression_intensity: Input data slice is empty.")]
234 EmptyInputData,
235 #[error("linear_regression_intensity: All values are NaN.")]
236 AllValuesNaN,
237 #[error(
238 "linear_regression_intensity: Invalid lookback period: lookback_period = {lookback_period}, data length = {data_len}"
239 )]
240 InvalidLookbackPeriod {
241 lookback_period: usize,
242 data_len: usize,
243 },
244 #[error(
245 "linear_regression_intensity: Invalid range tolerance: range_tolerance = {range_tolerance}"
246 )]
247 InvalidRangeTolerance { range_tolerance: f64 },
248 #[error(
249 "linear_regression_intensity: Invalid linreg length: linreg_length = {linreg_length}, data length = {data_len}"
250 )]
251 InvalidLinregLength {
252 linreg_length: usize,
253 data_len: usize,
254 },
255 #[error(
256 "linear_regression_intensity: Not enough valid data: needed = {needed}, valid = {valid}"
257 )]
258 NotEnoughValidData { needed: usize, valid: usize },
259 #[error(
260 "linear_regression_intensity: Output length mismatch: expected = {expected}, got = {got}"
261 )]
262 OutputLengthMismatch { expected: usize, got: usize },
263 #[error("linear_regression_intensity: Invalid range: start={start}, end={end}, step={step}")]
264 InvalidRangeUsize {
265 start: usize,
266 end: usize,
267 step: usize,
268 },
269 #[error("linear_regression_intensity: Invalid range: start={start}, end={end}, step={step}")]
270 InvalidRangeF64 { start: f64, end: f64, step: f64 },
271 #[error("linear_regression_intensity: Invalid kernel for batch: {0:?}")]
272 InvalidKernelForBatch(Kernel),
273}
274
275#[derive(Copy, Clone, Debug)]
276struct ResolvedParams {
277 lookback_period: usize,
278 range_tolerance: f64,
279 linreg_length: usize,
280}
281
282#[inline(always)]
283fn first_valid_index(data: &[f64]) -> Option<usize> {
284 data.iter().position(|x| x.is_finite())
285}
286
287#[inline(always)]
288fn total_combinations(lookback_period: usize) -> usize {
289 lookback_period.saturating_mul(lookback_period.saturating_sub(1)) / 2
290}
291
292#[inline(always)]
293fn trend_to_intensity(trend: i64, total_combinations: usize) -> f64 {
294 if total_combinations == 0 {
295 0.0
296 } else {
297 trend as f64 / total_combinations as f64
298 }
299}
300
301#[inline(always)]
302fn warmup_prefix(first: usize, params: ResolvedParams) -> usize {
303 first
304 .saturating_add(params.linreg_length)
305 .saturating_add(params.lookback_period)
306 .saturating_sub(2)
307}
308
309#[inline]
310fn linear_regression_intensity_prepare<'a>(
311 input: &'a LinearRegressionIntensityInput,
312) -> Result<(&'a [f64], usize, ResolvedParams), LinearRegressionIntensityError> {
313 let data = input.as_ref();
314 let data_len = data.len();
315 if data_len == 0 {
316 return Err(LinearRegressionIntensityError::EmptyInputData);
317 }
318
319 let first = first_valid_index(data).ok_or(LinearRegressionIntensityError::AllValuesNaN)?;
320 let valid = data_len - first;
321 let lookback_period = input.get_lookback_period();
322 let range_tolerance = input.get_range_tolerance();
323 let linreg_length = input.get_linreg_length();
324
325 if lookback_period == 0 || lookback_period > data_len {
326 return Err(LinearRegressionIntensityError::InvalidLookbackPeriod {
327 lookback_period,
328 data_len,
329 });
330 }
331 if !range_tolerance.is_finite() || !(0.0..=100.0).contains(&range_tolerance) {
332 return Err(LinearRegressionIntensityError::InvalidRangeTolerance { range_tolerance });
333 }
334 if linreg_length == 0 || linreg_length > data_len {
335 return Err(LinearRegressionIntensityError::InvalidLinregLength {
336 linreg_length,
337 data_len,
338 });
339 }
340
341 let needed = linreg_length + lookback_period - 1;
342 if valid < needed {
343 return Err(LinearRegressionIntensityError::NotEnoughValidData { needed, valid });
344 }
345
346 Ok((
347 data,
348 first,
349 ResolvedParams {
350 lookback_period,
351 range_tolerance,
352 linreg_length,
353 },
354 ))
355}
356
357#[inline]
358fn naive_window_trend(window: &VecDeque<f64>) -> i64 {
359 let mut trend = 0i64;
360 let len = window.len();
361 for i in 0..len.saturating_sub(1) {
362 let a = window[i];
363 for j in (i + 1)..len {
364 let b = window[j];
365 if a != b {
366 trend += if b > a { 1 } else { -1 };
367 }
368 }
369 }
370 trend
371}
372
373#[inline]
374fn compute_fallback(data: &[f64], params: ResolvedParams, out: &mut [f64]) {
375 let total = total_combinations(params.lookback_period);
376 let mut linreg_stream = LinRegStream::try_new(LinRegParams {
377 period: Some(params.linreg_length),
378 })
379 .expect("validated linreg params");
380 let mut window = VecDeque::with_capacity(params.lookback_period);
381
382 for (index, &value) in data.iter().enumerate() {
383 if !value.is_finite() {
384 linreg_stream = LinRegStream::try_new(LinRegParams {
385 period: Some(params.linreg_length),
386 })
387 .expect("validated linreg params");
388 window.clear();
389 continue;
390 }
391 let Some(lr) = linreg_stream.update(value) else {
392 continue;
393 };
394 if !lr.is_finite() {
395 window.clear();
396 continue;
397 }
398 window.push_back(lr);
399 if window.len() > params.lookback_period {
400 window.pop_front();
401 }
402 if window.len() != params.lookback_period {
403 continue;
404 }
405
406 let trend = naive_window_trend(&window);
407 out[index] = trend_to_intensity(trend, total);
408 }
409}
410
411#[derive(Clone, Debug)]
412struct Fenwick {
413 tree: Vec<i32>,
414}
415
416impl Fenwick {
417 #[inline]
418 fn new(size: usize) -> Self {
419 Self {
420 tree: vec![0; size + 1],
421 }
422 }
423
424 #[inline]
425 fn add(&mut self, mut index: usize, delta: i32) {
426 while index < self.tree.len() {
427 self.tree[index] += delta;
428 index += index & (!index + 1);
429 }
430 }
431
432 #[inline]
433 fn prefix_sum(&self, mut index: usize) -> i32 {
434 let mut sum = 0;
435 while index > 0 {
436 sum += self.tree[index];
437 index &= index - 1;
438 }
439 sum
440 }
441}
442
443#[inline]
444fn rank_of(values: &[f64], value: f64) -> usize {
445 values
446 .binary_search_by(|probe| probe.total_cmp(&value))
447 .expect("rank present")
448 + 1
449}
450
451#[inline]
452fn compute_fast_from_linreg(linreg: &[f64], params: ResolvedParams, out: &mut [f64]) {
453 let Some(first_lr) = first_valid_index(linreg) else {
454 return;
455 };
456 let total = total_combinations(params.lookback_period);
457
458 if params.lookback_period == 1 {
459 for value in &mut out[first_lr..] {
460 *value = 0.0;
461 }
462 return;
463 }
464
465 let mut unique = linreg[first_lr..]
466 .iter()
467 .copied()
468 .filter(|value| value.is_finite())
469 .collect::<Vec<_>>();
470 unique.sort_by(|a, b| a.total_cmp(b));
471 unique.dedup();
472
473 let first_out = first_lr + params.lookback_period - 1;
474 let mut fenwick = Fenwick::new(unique.len());
475 let mut window = VecDeque::with_capacity(params.lookback_period);
476 let mut trend = 0i64;
477
478 for &value in &linreg[first_lr..=first_out] {
479 let rank = rank_of(&unique, value);
480 let current = window.len() as i64;
481 let less = fenwick.prefix_sum(rank - 1) as i64;
482 let greater = current - fenwick.prefix_sum(rank) as i64;
483 trend += less - greater;
484 fenwick.add(rank, 1);
485 window.push_back(rank);
486 }
487 out[first_out] = trend_to_intensity(trend, total);
488
489 for index in (first_out + 1)..linreg.len() {
490 let old_rank = window.pop_front().expect("window");
491 fenwick.add(old_rank, -1);
492 let remaining = (params.lookback_period - 1) as i64;
493 let less_old = fenwick.prefix_sum(old_rank - 1) as i64;
494 let greater_old = remaining - fenwick.prefix_sum(old_rank) as i64;
495 trend -= greater_old - less_old;
496
497 let value = linreg[index];
498 let rank = rank_of(&unique, value);
499 let less_new = fenwick.prefix_sum(rank - 1) as i64;
500 let greater_new = remaining - fenwick.prefix_sum(rank) as i64;
501 trend += less_new - greater_new;
502 fenwick.add(rank, 1);
503 window.push_back(rank);
504 out[index] = trend_to_intensity(trend, total);
505 }
506}
507
508#[inline]
509fn linear_regression_intensity_compute_into(
510 data: &[f64],
511 params: ResolvedParams,
512 kernel: Kernel,
513 out: &mut [f64],
514) -> Result<(), LinearRegressionIntensityError> {
515 let linreg = linreg_with_kernel(
516 &LinRegInput::from_slice(
517 data,
518 LinRegParams {
519 period: Some(params.linreg_length),
520 },
521 ),
522 kernel,
523 )
524 .map_err(|err| match err {
525 crate::indicators::moving_averages::linreg::LinRegError::EmptyInputData => {
526 LinearRegressionIntensityError::EmptyInputData
527 }
528 crate::indicators::moving_averages::linreg::LinRegError::AllValuesNaN => {
529 LinearRegressionIntensityError::AllValuesNaN
530 }
531 crate::indicators::moving_averages::linreg::LinRegError::InvalidPeriod {
532 period,
533 data_len,
534 } => LinearRegressionIntensityError::InvalidLinregLength {
535 linreg_length: period,
536 data_len,
537 },
538 crate::indicators::moving_averages::linreg::LinRegError::NotEnoughValidData {
539 needed,
540 valid,
541 } => LinearRegressionIntensityError::NotEnoughValidData { needed, valid },
542 _ => LinearRegressionIntensityError::AllValuesNaN,
543 })?
544 .values;
545
546 let Some(first_lr) = first_valid_index(&linreg) else {
547 return Ok(());
548 };
549 if linreg[first_lr..].iter().all(|value| value.is_finite()) {
550 compute_fast_from_linreg(&linreg, params, out);
551 } else {
552 compute_fallback(data, params, out);
553 }
554 Ok(())
555}
556
557#[inline]
558pub fn linear_regression_intensity(
559 input: &LinearRegressionIntensityInput,
560) -> Result<LinearRegressionIntensityOutput, LinearRegressionIntensityError> {
561 linear_regression_intensity_with_kernel(input, Kernel::Auto)
562}
563
564pub fn linear_regression_intensity_with_kernel(
565 input: &LinearRegressionIntensityInput,
566 kernel: Kernel,
567) -> Result<LinearRegressionIntensityOutput, LinearRegressionIntensityError> {
568 let (data, first, params) = linear_regression_intensity_prepare(input)?;
569 let mut out = alloc_with_nan_prefix(data.len(), warmup_prefix(first, params));
570 linear_regression_intensity_compute_into(data, params, kernel, &mut out)?;
571 Ok(LinearRegressionIntensityOutput { values: out })
572}
573
574#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
575#[inline]
576pub fn linear_regression_intensity_into(
577 input: &LinearRegressionIntensityInput,
578 out: &mut [f64],
579) -> Result<(), LinearRegressionIntensityError> {
580 linear_regression_intensity_into_slice(out, input, Kernel::Auto)
581}
582
583pub fn linear_regression_intensity_into_slice(
584 out: &mut [f64],
585 input: &LinearRegressionIntensityInput,
586 kernel: Kernel,
587) -> Result<(), LinearRegressionIntensityError> {
588 let (data, _first, params) = linear_regression_intensity_prepare(input)?;
589 if out.len() != data.len() {
590 return Err(LinearRegressionIntensityError::OutputLengthMismatch {
591 expected: data.len(),
592 got: out.len(),
593 });
594 }
595 out.fill(f64::NAN);
596 linear_regression_intensity_compute_into(data, params, kernel, out)
597}
598
599#[derive(Clone, Debug)]
600pub struct LinearRegressionIntensityStream {
601 lookback_period: usize,
602 linreg_length: usize,
603 linreg_stream: LinRegStream,
604 window: VecDeque<f64>,
605}
606
607impl LinearRegressionIntensityStream {
608 #[inline]
609 pub fn try_new(
610 params: LinearRegressionIntensityParams,
611 ) -> Result<Self, LinearRegressionIntensityError> {
612 let lookback_period = params.lookback_period.unwrap_or(12);
613 let range_tolerance = params.range_tolerance.unwrap_or(90.0);
614 let linreg_length = params.linreg_length.unwrap_or(90);
615
616 if lookback_period == 0 {
617 return Err(LinearRegressionIntensityError::InvalidLookbackPeriod {
618 lookback_period,
619 data_len: 0,
620 });
621 }
622 if !range_tolerance.is_finite() || !(0.0..=100.0).contains(&range_tolerance) {
623 return Err(LinearRegressionIntensityError::InvalidRangeTolerance { range_tolerance });
624 }
625 if linreg_length == 0 {
626 return Err(LinearRegressionIntensityError::InvalidLinregLength {
627 linreg_length,
628 data_len: 0,
629 });
630 }
631
632 Ok(Self {
633 lookback_period,
634 linreg_length,
635 linreg_stream: LinRegStream::try_new(LinRegParams {
636 period: Some(linreg_length),
637 })
638 .map_err(|_| LinearRegressionIntensityError::InvalidLinregLength {
639 linreg_length,
640 data_len: 0,
641 })?,
642 window: VecDeque::with_capacity(lookback_period),
643 })
644 }
645
646 #[inline(always)]
647 pub fn update(&mut self, value: f64) -> Option<f64> {
648 if !value.is_finite() {
649 self.linreg_stream = LinRegStream::try_new(LinRegParams {
650 period: Some(self.linreg_length),
651 })
652 .expect("validated linreg length");
653 self.window.clear();
654 return None;
655 }
656 let linreg_value = self.linreg_stream.update(value)?;
657 if !linreg_value.is_finite() {
658 self.window.clear();
659 return None;
660 }
661 self.window.push_back(linreg_value);
662 if self.window.len() > self.lookback_period {
663 self.window.pop_front();
664 }
665 if self.window.len() != self.lookback_period {
666 return None;
667 }
668 Some(trend_to_intensity(
669 naive_window_trend(&self.window),
670 total_combinations(self.lookback_period),
671 ))
672 }
673
674 #[inline(always)]
675 pub fn update_reset_on_nan(&mut self, value: f64) -> Option<f64> {
676 self.update(value)
677 }
678}
679
680#[derive(Clone, Debug)]
681pub struct LinearRegressionIntensityBatchRange {
682 pub lookback_period: (usize, usize, usize),
683 pub range_tolerance: (f64, f64, f64),
684 pub linreg_length: (usize, usize, usize),
685}
686
687impl Default for LinearRegressionIntensityBatchRange {
688 fn default() -> Self {
689 Self {
690 lookback_period: (12, 12, 0),
691 range_tolerance: (90.0, 90.0, 0.0),
692 linreg_length: (90, 90, 0),
693 }
694 }
695}
696
697#[derive(Clone, Debug, Default)]
698pub struct LinearRegressionIntensityBatchBuilder {
699 range: LinearRegressionIntensityBatchRange,
700 kernel: Kernel,
701}
702
703impl LinearRegressionIntensityBatchBuilder {
704 pub fn new() -> Self {
705 Self::default()
706 }
707
708 pub fn kernel(mut self, kernel: Kernel) -> Self {
709 self.kernel = kernel;
710 self
711 }
712
713 pub fn lookback_period_range(mut self, start: usize, end: usize, step: usize) -> Self {
714 self.range.lookback_period = (start, end, step);
715 self
716 }
717
718 pub fn range_tolerance_range(mut self, start: f64, end: f64, step: f64) -> Self {
719 self.range.range_tolerance = (start, end, step);
720 self
721 }
722
723 pub fn linreg_length_range(mut self, start: usize, end: usize, step: usize) -> Self {
724 self.range.linreg_length = (start, end, step);
725 self
726 }
727
728 pub fn apply_slice(
729 self,
730 data: &[f64],
731 ) -> Result<LinearRegressionIntensityBatchOutput, LinearRegressionIntensityError> {
732 linear_regression_intensity_batch_with_kernel(data, &self.range, self.kernel)
733 }
734
735 pub fn apply_candles(
736 self,
737 candles: &Candles,
738 source: &str,
739 ) -> Result<LinearRegressionIntensityBatchOutput, LinearRegressionIntensityError> {
740 self.apply_slice(source_type(candles, source))
741 }
742}
743
744#[derive(Clone, Debug)]
745pub struct LinearRegressionIntensityBatchOutput {
746 pub values: Vec<f64>,
747 pub combos: Vec<LinearRegressionIntensityParams>,
748 pub rows: usize,
749 pub cols: usize,
750}
751
752impl LinearRegressionIntensityBatchOutput {
753 pub fn row_for_params(&self, params: &LinearRegressionIntensityParams) -> Option<usize> {
754 self.combos.iter().position(|combo| {
755 combo.lookback_period.unwrap_or(12) == params.lookback_period.unwrap_or(12)
756 && (combo.range_tolerance.unwrap_or(90.0) - params.range_tolerance.unwrap_or(90.0))
757 .abs()
758 <= 1e-12
759 && combo.linreg_length.unwrap_or(90) == params.linreg_length.unwrap_or(90)
760 })
761 }
762
763 pub fn values_for(&self, params: &LinearRegressionIntensityParams) -> Option<&[f64]> {
764 self.row_for_params(params).map(|row| {
765 let start = row * self.cols;
766 &self.values[start..start + self.cols]
767 })
768 }
769}
770
771#[inline]
772fn axis_usize(range: (usize, usize, usize)) -> Result<Vec<usize>, LinearRegressionIntensityError> {
773 let (start, end, step) = range;
774 if start < 1 || end < 1 {
775 return Err(LinearRegressionIntensityError::InvalidRangeUsize { start, end, step });
776 }
777 if step == 0 || start == end {
778 return Ok(vec![start]);
779 }
780
781 let mut out = Vec::new();
782 if start < end {
783 let mut value = start;
784 while value <= end {
785 out.push(value);
786 match value.checked_add(step) {
787 Some(next) if next > value => value = next,
788 _ => break,
789 }
790 }
791 } else {
792 let mut value = start;
793 while value >= end {
794 out.push(value);
795 if value < end + step {
796 break;
797 }
798 value = value.saturating_sub(step);
799 if value == 0 {
800 break;
801 }
802 }
803 }
804
805 if out.is_empty() {
806 return Err(LinearRegressionIntensityError::InvalidRangeUsize { start, end, step });
807 }
808 Ok(out)
809}
810
811#[inline]
812fn axis_f64(range: (f64, f64, f64)) -> Result<Vec<f64>, LinearRegressionIntensityError> {
813 let (start, end, step) = range;
814 if !start.is_finite() || !end.is_finite() || !step.is_finite() || step < 0.0 {
815 return Err(LinearRegressionIntensityError::InvalidRangeF64 { start, end, step });
816 }
817 if step == 0.0 || (start - end).abs() <= 1e-12 {
818 return Ok(vec![start]);
819 }
820
821 let mut out = Vec::new();
822 if start < end {
823 let mut value = start;
824 while value <= end + 1e-12 {
825 out.push(value);
826 value += step;
827 }
828 } else {
829 let mut value = start;
830 while value >= end - 1e-12 {
831 out.push(value);
832 value -= step;
833 }
834 }
835
836 if out.is_empty() {
837 return Err(LinearRegressionIntensityError::InvalidRangeF64 { start, end, step });
838 }
839 Ok(out)
840}
841
842pub fn expand_grid_linear_regression_intensity(
843 sweep: &LinearRegressionIntensityBatchRange,
844) -> Result<Vec<LinearRegressionIntensityParams>, LinearRegressionIntensityError> {
845 let lookback_periods = axis_usize(sweep.lookback_period)?;
846 let range_tolerances = axis_f64(sweep.range_tolerance)?;
847 let linreg_lengths = axis_usize(sweep.linreg_length)?;
848
849 let mut out = Vec::new();
850 for lookback_period in &lookback_periods {
851 for range_tolerance in &range_tolerances {
852 for linreg_length in &linreg_lengths {
853 out.push(LinearRegressionIntensityParams {
854 lookback_period: Some(*lookback_period),
855 range_tolerance: Some(*range_tolerance),
856 linreg_length: Some(*linreg_length),
857 });
858 }
859 }
860 }
861 Ok(out)
862}
863
864pub fn linear_regression_intensity_batch_with_kernel(
865 data: &[f64],
866 sweep: &LinearRegressionIntensityBatchRange,
867 kernel: Kernel,
868) -> Result<LinearRegressionIntensityBatchOutput, LinearRegressionIntensityError> {
869 let batch_kernel = match kernel {
870 Kernel::Auto => Kernel::ScalarBatch,
871 other if other.is_batch() => other,
872 other => return Err(LinearRegressionIntensityError::InvalidKernelForBatch(other)),
873 };
874 linear_regression_intensity_batch_impl(data, sweep, batch_kernel.to_non_batch(), true)
875}
876
877pub fn linear_regression_intensity_batch_slice(
878 data: &[f64],
879 sweep: &LinearRegressionIntensityBatchRange,
880) -> Result<LinearRegressionIntensityBatchOutput, LinearRegressionIntensityError> {
881 linear_regression_intensity_batch_impl(data, sweep, Kernel::Scalar, false)
882}
883
884pub fn linear_regression_intensity_batch_par_slice(
885 data: &[f64],
886 sweep: &LinearRegressionIntensityBatchRange,
887) -> Result<LinearRegressionIntensityBatchOutput, LinearRegressionIntensityError> {
888 linear_regression_intensity_batch_impl(data, sweep, Kernel::Scalar, true)
889}
890
891fn linear_regression_intensity_batch_impl(
892 data: &[f64],
893 sweep: &LinearRegressionIntensityBatchRange,
894 kernel: Kernel,
895 parallel: bool,
896) -> Result<LinearRegressionIntensityBatchOutput, LinearRegressionIntensityError> {
897 let combos = expand_grid_linear_regression_intensity(sweep)?;
898 let rows = combos.len();
899 let cols = data.len();
900 if cols == 0 {
901 return Err(LinearRegressionIntensityError::EmptyInputData);
902 }
903
904 for combo in &combos {
905 let input = LinearRegressionIntensityInput::from_slice(data, combo.clone());
906 let _ = linear_regression_intensity_prepare(&input)?;
907 }
908
909 let first = first_valid_index(data).ok_or(LinearRegressionIntensityError::AllValuesNaN)?;
910 let warmups = combos
911 .iter()
912 .map(|params| {
913 warmup_prefix(
914 first,
915 ResolvedParams {
916 lookback_period: params.lookback_period.unwrap_or(12),
917 range_tolerance: params.range_tolerance.unwrap_or(90.0),
918 linreg_length: params.linreg_length.unwrap_or(90),
919 },
920 )
921 })
922 .collect::<Vec<_>>();
923
924 let mut matrix = make_uninit_matrix(rows, cols);
925 init_matrix_prefixes(&mut matrix, cols, &warmups);
926 let mut guard = ManuallyDrop::new(matrix);
927 let out_mu: &mut [MaybeUninit<f64>] =
928 unsafe { std::slice::from_raw_parts_mut(guard.as_mut_ptr(), guard.len()) };
929
930 let do_row = |row: usize, row_mu: &mut [MaybeUninit<f64>]| {
931 let params = ResolvedParams {
932 lookback_period: combos[row].lookback_period.unwrap_or(12),
933 range_tolerance: combos[row].range_tolerance.unwrap_or(90.0),
934 linreg_length: combos[row].linreg_length.unwrap_or(90),
935 };
936 let row_out = unsafe {
937 std::slice::from_raw_parts_mut(row_mu.as_mut_ptr() as *mut f64, row_mu.len())
938 };
939 linear_regression_intensity_compute_into(data, params, kernel, row_out)
940 .expect("batch row validated");
941 };
942
943 if parallel {
944 #[cfg(not(target_arch = "wasm32"))]
945 out_mu
946 .par_chunks_mut(cols)
947 .enumerate()
948 .for_each(|(row, row_mu)| do_row(row, row_mu));
949 #[cfg(target_arch = "wasm32")]
950 for (row, row_mu) in out_mu.chunks_mut(cols).enumerate() {
951 do_row(row, row_mu);
952 }
953 } else {
954 for (row, row_mu) in out_mu.chunks_mut(cols).enumerate() {
955 do_row(row, row_mu);
956 }
957 }
958
959 let values = unsafe {
960 Vec::from_raw_parts(
961 guard.as_mut_ptr() as *mut f64,
962 guard.len(),
963 guard.capacity(),
964 )
965 };
966
967 Ok(LinearRegressionIntensityBatchOutput {
968 values,
969 combos,
970 rows,
971 cols,
972 })
973}
974
975fn linear_regression_intensity_batch_inner_into(
976 data: &[f64],
977 sweep: &LinearRegressionIntensityBatchRange,
978 kernel: Kernel,
979 parallel: bool,
980 out: &mut [f64],
981) -> Result<(), LinearRegressionIntensityError> {
982 let combos = expand_grid_linear_regression_intensity(sweep)?;
983 let rows = combos.len();
984 let cols = data.len();
985 for combo in &combos {
986 let input = LinearRegressionIntensityInput::from_slice(data, combo.clone());
987 let _ = linear_regression_intensity_prepare(&input)?;
988 }
989 let expected =
990 rows.checked_mul(cols)
991 .ok_or(LinearRegressionIntensityError::OutputLengthMismatch {
992 expected: usize::MAX,
993 got: out.len(),
994 })?;
995 if expected != out.len() {
996 return Err(LinearRegressionIntensityError::OutputLengthMismatch {
997 expected,
998 got: out.len(),
999 });
1000 }
1001
1002 for row_out in out.chunks_mut(cols) {
1003 row_out.fill(f64::NAN);
1004 }
1005
1006 let do_row = |row: usize, row_out: &mut [f64]| {
1007 let params = ResolvedParams {
1008 lookback_period: combos[row].lookback_period.unwrap_or(12),
1009 range_tolerance: combos[row].range_tolerance.unwrap_or(90.0),
1010 linreg_length: combos[row].linreg_length.unwrap_or(90),
1011 };
1012 linear_regression_intensity_compute_into(data, params, kernel, row_out)
1013 .expect("batch row validated");
1014 };
1015
1016 if parallel {
1017 #[cfg(not(target_arch = "wasm32"))]
1018 out.par_chunks_mut(cols)
1019 .enumerate()
1020 .for_each(|(row, row_out)| do_row(row, row_out));
1021 #[cfg(target_arch = "wasm32")]
1022 for (row, row_out) in out.chunks_mut(cols).enumerate() {
1023 do_row(row, row_out);
1024 }
1025 } else {
1026 for (row, row_out) in out.chunks_mut(cols).enumerate() {
1027 do_row(row, row_out);
1028 }
1029 }
1030
1031 Ok(())
1032}
1033
1034#[cfg(feature = "python")]
1035#[pyfunction(name = "linear_regression_intensity")]
1036#[pyo3(signature = (data, lookback_period=12, range_tolerance=90.0, linreg_length=90, kernel=None))]
1037pub fn linear_regression_intensity_py<'py>(
1038 py: Python<'py>,
1039 data: PyReadonlyArray1<'py, f64>,
1040 lookback_period: usize,
1041 range_tolerance: f64,
1042 linreg_length: usize,
1043 kernel: Option<&str>,
1044) -> PyResult<Bound<'py, PyArray1<f64>>> {
1045 let data = data.as_slice()?;
1046 let kernel = validate_kernel(kernel, false)?;
1047 let input = LinearRegressionIntensityInput::from_slice(
1048 data,
1049 LinearRegressionIntensityParams {
1050 lookback_period: Some(lookback_period),
1051 range_tolerance: Some(range_tolerance),
1052 linreg_length: Some(linreg_length),
1053 },
1054 );
1055 let output = py
1056 .allow_threads(|| linear_regression_intensity_with_kernel(&input, kernel))
1057 .map_err(|e| PyValueError::new_err(e.to_string()))?;
1058 Ok(output.values.into_pyarray(py))
1059}
1060
1061#[cfg(feature = "python")]
1062#[pyclass(name = "LinearRegressionIntensityStream")]
1063pub struct LinearRegressionIntensityStreamPy {
1064 stream: LinearRegressionIntensityStream,
1065}
1066
1067#[cfg(feature = "python")]
1068#[pymethods]
1069impl LinearRegressionIntensityStreamPy {
1070 #[new]
1071 #[pyo3(signature = (lookback_period=12, range_tolerance=90.0, linreg_length=90))]
1072 fn new(lookback_period: usize, range_tolerance: f64, linreg_length: usize) -> PyResult<Self> {
1073 let stream = LinearRegressionIntensityStream::try_new(LinearRegressionIntensityParams {
1074 lookback_period: Some(lookback_period),
1075 range_tolerance: Some(range_tolerance),
1076 linreg_length: Some(linreg_length),
1077 })
1078 .map_err(|e| PyValueError::new_err(e.to_string()))?;
1079 Ok(Self { stream })
1080 }
1081
1082 fn update(&mut self, value: f64) -> Option<f64> {
1083 self.stream.update_reset_on_nan(value)
1084 }
1085}
1086
1087#[cfg(feature = "python")]
1088#[pyfunction(name = "linear_regression_intensity_batch")]
1089#[pyo3(signature = (data, lookback_period_range=(12, 12, 0), range_tolerance_range=(90.0, 90.0, 0.0), linreg_length_range=(90, 90, 0), kernel=None))]
1090pub fn linear_regression_intensity_batch_py<'py>(
1091 py: Python<'py>,
1092 data: PyReadonlyArray1<'py, f64>,
1093 lookback_period_range: (usize, usize, usize),
1094 range_tolerance_range: (f64, f64, f64),
1095 linreg_length_range: (usize, usize, usize),
1096 kernel: Option<&str>,
1097) -> PyResult<Bound<'py, PyDict>> {
1098 let data = data.as_slice()?;
1099 let sweep = LinearRegressionIntensityBatchRange {
1100 lookback_period: lookback_period_range,
1101 range_tolerance: range_tolerance_range,
1102 linreg_length: linreg_length_range,
1103 };
1104 let combos = expand_grid_linear_regression_intensity(&sweep)
1105 .map_err(|e| PyValueError::new_err(e.to_string()))?;
1106 let rows = combos.len();
1107 let cols = data.len();
1108 let total = rows
1109 .checked_mul(cols)
1110 .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
1111 let arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1112 let out = unsafe { arr.as_slice_mut()? };
1113 let kernel = validate_kernel(kernel, true)?;
1114
1115 py.allow_threads(|| {
1116 let batch_kernel = match kernel {
1117 Kernel::Auto => detect_best_batch_kernel(),
1118 other => other,
1119 };
1120 linear_regression_intensity_batch_inner_into(
1121 data,
1122 &sweep,
1123 batch_kernel.to_non_batch(),
1124 true,
1125 out,
1126 )
1127 })
1128 .map_err(|e| PyValueError::new_err(e.to_string()))?;
1129
1130 let dict = PyDict::new(py);
1131 dict.set_item("values", arr.reshape((rows, cols))?)?;
1132 dict.set_item(
1133 "lookback_periods",
1134 combos
1135 .iter()
1136 .map(|params| params.lookback_period.unwrap_or(12) as u64)
1137 .collect::<Vec<_>>()
1138 .into_pyarray(py),
1139 )?;
1140 dict.set_item(
1141 "range_tolerances",
1142 combos
1143 .iter()
1144 .map(|params| params.range_tolerance.unwrap_or(90.0))
1145 .collect::<Vec<_>>()
1146 .into_pyarray(py),
1147 )?;
1148 dict.set_item(
1149 "linreg_lengths",
1150 combos
1151 .iter()
1152 .map(|params| params.linreg_length.unwrap_or(90) as u64)
1153 .collect::<Vec<_>>()
1154 .into_pyarray(py),
1155 )?;
1156 dict.set_item("rows", rows)?;
1157 dict.set_item("cols", cols)?;
1158 Ok(dict)
1159}
1160
1161#[cfg(feature = "python")]
1162pub fn register_linear_regression_intensity_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
1163 m.add_function(wrap_pyfunction!(linear_regression_intensity_py, m)?)?;
1164 m.add_function(wrap_pyfunction!(linear_regression_intensity_batch_py, m)?)?;
1165 m.add_class::<LinearRegressionIntensityStreamPy>()?;
1166 Ok(())
1167}
1168
1169#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1170#[derive(Debug, Clone, Serialize, Deserialize)]
1171struct LinearRegressionIntensityBatchConfig {
1172 lookback_period_range: Vec<usize>,
1173 range_tolerance_range: Vec<f64>,
1174 linreg_length_range: Vec<usize>,
1175}
1176
1177#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1178#[derive(Debug, Clone, Serialize, Deserialize)]
1179struct LinearRegressionIntensityBatchJsOutput {
1180 values: Vec<f64>,
1181 rows: usize,
1182 cols: usize,
1183 combos: Vec<LinearRegressionIntensityParams>,
1184}
1185
1186#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1187#[wasm_bindgen(js_name = "linear_regression_intensity_js")]
1188pub fn linear_regression_intensity_js(
1189 data: &[f64],
1190 lookback_period: usize,
1191 range_tolerance: f64,
1192 linreg_length: usize,
1193) -> Result<Vec<f64>, JsValue> {
1194 let input = LinearRegressionIntensityInput::from_slice(
1195 data,
1196 LinearRegressionIntensityParams {
1197 lookback_period: Some(lookback_period),
1198 range_tolerance: Some(range_tolerance),
1199 linreg_length: Some(linreg_length),
1200 },
1201 );
1202 let mut out = vec![0.0; data.len()];
1203 linear_regression_intensity_into_slice(&mut out, &input, Kernel::Auto)
1204 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1205 Ok(out)
1206}
1207
1208#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1209#[wasm_bindgen(js_name = "linear_regression_intensity_batch_js")]
1210pub fn linear_regression_intensity_batch_js(
1211 data: &[f64],
1212 config: JsValue,
1213) -> Result<JsValue, JsValue> {
1214 let config: LinearRegressionIntensityBatchConfig = serde_wasm_bindgen::from_value(config)
1215 .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
1216 if config.lookback_period_range.len() != 3
1217 || config.range_tolerance_range.len() != 3
1218 || config.linreg_length_range.len() != 3
1219 {
1220 return Err(JsValue::from_str(
1221 "Invalid config: each range must have exactly 3 elements [start, end, step]",
1222 ));
1223 }
1224 let sweep = LinearRegressionIntensityBatchRange {
1225 lookback_period: (
1226 config.lookback_period_range[0],
1227 config.lookback_period_range[1],
1228 config.lookback_period_range[2],
1229 ),
1230 range_tolerance: (
1231 config.range_tolerance_range[0],
1232 config.range_tolerance_range[1],
1233 config.range_tolerance_range[2],
1234 ),
1235 linreg_length: (
1236 config.linreg_length_range[0],
1237 config.linreg_length_range[1],
1238 config.linreg_length_range[2],
1239 ),
1240 };
1241 let output = linear_regression_intensity_batch_slice(data, &sweep)
1242 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1243 serde_wasm_bindgen::to_value(&LinearRegressionIntensityBatchJsOutput {
1244 values: output.values,
1245 rows: output.rows,
1246 cols: output.cols,
1247 combos: output.combos,
1248 })
1249 .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
1250}
1251
1252#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1253#[wasm_bindgen]
1254pub fn linear_regression_intensity_alloc(len: usize) -> *mut f64 {
1255 let mut vec = Vec::<f64>::with_capacity(len);
1256 let ptr = vec.as_mut_ptr();
1257 std::mem::forget(vec);
1258 ptr
1259}
1260
1261#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1262#[wasm_bindgen]
1263pub fn linear_regression_intensity_free(ptr: *mut f64, len: usize) {
1264 unsafe {
1265 let _ = Vec::from_raw_parts(ptr, len, len);
1266 }
1267}
1268
1269#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1270#[wasm_bindgen]
1271pub fn linear_regression_intensity_into(
1272 in_ptr: *const f64,
1273 out_ptr: *mut f64,
1274 len: usize,
1275 lookback_period: usize,
1276 range_tolerance: f64,
1277 linreg_length: usize,
1278) -> Result<(), JsValue> {
1279 if in_ptr.is_null() || out_ptr.is_null() {
1280 return Err(JsValue::from_str(
1281 "null pointer passed to linear_regression_intensity_into",
1282 ));
1283 }
1284 unsafe {
1285 let data = std::slice::from_raw_parts(in_ptr, len);
1286 let out = std::slice::from_raw_parts_mut(out_ptr, len);
1287 let input = LinearRegressionIntensityInput::from_slice(
1288 data,
1289 LinearRegressionIntensityParams {
1290 lookback_period: Some(lookback_period),
1291 range_tolerance: Some(range_tolerance),
1292 linreg_length: Some(linreg_length),
1293 },
1294 );
1295 linear_regression_intensity_into_slice(out, &input, Kernel::Auto)
1296 .map_err(|e| JsValue::from_str(&e.to_string()))
1297 }
1298}
1299
1300#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1301#[wasm_bindgen(js_name = "linear_regression_intensity_into_host")]
1302pub fn linear_regression_intensity_into_host(
1303 data: &[f64],
1304 out_ptr: *mut f64,
1305 lookback_period: usize,
1306 range_tolerance: f64,
1307 linreg_length: usize,
1308) -> Result<(), JsValue> {
1309 if out_ptr.is_null() {
1310 return Err(JsValue::from_str(
1311 "null pointer passed to linear_regression_intensity_into_host",
1312 ));
1313 }
1314 unsafe {
1315 let out = std::slice::from_raw_parts_mut(out_ptr, data.len());
1316 let input = LinearRegressionIntensityInput::from_slice(
1317 data,
1318 LinearRegressionIntensityParams {
1319 lookback_period: Some(lookback_period),
1320 range_tolerance: Some(range_tolerance),
1321 linreg_length: Some(linreg_length),
1322 },
1323 );
1324 linear_regression_intensity_into_slice(out, &input, Kernel::Auto)
1325 .map_err(|e| JsValue::from_str(&e.to_string()))
1326 }
1327}
1328
1329#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1330#[wasm_bindgen]
1331pub fn linear_regression_intensity_batch_into(
1332 in_ptr: *const f64,
1333 out_ptr: *mut f64,
1334 len: usize,
1335 lookback_period_start: usize,
1336 lookback_period_end: usize,
1337 lookback_period_step: usize,
1338 range_tolerance_start: f64,
1339 range_tolerance_end: f64,
1340 range_tolerance_step: f64,
1341 linreg_length_start: usize,
1342 linreg_length_end: usize,
1343 linreg_length_step: usize,
1344) -> Result<usize, JsValue> {
1345 if in_ptr.is_null() || out_ptr.is_null() {
1346 return Err(JsValue::from_str(
1347 "null pointer passed to linear_regression_intensity_batch_into",
1348 ));
1349 }
1350 unsafe {
1351 let data = std::slice::from_raw_parts(in_ptr, len);
1352 let sweep = LinearRegressionIntensityBatchRange {
1353 lookback_period: (
1354 lookback_period_start,
1355 lookback_period_end,
1356 lookback_period_step,
1357 ),
1358 range_tolerance: (
1359 range_tolerance_start,
1360 range_tolerance_end,
1361 range_tolerance_step,
1362 ),
1363 linreg_length: (linreg_length_start, linreg_length_end, linreg_length_step),
1364 };
1365 let combos = expand_grid_linear_regression_intensity(&sweep)
1366 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1367 let rows = combos.len();
1368 let out = std::slice::from_raw_parts_mut(out_ptr, rows * len);
1369 linear_regression_intensity_batch_inner_into(data, &sweep, Kernel::Scalar, false, out)
1370 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1371 Ok(rows)
1372 }
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377 use super::*;
1378 use crate::indicators::dispatch::{
1379 compute_cpu_batch, IndicatorBatchRequest, IndicatorDataRef, IndicatorParamSet, ParamKV,
1380 ParamValue,
1381 };
1382
1383 fn sample_data(len: usize) -> Vec<f64> {
1384 let mut out = Vec::with_capacity(len);
1385 for i in 0..len {
1386 let trend = 100.0 + i as f64 * 0.17;
1387 let wave = (i as f64 * 0.19).sin() * 2.8 + (i as f64 * 0.041).cos() * 1.3;
1388 out.push(trend + wave);
1389 }
1390 out
1391 }
1392
1393 fn naive_linear_regression_intensity(
1394 data: &[f64],
1395 lookback_period: usize,
1396 linreg_length: usize,
1397 ) -> Vec<f64> {
1398 let linreg = linreg_with_kernel(
1399 &LinRegInput::from_slice(
1400 data,
1401 LinRegParams {
1402 period: Some(linreg_length),
1403 },
1404 ),
1405 Kernel::Scalar,
1406 )
1407 .expect("linreg")
1408 .values;
1409 let total = total_combinations(lookback_period);
1410 let mut out = vec![f64::NAN; data.len()];
1411
1412 for index in 0..data.len() {
1413 if index + 1 < lookback_period {
1414 continue;
1415 }
1416 let start = index + 1 - lookback_period;
1417 let window = &linreg[start..=index];
1418 if window.iter().any(|value| !value.is_finite()) {
1419 continue;
1420 }
1421 let mut trend = 0i64;
1422 for i in 0..lookback_period.saturating_sub(1) {
1423 for j in (i + 1)..lookback_period {
1424 if window[i] != window[j] {
1425 trend += if window[j] > window[i] { 1 } else { -1 };
1426 }
1427 }
1428 }
1429 out[index] = trend_to_intensity(trend, total);
1430 }
1431 out
1432 }
1433
1434 fn assert_close(a: &[f64], b: &[f64]) {
1435 assert_eq!(a.len(), b.len());
1436 for i in 0..a.len() {
1437 if a[i].is_nan() || b[i].is_nan() {
1438 assert!(
1439 a[i].is_nan() && b[i].is_nan(),
1440 "nan mismatch at {i}: {} vs {}",
1441 a[i],
1442 b[i]
1443 );
1444 } else {
1445 assert!(
1446 (a[i] - b[i]).abs() <= 1e-10,
1447 "mismatch at {i}: {} vs {}",
1448 a[i],
1449 b[i]
1450 );
1451 }
1452 }
1453 }
1454
1455 #[test]
1456 fn linear_regression_intensity_matches_naive() {
1457 let data = sample_data(256);
1458 let input = LinearRegressionIntensityInput::from_slice(
1459 &data,
1460 LinearRegressionIntensityParams {
1461 lookback_period: Some(12),
1462 range_tolerance: Some(90.0),
1463 linreg_length: Some(30),
1464 },
1465 );
1466 let out = linear_regression_intensity(&input).expect("indicator");
1467 let expected = naive_linear_regression_intensity(&data, 12, 30);
1468 assert_close(&out.values, &expected);
1469 }
1470
1471 #[test]
1472 fn linear_regression_intensity_into_matches_api() {
1473 let data = sample_data(192);
1474 let input = LinearRegressionIntensityInput::from_slice(
1475 &data,
1476 LinearRegressionIntensityParams {
1477 lookback_period: Some(10),
1478 range_tolerance: Some(85.0),
1479 linreg_length: Some(24),
1480 },
1481 );
1482 let baseline = linear_regression_intensity(&input).expect("baseline");
1483 let mut out = vec![0.0; data.len()];
1484 linear_regression_intensity_into(&input, &mut out).expect("into");
1485 assert_close(&baseline.values, &out);
1486 }
1487
1488 #[test]
1489 fn linear_regression_intensity_stream_matches_batch() {
1490 let data = sample_data(192);
1491 let batch = linear_regression_intensity(&LinearRegressionIntensityInput::from_slice(
1492 &data,
1493 LinearRegressionIntensityParams {
1494 lookback_period: Some(12),
1495 range_tolerance: Some(90.0),
1496 linreg_length: Some(24),
1497 },
1498 ))
1499 .expect("batch");
1500 let mut stream =
1501 LinearRegressionIntensityStream::try_new(LinearRegressionIntensityParams {
1502 lookback_period: Some(12),
1503 range_tolerance: Some(90.0),
1504 linreg_length: Some(24),
1505 })
1506 .expect("stream");
1507 let mut values = Vec::with_capacity(data.len());
1508 for value in data {
1509 values.push(stream.update_reset_on_nan(value).unwrap_or(f64::NAN));
1510 }
1511 assert_close(&batch.values, &values);
1512 }
1513
1514 #[test]
1515 fn linear_regression_intensity_batch_single_param_matches_single() {
1516 let data = sample_data(192);
1517 let sweep = LinearRegressionIntensityBatchRange {
1518 lookback_period: (12, 12, 0),
1519 range_tolerance: (90.0, 90.0, 0.0),
1520 linreg_length: (24, 24, 0),
1521 };
1522 let batch =
1523 linear_regression_intensity_batch_with_kernel(&data, &sweep, Kernel::ScalarBatch)
1524 .expect("batch");
1525 let single = linear_regression_intensity(&LinearRegressionIntensityInput::from_slice(
1526 &data,
1527 LinearRegressionIntensityParams {
1528 lookback_period: Some(12),
1529 range_tolerance: Some(90.0),
1530 linreg_length: Some(24),
1531 },
1532 ))
1533 .expect("single");
1534 assert_eq!(batch.rows, 1);
1535 assert_eq!(batch.cols, data.len());
1536 assert_close(&batch.values, &single.values);
1537 }
1538
1539 #[test]
1540 fn linear_regression_intensity_rejects_invalid_tolerance() {
1541 let data = sample_data(64);
1542 let err = linear_regression_intensity(&LinearRegressionIntensityInput::from_slice(
1543 &data,
1544 LinearRegressionIntensityParams {
1545 lookback_period: Some(12),
1546 range_tolerance: Some(120.0),
1547 linreg_length: Some(24),
1548 },
1549 ))
1550 .expect_err("invalid tolerance");
1551 assert!(matches!(
1552 err,
1553 LinearRegressionIntensityError::InvalidRangeTolerance { .. }
1554 ));
1555 }
1556
1557 #[test]
1558 fn linear_regression_intensity_dispatch_matches_direct() {
1559 let data = sample_data(192);
1560 let params = [
1561 ParamKV {
1562 key: "lookback_period",
1563 value: ParamValue::Int(12),
1564 },
1565 ParamKV {
1566 key: "range_tolerance",
1567 value: ParamValue::Float(90.0),
1568 },
1569 ParamKV {
1570 key: "linreg_length",
1571 value: ParamValue::Int(24),
1572 },
1573 ];
1574 let combos = [IndicatorParamSet { params: ¶ms }];
1575 let out = compute_cpu_batch(IndicatorBatchRequest {
1576 indicator_id: "linear_regression_intensity",
1577 output_id: Some("value"),
1578 data: IndicatorDataRef::Slice { values: &data },
1579 combos: &combos,
1580 kernel: Kernel::ScalarBatch,
1581 })
1582 .expect("dispatch");
1583 let direct = linear_regression_intensity(&LinearRegressionIntensityInput::from_slice(
1584 &data,
1585 LinearRegressionIntensityParams {
1586 lookback_period: Some(12),
1587 range_tolerance: Some(90.0),
1588 linreg_length: Some(24),
1589 },
1590 ))
1591 .expect("direct");
1592 assert_eq!(out.rows, 1);
1593 assert_eq!(out.cols, data.len());
1594 assert_close(out.values_f64.as_ref().expect("values"), &direct.values);
1595 }
1596}