Skip to main content

vector_ta/indicators/
monotonicity_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, PyList};
9#[cfg(feature = "python")]
10use pyo3::wrap_pyfunction;
11
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use serde::{Deserialize, Serialize};
14#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
15use wasm_bindgen::prelude::*;
16
17use crate::utilities::data_loader::{source_type, Candles};
18use crate::utilities::enums::Kernel;
19use crate::utilities::helpers::{
20    alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
21    make_uninit_matrix,
22};
23#[cfg(feature = "python")]
24use crate::utilities::kernel_validation::validate_kernel;
25#[cfg(not(target_arch = "wasm32"))]
26use rayon::prelude::*;
27use std::convert::AsRef;
28use std::mem::ManuallyDrop;
29use thiserror::Error;
30
31const DEFAULT_LENGTH: usize = 20;
32const DEFAULT_INDEX_SMOOTH: usize = 5;
33const DEFAULT_SOURCE: &str = "close";
34
35impl<'a> AsRef<[f64]> for MonotonicityIndexInput<'a> {
36    #[inline(always)]
37    fn as_ref(&self) -> &[f64] {
38        match &self.data {
39            MonotonicityIndexData::Slice(slice) => slice,
40            MonotonicityIndexData::Candles { candles, source } => source_type(candles, source),
41        }
42    }
43}
44
45#[derive(Debug, Clone)]
46pub enum MonotonicityIndexData<'a> {
47    Candles {
48        candles: &'a Candles,
49        source: &'a str,
50    },
51    Slice(&'a [f64]),
52}
53
54#[derive(Debug, Clone)]
55pub struct MonotonicityIndexOutput {
56    pub index: Vec<f64>,
57    pub cumulative_mean: Vec<f64>,
58    pub upper_bound: Vec<f64>,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62#[cfg_attr(
63    all(target_arch = "wasm32", feature = "wasm"),
64    derive(Serialize, Deserialize)
65)]
66#[cfg_attr(
67    all(target_arch = "wasm32", feature = "wasm"),
68    serde(rename_all = "snake_case")
69)]
70pub enum MonotonicityIndexMode {
71    Complexity,
72    #[default]
73    Efficiency,
74}
75
76impl MonotonicityIndexMode {
77    #[inline]
78    pub fn parse(value: &str) -> Option<Self> {
79        if value.eq_ignore_ascii_case("complexity") {
80            Some(Self::Complexity)
81        } else if value.eq_ignore_ascii_case("efficiency") {
82            Some(Self::Efficiency)
83        } else {
84            None
85        }
86    }
87
88    #[inline]
89    pub fn as_str(self) -> &'static str {
90        match self {
91            Self::Complexity => "complexity",
92            Self::Efficiency => "efficiency",
93        }
94    }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[cfg_attr(
99    all(target_arch = "wasm32", feature = "wasm"),
100    derive(Serialize, Deserialize)
101)]
102pub struct MonotonicityIndexParams {
103    pub length: Option<usize>,
104    pub mode: Option<MonotonicityIndexMode>,
105    pub index_smooth: Option<usize>,
106}
107
108impl Default for MonotonicityIndexParams {
109    fn default() -> Self {
110        Self {
111            length: Some(DEFAULT_LENGTH),
112            mode: Some(MonotonicityIndexMode::Efficiency),
113            index_smooth: Some(DEFAULT_INDEX_SMOOTH),
114        }
115    }
116}
117
118#[derive(Debug, Clone)]
119pub struct MonotonicityIndexInput<'a> {
120    pub data: MonotonicityIndexData<'a>,
121    pub params: MonotonicityIndexParams,
122}
123
124impl<'a> MonotonicityIndexInput<'a> {
125    #[inline]
126    pub fn from_candles(
127        candles: &'a Candles,
128        source: &'a str,
129        params: MonotonicityIndexParams,
130    ) -> Self {
131        Self {
132            data: MonotonicityIndexData::Candles { candles, source },
133            params,
134        }
135    }
136
137    #[inline]
138    pub fn from_slice(slice: &'a [f64], params: MonotonicityIndexParams) -> Self {
139        Self {
140            data: MonotonicityIndexData::Slice(slice),
141            params,
142        }
143    }
144
145    #[inline]
146    pub fn with_default_candles(candles: &'a Candles) -> Self {
147        Self::from_candles(candles, DEFAULT_SOURCE, MonotonicityIndexParams::default())
148    }
149}
150
151#[derive(Clone, Copy, Debug, Default)]
152pub struct MonotonicityIndexBuilder {
153    length: Option<usize>,
154    mode: Option<MonotonicityIndexMode>,
155    index_smooth: Option<usize>,
156    kernel: Kernel,
157}
158
159impl MonotonicityIndexBuilder {
160    #[inline]
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    #[inline]
166    pub fn length(mut self, length: usize) -> Self {
167        self.length = Some(length);
168        self
169    }
170
171    #[inline]
172    pub fn mode(mut self, mode: MonotonicityIndexMode) -> Self {
173        self.mode = Some(mode);
174        self
175    }
176
177    #[inline]
178    pub fn index_smooth(mut self, index_smooth: usize) -> Self {
179        self.index_smooth = Some(index_smooth);
180        self
181    }
182
183    #[inline]
184    pub fn kernel(mut self, kernel: Kernel) -> Self {
185        self.kernel = kernel;
186        self
187    }
188
189    #[inline]
190    pub fn apply(
191        self,
192        candles: &Candles,
193        source: &str,
194    ) -> Result<MonotonicityIndexOutput, MonotonicityIndexError> {
195        let input = MonotonicityIndexInput::from_candles(
196            candles,
197            source,
198            MonotonicityIndexParams {
199                length: self.length,
200                mode: self.mode,
201                index_smooth: self.index_smooth,
202            },
203        );
204        monotonicity_index_with_kernel(&input, self.kernel)
205    }
206
207    #[inline]
208    pub fn apply_slice(
209        self,
210        data: &[f64],
211    ) -> Result<MonotonicityIndexOutput, MonotonicityIndexError> {
212        let input = MonotonicityIndexInput::from_slice(
213            data,
214            MonotonicityIndexParams {
215                length: self.length,
216                mode: self.mode,
217                index_smooth: self.index_smooth,
218            },
219        );
220        monotonicity_index_with_kernel(&input, self.kernel)
221    }
222
223    #[inline]
224    pub fn into_stream(self) -> Result<MonotonicityIndexStream, MonotonicityIndexError> {
225        MonotonicityIndexStream::try_new(MonotonicityIndexParams {
226            length: self.length,
227            mode: self.mode,
228            index_smooth: self.index_smooth,
229        })
230    }
231}
232
233#[derive(Debug, Error)]
234pub enum MonotonicityIndexError {
235    #[error("monotonicity_index: Input data slice is empty.")]
236    EmptyInputData,
237    #[error("monotonicity_index: All values are NaN.")]
238    AllValuesNaN,
239    #[error("monotonicity_index: Invalid length: {length}")]
240    InvalidLength { length: usize },
241    #[error("monotonicity_index: Invalid index_smooth: {index_smooth}")]
242    InvalidIndexSmooth { index_smooth: usize },
243    #[error("monotonicity_index: Invalid mode: {mode}")]
244    InvalidMode { mode: String },
245    #[error("monotonicity_index: Not enough valid data: needed = {needed}, valid = {valid}")]
246    NotEnoughValidData { needed: usize, valid: usize },
247    #[error(
248        "monotonicity_index: Output length mismatch: expected = {expected}, index = {index_got}, cumulative_mean = {cumulative_mean_got}, upper_bound = {upper_bound_got}"
249    )]
250    OutputLengthMismatch {
251        expected: usize,
252        index_got: usize,
253        cumulative_mean_got: usize,
254        upper_bound_got: usize,
255    },
256    #[error("monotonicity_index: Invalid range: start={start}, end={end}, step={step}")]
257    InvalidRange {
258        start: String,
259        end: String,
260        step: String,
261    },
262    #[error("monotonicity_index: Invalid kernel for batch: {0:?}")]
263    InvalidKernelForBatch(Kernel),
264}
265
266#[derive(Clone, Copy, Debug)]
267struct ResolvedParams {
268    length: usize,
269    mode: MonotonicityIndexMode,
270    index_smooth: usize,
271    warmup_period: usize,
272    needed_valid: usize,
273}
274
275#[derive(Clone, Copy, Debug, Default)]
276struct PavaFitSummary {
277    mse: f64,
278    pools: usize,
279    start_value: f64,
280    end_value: f64,
281}
282
283#[derive(Clone, Debug, Default)]
284struct PavaScratch {
285    inc_pool_vals: Vec<f64>,
286    inc_pool_weights: Vec<usize>,
287    dec_pool_vals: Vec<f64>,
288    dec_pool_weights: Vec<usize>,
289}
290
291impl PavaScratch {
292    #[inline]
293    fn fit(&mut self, data: &[f64], non_decreasing: bool) -> PavaFitSummary {
294        let (pool_vals, pool_weights) = if non_decreasing {
295            (&mut self.inc_pool_vals, &mut self.inc_pool_weights)
296        } else {
297            (&mut self.dec_pool_vals, &mut self.dec_pool_weights)
298        };
299        pool_vals.clear();
300        pool_weights.clear();
301
302        for &value in data {
303            let mut current_pool = value;
304            let mut current_weight = 1usize;
305            while let Some(&prev_pool) = pool_vals.last() {
306                let violation = if non_decreasing {
307                    prev_pool > current_pool
308                } else {
309                    prev_pool < current_pool
310                };
311                if !violation {
312                    break;
313                }
314
315                let prev_weight = pool_weights.pop().unwrap();
316                let last_pool = pool_vals.pop().unwrap();
317                let combined_weight = prev_weight + current_weight;
318                current_pool = (last_pool * prev_weight as f64
319                    + current_pool * current_weight as f64)
320                    / combined_weight as f64;
321                current_weight = combined_weight;
322            }
323
324            pool_vals.push(current_pool);
325            pool_weights.push(current_weight);
326        }
327
328        let mut total_error = 0.0;
329        let mut idx = 0usize;
330        for (&pool_value, &pool_weight) in pool_vals.iter().zip(pool_weights.iter()) {
331            for _ in 0..pool_weight {
332                let delta = data[idx] - pool_value;
333                total_error += delta * delta;
334                idx += 1;
335            }
336        }
337
338        PavaFitSummary {
339            mse: total_error / data.len() as f64,
340            pools: pool_vals.len(),
341            start_value: pool_vals.first().copied().unwrap_or(0.0),
342            end_value: pool_vals.last().copied().unwrap_or(0.0),
343        }
344    }
345}
346
347#[derive(Clone, Debug)]
348struct RollingWindow {
349    buf: Vec<f64>,
350    next: usize,
351    len: usize,
352}
353
354impl RollingWindow {
355    #[inline]
356    fn new(capacity: usize) -> Self {
357        Self {
358            buf: vec![0.0; capacity],
359            next: 0,
360            len: 0,
361        }
362    }
363
364    #[inline]
365    fn reset(&mut self) {
366        self.next = 0;
367        self.len = 0;
368    }
369
370    #[inline]
371    fn capacity(&self) -> usize {
372        self.buf.len()
373    }
374
375    #[inline]
376    fn len(&self) -> usize {
377        self.len
378    }
379
380    #[inline]
381    fn push(&mut self, value: f64) {
382        self.buf[self.next] = value;
383        self.next += 1;
384        if self.next == self.buf.len() {
385            self.next = 0;
386        }
387        if self.len < self.buf.len() {
388            self.len += 1;
389        }
390    }
391
392    #[inline]
393    fn copy_to_vec(&self, out: &mut Vec<f64>) {
394        out.clear();
395        if self.len == 0 {
396            return;
397        }
398
399        if self.len < self.buf.len() {
400            out.extend_from_slice(&self.buf[..self.len]);
401            return;
402        }
403
404        out.extend_from_slice(&self.buf[self.next..]);
405        out.extend_from_slice(&self.buf[..self.next]);
406    }
407}
408
409#[derive(Clone, Debug)]
410struct RollingSma {
411    buf: Vec<f64>,
412    next: usize,
413    len: usize,
414    sum: f64,
415}
416
417impl RollingSma {
418    #[inline]
419    fn new(period: usize) -> Self {
420        Self {
421            buf: vec![0.0; period],
422            next: 0,
423            len: 0,
424            sum: 0.0,
425        }
426    }
427
428    #[inline]
429    fn reset(&mut self) {
430        self.next = 0;
431        self.len = 0;
432        self.sum = 0.0;
433    }
434
435    #[inline]
436    fn update(&mut self, value: f64) -> Option<f64> {
437        if self.len == self.buf.len() {
438            self.sum -= self.buf[self.next];
439        } else {
440            self.len += 1;
441        }
442        self.buf[self.next] = value;
443        self.sum += value;
444        self.next += 1;
445        if self.next == self.buf.len() {
446            self.next = 0;
447        }
448
449        if self.len == self.buf.len() {
450            Some(self.sum / self.buf.len() as f64)
451        } else {
452            None
453        }
454    }
455}
456
457#[derive(Clone, Debug)]
458pub struct MonotonicityIndexStream {
459    params: ResolvedParams,
460    price_window: RollingWindow,
461    raw_sma: RollingSma,
462    cumulative_sum: f64,
463    cumulative_count: usize,
464    scratch: PavaScratch,
465    window_data: Vec<f64>,
466}
467
468impl MonotonicityIndexStream {
469    #[inline]
470    pub fn try_new(params: MonotonicityIndexParams) -> Result<Self, MonotonicityIndexError> {
471        let params = resolve_params(&params)?;
472        Ok(Self {
473            price_window: RollingWindow::new(params.length),
474            raw_sma: RollingSma::new(params.index_smooth),
475            cumulative_sum: 0.0,
476            cumulative_count: 0,
477            scratch: PavaScratch::default(),
478            window_data: Vec::with_capacity(params.length),
479            params,
480        })
481    }
482
483    #[inline]
484    pub fn reset(&mut self) {
485        self.price_window.reset();
486        self.raw_sma.reset();
487        self.cumulative_sum = 0.0;
488        self.cumulative_count = 0;
489        self.window_data.clear();
490    }
491
492    #[inline]
493    pub fn get_warmup_period(&self) -> usize {
494        self.params.warmup_period
495    }
496
497    #[inline]
498    pub fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> {
499        if !value.is_finite() {
500            self.reset();
501            return None;
502        }
503
504        self.price_window.push(value);
505        if self.price_window.len() < self.price_window.capacity() {
506            return None;
507        }
508
509        self.price_window.copy_to_vec(&mut self.window_data);
510        let raw_index = compute_raw_index(&self.window_data, self.params.mode, &mut self.scratch);
511        let smoothed = self.raw_sma.update(raw_index)?;
512        self.cumulative_sum += smoothed;
513        self.cumulative_count += 1;
514        let cumulative_mean = self.cumulative_sum / self.cumulative_count as f64;
515        Some((smoothed, cumulative_mean, cumulative_mean * 2.0))
516    }
517}
518
519#[inline(always)]
520fn first_valid_value(data: &[f64]) -> usize {
521    let mut i = 0usize;
522    while i < data.len() {
523        if data[i].is_finite() {
524            return i;
525        }
526        i += 1;
527    }
528    data.len()
529}
530
531#[inline(always)]
532fn max_consecutive_valid_values(data: &[f64]) -> usize {
533    let mut best = 0usize;
534    let mut run = 0usize;
535    for &value in data {
536        if value.is_finite() {
537            run += 1;
538            if run > best {
539                best = run;
540            }
541        } else {
542            run = 0;
543        }
544    }
545    best
546}
547
548#[inline(always)]
549fn resolve_params(
550    params: &MonotonicityIndexParams,
551) -> Result<ResolvedParams, MonotonicityIndexError> {
552    let length = params.length.unwrap_or(DEFAULT_LENGTH);
553    if length < 2 {
554        return Err(MonotonicityIndexError::InvalidLength { length });
555    }
556
557    let index_smooth = params.index_smooth.unwrap_or(DEFAULT_INDEX_SMOOTH);
558    if index_smooth == 0 {
559        return Err(MonotonicityIndexError::InvalidIndexSmooth { index_smooth });
560    }
561
562    let mode = params.mode.unwrap_or_default();
563    let needed_valid = length
564        .checked_add(index_smooth)
565        .and_then(|x| x.checked_sub(1))
566        .ok_or(MonotonicityIndexError::InvalidLength { length })?;
567
568    Ok(ResolvedParams {
569        length,
570        mode,
571        index_smooth,
572        warmup_period: needed_valid - 1,
573        needed_valid,
574    })
575}
576
577#[inline(always)]
578fn compute_raw_index(data: &[f64], mode: MonotonicityIndexMode, scratch: &mut PavaScratch) -> f64 {
579    let inc_fit = scratch.fit(data, true);
580    let dec_fit = scratch.fit(data, false);
581    let best_fit = if inc_fit.mse < dec_fit.mse {
582        inc_fit
583    } else {
584        dec_fit
585    };
586
587    match mode {
588        MonotonicityIndexMode::Efficiency => {
589            let mut price_path = 0.0;
590            let mut i = 1usize;
591            while i < data.len() {
592                price_path += (data[i] - data[i - 1]).abs();
593                i += 1;
594            }
595            if price_path > 0.0 {
596                (best_fit.end_value - best_fit.start_value).abs() / price_path * 100.0
597            } else {
598                0.0
599            }
600        }
601        MonotonicityIndexMode::Complexity => {
602            (best_fit.pools.saturating_sub(1) as f64 / (data.len() - 1) as f64) * 100.0
603        }
604    }
605}
606
607#[inline(always)]
608fn monotonicity_index_prepare<'a>(
609    input: &'a MonotonicityIndexInput,
610    kernel: Kernel,
611) -> Result<(&'a [f64], usize, ResolvedParams, Kernel), MonotonicityIndexError> {
612    let data = input.as_ref();
613    if data.is_empty() {
614        return Err(MonotonicityIndexError::EmptyInputData);
615    }
616
617    let first = first_valid_value(data);
618    if first >= data.len() {
619        return Err(MonotonicityIndexError::AllValuesNaN);
620    }
621
622    let params = resolve_params(&input.params)?;
623    let valid = max_consecutive_valid_values(data);
624    if valid < params.needed_valid {
625        return Err(MonotonicityIndexError::NotEnoughValidData {
626            needed: params.needed_valid,
627            valid,
628        });
629    }
630
631    let chosen = match kernel {
632        Kernel::Auto => detect_best_kernel(),
633        other => other.to_non_batch(),
634    };
635    Ok((data, first, params, chosen))
636}
637
638#[inline(always)]
639fn monotonicity_index_row_from_slice(
640    data: &[f64],
641    params: ResolvedParams,
642    index_out: &mut [f64],
643    cumulative_mean_out: &mut [f64],
644    upper_bound_out: &mut [f64],
645) {
646    let mut stream = MonotonicityIndexStream::try_new(MonotonicityIndexParams {
647        length: Some(params.length),
648        mode: Some(params.mode),
649        index_smooth: Some(params.index_smooth),
650    })
651    .unwrap();
652
653    for (((index_slot, cumulative_mean_slot), upper_bound_slot), &value) in index_out
654        .iter_mut()
655        .zip(cumulative_mean_out.iter_mut())
656        .zip(upper_bound_out.iter_mut())
657        .zip(data.iter())
658    {
659        if let Some((index, cumulative_mean, upper_bound)) = stream.update(value) {
660            *index_slot = index;
661            *cumulative_mean_slot = cumulative_mean;
662            *upper_bound_slot = upper_bound;
663        } else {
664            *index_slot = f64::NAN;
665            *cumulative_mean_slot = f64::NAN;
666            *upper_bound_slot = f64::NAN;
667        }
668    }
669}
670
671#[inline]
672pub fn monotonicity_index(
673    input: &MonotonicityIndexInput,
674) -> Result<MonotonicityIndexOutput, MonotonicityIndexError> {
675    monotonicity_index_with_kernel(input, Kernel::Auto)
676}
677
678#[inline]
679pub fn monotonicity_index_with_kernel(
680    input: &MonotonicityIndexInput,
681    kernel: Kernel,
682) -> Result<MonotonicityIndexOutput, MonotonicityIndexError> {
683    let (data, first, params, _chosen) = monotonicity_index_prepare(input, kernel)?;
684    let warmup = first.saturating_add(params.warmup_period).min(data.len());
685    let mut index = alloc_with_nan_prefix(data.len(), warmup);
686    let mut cumulative_mean = alloc_with_nan_prefix(data.len(), warmup);
687    let mut upper_bound = alloc_with_nan_prefix(data.len(), warmup);
688    monotonicity_index_row_from_slice(
689        data,
690        params,
691        &mut index,
692        &mut cumulative_mean,
693        &mut upper_bound,
694    );
695    Ok(MonotonicityIndexOutput {
696        index,
697        cumulative_mean,
698        upper_bound,
699    })
700}
701
702#[inline]
703pub fn monotonicity_index_into_slices(
704    index_out: &mut [f64],
705    cumulative_mean_out: &mut [f64],
706    upper_bound_out: &mut [f64],
707    input: &MonotonicityIndexInput,
708    kernel: Kernel,
709) -> Result<(), MonotonicityIndexError> {
710    let expected = input.as_ref().len();
711    if index_out.len() != expected
712        || cumulative_mean_out.len() != expected
713        || upper_bound_out.len() != expected
714    {
715        return Err(MonotonicityIndexError::OutputLengthMismatch {
716            expected,
717            index_got: index_out.len(),
718            cumulative_mean_got: cumulative_mean_out.len(),
719            upper_bound_got: upper_bound_out.len(),
720        });
721    }
722
723    let (data, _first, params, _chosen) = monotonicity_index_prepare(input, kernel)?;
724    monotonicity_index_row_from_slice(
725        data,
726        params,
727        index_out,
728        cumulative_mean_out,
729        upper_bound_out,
730    );
731    Ok(())
732}
733
734#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
735#[inline]
736pub fn monotonicity_index_into(
737    input: &MonotonicityIndexInput,
738    index_out: &mut [f64],
739    cumulative_mean_out: &mut [f64],
740    upper_bound_out: &mut [f64],
741) -> Result<(), MonotonicityIndexError> {
742    monotonicity_index_into_slices(
743        index_out,
744        cumulative_mean_out,
745        upper_bound_out,
746        input,
747        Kernel::Auto,
748    )
749}
750
751#[derive(Debug, Clone)]
752#[cfg_attr(
753    all(target_arch = "wasm32", feature = "wasm"),
754    derive(Serialize, Deserialize)
755)]
756pub struct MonotonicityIndexBatchRange {
757    pub length: (usize, usize, usize),
758    pub index_smooth: (usize, usize, usize),
759    pub mode: MonotonicityIndexMode,
760}
761
762impl Default for MonotonicityIndexBatchRange {
763    fn default() -> Self {
764        Self {
765            length: (DEFAULT_LENGTH, DEFAULT_LENGTH, 0),
766            index_smooth: (DEFAULT_INDEX_SMOOTH, DEFAULT_INDEX_SMOOTH, 0),
767            mode: MonotonicityIndexMode::Efficiency,
768        }
769    }
770}
771
772#[derive(Debug, Clone)]
773pub struct MonotonicityIndexBatchOutput {
774    pub index: Vec<f64>,
775    pub cumulative_mean: Vec<f64>,
776    pub upper_bound: Vec<f64>,
777    pub combos: Vec<MonotonicityIndexParams>,
778    pub rows: usize,
779    pub cols: usize,
780}
781
782impl MonotonicityIndexBatchOutput {
783    #[inline]
784    pub fn row_for_params(&self, params: &MonotonicityIndexParams) -> Option<usize> {
785        self.combos.iter().position(|combo| {
786            combo.length.unwrap_or(DEFAULT_LENGTH) == params.length.unwrap_or(DEFAULT_LENGTH)
787                && combo.mode.unwrap_or_default() == params.mode.unwrap_or_default()
788                && combo.index_smooth.unwrap_or(DEFAULT_INDEX_SMOOTH)
789                    == params.index_smooth.unwrap_or(DEFAULT_INDEX_SMOOTH)
790        })
791    }
792
793    #[inline]
794    pub fn row_slices(&self, row: usize) -> Option<(&[f64], &[f64], &[f64])> {
795        if row >= self.rows {
796            return None;
797        }
798        let start = row * self.cols;
799        let end = start + self.cols;
800        Some((
801            &self.index[start..end],
802            &self.cumulative_mean[start..end],
803            &self.upper_bound[start..end],
804        ))
805    }
806}
807
808#[derive(Clone, Debug, Default)]
809pub struct MonotonicityIndexBatchBuilder {
810    range: MonotonicityIndexBatchRange,
811    kernel: Kernel,
812}
813
814impl MonotonicityIndexBatchBuilder {
815    #[inline]
816    pub fn new() -> Self {
817        Self::default()
818    }
819
820    #[inline]
821    pub fn kernel(mut self, kernel: Kernel) -> Self {
822        self.kernel = kernel;
823        self
824    }
825
826    #[inline]
827    pub fn length_range(mut self, start: usize, end: usize, step: usize) -> Self {
828        self.range.length = (start, end, step);
829        self
830    }
831
832    #[inline]
833    pub fn index_smooth_range(mut self, start: usize, end: usize, step: usize) -> Self {
834        self.range.index_smooth = (start, end, step);
835        self
836    }
837
838    #[inline]
839    pub fn mode(mut self, mode: MonotonicityIndexMode) -> Self {
840        self.range.mode = mode;
841        self
842    }
843
844    #[inline]
845    pub fn apply_slice(
846        self,
847        data: &[f64],
848    ) -> Result<MonotonicityIndexBatchOutput, MonotonicityIndexError> {
849        monotonicity_index_batch_with_kernel(data, &self.range, self.kernel)
850    }
851
852    #[inline]
853    pub fn apply_candles(
854        self,
855        candles: &Candles,
856        source: &str,
857    ) -> Result<MonotonicityIndexBatchOutput, MonotonicityIndexError> {
858        self.apply_slice(source_type(candles, source))
859    }
860}
861
862#[inline(always)]
863fn expand_axis_usize(
864    (start, end, step): (usize, usize, usize),
865) -> Result<Vec<usize>, MonotonicityIndexError> {
866    if step == 0 || start == end {
867        return Ok(vec![start]);
868    }
869
870    let mut out = Vec::new();
871    if start < end {
872        let mut x = start;
873        while x <= end {
874            out.push(x);
875            let next = x.saturating_add(step);
876            if next == x {
877                break;
878            }
879            x = next;
880        }
881    } else {
882        let mut x = start;
883        loop {
884            out.push(x);
885            if x == end {
886                break;
887            }
888            let next = x.saturating_sub(step);
889            if next == x || next < end {
890                break;
891            }
892            x = next;
893        }
894    }
895
896    if out.is_empty() {
897        return Err(MonotonicityIndexError::InvalidRange {
898            start: start.to_string(),
899            end: end.to_string(),
900            step: step.to_string(),
901        });
902    }
903    Ok(out)
904}
905
906#[inline(always)]
907fn expand_grid_monotonicity_index(
908    sweep: &MonotonicityIndexBatchRange,
909) -> Result<Vec<MonotonicityIndexParams>, MonotonicityIndexError> {
910    let lengths = expand_axis_usize(sweep.length)?;
911    let index_smooths = expand_axis_usize(sweep.index_smooth)?;
912
913    let mut combos = Vec::with_capacity(lengths.len() * index_smooths.len());
914    for length in lengths {
915        for index_smooth in index_smooths.iter().copied() {
916            let combo = MonotonicityIndexParams {
917                length: Some(length),
918                mode: Some(sweep.mode),
919                index_smooth: Some(index_smooth),
920            };
921            let _ = resolve_params(&combo)?;
922            combos.push(combo);
923        }
924    }
925    Ok(combos)
926}
927
928#[inline]
929pub fn monotonicity_index_batch_with_kernel(
930    data: &[f64],
931    sweep: &MonotonicityIndexBatchRange,
932    kernel: Kernel,
933) -> Result<MonotonicityIndexBatchOutput, MonotonicityIndexError> {
934    let batch_kernel = match kernel {
935        Kernel::Auto => detect_best_batch_kernel(),
936        other if other.is_batch() => other,
937        other => return Err(MonotonicityIndexError::InvalidKernelForBatch(other)),
938    };
939    monotonicity_index_batch_par_slice(data, sweep, batch_kernel.to_non_batch())
940}
941
942#[inline]
943pub fn monotonicity_index_batch_slice(
944    data: &[f64],
945    sweep: &MonotonicityIndexBatchRange,
946    kernel: Kernel,
947) -> Result<MonotonicityIndexBatchOutput, MonotonicityIndexError> {
948    monotonicity_index_batch_inner(data, sweep, kernel, false)
949}
950
951#[inline]
952pub fn monotonicity_index_batch_par_slice(
953    data: &[f64],
954    sweep: &MonotonicityIndexBatchRange,
955    kernel: Kernel,
956) -> Result<MonotonicityIndexBatchOutput, MonotonicityIndexError> {
957    monotonicity_index_batch_inner(data, sweep, kernel, true)
958}
959
960#[inline]
961pub fn monotonicity_index_batch_inner(
962    data: &[f64],
963    sweep: &MonotonicityIndexBatchRange,
964    _kernel: Kernel,
965    parallel: bool,
966) -> Result<MonotonicityIndexBatchOutput, MonotonicityIndexError> {
967    let combos = expand_grid_monotonicity_index(sweep)?;
968    let rows = combos.len();
969    let cols = data.len();
970    if cols == 0 {
971        return Err(MonotonicityIndexError::EmptyInputData);
972    }
973
974    let first = first_valid_value(data);
975    if first >= cols {
976        return Err(MonotonicityIndexError::AllValuesNaN);
977    }
978
979    let max_needed = combos
980        .iter()
981        .map(|combo| resolve_params(combo).unwrap().needed_valid)
982        .max()
983        .unwrap_or(0);
984    let max_warmup = combos
985        .iter()
986        .map(|combo| resolve_params(combo).unwrap().warmup_period)
987        .max()
988        .unwrap_or(0);
989    let valid = max_consecutive_valid_values(data);
990    if valid < max_needed {
991        return Err(MonotonicityIndexError::NotEnoughValidData {
992            needed: max_needed,
993            valid,
994        });
995    }
996
997    let mut index_mu = make_uninit_matrix(rows, cols);
998    let mut cumulative_mean_mu = make_uninit_matrix(rows, cols);
999    let mut upper_bound_mu = make_uninit_matrix(rows, cols);
1000    init_matrix_prefixes(
1001        &mut index_mu,
1002        cols,
1003        &vec![first.saturating_add(max_warmup).min(cols); rows],
1004    );
1005    init_matrix_prefixes(
1006        &mut cumulative_mean_mu,
1007        cols,
1008        &vec![first.saturating_add(max_warmup).min(cols); rows],
1009    );
1010    init_matrix_prefixes(
1011        &mut upper_bound_mu,
1012        cols,
1013        &vec![first.saturating_add(max_warmup).min(cols); rows],
1014    );
1015
1016    let mut index_guard = ManuallyDrop::new(index_mu);
1017    let mut cumulative_mean_guard = ManuallyDrop::new(cumulative_mean_mu);
1018    let mut upper_bound_guard = ManuallyDrop::new(upper_bound_mu);
1019    let index_out = unsafe {
1020        std::slice::from_raw_parts_mut(index_guard.as_mut_ptr() as *mut f64, index_guard.len())
1021    };
1022    let cumulative_mean_out = unsafe {
1023        std::slice::from_raw_parts_mut(
1024            cumulative_mean_guard.as_mut_ptr() as *mut f64,
1025            cumulative_mean_guard.len(),
1026        )
1027    };
1028    let upper_bound_out = unsafe {
1029        std::slice::from_raw_parts_mut(
1030            upper_bound_guard.as_mut_ptr() as *mut f64,
1031            upper_bound_guard.len(),
1032        )
1033    };
1034
1035    let combos = monotonicity_index_batch_inner_into(
1036        data,
1037        sweep,
1038        _kernel,
1039        parallel,
1040        index_out,
1041        cumulative_mean_out,
1042        upper_bound_out,
1043    )?;
1044
1045    let index = unsafe {
1046        Vec::from_raw_parts(
1047            index_guard.as_mut_ptr() as *mut f64,
1048            index_guard.len(),
1049            index_guard.capacity(),
1050        )
1051    };
1052    let cumulative_mean = unsafe {
1053        Vec::from_raw_parts(
1054            cumulative_mean_guard.as_mut_ptr() as *mut f64,
1055            cumulative_mean_guard.len(),
1056            cumulative_mean_guard.capacity(),
1057        )
1058    };
1059    let upper_bound = unsafe {
1060        Vec::from_raw_parts(
1061            upper_bound_guard.as_mut_ptr() as *mut f64,
1062            upper_bound_guard.len(),
1063            upper_bound_guard.capacity(),
1064        )
1065    };
1066
1067    Ok(MonotonicityIndexBatchOutput {
1068        index,
1069        cumulative_mean,
1070        upper_bound,
1071        combos,
1072        rows,
1073        cols,
1074    })
1075}
1076
1077#[inline]
1078pub fn monotonicity_index_batch_inner_into(
1079    data: &[f64],
1080    sweep: &MonotonicityIndexBatchRange,
1081    _kernel: Kernel,
1082    parallel: bool,
1083    index_out: &mut [f64],
1084    cumulative_mean_out: &mut [f64],
1085    upper_bound_out: &mut [f64],
1086) -> Result<Vec<MonotonicityIndexParams>, MonotonicityIndexError> {
1087    let combos = expand_grid_monotonicity_index(sweep)?;
1088    let rows = combos.len();
1089    let cols = data.len();
1090    if cols == 0 {
1091        return Err(MonotonicityIndexError::EmptyInputData);
1092    }
1093
1094    let total = rows
1095        .checked_mul(cols)
1096        .ok_or(MonotonicityIndexError::OutputLengthMismatch {
1097            expected: usize::MAX,
1098            index_got: index_out.len(),
1099            cumulative_mean_got: cumulative_mean_out.len(),
1100            upper_bound_got: upper_bound_out.len(),
1101        })?;
1102    if index_out.len() != total
1103        || cumulative_mean_out.len() != total
1104        || upper_bound_out.len() != total
1105    {
1106        return Err(MonotonicityIndexError::OutputLengthMismatch {
1107            expected: total,
1108            index_got: index_out.len(),
1109            cumulative_mean_got: cumulative_mean_out.len(),
1110            upper_bound_got: upper_bound_out.len(),
1111        });
1112    }
1113
1114    let first = first_valid_value(data);
1115    if first >= cols {
1116        return Err(MonotonicityIndexError::AllValuesNaN);
1117    }
1118
1119    let max_needed = combos
1120        .iter()
1121        .map(|combo| resolve_params(combo).unwrap().needed_valid)
1122        .max()
1123        .unwrap_or(0);
1124    let valid = max_consecutive_valid_values(data);
1125    if valid < max_needed {
1126        return Err(MonotonicityIndexError::NotEnoughValidData {
1127            needed: max_needed,
1128            valid,
1129        });
1130    }
1131
1132    if parallel {
1133        #[cfg(not(target_arch = "wasm32"))]
1134        index_out
1135            .par_chunks_mut(cols)
1136            .zip(cumulative_mean_out.par_chunks_mut(cols))
1137            .zip(upper_bound_out.par_chunks_mut(cols))
1138            .enumerate()
1139            .for_each(
1140                |(row, ((index_row, cumulative_mean_row), upper_bound_row))| {
1141                    let params = resolve_params(&combos[row]).unwrap();
1142                    monotonicity_index_row_from_slice(
1143                        data,
1144                        params,
1145                        index_row,
1146                        cumulative_mean_row,
1147                        upper_bound_row,
1148                    );
1149                },
1150            );
1151
1152        #[cfg(target_arch = "wasm32")]
1153        for (row, ((index_row, cumulative_mean_row), upper_bound_row)) in index_out
1154            .chunks_mut(cols)
1155            .zip(cumulative_mean_out.chunks_mut(cols))
1156            .zip(upper_bound_out.chunks_mut(cols))
1157            .enumerate()
1158        {
1159            let params = resolve_params(&combos[row]).unwrap();
1160            monotonicity_index_row_from_slice(
1161                data,
1162                params,
1163                index_row,
1164                cumulative_mean_row,
1165                upper_bound_row,
1166            );
1167        }
1168    } else {
1169        for (row, ((index_row, cumulative_mean_row), upper_bound_row)) in index_out
1170            .chunks_mut(cols)
1171            .zip(cumulative_mean_out.chunks_mut(cols))
1172            .zip(upper_bound_out.chunks_mut(cols))
1173            .enumerate()
1174        {
1175            let params = resolve_params(&combos[row]).unwrap();
1176            monotonicity_index_row_from_slice(
1177                data,
1178                params,
1179                index_row,
1180                cumulative_mean_row,
1181                upper_bound_row,
1182            );
1183        }
1184    }
1185
1186    Ok(combos)
1187}
1188
1189#[cfg(feature = "python")]
1190fn parse_mode_py(value: &str) -> PyResult<MonotonicityIndexMode> {
1191    MonotonicityIndexMode::parse(value)
1192        .ok_or_else(|| PyValueError::new_err(format!("Invalid mode: {value}")))
1193}
1194
1195#[cfg(feature = "python")]
1196#[pyfunction(name = "monotonicity_index")]
1197#[pyo3(signature = (
1198    data,
1199    length=DEFAULT_LENGTH,
1200    mode="efficiency",
1201    index_smooth=DEFAULT_INDEX_SMOOTH,
1202    kernel=None
1203))]
1204pub fn monotonicity_index_py<'py>(
1205    py: Python<'py>,
1206    data: PyReadonlyArray1<'py, f64>,
1207    length: usize,
1208    mode: &str,
1209    index_smooth: usize,
1210    kernel: Option<&str>,
1211) -> PyResult<(
1212    Bound<'py, PyArray1<f64>>,
1213    Bound<'py, PyArray1<f64>>,
1214    Bound<'py, PyArray1<f64>>,
1215)> {
1216    let data = data.as_slice()?;
1217    let kernel = validate_kernel(kernel, false)?;
1218    let input = MonotonicityIndexInput::from_slice(
1219        data,
1220        MonotonicityIndexParams {
1221            length: Some(length),
1222            mode: Some(parse_mode_py(mode)?),
1223            index_smooth: Some(index_smooth),
1224        },
1225    );
1226    let output = py
1227        .allow_threads(|| monotonicity_index_with_kernel(&input, kernel))
1228        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1229    Ok((
1230        output.index.into_pyarray(py),
1231        output.cumulative_mean.into_pyarray(py),
1232        output.upper_bound.into_pyarray(py),
1233    ))
1234}
1235
1236#[cfg(feature = "python")]
1237#[pyclass(name = "MonotonicityIndexStream")]
1238pub struct MonotonicityIndexStreamPy {
1239    stream: MonotonicityIndexStream,
1240}
1241
1242#[cfg(feature = "python")]
1243#[pymethods]
1244impl MonotonicityIndexStreamPy {
1245    #[new]
1246    #[pyo3(signature = (
1247        length=DEFAULT_LENGTH,
1248        mode="efficiency",
1249        index_smooth=DEFAULT_INDEX_SMOOTH
1250    ))]
1251    fn new(length: usize, mode: &str, index_smooth: usize) -> PyResult<Self> {
1252        let stream = MonotonicityIndexStream::try_new(MonotonicityIndexParams {
1253            length: Some(length),
1254            mode: Some(parse_mode_py(mode)?),
1255            index_smooth: Some(index_smooth),
1256        })
1257        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1258        Ok(Self { stream })
1259    }
1260
1261    fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> {
1262        self.stream.update(value)
1263    }
1264
1265    #[getter]
1266    fn warmup_period(&self) -> usize {
1267        self.stream.get_warmup_period()
1268    }
1269}
1270
1271#[cfg(feature = "python")]
1272#[pyfunction(name = "monotonicity_index_batch")]
1273#[pyo3(signature = (
1274    data,
1275    length_range=(DEFAULT_LENGTH, DEFAULT_LENGTH, 0),
1276    index_smooth_range=(DEFAULT_INDEX_SMOOTH, DEFAULT_INDEX_SMOOTH, 0),
1277    mode="efficiency",
1278    kernel=None
1279))]
1280pub fn monotonicity_index_batch_py<'py>(
1281    py: Python<'py>,
1282    data: PyReadonlyArray1<'py, f64>,
1283    length_range: (usize, usize, usize),
1284    index_smooth_range: (usize, usize, usize),
1285    mode: &str,
1286    kernel: Option<&str>,
1287) -> PyResult<Bound<'py, PyDict>> {
1288    let data = data.as_slice()?;
1289    let kernel = validate_kernel(kernel, true)?;
1290    let sweep = MonotonicityIndexBatchRange {
1291        length: length_range,
1292        index_smooth: index_smooth_range,
1293        mode: parse_mode_py(mode)?,
1294    };
1295    let combos =
1296        expand_grid_monotonicity_index(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
1297    let rows = combos.len();
1298    let cols = data.len();
1299    let total = rows
1300        .checked_mul(cols)
1301        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
1302
1303    let index_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1304    let cumulative_mean_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1305    let upper_bound_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1306    let index_slice = unsafe { index_arr.as_slice_mut()? };
1307    let cumulative_mean_slice = unsafe { cumulative_mean_arr.as_slice_mut()? };
1308    let upper_bound_slice = unsafe { upper_bound_arr.as_slice_mut()? };
1309
1310    let combos = py
1311        .allow_threads(|| {
1312            let batch = match kernel {
1313                Kernel::Auto => detect_best_batch_kernel(),
1314                other => other,
1315            };
1316            monotonicity_index_batch_inner_into(
1317                data,
1318                &sweep,
1319                batch.to_non_batch(),
1320                true,
1321                index_slice,
1322                cumulative_mean_slice,
1323                upper_bound_slice,
1324            )
1325        })
1326        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1327
1328    let dict = PyDict::new(py);
1329    dict.set_item("index", index_arr.reshape((rows, cols))?)?;
1330    dict.set_item(
1331        "cumulative_mean",
1332        cumulative_mean_arr.reshape((rows, cols))?,
1333    )?;
1334    dict.set_item("upper_bound", upper_bound_arr.reshape((rows, cols))?)?;
1335    dict.set_item(
1336        "lengths",
1337        combos
1338            .iter()
1339            .map(|combo| combo.length.unwrap_or(DEFAULT_LENGTH) as u64)
1340            .collect::<Vec<_>>()
1341            .into_pyarray(py),
1342    )?;
1343    dict.set_item(
1344        "modes",
1345        PyList::new(
1346            py,
1347            combos
1348                .iter()
1349                .map(|combo| combo.mode.unwrap_or_default().as_str())
1350                .collect::<Vec<_>>(),
1351        )?,
1352    )?;
1353    dict.set_item(
1354        "index_smooths",
1355        combos
1356            .iter()
1357            .map(|combo| combo.index_smooth.unwrap_or(DEFAULT_INDEX_SMOOTH) as u64)
1358            .collect::<Vec<_>>()
1359            .into_pyarray(py),
1360    )?;
1361    dict.set_item("rows", rows)?;
1362    dict.set_item("cols", cols)?;
1363    Ok(dict)
1364}
1365
1366#[cfg(feature = "python")]
1367pub fn register_monotonicity_index_module(
1368    module: &Bound<'_, pyo3::types::PyModule>,
1369) -> PyResult<()> {
1370    module.add_function(wrap_pyfunction!(monotonicity_index_py, module)?)?;
1371    module.add_function(wrap_pyfunction!(monotonicity_index_batch_py, module)?)?;
1372    module.add_class::<MonotonicityIndexStreamPy>()?;
1373    Ok(())
1374}
1375
1376#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1377fn parse_mode_js(value: &str) -> Result<MonotonicityIndexMode, JsValue> {
1378    MonotonicityIndexMode::parse(value)
1379        .ok_or_else(|| JsValue::from_str(&format!("Invalid mode: {value}")))
1380}
1381
1382#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1383#[derive(Serialize, Deserialize)]
1384pub struct MonotonicityIndexJsOutput {
1385    pub index: Vec<f64>,
1386    pub cumulative_mean: Vec<f64>,
1387    pub upper_bound: Vec<f64>,
1388}
1389
1390#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1391#[wasm_bindgen(js_name = "monotonicity_index_js")]
1392pub fn monotonicity_index_js(
1393    data: &[f64],
1394    length: usize,
1395    mode: &str,
1396    index_smooth: usize,
1397) -> Result<JsValue, JsValue> {
1398    let input = MonotonicityIndexInput::from_slice(
1399        data,
1400        MonotonicityIndexParams {
1401            length: Some(length),
1402            mode: Some(parse_mode_js(mode)?),
1403            index_smooth: Some(index_smooth),
1404        },
1405    );
1406    let output = monotonicity_index(&input).map_err(|e| JsValue::from_str(&e.to_string()))?;
1407    serde_wasm_bindgen::to_value(&MonotonicityIndexJsOutput {
1408        index: output.index,
1409        cumulative_mean: output.cumulative_mean,
1410        upper_bound: output.upper_bound,
1411    })
1412    .map_err(|e| JsValue::from_str(&e.to_string()))
1413}
1414
1415#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1416#[wasm_bindgen]
1417pub fn monotonicity_index_alloc(len: usize) -> *mut f64 {
1418    let mut vec = Vec::<f64>::with_capacity(len);
1419    let ptr = vec.as_mut_ptr();
1420    std::mem::forget(vec);
1421    ptr
1422}
1423
1424#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1425#[wasm_bindgen]
1426pub fn monotonicity_index_free(ptr: *mut f64, len: usize) {
1427    if !ptr.is_null() {
1428        unsafe {
1429            let _ = Vec::from_raw_parts(ptr, len, len);
1430        }
1431    }
1432}
1433
1434#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1435#[wasm_bindgen]
1436pub fn monotonicity_index_into(
1437    in_ptr: *const f64,
1438    index_out_ptr: *mut f64,
1439    cumulative_mean_out_ptr: *mut f64,
1440    upper_bound_out_ptr: *mut f64,
1441    len: usize,
1442    length: usize,
1443    mode: &str,
1444    index_smooth: usize,
1445) -> Result<(), JsValue> {
1446    if in_ptr.is_null()
1447        || index_out_ptr.is_null()
1448        || cumulative_mean_out_ptr.is_null()
1449        || upper_bound_out_ptr.is_null()
1450    {
1451        return Err(JsValue::from_str("Null pointer provided"));
1452    }
1453
1454    unsafe {
1455        let data = std::slice::from_raw_parts(in_ptr, len);
1456        let input = MonotonicityIndexInput::from_slice(
1457            data,
1458            MonotonicityIndexParams {
1459                length: Some(length),
1460                mode: Some(parse_mode_js(mode)?),
1461                index_smooth: Some(index_smooth),
1462            },
1463        );
1464        let index_out = std::slice::from_raw_parts_mut(index_out_ptr, len);
1465        let cumulative_mean_out = std::slice::from_raw_parts_mut(cumulative_mean_out_ptr, len);
1466        let upper_bound_out = std::slice::from_raw_parts_mut(upper_bound_out_ptr, len);
1467        monotonicity_index_into_slices(
1468            index_out,
1469            cumulative_mean_out,
1470            upper_bound_out,
1471            &input,
1472            Kernel::Auto,
1473        )
1474        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1475    }
1476    Ok(())
1477}
1478
1479#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1480#[derive(Serialize, Deserialize)]
1481pub struct MonotonicityIndexBatchJsConfig {
1482    pub length_range: Option<(usize, usize, usize)>,
1483    pub index_smooth_range: Option<(usize, usize, usize)>,
1484    pub mode: Option<MonotonicityIndexMode>,
1485}
1486
1487#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1488#[derive(Serialize, Deserialize)]
1489pub struct MonotonicityIndexBatchJsOutput {
1490    pub index: Vec<f64>,
1491    pub cumulative_mean: Vec<f64>,
1492    pub upper_bound: Vec<f64>,
1493    pub combos: Vec<MonotonicityIndexParams>,
1494    pub rows: usize,
1495    pub cols: usize,
1496}
1497
1498#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1499#[wasm_bindgen(js_name = "monotonicity_index_batch_js")]
1500pub fn monotonicity_index_batch_js(data: &[f64], config: JsValue) -> Result<JsValue, JsValue> {
1501    let config: MonotonicityIndexBatchJsConfig =
1502        serde_wasm_bindgen::from_value(config).map_err(|e| JsValue::from_str(&e.to_string()))?;
1503    let sweep = MonotonicityIndexBatchRange {
1504        length: config
1505            .length_range
1506            .unwrap_or((DEFAULT_LENGTH, DEFAULT_LENGTH, 0)),
1507        index_smooth: config.index_smooth_range.unwrap_or((
1508            DEFAULT_INDEX_SMOOTH,
1509            DEFAULT_INDEX_SMOOTH,
1510            0,
1511        )),
1512        mode: config.mode.unwrap_or_default(),
1513    };
1514    let output = monotonicity_index_batch_with_kernel(data, &sweep, Kernel::Auto)
1515        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1516    serde_wasm_bindgen::to_value(&MonotonicityIndexBatchJsOutput {
1517        index: output.index,
1518        cumulative_mean: output.cumulative_mean,
1519        upper_bound: output.upper_bound,
1520        combos: output.combos,
1521        rows: output.rows,
1522        cols: output.cols,
1523    })
1524    .map_err(|e| JsValue::from_str(&e.to_string()))
1525}
1526
1527#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1528#[wasm_bindgen]
1529pub fn monotonicity_index_batch_into(
1530    in_ptr: *const f64,
1531    index_out_ptr: *mut f64,
1532    cumulative_mean_out_ptr: *mut f64,
1533    upper_bound_out_ptr: *mut f64,
1534    len: usize,
1535    length_start: usize,
1536    length_end: usize,
1537    length_step: usize,
1538    index_smooth_start: usize,
1539    index_smooth_end: usize,
1540    index_smooth_step: usize,
1541    mode: &str,
1542) -> Result<usize, JsValue> {
1543    if in_ptr.is_null()
1544        || index_out_ptr.is_null()
1545        || cumulative_mean_out_ptr.is_null()
1546        || upper_bound_out_ptr.is_null()
1547    {
1548        return Err(JsValue::from_str("Null pointer provided"));
1549    }
1550
1551    let sweep = MonotonicityIndexBatchRange {
1552        length: (length_start, length_end, length_step),
1553        index_smooth: (index_smooth_start, index_smooth_end, index_smooth_step),
1554        mode: parse_mode_js(mode)?,
1555    };
1556
1557    unsafe {
1558        let data = std::slice::from_raw_parts(in_ptr, len);
1559        let combos = expand_grid_monotonicity_index(&sweep)
1560            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1561        let rows = combos.len();
1562        let total = rows
1563            .checked_mul(len)
1564            .ok_or_else(|| JsValue::from_str("rows*cols overflow"))?;
1565        let index_out = std::slice::from_raw_parts_mut(index_out_ptr, total);
1566        let cumulative_mean_out = std::slice::from_raw_parts_mut(cumulative_mean_out_ptr, total);
1567        let upper_bound_out = std::slice::from_raw_parts_mut(upper_bound_out_ptr, total);
1568        let rows = monotonicity_index_batch_inner_into(
1569            data,
1570            &sweep,
1571            Kernel::Auto,
1572            false,
1573            index_out,
1574            cumulative_mean_out,
1575            upper_bound_out,
1576        )
1577        .map_err(|e| JsValue::from_str(&e.to_string()))?
1578        .len();
1579        Ok(rows)
1580    }
1581}
1582
1583#[cfg(test)]
1584mod tests {
1585    use super::*;
1586    use crate::utilities::data_loader::Candles;
1587
1588    fn sample_source(length: usize) -> Vec<f64> {
1589        let mut out = Vec::with_capacity(length);
1590        for i in 0..length {
1591            let x = i as f64;
1592            out.push(100.0 + x * 0.05 + (x * 0.17).sin() * 2.4 + (x * 0.04).cos() * 0.8);
1593        }
1594        out
1595    }
1596
1597    fn sample_candles(length: usize) -> Candles {
1598        let open: Vec<f64> = (0..length)
1599            .map(|i| 100.0 + i as f64 * 0.04 + (i as f64 * 0.08).sin())
1600            .collect();
1601        let close: Vec<f64> = open
1602            .iter()
1603            .enumerate()
1604            .map(|(i, &o)| o + (i as f64 * 0.13).cos() * 0.9)
1605            .collect();
1606        let high: Vec<f64> = open
1607            .iter()
1608            .zip(close.iter())
1609            .enumerate()
1610            .map(|(i, (&o, &c))| o.max(c) + 0.6 + (i as f64 * 0.05).sin().abs() * 0.2)
1611            .collect();
1612        let low: Vec<f64> = open
1613            .iter()
1614            .zip(close.iter())
1615            .enumerate()
1616            .map(|(i, (&o, &c))| o.min(c) - 0.6 - (i as f64 * 0.03).cos().abs() * 0.2)
1617            .collect();
1618        Candles::new(
1619            (0..length as i64).collect(),
1620            open,
1621            high,
1622            low,
1623            close,
1624            vec![1_000.0; length],
1625        )
1626    }
1627
1628    fn assert_series_eq(left: &[f64], right: &[f64], tol: f64) {
1629        assert_eq!(left.len(), right.len());
1630        for (&lhs, &rhs) in left.iter().zip(right.iter()) {
1631            if lhs.is_nan() && rhs.is_nan() {
1632                continue;
1633            }
1634            assert!((lhs - rhs).abs() <= tol, "lhs={lhs}, rhs={rhs}");
1635        }
1636    }
1637
1638    #[test]
1639    fn monotonicity_index_output_contract() {
1640        let data = sample_source(256);
1641        let out = monotonicity_index(&MonotonicityIndexInput::from_slice(
1642            &data,
1643            MonotonicityIndexParams::default(),
1644        ))
1645        .unwrap();
1646
1647        assert_eq!(out.index.len(), data.len());
1648        assert_eq!(out.cumulative_mean.len(), data.len());
1649        assert_eq!(out.upper_bound.len(), data.len());
1650        assert_eq!(out.index.iter().position(|v| v.is_finite()), Some(23));
1651        assert_eq!(
1652            out.cumulative_mean.iter().position(|v| v.is_finite()),
1653            Some(23)
1654        );
1655        assert_eq!(out.upper_bound.iter().position(|v| v.is_finite()), Some(23));
1656        assert!(out.index.last().copied().unwrap().is_finite());
1657        assert!(out.cumulative_mean.last().copied().unwrap().is_finite());
1658        assert!(out.upper_bound.last().copied().unwrap().is_finite());
1659    }
1660
1661    #[test]
1662    fn monotonicity_index_rejects_invalid_parameters() {
1663        let data = sample_source(64);
1664
1665        let err = monotonicity_index(&MonotonicityIndexInput::from_slice(
1666            &data,
1667            MonotonicityIndexParams {
1668                length: Some(1),
1669                ..MonotonicityIndexParams::default()
1670            },
1671        ))
1672        .unwrap_err();
1673        assert!(matches!(err, MonotonicityIndexError::InvalidLength { .. }));
1674
1675        let err = monotonicity_index(&MonotonicityIndexInput::from_slice(
1676            &data,
1677            MonotonicityIndexParams {
1678                index_smooth: Some(0),
1679                ..MonotonicityIndexParams::default()
1680            },
1681        ))
1682        .unwrap_err();
1683        assert!(matches!(
1684            err,
1685            MonotonicityIndexError::InvalidIndexSmooth { .. }
1686        ));
1687    }
1688
1689    #[test]
1690    fn monotonicity_index_builder_supports_candles() {
1691        let candles = sample_candles(220);
1692        let out = MonotonicityIndexBuilder::new()
1693            .mode(MonotonicityIndexMode::Complexity)
1694            .apply(&candles, "close")
1695            .unwrap();
1696        assert_eq!(out.index.len(), candles.close.len());
1697        assert_eq!(out.cumulative_mean.len(), candles.close.len());
1698        assert_eq!(out.upper_bound.len(), candles.close.len());
1699        assert!(out.index.last().copied().unwrap().is_finite());
1700    }
1701
1702    #[test]
1703    fn monotonicity_index_stream_matches_batch_with_reset() {
1704        let mut data = sample_source(240);
1705        data[120] = f64::NAN;
1706
1707        let batch = monotonicity_index(&MonotonicityIndexInput::from_slice(
1708            &data,
1709            MonotonicityIndexParams::default(),
1710        ))
1711        .unwrap();
1712        let mut stream =
1713            MonotonicityIndexStream::try_new(MonotonicityIndexParams::default()).unwrap();
1714
1715        let mut index = Vec::with_capacity(data.len());
1716        let mut cumulative_mean = Vec::with_capacity(data.len());
1717        let mut upper_bound = Vec::with_capacity(data.len());
1718        for &value in &data {
1719            if let Some((idx, mean, upper)) = stream.update(value) {
1720                index.push(idx);
1721                cumulative_mean.push(mean);
1722                upper_bound.push(upper);
1723            } else {
1724                index.push(f64::NAN);
1725                cumulative_mean.push(f64::NAN);
1726                upper_bound.push(f64::NAN);
1727            }
1728        }
1729
1730        assert_series_eq(&index, &batch.index, 1e-12);
1731        assert_series_eq(&cumulative_mean, &batch.cumulative_mean, 1e-12);
1732        assert_series_eq(&upper_bound, &batch.upper_bound, 1e-12);
1733    }
1734
1735    #[test]
1736    fn monotonicity_index_batch_single_param_matches_single() {
1737        let data = sample_source(192);
1738        let sweep = MonotonicityIndexBatchRange::default();
1739        let batch = monotonicity_index_batch_with_kernel(&data, &sweep, Kernel::Auto).unwrap();
1740        let single = monotonicity_index(&MonotonicityIndexInput::from_slice(
1741            &data,
1742            MonotonicityIndexParams::default(),
1743        ))
1744        .unwrap();
1745
1746        assert_eq!(batch.rows, 1);
1747        assert_eq!(batch.cols, data.len());
1748        let (index_row, cumulative_mean_row, upper_bound_row) = batch.row_slices(0).unwrap();
1749        assert_series_eq(index_row, &single.index, 1e-12);
1750        assert_series_eq(cumulative_mean_row, &single.cumulative_mean, 1e-12);
1751        assert_series_eq(upper_bound_row, &single.upper_bound, 1e-12);
1752    }
1753
1754    #[test]
1755    fn monotonicity_index_batch_metadata() {
1756        let data = sample_source(160);
1757        let sweep = MonotonicityIndexBatchRange {
1758            length: (18, 20, 2),
1759            index_smooth: (4, 5, 1),
1760            mode: MonotonicityIndexMode::Complexity,
1761        };
1762        let batch = monotonicity_index_batch_with_kernel(&data, &sweep, Kernel::Auto).unwrap();
1763
1764        assert_eq!(batch.rows, 4);
1765        assert_eq!(batch.cols, data.len());
1766        assert_eq!(batch.index.len(), 4 * data.len());
1767        assert_eq!(batch.cumulative_mean.len(), 4 * data.len());
1768        assert_eq!(batch.upper_bound.len(), 4 * data.len());
1769        assert_eq!(
1770            batch.row_for_params(&MonotonicityIndexParams {
1771                length: Some(20),
1772                mode: Some(MonotonicityIndexMode::Complexity),
1773                index_smooth: Some(5),
1774            }),
1775            Some(3)
1776        );
1777    }
1778}