Skip to main content

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