Skip to main content

vector_ta/indicators/
linear_regression_intensity.rs

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: &params }];
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}