Skip to main content

vector_ta/indicators/
moving_average_cross_probability.rs

1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7#[cfg(feature = "python")]
8use pyo3::types::PyDict;
9#[cfg(feature = "python")]
10use pyo3::wrap_pyfunction;
11
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use serde::{Deserialize, Serialize};
14#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
15use wasm_bindgen::prelude::*;
16
17use crate::indicators::dispatch::{
18    compute_cpu_batch, IndicatorBatchRequest, IndicatorDataRef, IndicatorParamSet, ParamKV,
19    ParamValue,
20};
21use crate::indicators::moving_averages::ema::{EmaParams, EmaStream};
22use crate::indicators::moving_averages::hma::{HmaParams, HmaStream};
23use crate::indicators::moving_averages::sma::{SmaParams, SmaStream};
24use crate::indicators::stddev::{StdDevParams, StdDevStream};
25use crate::utilities::data_loader::Candles;
26use crate::utilities::enums::Kernel;
27use crate::utilities::helpers::{
28    alloc_with_nan_prefix, detect_best_batch_kernel, init_matrix_prefixes, make_uninit_matrix,
29};
30#[cfg(feature = "python")]
31use crate::utilities::kernel_validation::validate_kernel;
32#[cfg(not(target_arch = "wasm32"))]
33use rayon::prelude::*;
34use std::collections::VecDeque;
35use std::str::FromStr;
36use thiserror::Error;
37
38const DEFAULT_MA_TYPE: MovingAverageCrossProbabilityMaType =
39    MovingAverageCrossProbabilityMaType::Ema;
40const DEFAULT_SMOOTHING_WINDOW: usize = 7;
41const DEFAULT_SLOW_LENGTH: usize = 30;
42const DEFAULT_FAST_LENGTH: usize = 14;
43const DEFAULT_RESOLUTION: usize = 50;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[cfg_attr(
47    all(target_arch = "wasm32", feature = "wasm"),
48    derive(Serialize, Deserialize),
49    serde(rename_all = "snake_case")
50)]
51pub enum MovingAverageCrossProbabilityMaType {
52    Ema,
53    Sma,
54}
55
56impl Default for MovingAverageCrossProbabilityMaType {
57    fn default() -> Self {
58        DEFAULT_MA_TYPE
59    }
60}
61
62impl MovingAverageCrossProbabilityMaType {
63    #[inline(always)]
64    fn as_str(self) -> &'static str {
65        match self {
66            Self::Ema => "ema",
67            Self::Sma => "sma",
68        }
69    }
70}
71
72impl FromStr for MovingAverageCrossProbabilityMaType {
73    type Err = String;
74
75    fn from_str(value: &str) -> Result<Self, Self::Err> {
76        match value.trim().to_ascii_lowercase().as_str() {
77            "ema" => Ok(Self::Ema),
78            "sma" => Ok(Self::Sma),
79            _ => Err(format!("invalid ma_type: {value}")),
80        }
81    }
82}
83
84#[derive(Debug, Clone)]
85pub enum MovingAverageCrossProbabilityData<'a> {
86    Candles { candles: &'a Candles },
87    Slice(&'a [f64]),
88}
89
90#[derive(Debug, Clone)]
91pub struct MovingAverageCrossProbabilityOutput {
92    pub value: Vec<f64>,
93    pub slow_ma: Vec<f64>,
94    pub fast_ma: Vec<f64>,
95    pub forecast: Vec<f64>,
96    pub upper: Vec<f64>,
97    pub lower: Vec<f64>,
98    pub direction: Vec<f64>,
99}
100
101#[derive(Debug, Clone)]
102#[cfg_attr(
103    all(target_arch = "wasm32", feature = "wasm"),
104    derive(Serialize, Deserialize)
105)]
106pub struct MovingAverageCrossProbabilityParams {
107    pub ma_type: Option<MovingAverageCrossProbabilityMaType>,
108    pub smoothing_window: Option<usize>,
109    pub slow_length: Option<usize>,
110    pub fast_length: Option<usize>,
111    pub resolution: Option<usize>,
112}
113
114impl Default for MovingAverageCrossProbabilityParams {
115    fn default() -> Self {
116        Self {
117            ma_type: Some(DEFAULT_MA_TYPE),
118            smoothing_window: Some(DEFAULT_SMOOTHING_WINDOW),
119            slow_length: Some(DEFAULT_SLOW_LENGTH),
120            fast_length: Some(DEFAULT_FAST_LENGTH),
121            resolution: Some(DEFAULT_RESOLUTION),
122        }
123    }
124}
125
126#[derive(Debug, Clone)]
127pub struct MovingAverageCrossProbabilityInput<'a> {
128    pub data: MovingAverageCrossProbabilityData<'a>,
129    pub params: MovingAverageCrossProbabilityParams,
130}
131
132impl<'a> MovingAverageCrossProbabilityInput<'a> {
133    #[inline]
134    pub fn from_candles(candles: &'a Candles, params: MovingAverageCrossProbabilityParams) -> Self {
135        Self {
136            data: MovingAverageCrossProbabilityData::Candles { candles },
137            params,
138        }
139    }
140
141    #[inline]
142    pub fn from_slice(data: &'a [f64], params: MovingAverageCrossProbabilityParams) -> Self {
143        Self {
144            data: MovingAverageCrossProbabilityData::Slice(data),
145            params,
146        }
147    }
148
149    #[inline]
150    pub fn with_default_candles(candles: &'a Candles) -> Self {
151        Self::from_candles(candles, MovingAverageCrossProbabilityParams::default())
152    }
153}
154
155#[derive(Copy, Clone, Debug)]
156pub struct MovingAverageCrossProbabilityBuilder {
157    ma_type: Option<MovingAverageCrossProbabilityMaType>,
158    smoothing_window: Option<usize>,
159    slow_length: Option<usize>,
160    fast_length: Option<usize>,
161    resolution: Option<usize>,
162    kernel: Kernel,
163}
164
165impl Default for MovingAverageCrossProbabilityBuilder {
166    fn default() -> Self {
167        Self {
168            ma_type: None,
169            smoothing_window: None,
170            slow_length: None,
171            fast_length: None,
172            resolution: None,
173            kernel: Kernel::Auto,
174        }
175    }
176}
177
178impl MovingAverageCrossProbabilityBuilder {
179    #[inline(always)]
180    pub fn new() -> Self {
181        Self::default()
182    }
183
184    #[inline(always)]
185    pub fn ma_type(mut self, value: MovingAverageCrossProbabilityMaType) -> Self {
186        self.ma_type = Some(value);
187        self
188    }
189
190    #[inline(always)]
191    pub fn smoothing_window(mut self, value: usize) -> Self {
192        self.smoothing_window = Some(value);
193        self
194    }
195
196    #[inline(always)]
197    pub fn slow_length(mut self, value: usize) -> Self {
198        self.slow_length = Some(value);
199        self
200    }
201
202    #[inline(always)]
203    pub fn fast_length(mut self, value: usize) -> Self {
204        self.fast_length = Some(value);
205        self
206    }
207
208    #[inline(always)]
209    pub fn resolution(mut self, value: usize) -> Self {
210        self.resolution = Some(value);
211        self
212    }
213
214    #[inline(always)]
215    pub fn kernel(mut self, value: Kernel) -> Self {
216        self.kernel = value;
217        self
218    }
219
220    #[inline(always)]
221    pub fn apply(
222        self,
223        candles: &Candles,
224    ) -> Result<MovingAverageCrossProbabilityOutput, MovingAverageCrossProbabilityError> {
225        let input = MovingAverageCrossProbabilityInput::from_candles(
226            candles,
227            MovingAverageCrossProbabilityParams {
228                ma_type: self.ma_type,
229                smoothing_window: self.smoothing_window,
230                slow_length: self.slow_length,
231                fast_length: self.fast_length,
232                resolution: self.resolution,
233            },
234        );
235        moving_average_cross_probability_with_kernel(&input, self.kernel)
236    }
237
238    #[inline(always)]
239    pub fn apply_slice(
240        self,
241        data: &[f64],
242    ) -> Result<MovingAverageCrossProbabilityOutput, MovingAverageCrossProbabilityError> {
243        let input = MovingAverageCrossProbabilityInput::from_slice(
244            data,
245            MovingAverageCrossProbabilityParams {
246                ma_type: self.ma_type,
247                smoothing_window: self.smoothing_window,
248                slow_length: self.slow_length,
249                fast_length: self.fast_length,
250                resolution: self.resolution,
251            },
252        );
253        moving_average_cross_probability_with_kernel(&input, self.kernel)
254    }
255
256    #[inline(always)]
257    pub fn into_stream(
258        self,
259    ) -> Result<MovingAverageCrossProbabilityStream, MovingAverageCrossProbabilityError> {
260        MovingAverageCrossProbabilityStream::try_new(MovingAverageCrossProbabilityParams {
261            ma_type: self.ma_type,
262            smoothing_window: self.smoothing_window,
263            slow_length: self.slow_length,
264            fast_length: self.fast_length,
265            resolution: self.resolution,
266        })
267    }
268}
269
270#[derive(Debug, Error)]
271pub enum MovingAverageCrossProbabilityError {
272    #[error("moving_average_cross_probability: Input data slice is empty.")]
273    EmptyInputData,
274    #[error("moving_average_cross_probability: All values are NaN.")]
275    AllValuesNaN,
276    #[error("moving_average_cross_probability: Invalid smoothing_window: {smoothing_window}")]
277    InvalidSmoothingWindow { smoothing_window: usize },
278    #[error("moving_average_cross_probability: Invalid slow_length: {slow_length}")]
279    InvalidSlowLength { slow_length: usize },
280    #[error("moving_average_cross_probability: Invalid fast_length: {fast_length}")]
281    InvalidFastLength { fast_length: usize },
282    #[error("moving_average_cross_probability: Invalid resolution: {resolution}")]
283    InvalidResolution { resolution: usize },
284    #[error("moving_average_cross_probability: Invalid length order: fast_length={fast_length}, slow_length={slow_length}")]
285    InvalidLengthOrder {
286        fast_length: usize,
287        slow_length: usize,
288    },
289    #[error(
290        "moving_average_cross_probability: Output length mismatch: expected={expected}, got={got}"
291    )]
292    OutputLengthMismatch { expected: usize, got: usize },
293    #[error(
294        "moving_average_cross_probability: Invalid range: start={start}, end={end}, step={step}"
295    )]
296    InvalidRange {
297        start: String,
298        end: String,
299        step: String,
300    },
301    #[error("moving_average_cross_probability: Invalid kernel for batch: {0:?}")]
302    InvalidKernelForBatch(Kernel),
303}
304
305#[derive(Debug, Clone)]
306struct ResolvedParams {
307    ma_type: MovingAverageCrossProbabilityMaType,
308    smoothing_window: usize,
309    slow_length: usize,
310    fast_length: usize,
311    resolution: usize,
312    history_window_len: usize,
313    slow_alpha: f64,
314    slow_beta: f64,
315    fast_alpha: f64,
316    fast_beta: f64,
317    slow_ma_warmup: usize,
318    fast_ma_warmup: usize,
319    direction_warmup: usize,
320    forecast_warmup: usize,
321    probability_warmup: usize,
322}
323
324#[derive(Debug, Clone)]
325enum CurrentMaStream {
326    Ema(EmaStream),
327    Sma(SmaStream),
328}
329
330impl CurrentMaStream {
331    #[inline(always)]
332    fn try_new(
333        ma_type: MovingAverageCrossProbabilityMaType,
334        period: usize,
335    ) -> Result<Self, MovingAverageCrossProbabilityError> {
336        match ma_type {
337            MovingAverageCrossProbabilityMaType::Ema => Ok(Self::Ema(
338                EmaStream::try_new(EmaParams {
339                    period: Some(period),
340                })
341                .map_err(|_| {
342                    MovingAverageCrossProbabilityError::InvalidSlowLength {
343                        slow_length: period,
344                    }
345                })?,
346            )),
347            MovingAverageCrossProbabilityMaType::Sma => Ok(Self::Sma(
348                SmaStream::try_new(SmaParams {
349                    period: Some(period),
350                })
351                .map_err(|_| {
352                    MovingAverageCrossProbabilityError::InvalidSlowLength {
353                        slow_length: period,
354                    }
355                })?,
356            )),
357        }
358    }
359
360    #[inline(always)]
361    fn update(&mut self, value: f64) -> Option<f64> {
362        match self {
363            Self::Ema(stream) => stream.update(value),
364            Self::Sma(stream) => stream.update(value),
365        }
366    }
367}
368
369#[inline(always)]
370fn resolve_params(
371    params: &MovingAverageCrossProbabilityParams,
372) -> Result<ResolvedParams, MovingAverageCrossProbabilityError> {
373    let ma_type = params.ma_type.unwrap_or(DEFAULT_MA_TYPE);
374    let smoothing_window = params.smoothing_window.unwrap_or(DEFAULT_SMOOTHING_WINDOW);
375    let slow_length = params.slow_length.unwrap_or(DEFAULT_SLOW_LENGTH);
376    let fast_length = params.fast_length.unwrap_or(DEFAULT_FAST_LENGTH);
377    let resolution = params.resolution.unwrap_or(DEFAULT_RESOLUTION);
378
379    if smoothing_window < 2 {
380        return Err(MovingAverageCrossProbabilityError::InvalidSmoothingWindow {
381            smoothing_window,
382        });
383    }
384    if slow_length < 2 {
385        return Err(MovingAverageCrossProbabilityError::InvalidSlowLength { slow_length });
386    }
387    if fast_length == 0 {
388        return Err(MovingAverageCrossProbabilityError::InvalidFastLength { fast_length });
389    }
390    if slow_length <= fast_length {
391        return Err(MovingAverageCrossProbabilityError::InvalidLengthOrder {
392            fast_length,
393            slow_length,
394        });
395    }
396    if resolution < 2 {
397        return Err(MovingAverageCrossProbabilityError::InvalidResolution { resolution });
398    }
399
400    let sqrt_len = (smoothing_window as f64).sqrt().floor() as usize;
401    let forecast_warmup = smoothing_window + sqrt_len - 1;
402    let slow_ma_warmup = slow_length - 1;
403    let fast_ma_warmup = fast_length - 1;
404    let direction_warmup = slow_ma_warmup.max(fast_ma_warmup);
405    let probability_warmup = forecast_warmup.max(direction_warmup).max(2 * slow_length);
406
407    Ok(ResolvedParams {
408        ma_type,
409        smoothing_window,
410        slow_length,
411        fast_length,
412        resolution,
413        history_window_len: 2 * slow_length + 1,
414        slow_alpha: 2.0 / (slow_length as f64 + 1.0),
415        slow_beta: 1.0 - 2.0 / (slow_length as f64 + 1.0),
416        fast_alpha: 2.0 / (fast_length as f64 + 1.0),
417        fast_beta: 1.0 - 2.0 / (fast_length as f64 + 1.0),
418        slow_ma_warmup,
419        fast_ma_warmup,
420        direction_warmup,
421        forecast_warmup,
422        probability_warmup,
423    })
424}
425
426#[inline(always)]
427fn extract_slice<'a>(
428    input: &'a MovingAverageCrossProbabilityInput<'a>,
429) -> Result<&'a [f64], MovingAverageCrossProbabilityError> {
430    let data = match &input.data {
431        MovingAverageCrossProbabilityData::Candles { candles } => candles.close.as_slice(),
432        MovingAverageCrossProbabilityData::Slice(values) => *values,
433    };
434    if data.is_empty() {
435        return Err(MovingAverageCrossProbabilityError::EmptyInputData);
436    }
437    if !data.iter().any(|v| v.is_finite()) {
438        return Err(MovingAverageCrossProbabilityError::AllValuesNaN);
439    }
440    Ok(data)
441}
442
443#[inline(always)]
444fn check_output_len(
445    out: &[f64],
446    expected: usize,
447) -> Result<(), MovingAverageCrossProbabilityError> {
448    if out.len() != expected {
449        return Err(MovingAverageCrossProbabilityError::OutputLengthMismatch {
450            expected,
451            got: out.len(),
452        });
453    }
454    Ok(())
455}
456
457#[inline(always)]
458fn truncated_ema_from_window(window: &VecDeque<f64>, alpha: f64, beta: f64) -> Option<f64> {
459    let mut iter = window.iter().rev();
460    let mut ema = *iter.next()?;
461    if !ema.is_finite() {
462        return None;
463    }
464    for value in iter {
465        if !value.is_finite() {
466            return None;
467        }
468        ema = alpha.mul_add(*value, beta * ema);
469    }
470    Some(ema)
471}
472
473#[inline(always)]
474fn probability_from_window(
475    window: &VecDeque<f64>,
476    params: &ResolvedParams,
477    lower: f64,
478    upper: f64,
479    direction: f64,
480) -> Option<f64> {
481    let step = (upper - lower) / (params.resolution - 1) as f64;
482    let mut hits = 0usize;
483
484    match params.ma_type {
485        MovingAverageCrossProbabilityMaType::Ema => {
486            let slow_current =
487                truncated_ema_from_window(window, params.slow_alpha, params.slow_beta)?;
488            let fast_current =
489                truncated_ema_from_window(window, params.fast_alpha, params.fast_beta)?;
490            for idx in 0..params.resolution {
491                let price = lower + step * idx as f64;
492                let slow_future = params
493                    .slow_alpha
494                    .mul_add(price, params.slow_beta * slow_current);
495                let fast_future = params
496                    .fast_alpha
497                    .mul_add(price, params.fast_beta * fast_current);
498                let crossed = if direction < 0.0 {
499                    slow_future > fast_future
500                } else {
501                    slow_future <= fast_future
502                };
503                if crossed {
504                    hits += 1;
505                }
506            }
507        }
508        MovingAverageCrossProbabilityMaType::Sma => {
509            let slow_needed = params.slow_length.saturating_sub(1);
510            let fast_needed = params.fast_length.saturating_sub(1);
511            let mut slow_sum = 0.0;
512            let mut fast_sum = 0.0;
513            for (idx, value) in window.iter().enumerate() {
514                if idx < slow_needed {
515                    slow_sum += *value;
516                }
517                if idx < fast_needed {
518                    fast_sum += *value;
519                }
520                if idx >= slow_needed && idx >= fast_needed {
521                    break;
522                }
523            }
524            for idx in 0..params.resolution {
525                let price = lower + step * idx as f64;
526                let slow_future = (price + slow_sum) / params.slow_length as f64;
527                let fast_future = (price + fast_sum) / params.fast_length as f64;
528                let crossed = if direction < 0.0 {
529                    slow_future > fast_future
530                } else {
531                    slow_future <= fast_future
532                };
533                if crossed {
534                    hits += 1;
535                }
536            }
537        }
538    }
539
540    Some(100.0 * hits as f64 / params.resolution as f64)
541}
542
543#[inline(always)]
544fn moving_average_cross_probability_compute_into(
545    data: &[f64],
546    params: &ResolvedParams,
547    out_value: &mut [f64],
548    out_slow_ma: &mut [f64],
549    out_fast_ma: &mut [f64],
550    out_forecast: &mut [f64],
551    out_upper: &mut [f64],
552    out_lower: &mut [f64],
553    out_direction: &mut [f64],
554) -> Result<(), MovingAverageCrossProbabilityError> {
555    let len = data.len();
556    check_output_len(out_value, len)?;
557    check_output_len(out_slow_ma, len)?;
558    check_output_len(out_fast_ma, len)?;
559    check_output_len(out_forecast, len)?;
560    check_output_len(out_upper, len)?;
561    check_output_len(out_lower, len)?;
562    check_output_len(out_direction, len)?;
563
564    out_value.fill(f64::NAN);
565    out_slow_ma.fill(f64::NAN);
566    out_fast_ma.fill(f64::NAN);
567    out_forecast.fill(f64::NAN);
568    out_upper.fill(f64::NAN);
569    out_lower.fill(f64::NAN);
570    out_direction.fill(f64::NAN);
571
572    let mut slow_stream = CurrentMaStream::try_new(params.ma_type, params.slow_length)?;
573    let mut fast_stream = CurrentMaStream::try_new(params.ma_type, params.fast_length)?;
574    let mut hma_stream = HmaStream::try_new(HmaParams {
575        period: Some(params.smoothing_window),
576    })
577    .map_err(
578        |_| MovingAverageCrossProbabilityError::InvalidSmoothingWindow {
579            smoothing_window: params.smoothing_window,
580        },
581    )?;
582    let mut stddev_stream = StdDevStream::try_new(StdDevParams {
583        period: Some(params.smoothing_window),
584        nbdev: Some(4.0),
585    })
586    .map_err(
587        |_| MovingAverageCrossProbabilityError::InvalidSmoothingWindow {
588            smoothing_window: params.smoothing_window,
589        },
590    )?;
591
592    let mut history: VecDeque<f64> = VecDeque::with_capacity(params.history_window_len);
593    let mut invalid_history = 0usize;
594    let mut previous_hma = f64::NAN;
595
596    for (idx, &value) in data.iter().enumerate() {
597        if history.len() == params.history_window_len {
598            if let Some(old) = history.pop_back() {
599                if !old.is_finite() {
600                    invalid_history = invalid_history.saturating_sub(1);
601                }
602            }
603        }
604        history.push_front(value);
605        if !value.is_finite() {
606            invalid_history += 1;
607        }
608
609        let slow_ma = slow_stream.update(value).unwrap_or(f64::NAN);
610        let fast_ma = fast_stream.update(value).unwrap_or(f64::NAN);
611        let current_hma = hma_stream.update(value).unwrap_or(f64::NAN);
612        let current_std = stddev_stream.update(value).unwrap_or(f64::NAN);
613
614        out_slow_ma[idx] = slow_ma;
615        out_fast_ma[idx] = fast_ma;
616
617        let direction = if slow_ma.is_finite() && fast_ma.is_finite() {
618            if fast_ma > slow_ma {
619                -1.0
620            } else {
621                1.0
622            }
623        } else {
624            f64::NAN
625        };
626        out_direction[idx] = direction;
627
628        if current_hma.is_finite() && previous_hma.is_finite() && current_std.is_finite() {
629            let forecast = current_hma + (current_hma - previous_hma);
630            out_forecast[idx] = forecast;
631            out_upper[idx] = forecast + current_std;
632            out_lower[idx] = forecast - current_std;
633
634            if direction.is_finite()
635                && history.len() == params.history_window_len
636                && invalid_history == 0
637                && out_upper[idx].is_finite()
638                && out_lower[idx].is_finite()
639            {
640                if let Some(probability) = probability_from_window(
641                    &history,
642                    params,
643                    out_lower[idx],
644                    out_upper[idx],
645                    direction,
646                ) {
647                    out_value[idx] = probability;
648                }
649            }
650        }
651
652        previous_hma = current_hma;
653    }
654
655    Ok(())
656}
657
658#[inline]
659pub fn moving_average_cross_probability(
660    input: &MovingAverageCrossProbabilityInput,
661) -> Result<MovingAverageCrossProbabilityOutput, MovingAverageCrossProbabilityError> {
662    moving_average_cross_probability_with_kernel(input, Kernel::Auto)
663}
664
665pub fn moving_average_cross_probability_with_kernel(
666    input: &MovingAverageCrossProbabilityInput,
667    _kernel: Kernel,
668) -> Result<MovingAverageCrossProbabilityOutput, MovingAverageCrossProbabilityError> {
669    let data = extract_slice(input)?;
670    let params = resolve_params(&input.params)?;
671    let len = data.len();
672
673    let mut value = alloc_with_nan_prefix(len, params.probability_warmup.min(len));
674    let mut slow_ma = alloc_with_nan_prefix(len, params.slow_ma_warmup.min(len));
675    let mut fast_ma = alloc_with_nan_prefix(len, params.fast_ma_warmup.min(len));
676    let mut forecast = alloc_with_nan_prefix(len, params.forecast_warmup.min(len));
677    let mut upper = alloc_with_nan_prefix(len, params.forecast_warmup.min(len));
678    let mut lower = alloc_with_nan_prefix(len, params.forecast_warmup.min(len));
679    let mut direction = alloc_with_nan_prefix(len, params.direction_warmup.min(len));
680
681    moving_average_cross_probability_compute_into(
682        data,
683        &params,
684        &mut value,
685        &mut slow_ma,
686        &mut fast_ma,
687        &mut forecast,
688        &mut upper,
689        &mut lower,
690        &mut direction,
691    )?;
692
693    Ok(MovingAverageCrossProbabilityOutput {
694        value,
695        slow_ma,
696        fast_ma,
697        forecast,
698        upper,
699        lower,
700        direction,
701    })
702}
703
704#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
705pub fn moving_average_cross_probability_into(
706    input: &MovingAverageCrossProbabilityInput,
707    out_value: &mut [f64],
708    out_slow_ma: &mut [f64],
709    out_fast_ma: &mut [f64],
710    out_forecast: &mut [f64],
711    out_upper: &mut [f64],
712    out_lower: &mut [f64],
713    out_direction: &mut [f64],
714) -> Result<(), MovingAverageCrossProbabilityError> {
715    moving_average_cross_probability_into_slice(
716        out_value,
717        out_slow_ma,
718        out_fast_ma,
719        out_forecast,
720        out_upper,
721        out_lower,
722        out_direction,
723        input,
724        Kernel::Auto,
725    )
726}
727
728pub fn moving_average_cross_probability_into_slice(
729    out_value: &mut [f64],
730    out_slow_ma: &mut [f64],
731    out_fast_ma: &mut [f64],
732    out_forecast: &mut [f64],
733    out_upper: &mut [f64],
734    out_lower: &mut [f64],
735    out_direction: &mut [f64],
736    input: &MovingAverageCrossProbabilityInput,
737    _kernel: Kernel,
738) -> Result<(), MovingAverageCrossProbabilityError> {
739    let data = extract_slice(input)?;
740    let params = resolve_params(&input.params)?;
741    moving_average_cross_probability_compute_into(
742        data,
743        &params,
744        out_value,
745        out_slow_ma,
746        out_fast_ma,
747        out_forecast,
748        out_upper,
749        out_lower,
750        out_direction,
751    )
752}
753
754#[derive(Debug)]
755pub struct MovingAverageCrossProbabilityStream {
756    params: ResolvedParams,
757    slow_stream: CurrentMaStream,
758    fast_stream: CurrentMaStream,
759    hma_stream: HmaStream,
760    stddev_stream: StdDevStream,
761    history: VecDeque<f64>,
762    invalid_history: usize,
763    previous_hma: f64,
764}
765
766impl MovingAverageCrossProbabilityStream {
767    pub fn try_new(
768        params: MovingAverageCrossProbabilityParams,
769    ) -> Result<Self, MovingAverageCrossProbabilityError> {
770        let params = resolve_params(&params)?;
771        Ok(Self {
772            slow_stream: CurrentMaStream::try_new(params.ma_type, params.slow_length)?,
773            fast_stream: CurrentMaStream::try_new(params.ma_type, params.fast_length)?,
774            hma_stream: HmaStream::try_new(HmaParams {
775                period: Some(params.smoothing_window),
776            })
777            .map_err(|_| {
778                MovingAverageCrossProbabilityError::InvalidSmoothingWindow {
779                    smoothing_window: params.smoothing_window,
780                }
781            })?,
782            stddev_stream: StdDevStream::try_new(StdDevParams {
783                period: Some(params.smoothing_window),
784                nbdev: Some(4.0),
785            })
786            .map_err(|_| {
787                MovingAverageCrossProbabilityError::InvalidSmoothingWindow {
788                    smoothing_window: params.smoothing_window,
789                }
790            })?,
791            history: VecDeque::with_capacity(params.history_window_len),
792            invalid_history: 0,
793            previous_hma: f64::NAN,
794            params,
795        })
796    }
797
798    #[inline(always)]
799    pub fn update(&mut self, value: f64) -> (f64, f64, f64, f64, f64, f64, f64) {
800        if self.history.len() == self.params.history_window_len {
801            if let Some(old) = self.history.pop_back() {
802                if !old.is_finite() {
803                    self.invalid_history = self.invalid_history.saturating_sub(1);
804                }
805            }
806        }
807        self.history.push_front(value);
808        if !value.is_finite() {
809            self.invalid_history += 1;
810        }
811
812        let slow_ma = self.slow_stream.update(value).unwrap_or(f64::NAN);
813        let fast_ma = self.fast_stream.update(value).unwrap_or(f64::NAN);
814        let current_hma = self.hma_stream.update(value).unwrap_or(f64::NAN);
815        let current_std = self.stddev_stream.update(value).unwrap_or(f64::NAN);
816        let direction = if slow_ma.is_finite() && fast_ma.is_finite() {
817            if fast_ma > slow_ma {
818                -1.0
819            } else {
820                1.0
821            }
822        } else {
823            f64::NAN
824        };
825
826        let mut forecast = f64::NAN;
827        let mut upper = f64::NAN;
828        let mut lower = f64::NAN;
829        let mut probability = f64::NAN;
830        if current_hma.is_finite() && self.previous_hma.is_finite() && current_std.is_finite() {
831            forecast = current_hma + (current_hma - self.previous_hma);
832            upper = forecast + current_std;
833            lower = forecast - current_std;
834            if direction.is_finite()
835                && self.history.len() == self.params.history_window_len
836                && self.invalid_history == 0
837            {
838                probability =
839                    probability_from_window(&self.history, &self.params, lower, upper, direction)
840                        .unwrap_or(f64::NAN);
841            }
842        }
843        self.previous_hma = current_hma;
844
845        (
846            probability,
847            slow_ma,
848            fast_ma,
849            forecast,
850            upper,
851            lower,
852            direction,
853        )
854    }
855}
856
857#[derive(Clone, Debug)]
858pub struct MovingAverageCrossProbabilityBatchRange {
859    pub smoothing_window: (usize, usize, usize),
860    pub slow_length: (usize, usize, usize),
861    pub fast_length: (usize, usize, usize),
862    pub resolution: (usize, usize, usize),
863    pub ma_type: MovingAverageCrossProbabilityMaType,
864}
865
866impl Default for MovingAverageCrossProbabilityBatchRange {
867    fn default() -> Self {
868        Self {
869            smoothing_window: (DEFAULT_SMOOTHING_WINDOW, DEFAULT_SMOOTHING_WINDOW, 0),
870            slow_length: (DEFAULT_SLOW_LENGTH, DEFAULT_SLOW_LENGTH, 0),
871            fast_length: (DEFAULT_FAST_LENGTH, DEFAULT_FAST_LENGTH, 0),
872            resolution: (DEFAULT_RESOLUTION, DEFAULT_RESOLUTION, 0),
873            ma_type: DEFAULT_MA_TYPE,
874        }
875    }
876}
877
878#[derive(Clone, Debug, Default)]
879pub struct MovingAverageCrossProbabilityBatchBuilder {
880    range: MovingAverageCrossProbabilityBatchRange,
881    kernel: Kernel,
882}
883
884impl MovingAverageCrossProbabilityBatchBuilder {
885    pub fn new() -> Self {
886        Self::default()
887    }
888
889    pub fn kernel(mut self, kernel: Kernel) -> Self {
890        self.kernel = kernel;
891        self
892    }
893
894    #[inline(always)]
895    pub fn smoothing_window_range(mut self, start: usize, end: usize, step: usize) -> Self {
896        self.range.smoothing_window = (start, end, step);
897        self
898    }
899
900    #[inline(always)]
901    pub fn slow_length_range(mut self, start: usize, end: usize, step: usize) -> Self {
902        self.range.slow_length = (start, end, step);
903        self
904    }
905
906    #[inline(always)]
907    pub fn fast_length_range(mut self, start: usize, end: usize, step: usize) -> Self {
908        self.range.fast_length = (start, end, step);
909        self
910    }
911
912    #[inline(always)]
913    pub fn resolution_range(mut self, start: usize, end: usize, step: usize) -> Self {
914        self.range.resolution = (start, end, step);
915        self
916    }
917
918    #[inline(always)]
919    pub fn ma_type(mut self, value: MovingAverageCrossProbabilityMaType) -> Self {
920        self.range.ma_type = value;
921        self
922    }
923
924    pub fn apply_slice(
925        self,
926        data: &[f64],
927    ) -> Result<MovingAverageCrossProbabilityBatchOutput, MovingAverageCrossProbabilityError> {
928        moving_average_cross_probability_batch_with_kernel(data, &self.range, self.kernel)
929    }
930
931    pub fn apply(
932        self,
933        candles: &Candles,
934    ) -> Result<MovingAverageCrossProbabilityBatchOutput, MovingAverageCrossProbabilityError> {
935        self.apply_slice(candles.close.as_slice())
936    }
937}
938
939#[derive(Clone, Debug)]
940pub struct MovingAverageCrossProbabilityBatchOutput {
941    pub value: Vec<f64>,
942    pub slow_ma: Vec<f64>,
943    pub fast_ma: Vec<f64>,
944    pub forecast: Vec<f64>,
945    pub upper: Vec<f64>,
946    pub lower: Vec<f64>,
947    pub direction: Vec<f64>,
948    pub combos: Vec<MovingAverageCrossProbabilityParams>,
949    pub rows: usize,
950    pub cols: usize,
951}
952
953fn axis_usize(
954    (start, end, step): (usize, usize, usize),
955) -> Result<Vec<usize>, MovingAverageCrossProbabilityError> {
956    if step == 0 || start == end {
957        return Ok(vec![start]);
958    }
959    let mut values = Vec::new();
960    if start < end {
961        let mut current = start;
962        while current <= end {
963            values.push(current);
964            let next = current.saturating_add(step);
965            if next <= current {
966                break;
967            }
968            current = next;
969        }
970    } else {
971        let mut current = start;
972        while current >= end {
973            values.push(current);
974            let next = current.saturating_sub(step);
975            if next == current {
976                break;
977            }
978            current = next;
979            if current == 0 && end > 0 {
980                break;
981            }
982        }
983    }
984    if values.is_empty() {
985        return Err(MovingAverageCrossProbabilityError::InvalidRange {
986            start: start.to_string(),
987            end: end.to_string(),
988            step: step.to_string(),
989        });
990    }
991    Ok(values)
992}
993
994pub fn moving_average_cross_probability_expand_grid(
995    range: &MovingAverageCrossProbabilityBatchRange,
996) -> Result<Vec<MovingAverageCrossProbabilityParams>, MovingAverageCrossProbabilityError> {
997    let smoothing_windows = axis_usize(range.smoothing_window)?;
998    let slow_lengths = axis_usize(range.slow_length)?;
999    let fast_lengths = axis_usize(range.fast_length)?;
1000    let resolutions = axis_usize(range.resolution)?;
1001
1002    let cap = smoothing_windows
1003        .len()
1004        .checked_mul(slow_lengths.len())
1005        .and_then(|v| v.checked_mul(fast_lengths.len()))
1006        .and_then(|v| v.checked_mul(resolutions.len()))
1007        .ok_or_else(|| MovingAverageCrossProbabilityError::InvalidRange {
1008            start: "grid".to_string(),
1009            end: "overflow".to_string(),
1010            step: "n/a".to_string(),
1011        })?;
1012
1013    let mut out = Vec::with_capacity(cap);
1014    for &smoothing_window in &smoothing_windows {
1015        for &slow_length in &slow_lengths {
1016            for &fast_length in &fast_lengths {
1017                for &resolution in &resolutions {
1018                    out.push(MovingAverageCrossProbabilityParams {
1019                        ma_type: Some(range.ma_type),
1020                        smoothing_window: Some(smoothing_window),
1021                        slow_length: Some(slow_length),
1022                        fast_length: Some(fast_length),
1023                        resolution: Some(resolution),
1024                    });
1025                }
1026            }
1027        }
1028    }
1029    Ok(out)
1030}
1031
1032#[inline(always)]
1033fn batch_shape(rows: usize, cols: usize) -> Result<usize, MovingAverageCrossProbabilityError> {
1034    rows.checked_mul(cols)
1035        .ok_or_else(|| MovingAverageCrossProbabilityError::InvalidRange {
1036            start: rows.to_string(),
1037            end: cols.to_string(),
1038            step: "overflow".to_string(),
1039        })
1040}
1041
1042#[inline(always)]
1043pub fn moving_average_cross_probability_batch_slice(
1044    data: &[f64],
1045    range: &MovingAverageCrossProbabilityBatchRange,
1046    _kernel: Kernel,
1047) -> Result<MovingAverageCrossProbabilityBatchOutput, MovingAverageCrossProbabilityError> {
1048    moving_average_cross_probability_batch_inner(data, range, false)
1049}
1050
1051#[inline(always)]
1052pub fn moving_average_cross_probability_batch_par_slice(
1053    data: &[f64],
1054    range: &MovingAverageCrossProbabilityBatchRange,
1055    _kernel: Kernel,
1056) -> Result<MovingAverageCrossProbabilityBatchOutput, MovingAverageCrossProbabilityError> {
1057    moving_average_cross_probability_batch_inner(data, range, true)
1058}
1059
1060pub fn moving_average_cross_probability_batch_with_kernel(
1061    data: &[f64],
1062    range: &MovingAverageCrossProbabilityBatchRange,
1063    kernel: Kernel,
1064) -> Result<MovingAverageCrossProbabilityBatchOutput, MovingAverageCrossProbabilityError> {
1065    let batch_kernel = match kernel {
1066        Kernel::Auto => detect_best_batch_kernel(),
1067        other if other.is_batch() => other,
1068        other => {
1069            return Err(MovingAverageCrossProbabilityError::InvalidKernelForBatch(
1070                other,
1071            ))
1072        }
1073    };
1074    moving_average_cross_probability_batch_inner(data, range, batch_kernel.is_batch())
1075}
1076
1077fn moving_average_cross_probability_batch_inner(
1078    data: &[f64],
1079    range: &MovingAverageCrossProbabilityBatchRange,
1080    parallel: bool,
1081) -> Result<MovingAverageCrossProbabilityBatchOutput, MovingAverageCrossProbabilityError> {
1082    let combos = moving_average_cross_probability_expand_grid(range)?;
1083    let rows = combos.len();
1084    let cols = data.len();
1085    let total = batch_shape(rows, cols)?;
1086
1087    let mut value_buf = make_uninit_matrix(rows, cols);
1088    let mut slow_buf = make_uninit_matrix(rows, cols);
1089    let mut fast_buf = make_uninit_matrix(rows, cols);
1090    let mut forecast_buf = make_uninit_matrix(rows, cols);
1091    let mut upper_buf = make_uninit_matrix(rows, cols);
1092    let mut lower_buf = make_uninit_matrix(rows, cols);
1093    let mut direction_buf = make_uninit_matrix(rows, cols);
1094
1095    let mut value_warmups = Vec::with_capacity(rows);
1096    let mut slow_warmups = Vec::with_capacity(rows);
1097    let mut fast_warmups = Vec::with_capacity(rows);
1098    let mut forecast_warmups = Vec::with_capacity(rows);
1099    let mut direction_warmups = Vec::with_capacity(rows);
1100    for combo in &combos {
1101        let resolved = resolve_params(combo)?;
1102        value_warmups.push(resolved.probability_warmup);
1103        slow_warmups.push(resolved.slow_ma_warmup);
1104        fast_warmups.push(resolved.fast_ma_warmup);
1105        forecast_warmups.push(resolved.forecast_warmup);
1106        direction_warmups.push(resolved.direction_warmup);
1107    }
1108
1109    init_matrix_prefixes(&mut value_buf, cols, &value_warmups);
1110    init_matrix_prefixes(&mut slow_buf, cols, &slow_warmups);
1111    init_matrix_prefixes(&mut fast_buf, cols, &fast_warmups);
1112    init_matrix_prefixes(&mut forecast_buf, cols, &forecast_warmups);
1113    init_matrix_prefixes(&mut upper_buf, cols, &forecast_warmups);
1114    init_matrix_prefixes(&mut lower_buf, cols, &forecast_warmups);
1115    init_matrix_prefixes(&mut direction_buf, cols, &direction_warmups);
1116
1117    let mut value = unsafe {
1118        Vec::from_raw_parts(
1119            value_buf.as_mut_ptr() as *mut f64,
1120            total,
1121            value_buf.capacity(),
1122        )
1123    };
1124    let mut slow_ma = unsafe {
1125        Vec::from_raw_parts(
1126            slow_buf.as_mut_ptr() as *mut f64,
1127            total,
1128            slow_buf.capacity(),
1129        )
1130    };
1131    let mut fast_ma = unsafe {
1132        Vec::from_raw_parts(
1133            fast_buf.as_mut_ptr() as *mut f64,
1134            total,
1135            fast_buf.capacity(),
1136        )
1137    };
1138    let mut forecast = unsafe {
1139        Vec::from_raw_parts(
1140            forecast_buf.as_mut_ptr() as *mut f64,
1141            total,
1142            forecast_buf.capacity(),
1143        )
1144    };
1145    let mut upper = unsafe {
1146        Vec::from_raw_parts(
1147            upper_buf.as_mut_ptr() as *mut f64,
1148            total,
1149            upper_buf.capacity(),
1150        )
1151    };
1152    let mut lower = unsafe {
1153        Vec::from_raw_parts(
1154            lower_buf.as_mut_ptr() as *mut f64,
1155            total,
1156            lower_buf.capacity(),
1157        )
1158    };
1159    let mut direction = unsafe {
1160        Vec::from_raw_parts(
1161            direction_buf.as_mut_ptr() as *mut f64,
1162            total,
1163            direction_buf.capacity(),
1164        )
1165    };
1166    std::mem::forget(value_buf);
1167    std::mem::forget(slow_buf);
1168    std::mem::forget(fast_buf);
1169    std::mem::forget(forecast_buf);
1170    std::mem::forget(upper_buf);
1171    std::mem::forget(lower_buf);
1172    std::mem::forget(direction_buf);
1173
1174    moving_average_cross_probability_batch_inner_into(
1175        data,
1176        range,
1177        parallel,
1178        &mut value,
1179        &mut slow_ma,
1180        &mut fast_ma,
1181        &mut forecast,
1182        &mut upper,
1183        &mut lower,
1184        &mut direction,
1185    )?;
1186
1187    Ok(MovingAverageCrossProbabilityBatchOutput {
1188        value,
1189        slow_ma,
1190        fast_ma,
1191        forecast,
1192        upper,
1193        lower,
1194        direction,
1195        combos,
1196        rows,
1197        cols,
1198    })
1199}
1200
1201pub fn moving_average_cross_probability_batch_into_slice(
1202    out_value: &mut [f64],
1203    out_slow_ma: &mut [f64],
1204    out_fast_ma: &mut [f64],
1205    out_forecast: &mut [f64],
1206    out_upper: &mut [f64],
1207    out_lower: &mut [f64],
1208    out_direction: &mut [f64],
1209    data: &[f64],
1210    range: &MovingAverageCrossProbabilityBatchRange,
1211    kernel: Kernel,
1212) -> Result<(), MovingAverageCrossProbabilityError> {
1213    let batch_kernel = match kernel {
1214        Kernel::Auto => detect_best_batch_kernel(),
1215        other if other.is_batch() => other,
1216        other => {
1217            return Err(MovingAverageCrossProbabilityError::InvalidKernelForBatch(
1218                other,
1219            ))
1220        }
1221    };
1222    moving_average_cross_probability_batch_inner_into(
1223        data,
1224        range,
1225        batch_kernel.is_batch(),
1226        out_value,
1227        out_slow_ma,
1228        out_fast_ma,
1229        out_forecast,
1230        out_upper,
1231        out_lower,
1232        out_direction,
1233    )?;
1234    Ok(())
1235}
1236
1237fn moving_average_cross_probability_batch_inner_into(
1238    data: &[f64],
1239    range: &MovingAverageCrossProbabilityBatchRange,
1240    parallel: bool,
1241    out_value: &mut [f64],
1242    out_slow_ma: &mut [f64],
1243    out_fast_ma: &mut [f64],
1244    out_forecast: &mut [f64],
1245    out_upper: &mut [f64],
1246    out_lower: &mut [f64],
1247    out_direction: &mut [f64],
1248) -> Result<Vec<MovingAverageCrossProbabilityParams>, MovingAverageCrossProbabilityError> {
1249    if data.is_empty() {
1250        return Err(MovingAverageCrossProbabilityError::EmptyInputData);
1251    }
1252    if !data.iter().any(|value| value.is_finite()) {
1253        return Err(MovingAverageCrossProbabilityError::AllValuesNaN);
1254    }
1255    let combos = moving_average_cross_probability_expand_grid(range)?;
1256    let rows = combos.len();
1257    let cols = data.len();
1258    let total = batch_shape(rows, cols)?;
1259    check_output_len(out_value, total)?;
1260    check_output_len(out_slow_ma, total)?;
1261    check_output_len(out_fast_ma, total)?;
1262    check_output_len(out_forecast, total)?;
1263    check_output_len(out_upper, total)?;
1264    check_output_len(out_lower, total)?;
1265    check_output_len(out_direction, total)?;
1266
1267    #[cfg(not(target_arch = "wasm32"))]
1268    if parallel {
1269        let results: Vec<Result<(), MovingAverageCrossProbabilityError>> = out_value
1270            .par_chunks_mut(cols)
1271            .zip(out_slow_ma.par_chunks_mut(cols))
1272            .zip(out_fast_ma.par_chunks_mut(cols))
1273            .zip(out_forecast.par_chunks_mut(cols))
1274            .zip(out_upper.par_chunks_mut(cols))
1275            .zip(out_lower.par_chunks_mut(cols))
1276            .zip(out_direction.par_chunks_mut(cols))
1277            .zip(combos.par_iter())
1278            .map(
1279                |(
1280                    (
1281                        (((((value_row, slow_row), fast_row), forecast_row), upper_row), lower_row),
1282                        direction_row,
1283                    ),
1284                    combo,
1285                )| {
1286                    let params = resolve_params(combo)?;
1287                    moving_average_cross_probability_compute_into(
1288                        data,
1289                        &params,
1290                        value_row,
1291                        slow_row,
1292                        fast_row,
1293                        forecast_row,
1294                        upper_row,
1295                        lower_row,
1296                        direction_row,
1297                    )
1298                },
1299            )
1300            .collect();
1301        for result in results {
1302            result?;
1303        }
1304    }
1305
1306    if !parallel || cfg!(target_arch = "wasm32") {
1307        for (row, combo) in combos.iter().enumerate() {
1308            let start = row * cols;
1309            let end = start + cols;
1310            let params = resolve_params(combo)?;
1311            moving_average_cross_probability_compute_into(
1312                data,
1313                &params,
1314                &mut out_value[start..end],
1315                &mut out_slow_ma[start..end],
1316                &mut out_fast_ma[start..end],
1317                &mut out_forecast[start..end],
1318                &mut out_upper[start..end],
1319                &mut out_lower[start..end],
1320                &mut out_direction[start..end],
1321            )?;
1322        }
1323    }
1324
1325    Ok(combos)
1326}
1327
1328#[cfg(feature = "python")]
1329#[pyfunction(name = "moving_average_cross_probability")]
1330#[pyo3(signature = (
1331    data,
1332    ma_type="ema",
1333    smoothing_window=7,
1334    slow_length=30,
1335    fast_length=14,
1336    resolution=50,
1337    kernel=None
1338))]
1339pub fn moving_average_cross_probability_py<'py>(
1340    py: Python<'py>,
1341    data: PyReadonlyArray1<'py, f64>,
1342    ma_type: &str,
1343    smoothing_window: usize,
1344    slow_length: usize,
1345    fast_length: usize,
1346    resolution: usize,
1347    kernel: Option<&str>,
1348) -> PyResult<Bound<'py, PyDict>> {
1349    let data = data.as_slice()?;
1350    let input = MovingAverageCrossProbabilityInput::from_slice(
1351        data,
1352        MovingAverageCrossProbabilityParams {
1353            ma_type: Some(
1354                MovingAverageCrossProbabilityMaType::from_str(ma_type)
1355                    .map_err(|e| PyValueError::new_err(e.to_string()))?,
1356            ),
1357            smoothing_window: Some(smoothing_window),
1358            slow_length: Some(slow_length),
1359            fast_length: Some(fast_length),
1360            resolution: Some(resolution),
1361        },
1362    );
1363    let kernel = validate_kernel(kernel, false)?;
1364    let out = py
1365        .allow_threads(|| moving_average_cross_probability_with_kernel(&input, kernel))
1366        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1367    let dict = PyDict::new(py);
1368    dict.set_item("value", out.value.into_pyarray(py))?;
1369    dict.set_item("slow_ma", out.slow_ma.into_pyarray(py))?;
1370    dict.set_item("fast_ma", out.fast_ma.into_pyarray(py))?;
1371    dict.set_item("forecast", out.forecast.into_pyarray(py))?;
1372    dict.set_item("upper", out.upper.into_pyarray(py))?;
1373    dict.set_item("lower", out.lower.into_pyarray(py))?;
1374    dict.set_item("direction", out.direction.into_pyarray(py))?;
1375    Ok(dict)
1376}
1377
1378#[cfg(feature = "python")]
1379#[pyclass(name = "MovingAverageCrossProbabilityStream")]
1380pub struct MovingAverageCrossProbabilityStreamPy {
1381    stream: MovingAverageCrossProbabilityStream,
1382}
1383
1384#[cfg(feature = "python")]
1385#[pymethods]
1386impl MovingAverageCrossProbabilityStreamPy {
1387    #[new]
1388    #[pyo3(signature = (
1389        ma_type="ema",
1390        smoothing_window=7,
1391        slow_length=30,
1392        fast_length=14,
1393        resolution=50
1394    ))]
1395    fn new(
1396        ma_type: &str,
1397        smoothing_window: usize,
1398        slow_length: usize,
1399        fast_length: usize,
1400        resolution: usize,
1401    ) -> PyResult<Self> {
1402        let stream =
1403            MovingAverageCrossProbabilityStream::try_new(MovingAverageCrossProbabilityParams {
1404                ma_type: Some(
1405                    MovingAverageCrossProbabilityMaType::from_str(ma_type)
1406                        .map_err(|e| PyValueError::new_err(e.to_string()))?,
1407                ),
1408                smoothing_window: Some(smoothing_window),
1409                slow_length: Some(slow_length),
1410                fast_length: Some(fast_length),
1411                resolution: Some(resolution),
1412            })
1413            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1414        Ok(Self { stream })
1415    }
1416
1417    fn update(&mut self, value: f64) -> (f64, f64, f64, f64, f64, f64, f64) {
1418        self.stream.update(value)
1419    }
1420}
1421
1422#[cfg(feature = "python")]
1423#[pyfunction(name = "moving_average_cross_probability_batch")]
1424#[pyo3(signature = (
1425    data,
1426    smoothing_window_range=(7,7,0),
1427    slow_length_range=(30,30,0),
1428    fast_length_range=(14,14,0),
1429    resolution_range=(50,50,0),
1430    ma_type="ema",
1431    kernel=None
1432))]
1433pub fn moving_average_cross_probability_batch_py<'py>(
1434    py: Python<'py>,
1435    data: PyReadonlyArray1<'py, f64>,
1436    smoothing_window_range: (usize, usize, usize),
1437    slow_length_range: (usize, usize, usize),
1438    fast_length_range: (usize, usize, usize),
1439    resolution_range: (usize, usize, usize),
1440    ma_type: &str,
1441    kernel: Option<&str>,
1442) -> PyResult<Bound<'py, PyDict>> {
1443    let data = data.as_slice()?;
1444    let sweep = MovingAverageCrossProbabilityBatchRange {
1445        smoothing_window: smoothing_window_range,
1446        slow_length: slow_length_range,
1447        fast_length: fast_length_range,
1448        resolution: resolution_range,
1449        ma_type: MovingAverageCrossProbabilityMaType::from_str(ma_type)
1450            .map_err(|e| PyValueError::new_err(e.to_string()))?,
1451    };
1452    let combos = moving_average_cross_probability_expand_grid(&sweep)
1453        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1454    let rows = combos.len();
1455    let cols = data.len();
1456    let total = rows
1457        .checked_mul(cols)
1458        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
1459
1460    let out_value = unsafe { PyArray1::<f64>::new(py, [total], false) };
1461    let out_slow_ma = unsafe { PyArray1::<f64>::new(py, [total], false) };
1462    let out_fast_ma = unsafe { PyArray1::<f64>::new(py, [total], false) };
1463    let out_forecast = unsafe { PyArray1::<f64>::new(py, [total], false) };
1464    let out_upper = unsafe { PyArray1::<f64>::new(py, [total], false) };
1465    let out_lower = unsafe { PyArray1::<f64>::new(py, [total], false) };
1466    let out_direction = unsafe { PyArray1::<f64>::new(py, [total], false) };
1467
1468    let value_slice = unsafe { out_value.as_slice_mut()? };
1469    let slow_slice = unsafe { out_slow_ma.as_slice_mut()? };
1470    let fast_slice = unsafe { out_fast_ma.as_slice_mut()? };
1471    let forecast_slice = unsafe { out_forecast.as_slice_mut()? };
1472    let upper_slice = unsafe { out_upper.as_slice_mut()? };
1473    let lower_slice = unsafe { out_lower.as_slice_mut()? };
1474    let direction_slice = unsafe { out_direction.as_slice_mut()? };
1475    let kernel = validate_kernel(kernel, true)?;
1476
1477    py.allow_threads(|| {
1478        let batch_kernel = match kernel {
1479            Kernel::Auto => detect_best_batch_kernel(),
1480            other => other,
1481        };
1482        moving_average_cross_probability_batch_inner_into(
1483            data,
1484            &sweep,
1485            batch_kernel.is_batch(),
1486            value_slice,
1487            slow_slice,
1488            fast_slice,
1489            forecast_slice,
1490            upper_slice,
1491            lower_slice,
1492            direction_slice,
1493        )
1494    })
1495    .map_err(|e| PyValueError::new_err(e.to_string()))?;
1496
1497    let dict = PyDict::new(py);
1498    dict.set_item("value", out_value.reshape((rows, cols))?)?;
1499    dict.set_item("slow_ma", out_slow_ma.reshape((rows, cols))?)?;
1500    dict.set_item("fast_ma", out_fast_ma.reshape((rows, cols))?)?;
1501    dict.set_item("forecast", out_forecast.reshape((rows, cols))?)?;
1502    dict.set_item("upper", out_upper.reshape((rows, cols))?)?;
1503    dict.set_item("lower", out_lower.reshape((rows, cols))?)?;
1504    dict.set_item("direction", out_direction.reshape((rows, cols))?)?;
1505    dict.set_item(
1506        "smoothing_windows",
1507        combos
1508            .iter()
1509            .map(|combo| combo.smoothing_window.unwrap_or(DEFAULT_SMOOTHING_WINDOW))
1510            .collect::<Vec<_>>()
1511            .into_pyarray(py),
1512    )?;
1513    dict.set_item(
1514        "slow_lengths",
1515        combos
1516            .iter()
1517            .map(|combo| combo.slow_length.unwrap_or(DEFAULT_SLOW_LENGTH))
1518            .collect::<Vec<_>>()
1519            .into_pyarray(py),
1520    )?;
1521    dict.set_item(
1522        "fast_lengths",
1523        combos
1524            .iter()
1525            .map(|combo| combo.fast_length.unwrap_or(DEFAULT_FAST_LENGTH))
1526            .collect::<Vec<_>>()
1527            .into_pyarray(py),
1528    )?;
1529    dict.set_item(
1530        "resolutions",
1531        combos
1532            .iter()
1533            .map(|combo| combo.resolution.unwrap_or(DEFAULT_RESOLUTION))
1534            .collect::<Vec<_>>()
1535            .into_pyarray(py),
1536    )?;
1537    dict.set_item("rows", rows)?;
1538    dict.set_item("cols", cols)?;
1539    Ok(dict)
1540}
1541
1542#[cfg(feature = "python")]
1543pub fn register_moving_average_cross_probability_module(
1544    m: &Bound<'_, pyo3::types::PyModule>,
1545) -> PyResult<()> {
1546    m.add_function(wrap_pyfunction!(moving_average_cross_probability_py, m)?)?;
1547    m.add_function(wrap_pyfunction!(
1548        moving_average_cross_probability_batch_py,
1549        m
1550    )?)?;
1551    m.add_class::<MovingAverageCrossProbabilityStreamPy>()?;
1552    Ok(())
1553}
1554
1555#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1556#[derive(Serialize, Deserialize)]
1557pub struct MovingAverageCrossProbabilityJsOutput {
1558    pub value: Vec<f64>,
1559    pub slow_ma: Vec<f64>,
1560    pub fast_ma: Vec<f64>,
1561    pub forecast: Vec<f64>,
1562    pub upper: Vec<f64>,
1563    pub lower: Vec<f64>,
1564    pub direction: Vec<f64>,
1565}
1566
1567#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1568fn js_vec3_to_usize(name: &str, values: &[f64]) -> Result<(usize, usize, usize), JsValue> {
1569    if values.len() != 3 {
1570        return Err(JsValue::from_str(&format!(
1571            "Invalid config: {name} must have exactly 3 elements [start, end, step]"
1572        )));
1573    }
1574    let mut out = [0usize; 3];
1575    for (idx, value) in values.iter().enumerate() {
1576        if !value.is_finite() || *value < 0.0 || value.fract() != 0.0 {
1577            return Err(JsValue::from_str(&format!(
1578                "Invalid config: {name} values must be non-negative integers"
1579            )));
1580        }
1581        out[idx] = *value as usize;
1582    }
1583    Ok((out[0], out[1], out[2]))
1584}
1585
1586#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1587#[wasm_bindgen(js_name = "moving_average_cross_probability_js")]
1588pub fn moving_average_cross_probability_js(
1589    data: &[f64],
1590    ma_type: String,
1591    smoothing_window: usize,
1592    slow_length: usize,
1593    fast_length: usize,
1594    resolution: usize,
1595) -> Result<JsValue, JsValue> {
1596    let input = MovingAverageCrossProbabilityInput::from_slice(
1597        data,
1598        MovingAverageCrossProbabilityParams {
1599            ma_type: Some(
1600                MovingAverageCrossProbabilityMaType::from_str(&ma_type)
1601                    .map_err(|e| JsValue::from_str(&e))?,
1602            ),
1603            smoothing_window: Some(smoothing_window),
1604            slow_length: Some(slow_length),
1605            fast_length: Some(fast_length),
1606            resolution: Some(resolution),
1607        },
1608    );
1609    let out = moving_average_cross_probability_with_kernel(&input, Kernel::Auto)
1610        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1611    serde_wasm_bindgen::to_value(&MovingAverageCrossProbabilityJsOutput {
1612        value: out.value,
1613        slow_ma: out.slow_ma,
1614        fast_ma: out.fast_ma,
1615        forecast: out.forecast,
1616        upper: out.upper,
1617        lower: out.lower,
1618        direction: out.direction,
1619    })
1620    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
1621}
1622
1623#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1624#[derive(Serialize, Deserialize)]
1625pub struct MovingAverageCrossProbabilityBatchConfig {
1626    pub smoothing_window_range: Vec<f64>,
1627    pub slow_length_range: Vec<f64>,
1628    pub fast_length_range: Vec<f64>,
1629    pub resolution_range: Vec<f64>,
1630    pub ma_type: Option<String>,
1631}
1632
1633#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1634#[derive(Serialize, Deserialize)]
1635pub struct MovingAverageCrossProbabilityBatchJsOutput {
1636    pub value: Vec<f64>,
1637    pub slow_ma: Vec<f64>,
1638    pub fast_ma: Vec<f64>,
1639    pub forecast: Vec<f64>,
1640    pub upper: Vec<f64>,
1641    pub lower: Vec<f64>,
1642    pub direction: Vec<f64>,
1643    pub smoothing_windows: Vec<usize>,
1644    pub slow_lengths: Vec<usize>,
1645    pub fast_lengths: Vec<usize>,
1646    pub resolutions: Vec<usize>,
1647    pub rows: usize,
1648    pub cols: usize,
1649}
1650
1651#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1652#[wasm_bindgen(js_name = "moving_average_cross_probability_batch_js")]
1653pub fn moving_average_cross_probability_batch_js(
1654    data: &[f64],
1655    config: JsValue,
1656) -> Result<JsValue, JsValue> {
1657    let config: MovingAverageCrossProbabilityBatchConfig =
1658        serde_wasm_bindgen::from_value(config)
1659            .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
1660    let ma_type = config
1661        .ma_type
1662        .as_deref()
1663        .map(MovingAverageCrossProbabilityMaType::from_str)
1664        .transpose()
1665        .map_err(|e| JsValue::from_str(&e))?
1666        .unwrap_or(DEFAULT_MA_TYPE);
1667    let sweep = MovingAverageCrossProbabilityBatchRange {
1668        smoothing_window: js_vec3_to_usize(
1669            "smoothing_window_range",
1670            &config.smoothing_window_range,
1671        )?,
1672        slow_length: js_vec3_to_usize("slow_length_range", &config.slow_length_range)?,
1673        fast_length: js_vec3_to_usize("fast_length_range", &config.fast_length_range)?,
1674        resolution: js_vec3_to_usize("resolution_range", &config.resolution_range)?,
1675        ma_type,
1676    };
1677    let out = moving_average_cross_probability_batch_with_kernel(data, &sweep, Kernel::Auto)
1678        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1679    serde_wasm_bindgen::to_value(&MovingAverageCrossProbabilityBatchJsOutput {
1680        value: out.value,
1681        slow_ma: out.slow_ma,
1682        fast_ma: out.fast_ma,
1683        forecast: out.forecast,
1684        upper: out.upper,
1685        lower: out.lower,
1686        direction: out.direction,
1687        smoothing_windows: out
1688            .combos
1689            .iter()
1690            .map(|combo| combo.smoothing_window.unwrap_or(DEFAULT_SMOOTHING_WINDOW))
1691            .collect(),
1692        slow_lengths: out
1693            .combos
1694            .iter()
1695            .map(|combo| combo.slow_length.unwrap_or(DEFAULT_SLOW_LENGTH))
1696            .collect(),
1697        fast_lengths: out
1698            .combos
1699            .iter()
1700            .map(|combo| combo.fast_length.unwrap_or(DEFAULT_FAST_LENGTH))
1701            .collect(),
1702        resolutions: out
1703            .combos
1704            .iter()
1705            .map(|combo| combo.resolution.unwrap_or(DEFAULT_RESOLUTION))
1706            .collect(),
1707        rows: out.rows,
1708        cols: out.cols,
1709    })
1710    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
1711}
1712
1713#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1714#[wasm_bindgen]
1715pub fn moving_average_cross_probability_alloc(len: usize) -> *mut f64 {
1716    let mut vec = Vec::<f64>::with_capacity(len);
1717    let ptr = vec.as_mut_ptr();
1718    std::mem::forget(vec);
1719    ptr
1720}
1721
1722#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1723#[wasm_bindgen]
1724pub fn moving_average_cross_probability_free(ptr: *mut f64, len: usize) {
1725    if !ptr.is_null() {
1726        unsafe {
1727            let _ = Vec::from_raw_parts(ptr, len, len);
1728        }
1729    }
1730}
1731
1732#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1733#[wasm_bindgen]
1734pub fn moving_average_cross_probability_into(
1735    in_ptr: *const f64,
1736    out_value_ptr: *mut f64,
1737    out_slow_ma_ptr: *mut f64,
1738    out_fast_ma_ptr: *mut f64,
1739    out_forecast_ptr: *mut f64,
1740    out_upper_ptr: *mut f64,
1741    out_lower_ptr: *mut f64,
1742    out_direction_ptr: *mut f64,
1743    len: usize,
1744    ma_type: String,
1745    smoothing_window: usize,
1746    slow_length: usize,
1747    fast_length: usize,
1748    resolution: usize,
1749) -> Result<(), JsValue> {
1750    if in_ptr.is_null()
1751        || out_value_ptr.is_null()
1752        || out_slow_ma_ptr.is_null()
1753        || out_fast_ma_ptr.is_null()
1754        || out_forecast_ptr.is_null()
1755        || out_upper_ptr.is_null()
1756        || out_lower_ptr.is_null()
1757        || out_direction_ptr.is_null()
1758    {
1759        return Err(JsValue::from_str(
1760            "null pointer passed to moving_average_cross_probability_into",
1761        ));
1762    }
1763    unsafe {
1764        let data = std::slice::from_raw_parts(in_ptr, len);
1765        let out_value = std::slice::from_raw_parts_mut(out_value_ptr, len);
1766        let out_slow_ma = std::slice::from_raw_parts_mut(out_slow_ma_ptr, len);
1767        let out_fast_ma = std::slice::from_raw_parts_mut(out_fast_ma_ptr, len);
1768        let out_forecast = std::slice::from_raw_parts_mut(out_forecast_ptr, len);
1769        let out_upper = std::slice::from_raw_parts_mut(out_upper_ptr, len);
1770        let out_lower = std::slice::from_raw_parts_mut(out_lower_ptr, len);
1771        let out_direction = std::slice::from_raw_parts_mut(out_direction_ptr, len);
1772        let input = MovingAverageCrossProbabilityInput::from_slice(
1773            data,
1774            MovingAverageCrossProbabilityParams {
1775                ma_type: Some(
1776                    MovingAverageCrossProbabilityMaType::from_str(&ma_type)
1777                        .map_err(|e| JsValue::from_str(&e))?,
1778                ),
1779                smoothing_window: Some(smoothing_window),
1780                slow_length: Some(slow_length),
1781                fast_length: Some(fast_length),
1782                resolution: Some(resolution),
1783            },
1784        );
1785        moving_average_cross_probability_into_slice(
1786            out_value,
1787            out_slow_ma,
1788            out_fast_ma,
1789            out_forecast,
1790            out_upper,
1791            out_lower,
1792            out_direction,
1793            &input,
1794            Kernel::Auto,
1795        )
1796        .map_err(|e| JsValue::from_str(&e.to_string()))
1797    }
1798}
1799
1800#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1801#[wasm_bindgen]
1802pub fn moving_average_cross_probability_batch_into(
1803    in_ptr: *const f64,
1804    out_value_ptr: *mut f64,
1805    out_slow_ma_ptr: *mut f64,
1806    out_fast_ma_ptr: *mut f64,
1807    out_forecast_ptr: *mut f64,
1808    out_upper_ptr: *mut f64,
1809    out_lower_ptr: *mut f64,
1810    out_direction_ptr: *mut f64,
1811    len: usize,
1812    smoothing_window_start: usize,
1813    smoothing_window_end: usize,
1814    smoothing_window_step: usize,
1815    slow_length_start: usize,
1816    slow_length_end: usize,
1817    slow_length_step: usize,
1818    fast_length_start: usize,
1819    fast_length_end: usize,
1820    fast_length_step: usize,
1821    resolution_start: usize,
1822    resolution_end: usize,
1823    resolution_step: usize,
1824    ma_type: String,
1825) -> Result<usize, JsValue> {
1826    if in_ptr.is_null()
1827        || out_value_ptr.is_null()
1828        || out_slow_ma_ptr.is_null()
1829        || out_fast_ma_ptr.is_null()
1830        || out_forecast_ptr.is_null()
1831        || out_upper_ptr.is_null()
1832        || out_lower_ptr.is_null()
1833        || out_direction_ptr.is_null()
1834    {
1835        return Err(JsValue::from_str(
1836            "null pointer passed to moving_average_cross_probability_batch_into",
1837        ));
1838    }
1839    unsafe {
1840        let data = std::slice::from_raw_parts(in_ptr, len);
1841        let sweep = MovingAverageCrossProbabilityBatchRange {
1842            smoothing_window: (
1843                smoothing_window_start,
1844                smoothing_window_end,
1845                smoothing_window_step,
1846            ),
1847            slow_length: (slow_length_start, slow_length_end, slow_length_step),
1848            fast_length: (fast_length_start, fast_length_end, fast_length_step),
1849            resolution: (resolution_start, resolution_end, resolution_step),
1850            ma_type: MovingAverageCrossProbabilityMaType::from_str(&ma_type)
1851                .map_err(|e| JsValue::from_str(&e))?,
1852        };
1853        let combos = moving_average_cross_probability_expand_grid(&sweep)
1854            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1855        let rows = combos.len();
1856        let total = rows.checked_mul(len).ok_or_else(|| {
1857            JsValue::from_str("rows*cols overflow in moving_average_cross_probability_batch_into")
1858        })?;
1859        let out_value = std::slice::from_raw_parts_mut(out_value_ptr, total);
1860        let out_slow_ma = std::slice::from_raw_parts_mut(out_slow_ma_ptr, total);
1861        let out_fast_ma = std::slice::from_raw_parts_mut(out_fast_ma_ptr, total);
1862        let out_forecast = std::slice::from_raw_parts_mut(out_forecast_ptr, total);
1863        let out_upper = std::slice::from_raw_parts_mut(out_upper_ptr, total);
1864        let out_lower = std::slice::from_raw_parts_mut(out_lower_ptr, total);
1865        let out_direction = std::slice::from_raw_parts_mut(out_direction_ptr, total);
1866        moving_average_cross_probability_batch_into_slice(
1867            out_value,
1868            out_slow_ma,
1869            out_fast_ma,
1870            out_forecast,
1871            out_upper,
1872            out_lower,
1873            out_direction,
1874            data,
1875            &sweep,
1876            Kernel::Auto,
1877        )
1878        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1879        Ok(rows)
1880    }
1881}
1882
1883#[cfg(test)]
1884mod tests {
1885    use super::*;
1886
1887    fn sample_data(len: usize) -> Vec<f64> {
1888        (0..len)
1889            .map(|i| {
1890                let x = i as f64;
1891                100.0 + x * 0.13 + (x * 0.11).sin() * 2.4 + (x * 0.03).cos() * 0.7
1892            })
1893            .collect()
1894    }
1895
1896    fn sample_candles(len: usize) -> Candles {
1897        let close = sample_data(len);
1898        let open = close.iter().map(|v| v - 0.3).collect::<Vec<_>>();
1899        let high = close.iter().map(|v| v + 0.8).collect::<Vec<_>>();
1900        let low = close.iter().map(|v| v - 0.9).collect::<Vec<_>>();
1901        let volume = vec![1_000.0; len];
1902        let timestamp = (0..len as i64).collect::<Vec<_>>();
1903        Candles::new(timestamp, open, high, low, close, volume)
1904    }
1905
1906    fn assert_vec_close(lhs: &[f64], rhs: &[f64]) {
1907        assert_eq!(lhs.len(), rhs.len());
1908        for (idx, (a, b)) in lhs.iter().zip(rhs.iter()).enumerate() {
1909            if a.is_nan() && b.is_nan() {
1910                continue;
1911            }
1912            let diff = (a - b).abs();
1913            assert!(diff <= 1e-9, "mismatch at {idx}: {a} vs {b}");
1914        }
1915    }
1916
1917    fn sma(data: &[f64], period: usize) -> Vec<f64> {
1918        let mut out = vec![f64::NAN; data.len()];
1919        let mut sum = 0.0;
1920        for i in 0..data.len() {
1921            sum += data[i];
1922            if i + 1 >= period {
1923                if i + 1 > period {
1924                    sum -= data[i - period];
1925                }
1926                out[i] = sum / period as f64;
1927            }
1928        }
1929        out
1930    }
1931
1932    fn ema(data: &[f64], period: usize) -> Vec<f64> {
1933        let mut out = vec![f64::NAN; data.len()];
1934        let alpha = 2.0 / (period as f64 + 1.0);
1935        let mut seed_sum = 0.0;
1936        let mut current = f64::NAN;
1937        for i in 0..data.len() {
1938            seed_sum += data[i];
1939            if i + 1 < period {
1940                continue;
1941            }
1942            if i + 1 == period {
1943                current = seed_sum / period as f64;
1944            } else {
1945                current = alpha.mul_add(data[i], (1.0 - alpha) * current);
1946            }
1947            out[i] = current;
1948        }
1949        out
1950    }
1951
1952    fn wma_window(data: &[f64]) -> f64 {
1953        let mut num = 0.0;
1954        let mut den = 0.0;
1955        for (idx, value) in data.iter().enumerate() {
1956            let w = (idx + 1) as f64;
1957            num += *value * w;
1958            den += w;
1959        }
1960        num / den
1961    }
1962
1963    fn hma(data: &[f64], period: usize) -> Vec<f64> {
1964        let n = data.len();
1965        let half = period / 2;
1966        let sqrt_len = (period as f64).sqrt().floor() as usize;
1967        let mut diff = vec![f64::NAN; n];
1968        let mut out = vec![f64::NAN; n];
1969        for i in 0..n {
1970            if i + 1 >= period && i + 1 >= half {
1971                let full = wma_window(&data[i + 1 - period..=i]);
1972                let half_val = wma_window(&data[i + 1 - half..=i]);
1973                diff[i] = 2.0 * half_val - full;
1974            }
1975            if i + 1 >= period + sqrt_len - 1 {
1976                let start = i + 1 - sqrt_len;
1977                let window = diff[start..=i].iter().copied().collect::<Vec<_>>();
1978                if window.iter().all(|v| v.is_finite()) {
1979                    out[i] = wma_window(&window);
1980                }
1981            }
1982        }
1983        out
1984    }
1985
1986    fn stddev4(data: &[f64], period: usize) -> Vec<f64> {
1987        let n = data.len();
1988        let mut out = vec![f64::NAN; n];
1989        for i in period.saturating_sub(1)..n {
1990            let window = &data[i + 1 - period..=i];
1991            let mean = window.iter().sum::<f64>() / period as f64;
1992            let var = window
1993                .iter()
1994                .map(|v| {
1995                    let d = *v - mean;
1996                    d * d
1997                })
1998                .sum::<f64>()
1999                / period as f64;
2000            out[i] = var.sqrt() * 4.0;
2001        }
2002        out
2003    }
2004
2005    fn array_sma(temp: &[f64], period: usize) -> f64 {
2006        temp[..period].iter().sum::<f64>() / period as f64
2007    }
2008
2009    fn array_ema(temp: &[f64], period: usize) -> f64 {
2010        let alpha = 2.0 / (period as f64 + 1.0);
2011        let mut ema = *temp.last().unwrap();
2012        for idx in (0..temp.len() - 1).rev() {
2013            ema = alpha.mul_add(temp[idx], (1.0 - alpha) * ema);
2014        }
2015        ema
2016    }
2017
2018    fn manual_reference(
2019        data: &[f64],
2020        ma_type: MovingAverageCrossProbabilityMaType,
2021        smoothing_window: usize,
2022        slow_length: usize,
2023        fast_length: usize,
2024        resolution: usize,
2025    ) -> MovingAverageCrossProbabilityOutput {
2026        let n = data.len();
2027        let mut value = vec![f64::NAN; n];
2028        let slow_ma = match ma_type {
2029            MovingAverageCrossProbabilityMaType::Ema => ema(data, slow_length),
2030            MovingAverageCrossProbabilityMaType::Sma => sma(data, slow_length),
2031        };
2032        let fast_ma = match ma_type {
2033            MovingAverageCrossProbabilityMaType::Ema => ema(data, fast_length),
2034            MovingAverageCrossProbabilityMaType::Sma => sma(data, fast_length),
2035        };
2036        let price = hma(data, smoothing_window);
2037        let stdev = stddev4(data, smoothing_window);
2038        let mut forecast = vec![f64::NAN; n];
2039        let mut upper = vec![f64::NAN; n];
2040        let mut lower = vec![f64::NAN; n];
2041        let mut direction = vec![f64::NAN; n];
2042
2043        for i in 0..n {
2044            if slow_ma[i].is_finite() && fast_ma[i].is_finite() {
2045                direction[i] = if fast_ma[i] > slow_ma[i] { -1.0 } else { 1.0 };
2046            }
2047            if i > 0 && price[i].is_finite() && price[i - 1].is_finite() && stdev[i].is_finite() {
2048                forecast[i] = price[i] + (price[i] - price[i - 1]);
2049                upper[i] = forecast[i] + stdev[i];
2050                lower[i] = forecast[i] - stdev[i];
2051            }
2052            if i < 2 * slow_length
2053                || !direction[i].is_finite()
2054                || !upper[i].is_finite()
2055                || !lower[i].is_finite()
2056            {
2057                continue;
2058            }
2059            let mut memory = Vec::with_capacity(2 * slow_length + 1);
2060            for j in 0..=2 * slow_length {
2061                memory.push(data[i - j]);
2062            }
2063            let seg = (upper[i] - lower[i]) / (resolution - 1) as f64;
2064            let mut hits = 0usize;
2065            for k in 0..resolution {
2066                let possibility = lower[i] + seg * k as f64;
2067                let mut temp = Vec::with_capacity(memory.len() + 1);
2068                temp.push(possibility);
2069                temp.extend_from_slice(&memory);
2070                let slow_future = match ma_type {
2071                    MovingAverageCrossProbabilityMaType::Ema => array_ema(&temp, slow_length),
2072                    MovingAverageCrossProbabilityMaType::Sma => array_sma(&temp, slow_length),
2073                };
2074                let fast_future = match ma_type {
2075                    MovingAverageCrossProbabilityMaType::Ema => array_ema(&temp, fast_length),
2076                    MovingAverageCrossProbabilityMaType::Sma => array_sma(&temp, fast_length),
2077                };
2078                let crossed = if direction[i] < 0.0 {
2079                    slow_future > fast_future
2080                } else {
2081                    slow_future <= fast_future
2082                };
2083                if crossed {
2084                    hits += 1;
2085                }
2086            }
2087            value[i] = 100.0 * hits as f64 / resolution as f64;
2088        }
2089
2090        MovingAverageCrossProbabilityOutput {
2091            value,
2092            slow_ma,
2093            fast_ma,
2094            forecast,
2095            upper,
2096            lower,
2097            direction,
2098        }
2099    }
2100
2101    #[test]
2102    fn manual_reference_matches_ema_single() {
2103        let data = sample_data(220);
2104        let input = MovingAverageCrossProbabilityInput::from_slice(
2105            &data,
2106            MovingAverageCrossProbabilityParams::default(),
2107        );
2108        let out = moving_average_cross_probability(&input).unwrap();
2109        let expected = manual_reference(&data, DEFAULT_MA_TYPE, 7, 30, 14, 50);
2110        assert_vec_close(&out.value, &expected.value);
2111        assert_vec_close(&out.slow_ma, &expected.slow_ma);
2112        assert_vec_close(&out.fast_ma, &expected.fast_ma);
2113        assert_vec_close(&out.forecast, &expected.forecast);
2114        assert_vec_close(&out.upper, &expected.upper);
2115        assert_vec_close(&out.lower, &expected.lower);
2116        assert_vec_close(&out.direction, &expected.direction);
2117    }
2118
2119    #[test]
2120    fn manual_reference_matches_sma_single() {
2121        let data = sample_data(220);
2122        let params = MovingAverageCrossProbabilityParams {
2123            ma_type: Some(MovingAverageCrossProbabilityMaType::Sma),
2124            ..MovingAverageCrossProbabilityParams::default()
2125        };
2126        let input = MovingAverageCrossProbabilityInput::from_slice(&data, params);
2127        let out = moving_average_cross_probability(&input).unwrap();
2128        let expected = manual_reference(
2129            &data,
2130            MovingAverageCrossProbabilityMaType::Sma,
2131            7,
2132            30,
2133            14,
2134            50,
2135        );
2136        assert_vec_close(&out.value, &expected.value);
2137    }
2138
2139    #[test]
2140    fn stream_matches_batch() {
2141        let data = sample_data(200);
2142        let params = MovingAverageCrossProbabilityParams {
2143            ma_type: Some(MovingAverageCrossProbabilityMaType::Sma),
2144            smoothing_window: Some(8),
2145            slow_length: Some(26),
2146            fast_length: Some(11),
2147            resolution: Some(40),
2148        };
2149        let input = MovingAverageCrossProbabilityInput::from_slice(&data, params.clone());
2150        let batch = moving_average_cross_probability(&input).unwrap();
2151        let mut stream = MovingAverageCrossProbabilityStream::try_new(params).unwrap();
2152        let mut value = Vec::with_capacity(data.len());
2153        let mut slow_ma = Vec::with_capacity(data.len());
2154        let mut fast_ma = Vec::with_capacity(data.len());
2155        let mut forecast = Vec::with_capacity(data.len());
2156        let mut upper = Vec::with_capacity(data.len());
2157        let mut lower = Vec::with_capacity(data.len());
2158        let mut direction = Vec::with_capacity(data.len());
2159        for item in data {
2160            let (v, s, f, fc, u, l, d) = stream.update(item);
2161            value.push(v);
2162            slow_ma.push(s);
2163            fast_ma.push(f);
2164            forecast.push(fc);
2165            upper.push(u);
2166            lower.push(l);
2167            direction.push(d);
2168        }
2169        assert_vec_close(&value, &batch.value);
2170        assert_vec_close(&slow_ma, &batch.slow_ma);
2171        assert_vec_close(&fast_ma, &batch.fast_ma);
2172        assert_vec_close(&forecast, &batch.forecast);
2173        assert_vec_close(&upper, &batch.upper);
2174        assert_vec_close(&lower, &batch.lower);
2175        assert_vec_close(&direction, &batch.direction);
2176    }
2177
2178    #[test]
2179    fn batch_first_row_matches_single() {
2180        let data = sample_data(180);
2181        let sweep = MovingAverageCrossProbabilityBatchRange {
2182            smoothing_window: (7, 8, 1),
2183            slow_length: (30, 30, 0),
2184            fast_length: (14, 14, 0),
2185            resolution: (50, 50, 0),
2186            ma_type: MovingAverageCrossProbabilityMaType::Ema,
2187        };
2188        let batch = moving_average_cross_probability_batch_with_kernel(&data, &sweep, Kernel::Auto)
2189            .unwrap();
2190        let input = MovingAverageCrossProbabilityInput::from_slice(
2191            &data,
2192            MovingAverageCrossProbabilityParams::default(),
2193        );
2194        let single = moving_average_cross_probability(&input).unwrap();
2195        assert_eq!(batch.rows, 2);
2196        assert_eq!(batch.cols, data.len());
2197        assert_vec_close(&batch.value[..data.len()], &single.value);
2198        assert_vec_close(&batch.slow_ma[..data.len()], &single.slow_ma);
2199        assert_vec_close(&batch.fast_ma[..data.len()], &single.fast_ma);
2200    }
2201
2202    #[test]
2203    fn invalid_length_order_fails() {
2204        let data = sample_data(96);
2205        let input = MovingAverageCrossProbabilityInput::from_slice(
2206            &data,
2207            MovingAverageCrossProbabilityParams {
2208                slow_length: Some(10),
2209                fast_length: Some(14),
2210                ..MovingAverageCrossProbabilityParams::default()
2211            },
2212        );
2213        let err = moving_average_cross_probability(&input).unwrap_err();
2214        assert!(matches!(
2215            err,
2216            MovingAverageCrossProbabilityError::InvalidLengthOrder { .. }
2217        ));
2218    }
2219
2220    #[test]
2221    fn cpu_dispatch_matches_direct() {
2222        let candles = sample_candles(180);
2223        let combos = [IndicatorParamSet {
2224            params: &[
2225                ParamKV {
2226                    key: "ma_type",
2227                    value: ParamValue::EnumString("ema"),
2228                },
2229                ParamKV {
2230                    key: "smoothing_window",
2231                    value: ParamValue::Int(7),
2232                },
2233                ParamKV {
2234                    key: "slow_length",
2235                    value: ParamValue::Int(30),
2236                },
2237                ParamKV {
2238                    key: "fast_length",
2239                    value: ParamValue::Int(14),
2240                },
2241                ParamKV {
2242                    key: "resolution",
2243                    value: ParamValue::Int(50),
2244                },
2245            ],
2246        }];
2247        let dispatched = compute_cpu_batch(IndicatorBatchRequest {
2248            indicator_id: "moving_average_cross_probability",
2249            output_id: Some("value"),
2250            data: IndicatorDataRef::Candles {
2251                candles: &candles,
2252                source: Some("close"),
2253            },
2254            combos: &combos,
2255            kernel: Kernel::Auto,
2256        })
2257        .unwrap();
2258        let direct =
2259            moving_average_cross_probability(&MovingAverageCrossProbabilityInput::from_candles(
2260                &candles,
2261                MovingAverageCrossProbabilityParams::default(),
2262            ))
2263            .unwrap();
2264        assert_vec_close(dispatched.values_f64.as_ref().unwrap(), &direct.value);
2265    }
2266}