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}