Skip to main content

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