Skip to main content

vector_ta/indicators/
bulls_v_bears.rs

1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7#[cfg(feature = "python")]
8use pyo3::types::PyDict;
9#[cfg(feature = "python")]
10use pyo3::wrap_pyfunction;
11
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use serde::{Deserialize, Serialize};
14#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
15use wasm_bindgen::prelude::*;
16
17use crate::utilities::data_loader::Candles;
18use crate::utilities::enums::Kernel;
19use crate::utilities::helpers::{
20    alloc_with_nan_prefix, detect_best_batch_kernel, init_matrix_prefixes, make_uninit_matrix,
21};
22#[cfg(feature = "python")]
23use crate::utilities::kernel_validation::validate_kernel;
24#[cfg(not(target_arch = "wasm32"))]
25use rayon::prelude::*;
26use std::collections::VecDeque;
27use std::mem::ManuallyDrop;
28use std::str::FromStr;
29use thiserror::Error;
30
31const DEFAULT_PERIOD: usize = 14;
32const DEFAULT_MA_TYPE: BullsVBearsMaType = BullsVBearsMaType::Ema;
33const DEFAULT_CALCULATION_METHOD: BullsVBearsCalculationMethod =
34    BullsVBearsCalculationMethod::Normalized;
35const DEFAULT_NORMALIZED_BARS_BACK: usize = 120;
36const DEFAULT_RAW_ROLLING_PERIOD: usize = 50;
37const DEFAULT_RAW_THRESHOLD_PERCENTILE: f64 = 95.0;
38const DEFAULT_THRESHOLD_LEVEL: f64 = 80.0;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41#[cfg_attr(
42    all(target_arch = "wasm32", feature = "wasm"),
43    derive(Serialize, Deserialize),
44    serde(rename_all = "snake_case")
45)]
46pub enum BullsVBearsMaType {
47    Ema,
48    Sma,
49    Wma,
50}
51
52impl Default for BullsVBearsMaType {
53    fn default() -> Self {
54        DEFAULT_MA_TYPE
55    }
56}
57
58impl BullsVBearsMaType {
59    #[inline(always)]
60    fn as_str(self) -> &'static str {
61        match self {
62            Self::Ema => "ema",
63            Self::Sma => "sma",
64            Self::Wma => "wma",
65        }
66    }
67
68    #[inline(always)]
69    fn warmup(self, period: usize) -> usize {
70        match self {
71            Self::Ema => 0,
72            Self::Sma | Self::Wma => period.saturating_sub(1),
73        }
74    }
75}
76
77impl FromStr for BullsVBearsMaType {
78    type Err = String;
79
80    fn from_str(value: &str) -> Result<Self, Self::Err> {
81        match value.trim().to_ascii_lowercase().as_str() {
82            "ema" => Ok(Self::Ema),
83            "sma" => Ok(Self::Sma),
84            "wma" => Ok(Self::Wma),
85            _ => Err(format!("invalid ma_type: {value}")),
86        }
87    }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91#[cfg_attr(
92    all(target_arch = "wasm32", feature = "wasm"),
93    derive(Serialize, Deserialize),
94    serde(rename_all = "snake_case")
95)]
96pub enum BullsVBearsCalculationMethod {
97    Normalized,
98    Raw,
99}
100
101impl Default for BullsVBearsCalculationMethod {
102    fn default() -> Self {
103        DEFAULT_CALCULATION_METHOD
104    }
105}
106
107impl BullsVBearsCalculationMethod {
108    #[inline(always)]
109    fn as_str(self) -> &'static str {
110        match self {
111            Self::Normalized => "normalized",
112            Self::Raw => "raw",
113        }
114    }
115}
116
117impl FromStr for BullsVBearsCalculationMethod {
118    type Err = String;
119
120    fn from_str(value: &str) -> Result<Self, Self::Err> {
121        match value.trim().to_ascii_lowercase().as_str() {
122            "normalized" => Ok(Self::Normalized),
123            "raw" => Ok(Self::Raw),
124            _ => Err(format!("invalid calculation_method: {value}")),
125        }
126    }
127}
128
129#[derive(Debug, Clone)]
130pub enum BullsVBearsData<'a> {
131    Candles {
132        candles: &'a Candles,
133    },
134    Slices {
135        high: &'a [f64],
136        low: &'a [f64],
137        close: &'a [f64],
138    },
139}
140
141#[derive(Debug, Clone)]
142pub struct BullsVBearsOutput {
143    pub value: Vec<f64>,
144    pub bull: Vec<f64>,
145    pub bear: Vec<f64>,
146    pub ma: Vec<f64>,
147    pub upper: Vec<f64>,
148    pub lower: Vec<f64>,
149    pub bullish_signal: Vec<f64>,
150    pub bearish_signal: Vec<f64>,
151    pub zero_cross_up: Vec<f64>,
152    pub zero_cross_down: Vec<f64>,
153}
154
155#[derive(Debug, Clone)]
156#[cfg_attr(
157    all(target_arch = "wasm32", feature = "wasm"),
158    derive(Serialize, Deserialize)
159)]
160pub struct BullsVBearsParams {
161    pub period: Option<usize>,
162    pub ma_type: Option<BullsVBearsMaType>,
163    pub calculation_method: Option<BullsVBearsCalculationMethod>,
164    pub normalized_bars_back: Option<usize>,
165    pub raw_rolling_period: Option<usize>,
166    pub raw_threshold_percentile: Option<f64>,
167    pub threshold_level: Option<f64>,
168}
169
170impl Default for BullsVBearsParams {
171    fn default() -> Self {
172        Self {
173            period: Some(DEFAULT_PERIOD),
174            ma_type: Some(DEFAULT_MA_TYPE),
175            calculation_method: Some(DEFAULT_CALCULATION_METHOD),
176            normalized_bars_back: Some(DEFAULT_NORMALIZED_BARS_BACK),
177            raw_rolling_period: Some(DEFAULT_RAW_ROLLING_PERIOD),
178            raw_threshold_percentile: Some(DEFAULT_RAW_THRESHOLD_PERCENTILE),
179            threshold_level: Some(DEFAULT_THRESHOLD_LEVEL),
180        }
181    }
182}
183
184#[derive(Debug, Clone)]
185pub struct BullsVBearsInput<'a> {
186    pub data: BullsVBearsData<'a>,
187    pub params: BullsVBearsParams,
188}
189
190impl<'a> BullsVBearsInput<'a> {
191    #[inline]
192    pub fn from_candles(candles: &'a Candles, params: BullsVBearsParams) -> Self {
193        Self {
194            data: BullsVBearsData::Candles { candles },
195            params,
196        }
197    }
198
199    #[inline]
200    pub fn from_slices(
201        high: &'a [f64],
202        low: &'a [f64],
203        close: &'a [f64],
204        params: BullsVBearsParams,
205    ) -> Self {
206        Self {
207            data: BullsVBearsData::Slices { high, low, close },
208            params,
209        }
210    }
211
212    #[inline]
213    pub fn with_default_candles(candles: &'a Candles) -> Self {
214        Self::from_candles(candles, BullsVBearsParams::default())
215    }
216}
217
218#[derive(Copy, Clone, Debug)]
219pub struct BullsVBearsBuilder {
220    period: Option<usize>,
221    ma_type: Option<BullsVBearsMaType>,
222    calculation_method: Option<BullsVBearsCalculationMethod>,
223    normalized_bars_back: Option<usize>,
224    raw_rolling_period: Option<usize>,
225    raw_threshold_percentile: Option<f64>,
226    threshold_level: Option<f64>,
227    kernel: Kernel,
228}
229
230impl Default for BullsVBearsBuilder {
231    fn default() -> Self {
232        Self {
233            period: None,
234            ma_type: None,
235            calculation_method: None,
236            normalized_bars_back: None,
237            raw_rolling_period: None,
238            raw_threshold_percentile: None,
239            threshold_level: None,
240            kernel: Kernel::Auto,
241        }
242    }
243}
244
245impl BullsVBearsBuilder {
246    #[inline(always)]
247    pub fn new() -> Self {
248        Self::default()
249    }
250
251    #[inline(always)]
252    pub fn period(mut self, value: usize) -> Self {
253        self.period = Some(value);
254        self
255    }
256
257    #[inline(always)]
258    pub fn ma_type(mut self, value: BullsVBearsMaType) -> Self {
259        self.ma_type = Some(value);
260        self
261    }
262
263    #[inline(always)]
264    pub fn calculation_method(mut self, value: BullsVBearsCalculationMethod) -> Self {
265        self.calculation_method = Some(value);
266        self
267    }
268
269    #[inline(always)]
270    pub fn normalized_bars_back(mut self, value: usize) -> Self {
271        self.normalized_bars_back = Some(value);
272        self
273    }
274
275    #[inline(always)]
276    pub fn raw_rolling_period(mut self, value: usize) -> Self {
277        self.raw_rolling_period = Some(value);
278        self
279    }
280
281    #[inline(always)]
282    pub fn raw_threshold_percentile(mut self, value: f64) -> Self {
283        self.raw_threshold_percentile = Some(value);
284        self
285    }
286
287    #[inline(always)]
288    pub fn threshold_level(mut self, value: f64) -> Self {
289        self.threshold_level = Some(value);
290        self
291    }
292
293    #[inline(always)]
294    pub fn kernel(mut self, value: Kernel) -> Self {
295        self.kernel = value;
296        self
297    }
298
299    #[inline(always)]
300    pub fn apply(self, candles: &Candles) -> Result<BullsVBearsOutput, BullsVBearsError> {
301        let input = BullsVBearsInput::from_candles(
302            candles,
303            BullsVBearsParams {
304                period: self.period,
305                ma_type: self.ma_type,
306                calculation_method: self.calculation_method,
307                normalized_bars_back: self.normalized_bars_back,
308                raw_rolling_period: self.raw_rolling_period,
309                raw_threshold_percentile: self.raw_threshold_percentile,
310                threshold_level: self.threshold_level,
311            },
312        );
313        bulls_v_bears_with_kernel(&input, self.kernel)
314    }
315
316    #[inline(always)]
317    pub fn apply_slices(
318        self,
319        high: &[f64],
320        low: &[f64],
321        close: &[f64],
322    ) -> Result<BullsVBearsOutput, BullsVBearsError> {
323        let input = BullsVBearsInput::from_slices(
324            high,
325            low,
326            close,
327            BullsVBearsParams {
328                period: self.period,
329                ma_type: self.ma_type,
330                calculation_method: self.calculation_method,
331                normalized_bars_back: self.normalized_bars_back,
332                raw_rolling_period: self.raw_rolling_period,
333                raw_threshold_percentile: self.raw_threshold_percentile,
334                threshold_level: self.threshold_level,
335            },
336        );
337        bulls_v_bears_with_kernel(&input, self.kernel)
338    }
339
340    #[inline(always)]
341    pub fn into_stream(self) -> Result<BullsVBearsStream, BullsVBearsError> {
342        BullsVBearsStream::try_new(BullsVBearsParams {
343            period: self.period,
344            ma_type: self.ma_type,
345            calculation_method: self.calculation_method,
346            normalized_bars_back: self.normalized_bars_back,
347            raw_rolling_period: self.raw_rolling_period,
348            raw_threshold_percentile: self.raw_threshold_percentile,
349            threshold_level: self.threshold_level,
350        })
351    }
352}
353
354#[derive(Debug, Error)]
355pub enum BullsVBearsError {
356    #[error("bulls_v_bears: Input data slice is empty.")]
357    EmptyInputData,
358    #[error("bulls_v_bears: All values are NaN.")]
359    AllValuesNaN,
360    #[error("bulls_v_bears: Inconsistent slice lengths: high={high_len}, low={low_len}, close={close_len}")]
361    InconsistentSliceLengths {
362        high_len: usize,
363        low_len: usize,
364        close_len: usize,
365    },
366    #[error("bulls_v_bears: Invalid period: {period}")]
367    InvalidPeriod { period: usize },
368    #[error("bulls_v_bears: Invalid normalized_bars_back: {normalized_bars_back}")]
369    InvalidNormalizedBarsBack { normalized_bars_back: usize },
370    #[error("bulls_v_bears: Invalid raw_rolling_period: {raw_rolling_period}")]
371    InvalidRawRollingPeriod { raw_rolling_period: usize },
372    #[error("bulls_v_bears: Invalid raw_threshold_percentile: {raw_threshold_percentile}")]
373    InvalidRawThresholdPercentile { raw_threshold_percentile: f64 },
374    #[error("bulls_v_bears: Invalid threshold_level: {threshold_level}")]
375    InvalidThresholdLevel { threshold_level: f64 },
376    #[error("bulls_v_bears: Output length mismatch: expected={expected}, got={got}")]
377    OutputLengthMismatch { expected: usize, got: usize },
378    #[error("bulls_v_bears: Invalid range: start={start}, end={end}, step={step}")]
379    InvalidRange {
380        start: String,
381        end: String,
382        step: String,
383    },
384    #[error("bulls_v_bears: Invalid kernel for batch: {0:?}")]
385    InvalidKernelForBatch(Kernel),
386}
387
388#[derive(Clone, Copy, Debug)]
389struct ResolvedParams {
390    period: usize,
391    ma_type: BullsVBearsMaType,
392    calculation_method: BullsVBearsCalculationMethod,
393    normalized_bars_back: usize,
394    raw_rolling_period: usize,
395    raw_threshold_percentile: f64,
396    threshold_level: f64,
397}
398
399#[inline(always)]
400fn extract_hlc<'a>(
401    input: &'a BullsVBearsInput<'a>,
402) -> Result<(&'a [f64], &'a [f64], &'a [f64]), BullsVBearsError> {
403    let (high, low, close) = match &input.data {
404        BullsVBearsData::Candles { candles } => (
405            candles.high.as_slice(),
406            candles.low.as_slice(),
407            candles.close.as_slice(),
408        ),
409        BullsVBearsData::Slices { high, low, close } => (*high, *low, *close),
410    };
411    if high.is_empty() || low.is_empty() || close.is_empty() {
412        return Err(BullsVBearsError::EmptyInputData);
413    }
414    if high.len() != low.len() || high.len() != close.len() {
415        return Err(BullsVBearsError::InconsistentSliceLengths {
416            high_len: high.len(),
417            low_len: low.len(),
418            close_len: close.len(),
419        });
420    }
421    Ok((high, low, close))
422}
423
424#[inline(always)]
425fn first_valid_hlc(high: &[f64], low: &[f64], close: &[f64]) -> Option<usize> {
426    (0..close.len()).find(|&i| high[i].is_finite() && low[i].is_finite() && close[i].is_finite())
427}
428
429#[inline(always)]
430fn resolve_params(params: &BullsVBearsParams) -> Result<ResolvedParams, BullsVBearsError> {
431    let period = params.period.unwrap_or(DEFAULT_PERIOD);
432    if period == 0 {
433        return Err(BullsVBearsError::InvalidPeriod { period });
434    }
435    let normalized_bars_back = params
436        .normalized_bars_back
437        .unwrap_or(DEFAULT_NORMALIZED_BARS_BACK);
438    if normalized_bars_back == 0 {
439        return Err(BullsVBearsError::InvalidNormalizedBarsBack {
440            normalized_bars_back,
441        });
442    }
443    let raw_rolling_period = params
444        .raw_rolling_period
445        .unwrap_or(DEFAULT_RAW_ROLLING_PERIOD);
446    if raw_rolling_period == 0 {
447        return Err(BullsVBearsError::InvalidRawRollingPeriod { raw_rolling_period });
448    }
449    let raw_threshold_percentile = params
450        .raw_threshold_percentile
451        .unwrap_or(DEFAULT_RAW_THRESHOLD_PERCENTILE);
452    if !raw_threshold_percentile.is_finite() || !(80.0..=99.0).contains(&raw_threshold_percentile) {
453        return Err(BullsVBearsError::InvalidRawThresholdPercentile {
454            raw_threshold_percentile,
455        });
456    }
457    let threshold_level = params.threshold_level.unwrap_or(DEFAULT_THRESHOLD_LEVEL);
458    if !threshold_level.is_finite() || !(0.0..=100.0).contains(&threshold_level) {
459        return Err(BullsVBearsError::InvalidThresholdLevel { threshold_level });
460    }
461    Ok(ResolvedParams {
462        period,
463        ma_type: params.ma_type.unwrap_or(DEFAULT_MA_TYPE),
464        calculation_method: params
465            .calculation_method
466            .unwrap_or(DEFAULT_CALCULATION_METHOD),
467        normalized_bars_back,
468        raw_rolling_period,
469        raw_threshold_percentile,
470        threshold_level,
471    })
472}
473
474#[inline(always)]
475fn validate_input<'a>(
476    input: &'a BullsVBearsInput<'a>,
477    kernel: Kernel,
478) -> Result<
479    (
480        &'a [f64],
481        &'a [f64],
482        &'a [f64],
483        ResolvedParams,
484        usize,
485        Kernel,
486    ),
487    BullsVBearsError,
488> {
489    let (high, low, close) = extract_hlc(input)?;
490    let params = resolve_params(&input.params)?;
491    let first = first_valid_hlc(high, low, close).ok_or(BullsVBearsError::AllValuesNaN)?;
492    Ok((high, low, close, params, first, kernel.to_non_batch()))
493}
494
495#[inline(always)]
496fn check_output_len(out: &[f64], expected: usize) -> Result<(), BullsVBearsError> {
497    if out.len() != expected {
498        return Err(BullsVBearsError::OutputLengthMismatch {
499            expected,
500            got: out.len(),
501        });
502    }
503    Ok(())
504}
505
506#[inline(always)]
507fn fill_moving_average(
508    close: &[f64],
509    params: ResolvedParams,
510    out_ma: &mut [f64],
511) -> Result<(), BullsVBearsError> {
512    let len = close.len();
513    check_output_len(out_ma, len)?;
514
515    match params.ma_type {
516        BullsVBearsMaType::Ema => {
517            let alpha = 2.0 / (params.period as f64 + 1.0);
518            let mut prev = f64::NAN;
519            for i in 0..len {
520                let x = close[i];
521                if !x.is_finite() {
522                    prev = f64::NAN;
523                    out_ma[i] = f64::NAN;
524                    continue;
525                }
526                if prev.is_finite() {
527                    prev += alpha * (x - prev);
528                } else {
529                    prev = x;
530                }
531                out_ma[i] = prev;
532            }
533        }
534        BullsVBearsMaType::Sma => {
535            let mut sum = 0.0;
536            let mut finite_count = 0usize;
537            for i in 0..len {
538                let x = close[i];
539                if x.is_finite() {
540                    sum += x;
541                    finite_count += 1;
542                }
543                if i >= params.period {
544                    let old = close[i - params.period];
545                    if old.is_finite() {
546                        sum -= old;
547                        finite_count -= 1;
548                    }
549                }
550                out_ma[i] = if i + 1 >= params.period && finite_count == params.period {
551                    sum / params.period as f64
552                } else {
553                    f64::NAN
554                };
555            }
556        }
557        BullsVBearsMaType::Wma => {
558            let denom = (params.period * (params.period + 1) / 2) as f64;
559            let mut window: VecDeque<f64> = VecDeque::with_capacity(params.period);
560            let mut sum = 0.0;
561            let mut finite_count = 0usize;
562            let mut weighted = 0.0;
563            let mut prev_full_valid = false;
564
565            for i in 0..len {
566                let x = close[i];
567                let old_window_sum = sum;
568                let popped = if window.len() == params.period {
569                    let old = window.pop_front().unwrap();
570                    if old.is_finite() {
571                        sum -= old;
572                        finite_count -= 1;
573                    }
574                    Some(old)
575                } else {
576                    None
577                };
578                window.push_back(x);
579                if x.is_finite() {
580                    sum += x;
581                    finite_count += 1;
582                }
583
584                let full_valid = window.len() == params.period && finite_count == params.period;
585                if full_valid {
586                    if prev_full_valid && popped.is_some() && x.is_finite() {
587                        weighted = weighted + params.period as f64 * x - old_window_sum;
588                    } else {
589                        weighted = 0.0;
590                        for (idx, value) in window.iter().enumerate() {
591                            weighted += *value * (idx + 1) as f64;
592                        }
593                    }
594                    out_ma[i] = weighted / denom;
595                    prev_full_valid = true;
596                } else {
597                    out_ma[i] = f64::NAN;
598                    prev_full_valid = false;
599                    weighted = 0.0;
600                }
601            }
602        }
603    }
604    Ok(())
605}
606
607#[inline(always)]
608fn push_min_queue(queue: &mut VecDeque<(usize, f64)>, idx: usize, value: f64) {
609    while let Some((_, back)) = queue.back() {
610        if *back <= value {
611            break;
612        }
613        queue.pop_back();
614    }
615    queue.push_back((idx, value));
616}
617
618#[inline(always)]
619fn push_max_queue(queue: &mut VecDeque<(usize, f64)>, idx: usize, value: f64) {
620    while let Some((_, back)) = queue.back() {
621        if *back >= value {
622            break;
623        }
624        queue.pop_back();
625    }
626    queue.push_back((idx, value));
627}
628
629#[inline(always)]
630fn expire_queue(queue: &mut VecDeque<(usize, f64)>, min_index: usize) {
631    while let Some((idx, _)) = queue.front() {
632        if *idx >= min_index {
633            break;
634        }
635        queue.pop_front();
636    }
637}
638
639#[inline(always)]
640fn compute_signals(
641    out_value: &[f64],
642    out_upper: &[f64],
643    out_lower: &[f64],
644    out_bullish_signal: &mut [f64],
645    out_bearish_signal: &mut [f64],
646    out_zero_cross_up: &mut [f64],
647    out_zero_cross_down: &mut [f64],
648) -> Result<(), BullsVBearsError> {
649    let len = out_value.len();
650    check_output_len(out_upper, len)?;
651    check_output_len(out_lower, len)?;
652    check_output_len(out_bullish_signal, len)?;
653    check_output_len(out_bearish_signal, len)?;
654    check_output_len(out_zero_cross_up, len)?;
655    check_output_len(out_zero_cross_down, len)?;
656
657    let mut prev_total = f64::NAN;
658    for i in 0..len {
659        let total = out_value[i];
660        let upper = out_upper[i];
661        let lower = out_lower[i];
662        if total.is_finite() && upper.is_finite() && lower.is_finite() {
663            out_bullish_signal[i] = if total > upper { 1.0 } else { 0.0 };
664            out_bearish_signal[i] = if total < lower { 1.0 } else { 0.0 };
665            out_zero_cross_up[i] = if prev_total.is_finite() && total > 0.0 && prev_total <= 0.0 {
666                1.0
667            } else {
668                0.0
669            };
670            out_zero_cross_down[i] = if prev_total.is_finite() && total < 0.0 && prev_total >= 0.0 {
671                1.0
672            } else {
673                0.0
674            };
675            prev_total = total;
676        } else {
677            out_bullish_signal[i] = f64::NAN;
678            out_bearish_signal[i] = f64::NAN;
679            out_zero_cross_up[i] = f64::NAN;
680            out_zero_cross_down[i] = f64::NAN;
681        }
682    }
683    Ok(())
684}
685
686#[allow(clippy::too_many_arguments)]
687#[inline(always)]
688fn bulls_v_bears_compute_into(
689    high: &[f64],
690    low: &[f64],
691    close: &[f64],
692    params: ResolvedParams,
693    out_value: &mut [f64],
694    out_bull: &mut [f64],
695    out_bear: &mut [f64],
696    out_ma: &mut [f64],
697    out_upper: &mut [f64],
698    out_lower: &mut [f64],
699    out_bullish_signal: &mut [f64],
700    out_bearish_signal: &mut [f64],
701    out_zero_cross_up: &mut [f64],
702    out_zero_cross_down: &mut [f64],
703) -> Result<(), BullsVBearsError> {
704    let len = close.len();
705    check_output_len(out_value, len)?;
706    check_output_len(out_bull, len)?;
707    check_output_len(out_bear, len)?;
708    check_output_len(out_ma, len)?;
709    check_output_len(out_upper, len)?;
710    check_output_len(out_lower, len)?;
711    check_output_len(out_bullish_signal, len)?;
712    check_output_len(out_bearish_signal, len)?;
713    check_output_len(out_zero_cross_up, len)?;
714    check_output_len(out_zero_cross_down, len)?;
715
716    fill_moving_average(close, params, out_ma)?;
717
718    for i in 0..len {
719        let h = high[i];
720        let l = low[i];
721        let ma = out_ma[i];
722        if h.is_finite() && l.is_finite() && ma.is_finite() {
723            out_bull[i] = h - ma;
724            out_bear[i] = ma - l;
725        } else {
726            out_bull[i] = f64::NAN;
727            out_bear[i] = f64::NAN;
728        }
729    }
730
731    match params.calculation_method {
732        BullsVBearsCalculationMethod::Normalized => {
733            let mut bull_min = VecDeque::new();
734            let mut bull_max = VecDeque::new();
735            let mut bear_min = VecDeque::new();
736            let mut bear_max = VecDeque::new();
737
738            for i in 0..len {
739                let min_index = i
740                    .saturating_add(1)
741                    .saturating_sub(params.normalized_bars_back);
742                expire_queue(&mut bull_min, min_index);
743                expire_queue(&mut bull_max, min_index);
744                expire_queue(&mut bear_min, min_index);
745                expire_queue(&mut bear_max, min_index);
746
747                let bull = out_bull[i];
748                let bear = out_bear[i];
749                if bull.is_finite() {
750                    push_min_queue(&mut bull_min, i, bull);
751                    push_max_queue(&mut bull_max, i, bull);
752                }
753                if bear.is_finite() {
754                    push_min_queue(&mut bear_min, i, bear);
755                    push_max_queue(&mut bear_max, i, bear);
756                }
757
758                out_upper[i] = params.threshold_level;
759                out_lower[i] = -params.threshold_level;
760
761                if !(bull.is_finite() && bear.is_finite()) {
762                    out_value[i] = f64::NAN;
763                    continue;
764                }
765
766                let bull_min_value = bull_min.front().map(|(_, v)| *v).unwrap_or(f64::NAN);
767                let bull_max_value = bull_max.front().map(|(_, v)| *v).unwrap_or(f64::NAN);
768                let bear_min_value = bear_min.front().map(|(_, v)| *v).unwrap_or(f64::NAN);
769                let bear_max_value = bear_max.front().map(|(_, v)| *v).unwrap_or(f64::NAN);
770                let bull_range = bull_max_value - bull_min_value;
771                let bear_range = bear_max_value - bear_min_value;
772
773                if bull_range > 0.0 && bear_range > 0.0 {
774                    let norm_bull = ((bull - bull_min_value) / bull_range - 0.5) * 100.0;
775                    let norm_bear = ((bear - bear_min_value) / bear_range - 0.5) * 100.0;
776                    out_value[i] = norm_bull - norm_bear;
777                } else {
778                    out_value[i] = f64::NAN;
779                }
780            }
781        }
782        BullsVBearsCalculationMethod::Raw => {
783            let mut raw_min = VecDeque::new();
784            let mut raw_max = VecDeque::new();
785            let upper_factor = params.raw_threshold_percentile / 100.0;
786            let lower_factor = (100.0 - params.raw_threshold_percentile) / 100.0;
787
788            for i in 0..len {
789                let bull = out_bull[i];
790                let bear = out_bear[i];
791                out_value[i] = if bull.is_finite() && bear.is_finite() {
792                    bull - bear
793                } else {
794                    f64::NAN
795                };
796            }
797
798            for i in 0..len {
799                let min_index = i
800                    .saturating_add(1)
801                    .saturating_sub(params.raw_rolling_period);
802                expire_queue(&mut raw_min, min_index);
803                expire_queue(&mut raw_max, min_index);
804
805                let total = out_value[i];
806                if total.is_finite() {
807                    push_min_queue(&mut raw_min, i, total);
808                    push_max_queue(&mut raw_max, i, total);
809                }
810
811                let lowest = raw_min.front().map(|(_, v)| *v).unwrap_or(f64::NAN);
812                let highest = raw_max.front().map(|(_, v)| *v).unwrap_or(f64::NAN);
813                if lowest.is_finite() && highest.is_finite() {
814                    let range = highest - lowest;
815                    out_upper[i] = lowest + range * upper_factor;
816                    out_lower[i] = lowest + range * lower_factor;
817                } else {
818                    out_upper[i] = f64::NAN;
819                    out_lower[i] = f64::NAN;
820                }
821            }
822        }
823    }
824
825    compute_signals(
826        out_value,
827        out_upper,
828        out_lower,
829        out_bullish_signal,
830        out_bearish_signal,
831        out_zero_cross_up,
832        out_zero_cross_down,
833    )?;
834    Ok(())
835}
836
837#[inline]
838pub fn bulls_v_bears(input: &BullsVBearsInput) -> Result<BullsVBearsOutput, BullsVBearsError> {
839    bulls_v_bears_with_kernel(input, Kernel::Auto)
840}
841
842pub fn bulls_v_bears_with_kernel(
843    input: &BullsVBearsInput,
844    kernel: Kernel,
845) -> Result<BullsVBearsOutput, BullsVBearsError> {
846    let (high, low, close, params, _first, _kernel) = validate_input(input, kernel)?;
847    let len = close.len();
848    let warm = params.ma_type.warmup(params.period);
849
850    let mut value = alloc_with_nan_prefix(len, warm);
851    let mut bull = alloc_with_nan_prefix(len, warm);
852    let mut bear = alloc_with_nan_prefix(len, warm);
853    let mut ma = alloc_with_nan_prefix(len, warm);
854    let mut upper = alloc_with_nan_prefix(len, warm);
855    let mut lower = alloc_with_nan_prefix(len, warm);
856    let mut bullish_signal = alloc_with_nan_prefix(len, warm);
857    let mut bearish_signal = alloc_with_nan_prefix(len, warm);
858    let mut zero_cross_up = alloc_with_nan_prefix(len, warm);
859    let mut zero_cross_down = alloc_with_nan_prefix(len, warm);
860
861    bulls_v_bears_compute_into(
862        high,
863        low,
864        close,
865        params,
866        &mut value,
867        &mut bull,
868        &mut bear,
869        &mut ma,
870        &mut upper,
871        &mut lower,
872        &mut bullish_signal,
873        &mut bearish_signal,
874        &mut zero_cross_up,
875        &mut zero_cross_down,
876    )?;
877
878    Ok(BullsVBearsOutput {
879        value,
880        bull,
881        bear,
882        ma,
883        upper,
884        lower,
885        bullish_signal,
886        bearish_signal,
887        zero_cross_up,
888        zero_cross_down,
889    })
890}
891
892#[allow(clippy::too_many_arguments)]
893#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
894pub fn bulls_v_bears_into(
895    out_value: &mut [f64],
896    out_bull: &mut [f64],
897    out_bear: &mut [f64],
898    out_ma: &mut [f64],
899    out_upper: &mut [f64],
900    out_lower: &mut [f64],
901    out_bullish_signal: &mut [f64],
902    out_bearish_signal: &mut [f64],
903    out_zero_cross_up: &mut [f64],
904    out_zero_cross_down: &mut [f64],
905    high: &[f64],
906    low: &[f64],
907    close: &[f64],
908    params: BullsVBearsParams,
909) -> Result<(), BullsVBearsError> {
910    bulls_v_bears_into_slice(
911        out_value,
912        out_bull,
913        out_bear,
914        out_ma,
915        out_upper,
916        out_lower,
917        out_bullish_signal,
918        out_bearish_signal,
919        out_zero_cross_up,
920        out_zero_cross_down,
921        high,
922        low,
923        close,
924        params,
925        Kernel::Auto,
926    )
927}
928
929#[allow(clippy::too_many_arguments)]
930pub fn bulls_v_bears_into_slice(
931    out_value: &mut [f64],
932    out_bull: &mut [f64],
933    out_bear: &mut [f64],
934    out_ma: &mut [f64],
935    out_upper: &mut [f64],
936    out_lower: &mut [f64],
937    out_bullish_signal: &mut [f64],
938    out_bearish_signal: &mut [f64],
939    out_zero_cross_up: &mut [f64],
940    out_zero_cross_down: &mut [f64],
941    high: &[f64],
942    low: &[f64],
943    close: &[f64],
944    params: BullsVBearsParams,
945    kernel: Kernel,
946) -> Result<(), BullsVBearsError> {
947    let input = BullsVBearsInput::from_slices(high, low, close, params);
948    let (_, _, _, resolved, _, _) = validate_input(&input, kernel)?;
949    bulls_v_bears_compute_into(
950        high,
951        low,
952        close,
953        resolved,
954        out_value,
955        out_bull,
956        out_bear,
957        out_ma,
958        out_upper,
959        out_lower,
960        out_bullish_signal,
961        out_bearish_signal,
962        out_zero_cross_up,
963        out_zero_cross_down,
964    )
965}
966
967#[derive(Debug, Clone)]
968enum StreamMaState {
969    Ema {
970        alpha: f64,
971        prev: f64,
972    },
973    Sma {
974        period: usize,
975        window: VecDeque<f64>,
976        sum: f64,
977        finite_count: usize,
978    },
979    Wma {
980        period: usize,
981        denom: f64,
982        window: VecDeque<f64>,
983        sum: f64,
984        finite_count: usize,
985        weighted: f64,
986        prev_full_valid: bool,
987    },
988}
989
990impl StreamMaState {
991    fn new(params: ResolvedParams) -> Self {
992        match params.ma_type {
993            BullsVBearsMaType::Ema => Self::Ema {
994                alpha: 2.0 / (params.period as f64 + 1.0),
995                prev: f64::NAN,
996            },
997            BullsVBearsMaType::Sma => Self::Sma {
998                period: params.period,
999                window: VecDeque::<f64>::with_capacity(params.period),
1000                sum: 0.0,
1001                finite_count: 0,
1002            },
1003            BullsVBearsMaType::Wma => Self::Wma {
1004                period: params.period,
1005                denom: (params.period * (params.period + 1) / 2) as f64,
1006                window: VecDeque::<f64>::with_capacity(params.period),
1007                sum: 0.0,
1008                finite_count: 0,
1009                weighted: 0.0,
1010                prev_full_valid: false,
1011            },
1012        }
1013    }
1014
1015    fn update(&mut self, close: f64) -> f64 {
1016        match self {
1017            Self::Ema { alpha, prev } => {
1018                if !close.is_finite() {
1019                    *prev = f64::NAN;
1020                    return f64::NAN;
1021                }
1022                if prev.is_finite() {
1023                    *prev += *alpha * (close - *prev);
1024                } else {
1025                    *prev = close;
1026                }
1027                *prev
1028            }
1029            Self::Sma {
1030                period,
1031                window,
1032                sum,
1033                finite_count,
1034            } => {
1035                if window.len() == *period {
1036                    let old = window.pop_front().unwrap();
1037                    if old.is_finite() {
1038                        *sum -= old;
1039                        *finite_count -= 1;
1040                    }
1041                }
1042                window.push_back(close);
1043                if close.is_finite() {
1044                    *sum += close;
1045                    *finite_count += 1;
1046                }
1047                if window.len() == *period && *finite_count == *period {
1048                    *sum / *period as f64
1049                } else {
1050                    f64::NAN
1051                }
1052            }
1053            Self::Wma {
1054                period,
1055                denom,
1056                window,
1057                sum,
1058                finite_count,
1059                weighted,
1060                prev_full_valid,
1061            } => {
1062                let old_window_sum = *sum;
1063                let popped = if window.len() == *period {
1064                    let old = window.pop_front().unwrap();
1065                    if old.is_finite() {
1066                        *sum -= old;
1067                        *finite_count -= 1;
1068                    }
1069                    Some(old)
1070                } else {
1071                    None
1072                };
1073                window.push_back(close);
1074                if close.is_finite() {
1075                    *sum += close;
1076                    *finite_count += 1;
1077                }
1078                let full_valid = window.len() == *period && *finite_count == *period;
1079                if full_valid {
1080                    if *prev_full_valid && popped.is_some() && close.is_finite() {
1081                        *weighted = *weighted + *period as f64 * close - old_window_sum;
1082                    } else {
1083                        *weighted = 0.0;
1084                        for (idx, value) in window.iter().enumerate() {
1085                            *weighted += *value * (idx + 1) as f64;
1086                        }
1087                    }
1088                    *prev_full_valid = true;
1089                    *weighted / *denom
1090                } else {
1091                    *prev_full_valid = false;
1092                    *weighted = 0.0;
1093                    f64::NAN
1094                }
1095            }
1096        }
1097    }
1098}
1099
1100#[derive(Debug, Clone)]
1101pub struct BullsVBearsStream {
1102    params: ResolvedParams,
1103    index: usize,
1104    ma_state: StreamMaState,
1105    bull_min: VecDeque<(usize, f64)>,
1106    bull_max: VecDeque<(usize, f64)>,
1107    bear_min: VecDeque<(usize, f64)>,
1108    bear_max: VecDeque<(usize, f64)>,
1109    raw_min: VecDeque<(usize, f64)>,
1110    raw_max: VecDeque<(usize, f64)>,
1111    prev_total: f64,
1112}
1113
1114impl BullsVBearsStream {
1115    pub fn try_new(params: BullsVBearsParams) -> Result<Self, BullsVBearsError> {
1116        let params = resolve_params(&params)?;
1117        Ok(Self {
1118            params,
1119            index: 0,
1120            ma_state: StreamMaState::new(params),
1121            bull_min: VecDeque::new(),
1122            bull_max: VecDeque::new(),
1123            bear_min: VecDeque::new(),
1124            bear_max: VecDeque::new(),
1125            raw_min: VecDeque::new(),
1126            raw_max: VecDeque::new(),
1127            prev_total: f64::NAN,
1128        })
1129    }
1130
1131    pub fn update(
1132        &mut self,
1133        high: f64,
1134        low: f64,
1135        close: f64,
1136    ) -> (f64, f64, f64, f64, f64, f64, f64, f64, f64, f64) {
1137        let idx = self.index;
1138        self.index = self.index.saturating_add(1);
1139
1140        let ma = self.ma_state.update(close);
1141        if !(high.is_finite() && low.is_finite() && ma.is_finite()) {
1142            return (
1143                f64::NAN,
1144                f64::NAN,
1145                f64::NAN,
1146                ma,
1147                f64::NAN,
1148                f64::NAN,
1149                f64::NAN,
1150                f64::NAN,
1151                f64::NAN,
1152                f64::NAN,
1153            );
1154        }
1155
1156        let bull = high - ma;
1157        let bear = ma - low;
1158        let (value, upper, lower) = match self.params.calculation_method {
1159            BullsVBearsCalculationMethod::Normalized => {
1160                let min_index = idx
1161                    .saturating_add(1)
1162                    .saturating_sub(self.params.normalized_bars_back);
1163                expire_queue(&mut self.bull_min, min_index);
1164                expire_queue(&mut self.bull_max, min_index);
1165                expire_queue(&mut self.bear_min, min_index);
1166                expire_queue(&mut self.bear_max, min_index);
1167                push_min_queue(&mut self.bull_min, idx, bull);
1168                push_max_queue(&mut self.bull_max, idx, bull);
1169                push_min_queue(&mut self.bear_min, idx, bear);
1170                push_max_queue(&mut self.bear_max, idx, bear);
1171                let bull_min_value = self.bull_min.front().map(|(_, v)| *v).unwrap_or(f64::NAN);
1172                let bull_max_value = self.bull_max.front().map(|(_, v)| *v).unwrap_or(f64::NAN);
1173                let bear_min_value = self.bear_min.front().map(|(_, v)| *v).unwrap_or(f64::NAN);
1174                let bear_max_value = self.bear_max.front().map(|(_, v)| *v).unwrap_or(f64::NAN);
1175                let bull_range = bull_max_value - bull_min_value;
1176                let bear_range = bear_max_value - bear_min_value;
1177                let total = if bull_range > 0.0 && bear_range > 0.0 {
1178                    ((bull - bull_min_value) / bull_range - 0.5) * 100.0
1179                        - ((bear - bear_min_value) / bear_range - 0.5) * 100.0
1180                } else {
1181                    f64::NAN
1182                };
1183                (
1184                    total,
1185                    self.params.threshold_level,
1186                    -self.params.threshold_level,
1187                )
1188            }
1189            BullsVBearsCalculationMethod::Raw => {
1190                let total = bull - bear;
1191                let min_index = idx
1192                    .saturating_add(1)
1193                    .saturating_sub(self.params.raw_rolling_period);
1194                expire_queue(&mut self.raw_min, min_index);
1195                expire_queue(&mut self.raw_max, min_index);
1196                push_min_queue(&mut self.raw_min, idx, total);
1197                push_max_queue(&mut self.raw_max, idx, total);
1198                let raw_lowest = self.raw_min.front().map(|(_, v)| *v).unwrap_or(f64::NAN);
1199                let raw_highest = self.raw_max.front().map(|(_, v)| *v).unwrap_or(f64::NAN);
1200                let raw_range = raw_highest - raw_lowest;
1201                let upper = raw_lowest + raw_range * (self.params.raw_threshold_percentile / 100.0);
1202                let lower = raw_lowest
1203                    + raw_range * ((100.0 - self.params.raw_threshold_percentile) / 100.0);
1204                (total, upper, lower)
1205            }
1206        };
1207
1208        if !(value.is_finite() && upper.is_finite() && lower.is_finite()) {
1209            return (
1210                f64::NAN,
1211                bull,
1212                bear,
1213                ma,
1214                upper,
1215                lower,
1216                f64::NAN,
1217                f64::NAN,
1218                f64::NAN,
1219                f64::NAN,
1220            );
1221        }
1222
1223        let bullish_signal = if value > upper { 1.0 } else { 0.0 };
1224        let bearish_signal = if value < lower { 1.0 } else { 0.0 };
1225        let zero_cross_up = if self.prev_total.is_finite() && value > 0.0 && self.prev_total <= 0.0
1226        {
1227            1.0
1228        } else {
1229            0.0
1230        };
1231        let zero_cross_down =
1232            if self.prev_total.is_finite() && value < 0.0 && self.prev_total >= 0.0 {
1233                1.0
1234            } else {
1235                0.0
1236            };
1237        self.prev_total = value;
1238        (
1239            value,
1240            bull,
1241            bear,
1242            ma,
1243            upper,
1244            lower,
1245            bullish_signal,
1246            bearish_signal,
1247            zero_cross_up,
1248            zero_cross_down,
1249        )
1250    }
1251}
1252
1253#[derive(Debug, Clone)]
1254pub struct BullsVBearsBatchRange {
1255    pub period: (usize, usize, usize),
1256    pub normalized_bars_back: (usize, usize, usize),
1257    pub raw_rolling_period: (usize, usize, usize),
1258    pub raw_threshold_percentile: (f64, f64, f64),
1259    pub threshold_level: (f64, f64, f64),
1260    pub ma_type: BullsVBearsMaType,
1261    pub calculation_method: BullsVBearsCalculationMethod,
1262}
1263
1264impl Default for BullsVBearsBatchRange {
1265    fn default() -> Self {
1266        Self {
1267            period: (DEFAULT_PERIOD, DEFAULT_PERIOD, 0),
1268            normalized_bars_back: (
1269                DEFAULT_NORMALIZED_BARS_BACK,
1270                DEFAULT_NORMALIZED_BARS_BACK,
1271                0,
1272            ),
1273            raw_rolling_period: (DEFAULT_RAW_ROLLING_PERIOD, DEFAULT_RAW_ROLLING_PERIOD, 0),
1274            raw_threshold_percentile: (
1275                DEFAULT_RAW_THRESHOLD_PERCENTILE,
1276                DEFAULT_RAW_THRESHOLD_PERCENTILE,
1277                0.0,
1278            ),
1279            threshold_level: (DEFAULT_THRESHOLD_LEVEL, DEFAULT_THRESHOLD_LEVEL, 0.0),
1280            ma_type: DEFAULT_MA_TYPE,
1281            calculation_method: DEFAULT_CALCULATION_METHOD,
1282        }
1283    }
1284}
1285
1286#[derive(Debug, Clone)]
1287pub struct BullsVBearsBatchOutput {
1288    pub value: Vec<f64>,
1289    pub bull: Vec<f64>,
1290    pub bear: Vec<f64>,
1291    pub ma: Vec<f64>,
1292    pub upper: Vec<f64>,
1293    pub lower: Vec<f64>,
1294    pub bullish_signal: Vec<f64>,
1295    pub bearish_signal: Vec<f64>,
1296    pub zero_cross_up: Vec<f64>,
1297    pub zero_cross_down: Vec<f64>,
1298    pub combos: Vec<BullsVBearsParams>,
1299    pub rows: usize,
1300    pub cols: usize,
1301}
1302
1303#[derive(Clone, Debug)]
1304pub struct BullsVBearsBatchBuilder {
1305    range: BullsVBearsBatchRange,
1306    kernel: Kernel,
1307}
1308
1309impl Default for BullsVBearsBatchBuilder {
1310    fn default() -> Self {
1311        Self {
1312            range: BullsVBearsBatchRange::default(),
1313            kernel: Kernel::Auto,
1314        }
1315    }
1316}
1317
1318impl BullsVBearsBatchBuilder {
1319    #[inline(always)]
1320    pub fn new() -> Self {
1321        Self::default()
1322    }
1323
1324    #[inline(always)]
1325    pub fn range(mut self, value: BullsVBearsBatchRange) -> Self {
1326        self.range = value;
1327        self
1328    }
1329
1330    #[inline(always)]
1331    pub fn kernel(mut self, value: Kernel) -> Self {
1332        self.kernel = value;
1333        self
1334    }
1335
1336    #[inline(always)]
1337    pub fn apply(self, candles: &Candles) -> Result<BullsVBearsBatchOutput, BullsVBearsError> {
1338        bulls_v_bears_batch_with_kernel(
1339            candles.high.as_slice(),
1340            candles.low.as_slice(),
1341            candles.close.as_slice(),
1342            &self.range,
1343            self.kernel,
1344        )
1345    }
1346
1347    #[inline(always)]
1348    pub fn apply_slices(
1349        self,
1350        high: &[f64],
1351        low: &[f64],
1352        close: &[f64],
1353    ) -> Result<BullsVBearsBatchOutput, BullsVBearsError> {
1354        bulls_v_bears_batch_with_kernel(high, low, close, &self.range, self.kernel)
1355    }
1356}
1357
1358#[inline(always)]
1359fn expand_usize_range(
1360    start: usize,
1361    end: usize,
1362    step: usize,
1363) -> Result<Vec<usize>, BullsVBearsError> {
1364    if start > end || (start != end && step == 0) {
1365        return Err(BullsVBearsError::InvalidRange {
1366            start: start.to_string(),
1367            end: end.to_string(),
1368            step: step.to_string(),
1369        });
1370    }
1371    let mut out = Vec::new();
1372    let mut current = start;
1373    loop {
1374        out.push(current);
1375        if current >= end {
1376            break;
1377        }
1378        current = current
1379            .checked_add(step)
1380            .ok_or_else(|| BullsVBearsError::InvalidRange {
1381                start: start.to_string(),
1382                end: end.to_string(),
1383                step: step.to_string(),
1384            })?;
1385        if current > end && out.last().copied() != Some(end) {
1386            break;
1387        }
1388        if out.len() > 1_000_000 {
1389            return Err(BullsVBearsError::InvalidRange {
1390                start: start.to_string(),
1391                end: end.to_string(),
1392                step: step.to_string(),
1393            });
1394        }
1395    }
1396    Ok(out)
1397}
1398
1399#[inline(always)]
1400fn expand_float_range(start: f64, end: f64, step: f64) -> Result<Vec<f64>, BullsVBearsError> {
1401    if !start.is_finite() || !end.is_finite() || !step.is_finite() {
1402        return Err(BullsVBearsError::InvalidRange {
1403            start: start.to_string(),
1404            end: end.to_string(),
1405            step: step.to_string(),
1406        });
1407    }
1408    if start > end || ((start - end).abs() > f64::EPSILON && step <= 0.0) {
1409        return Err(BullsVBearsError::InvalidRange {
1410            start: start.to_string(),
1411            end: end.to_string(),
1412            step: step.to_string(),
1413        });
1414    }
1415    let mut out = Vec::new();
1416    let mut current = start;
1417    while current <= end + 1e-12 {
1418        out.push(current);
1419        if out.len() > 1_000_000 {
1420            return Err(BullsVBearsError::InvalidRange {
1421                start: start.to_string(),
1422                end: end.to_string(),
1423                step: step.to_string(),
1424            });
1425        }
1426        if (current - end).abs() <= 1e-12 {
1427            break;
1428        }
1429        current += step;
1430    }
1431    Ok(out)
1432}
1433
1434pub fn bulls_v_bears_expand_grid(
1435    sweep: &BullsVBearsBatchRange,
1436) -> Result<Vec<BullsVBearsParams>, BullsVBearsError> {
1437    let periods = expand_usize_range(sweep.period.0, sweep.period.1, sweep.period.2)?;
1438    let normalized_bars_backs = expand_usize_range(
1439        sweep.normalized_bars_back.0,
1440        sweep.normalized_bars_back.1,
1441        sweep.normalized_bars_back.2,
1442    )?;
1443    let raw_rolling_periods = expand_usize_range(
1444        sweep.raw_rolling_period.0,
1445        sweep.raw_rolling_period.1,
1446        sweep.raw_rolling_period.2,
1447    )?;
1448    let raw_threshold_percentiles = expand_float_range(
1449        sweep.raw_threshold_percentile.0,
1450        sweep.raw_threshold_percentile.1,
1451        sweep.raw_threshold_percentile.2,
1452    )?;
1453    let threshold_levels = expand_float_range(
1454        sweep.threshold_level.0,
1455        sweep.threshold_level.1,
1456        sweep.threshold_level.2,
1457    )?;
1458
1459    let mut out = Vec::with_capacity(
1460        periods.len()
1461            * normalized_bars_backs.len()
1462            * raw_rolling_periods.len()
1463            * raw_threshold_percentiles.len()
1464            * threshold_levels.len(),
1465    );
1466    for period in periods {
1467        for normalized_bars_back in &normalized_bars_backs {
1468            for raw_rolling_period in &raw_rolling_periods {
1469                for raw_threshold_percentile in &raw_threshold_percentiles {
1470                    for threshold_level in &threshold_levels {
1471                        out.push(BullsVBearsParams {
1472                            period: Some(period),
1473                            ma_type: Some(sweep.ma_type),
1474                            calculation_method: Some(sweep.calculation_method),
1475                            normalized_bars_back: Some(*normalized_bars_back),
1476                            raw_rolling_period: Some(*raw_rolling_period),
1477                            raw_threshold_percentile: Some(*raw_threshold_percentile),
1478                            threshold_level: Some(*threshold_level),
1479                        });
1480                    }
1481                }
1482            }
1483        }
1484    }
1485    Ok(out)
1486}
1487
1488#[inline(always)]
1489fn validate_raw_slices(
1490    high: &[f64],
1491    low: &[f64],
1492    close: &[f64],
1493) -> Result<usize, BullsVBearsError> {
1494    if high.is_empty() || low.is_empty() || close.is_empty() {
1495        return Err(BullsVBearsError::EmptyInputData);
1496    }
1497    if high.len() != low.len() || high.len() != close.len() {
1498        return Err(BullsVBearsError::InconsistentSliceLengths {
1499            high_len: high.len(),
1500            low_len: low.len(),
1501            close_len: close.len(),
1502        });
1503    }
1504    first_valid_hlc(high, low, close).ok_or(BullsVBearsError::AllValuesNaN)
1505}
1506
1507#[inline(always)]
1508fn batch_shape(rows: usize, cols: usize) -> Result<usize, BullsVBearsError> {
1509    rows.checked_mul(cols)
1510        .ok_or_else(|| BullsVBearsError::InvalidRange {
1511            start: rows.to_string(),
1512            end: cols.to_string(),
1513            step: "rows*cols".to_string(),
1514        })
1515}
1516
1517pub fn bulls_v_bears_batch_with_kernel(
1518    high: &[f64],
1519    low: &[f64],
1520    close: &[f64],
1521    sweep: &BullsVBearsBatchRange,
1522    kernel: Kernel,
1523) -> Result<BullsVBearsBatchOutput, BullsVBearsError> {
1524    let batch_kernel = match kernel {
1525        Kernel::Auto => detect_best_batch_kernel(),
1526        other if other.is_batch() => other,
1527        _ => return Err(BullsVBearsError::InvalidKernelForBatch(kernel)),
1528    };
1529    bulls_v_bears_batch_par_slice(high, low, close, sweep, batch_kernel.to_non_batch())
1530}
1531
1532#[inline(always)]
1533pub fn bulls_v_bears_batch_slice(
1534    high: &[f64],
1535    low: &[f64],
1536    close: &[f64],
1537    sweep: &BullsVBearsBatchRange,
1538    kernel: Kernel,
1539) -> Result<BullsVBearsBatchOutput, BullsVBearsError> {
1540    bulls_v_bears_batch_inner(high, low, close, sweep, kernel, false)
1541}
1542
1543#[inline(always)]
1544pub fn bulls_v_bears_batch_par_slice(
1545    high: &[f64],
1546    low: &[f64],
1547    close: &[f64],
1548    sweep: &BullsVBearsBatchRange,
1549    kernel: Kernel,
1550) -> Result<BullsVBearsBatchOutput, BullsVBearsError> {
1551    bulls_v_bears_batch_inner(high, low, close, sweep, kernel, true)
1552}
1553
1554fn bulls_v_bears_batch_inner(
1555    high: &[f64],
1556    low: &[f64],
1557    close: &[f64],
1558    sweep: &BullsVBearsBatchRange,
1559    kernel: Kernel,
1560    parallel: bool,
1561) -> Result<BullsVBearsBatchOutput, BullsVBearsError> {
1562    let combos = bulls_v_bears_expand_grid(sweep)?;
1563    let rows = combos.len();
1564    let cols = close.len();
1565    let total = batch_shape(rows, cols)?;
1566    validate_raw_slices(high, low, close)?;
1567    let warmups = combos
1568        .iter()
1569        .map(|params| resolve_params(params).map(|p| p.ma_type.warmup(p.period)))
1570        .collect::<Result<Vec<_>, _>>()?;
1571
1572    let mut value_buf = make_uninit_matrix(rows, cols);
1573    let mut bull_buf = make_uninit_matrix(rows, cols);
1574    let mut bear_buf = make_uninit_matrix(rows, cols);
1575    let mut ma_buf = make_uninit_matrix(rows, cols);
1576    let mut upper_buf = make_uninit_matrix(rows, cols);
1577    let mut lower_buf = make_uninit_matrix(rows, cols);
1578    let mut bullish_signal_buf = make_uninit_matrix(rows, cols);
1579    let mut bearish_signal_buf = make_uninit_matrix(rows, cols);
1580    let mut zero_cross_up_buf = make_uninit_matrix(rows, cols);
1581    let mut zero_cross_down_buf = make_uninit_matrix(rows, cols);
1582    init_matrix_prefixes(&mut value_buf, cols, &warmups);
1583    init_matrix_prefixes(&mut bull_buf, cols, &warmups);
1584    init_matrix_prefixes(&mut bear_buf, cols, &warmups);
1585    init_matrix_prefixes(&mut ma_buf, cols, &warmups);
1586    init_matrix_prefixes(&mut upper_buf, cols, &warmups);
1587    init_matrix_prefixes(&mut lower_buf, cols, &warmups);
1588    init_matrix_prefixes(&mut bullish_signal_buf, cols, &warmups);
1589    init_matrix_prefixes(&mut bearish_signal_buf, cols, &warmups);
1590    init_matrix_prefixes(&mut zero_cross_up_buf, cols, &warmups);
1591    init_matrix_prefixes(&mut zero_cross_down_buf, cols, &warmups);
1592
1593    let mut value_guard = ManuallyDrop::new(value_buf);
1594    let mut bull_guard = ManuallyDrop::new(bull_buf);
1595    let mut bear_guard = ManuallyDrop::new(bear_buf);
1596    let mut ma_guard = ManuallyDrop::new(ma_buf);
1597    let mut upper_guard = ManuallyDrop::new(upper_buf);
1598    let mut lower_guard = ManuallyDrop::new(lower_buf);
1599    let mut bullish_signal_guard = ManuallyDrop::new(bullish_signal_buf);
1600    let mut bearish_signal_guard = ManuallyDrop::new(bearish_signal_buf);
1601    let mut zero_cross_up_guard = ManuallyDrop::new(zero_cross_up_buf);
1602    let mut zero_cross_down_guard = ManuallyDrop::new(zero_cross_down_buf);
1603
1604    let out_value = unsafe {
1605        core::slice::from_raw_parts_mut(value_guard.as_mut_ptr() as *mut f64, value_guard.len())
1606    };
1607    let out_bull = unsafe {
1608        core::slice::from_raw_parts_mut(bull_guard.as_mut_ptr() as *mut f64, bull_guard.len())
1609    };
1610    let out_bear = unsafe {
1611        core::slice::from_raw_parts_mut(bear_guard.as_mut_ptr() as *mut f64, bear_guard.len())
1612    };
1613    let out_ma = unsafe {
1614        core::slice::from_raw_parts_mut(ma_guard.as_mut_ptr() as *mut f64, ma_guard.len())
1615    };
1616    let out_upper = unsafe {
1617        core::slice::from_raw_parts_mut(upper_guard.as_mut_ptr() as *mut f64, upper_guard.len())
1618    };
1619    let out_lower = unsafe {
1620        core::slice::from_raw_parts_mut(lower_guard.as_mut_ptr() as *mut f64, lower_guard.len())
1621    };
1622    let out_bullish_signal = unsafe {
1623        core::slice::from_raw_parts_mut(
1624            bullish_signal_guard.as_mut_ptr() as *mut f64,
1625            bullish_signal_guard.len(),
1626        )
1627    };
1628    let out_bearish_signal = unsafe {
1629        core::slice::from_raw_parts_mut(
1630            bearish_signal_guard.as_mut_ptr() as *mut f64,
1631            bearish_signal_guard.len(),
1632        )
1633    };
1634    let out_zero_cross_up = unsafe {
1635        core::slice::from_raw_parts_mut(
1636            zero_cross_up_guard.as_mut_ptr() as *mut f64,
1637            zero_cross_up_guard.len(),
1638        )
1639    };
1640    let out_zero_cross_down = unsafe {
1641        core::slice::from_raw_parts_mut(
1642            zero_cross_down_guard.as_mut_ptr() as *mut f64,
1643            zero_cross_down_guard.len(),
1644        )
1645    };
1646
1647    bulls_v_bears_batch_inner_into(
1648        high,
1649        low,
1650        close,
1651        sweep,
1652        kernel,
1653        parallel,
1654        out_value,
1655        out_bull,
1656        out_bear,
1657        out_ma,
1658        out_upper,
1659        out_lower,
1660        out_bullish_signal,
1661        out_bearish_signal,
1662        out_zero_cross_up,
1663        out_zero_cross_down,
1664    )?;
1665
1666    let value = unsafe {
1667        Vec::from_raw_parts(
1668            value_guard.as_mut_ptr() as *mut f64,
1669            total,
1670            value_guard.capacity(),
1671        )
1672    };
1673    let bull = unsafe {
1674        Vec::from_raw_parts(
1675            bull_guard.as_mut_ptr() as *mut f64,
1676            total,
1677            bull_guard.capacity(),
1678        )
1679    };
1680    let bear = unsafe {
1681        Vec::from_raw_parts(
1682            bear_guard.as_mut_ptr() as *mut f64,
1683            total,
1684            bear_guard.capacity(),
1685        )
1686    };
1687    let ma = unsafe {
1688        Vec::from_raw_parts(
1689            ma_guard.as_mut_ptr() as *mut f64,
1690            total,
1691            ma_guard.capacity(),
1692        )
1693    };
1694    let upper = unsafe {
1695        Vec::from_raw_parts(
1696            upper_guard.as_mut_ptr() as *mut f64,
1697            total,
1698            upper_guard.capacity(),
1699        )
1700    };
1701    let lower = unsafe {
1702        Vec::from_raw_parts(
1703            lower_guard.as_mut_ptr() as *mut f64,
1704            total,
1705            lower_guard.capacity(),
1706        )
1707    };
1708    let bullish_signal = unsafe {
1709        Vec::from_raw_parts(
1710            bullish_signal_guard.as_mut_ptr() as *mut f64,
1711            total,
1712            bullish_signal_guard.capacity(),
1713        )
1714    };
1715    let bearish_signal = unsafe {
1716        Vec::from_raw_parts(
1717            bearish_signal_guard.as_mut_ptr() as *mut f64,
1718            total,
1719            bearish_signal_guard.capacity(),
1720        )
1721    };
1722    let zero_cross_up = unsafe {
1723        Vec::from_raw_parts(
1724            zero_cross_up_guard.as_mut_ptr() as *mut f64,
1725            total,
1726            zero_cross_up_guard.capacity(),
1727        )
1728    };
1729    let zero_cross_down = unsafe {
1730        Vec::from_raw_parts(
1731            zero_cross_down_guard.as_mut_ptr() as *mut f64,
1732            total,
1733            zero_cross_down_guard.capacity(),
1734        )
1735    };
1736
1737    Ok(BullsVBearsBatchOutput {
1738        value,
1739        bull,
1740        bear,
1741        ma,
1742        upper,
1743        lower,
1744        bullish_signal,
1745        bearish_signal,
1746        zero_cross_up,
1747        zero_cross_down,
1748        combos,
1749        rows,
1750        cols,
1751    })
1752}
1753
1754#[allow(clippy::too_many_arguments)]
1755pub fn bulls_v_bears_batch_into_slice(
1756    out_value: &mut [f64],
1757    out_bull: &mut [f64],
1758    out_bear: &mut [f64],
1759    out_ma: &mut [f64],
1760    out_upper: &mut [f64],
1761    out_lower: &mut [f64],
1762    out_bullish_signal: &mut [f64],
1763    out_bearish_signal: &mut [f64],
1764    out_zero_cross_up: &mut [f64],
1765    out_zero_cross_down: &mut [f64],
1766    high: &[f64],
1767    low: &[f64],
1768    close: &[f64],
1769    sweep: &BullsVBearsBatchRange,
1770    kernel: Kernel,
1771) -> Result<(), BullsVBearsError> {
1772    bulls_v_bears_batch_inner_into(
1773        high,
1774        low,
1775        close,
1776        sweep,
1777        kernel,
1778        false,
1779        out_value,
1780        out_bull,
1781        out_bear,
1782        out_ma,
1783        out_upper,
1784        out_lower,
1785        out_bullish_signal,
1786        out_bearish_signal,
1787        out_zero_cross_up,
1788        out_zero_cross_down,
1789    )?;
1790    Ok(())
1791}
1792
1793#[allow(clippy::too_many_arguments)]
1794fn bulls_v_bears_batch_inner_into(
1795    high: &[f64],
1796    low: &[f64],
1797    close: &[f64],
1798    sweep: &BullsVBearsBatchRange,
1799    _kernel: Kernel,
1800    parallel: bool,
1801    out_value: &mut [f64],
1802    out_bull: &mut [f64],
1803    out_bear: &mut [f64],
1804    out_ma: &mut [f64],
1805    out_upper: &mut [f64],
1806    out_lower: &mut [f64],
1807    out_bullish_signal: &mut [f64],
1808    out_bearish_signal: &mut [f64],
1809    out_zero_cross_up: &mut [f64],
1810    out_zero_cross_down: &mut [f64],
1811) -> Result<Vec<BullsVBearsParams>, BullsVBearsError> {
1812    let combos = bulls_v_bears_expand_grid(sweep)?;
1813    validate_raw_slices(high, low, close)?;
1814    let rows = combos.len();
1815    let cols = close.len();
1816    let total = batch_shape(rows, cols)?;
1817    check_output_len(out_value, total)?;
1818    check_output_len(out_bull, total)?;
1819    check_output_len(out_bear, total)?;
1820    check_output_len(out_ma, total)?;
1821    check_output_len(out_upper, total)?;
1822    check_output_len(out_lower, total)?;
1823    check_output_len(out_bullish_signal, total)?;
1824    check_output_len(out_bearish_signal, total)?;
1825    check_output_len(out_zero_cross_up, total)?;
1826    check_output_len(out_zero_cross_down, total)?;
1827
1828    #[cfg(not(target_arch = "wasm32"))]
1829    if parallel {
1830        let results: Vec<Result<(), BullsVBearsError>> = out_value
1831            .par_chunks_mut(cols)
1832            .zip(out_bull.par_chunks_mut(cols))
1833            .zip(out_bear.par_chunks_mut(cols))
1834            .zip(out_ma.par_chunks_mut(cols))
1835            .zip(out_upper.par_chunks_mut(cols))
1836            .zip(out_lower.par_chunks_mut(cols))
1837            .zip(out_bullish_signal.par_chunks_mut(cols))
1838            .zip(out_bearish_signal.par_chunks_mut(cols))
1839            .zip(out_zero_cross_up.par_chunks_mut(cols))
1840            .zip(out_zero_cross_down.par_chunks_mut(cols))
1841            .zip(combos.par_iter())
1842            .map(
1843                |(
1844                    (
1845                        (
1846                            (
1847                                (
1848                                    (
1849                                        ((((value_row, bull_row), bear_row), ma_row), upper_row),
1850                                        lower_row,
1851                                    ),
1852                                    bullish_row,
1853                                ),
1854                                bearish_row,
1855                            ),
1856                            up_row,
1857                        ),
1858                        down_row,
1859                    ),
1860                    params,
1861                )| {
1862                    let resolved = resolve_params(params)?;
1863                    bulls_v_bears_compute_into(
1864                        high,
1865                        low,
1866                        close,
1867                        resolved,
1868                        value_row,
1869                        bull_row,
1870                        bear_row,
1871                        ma_row,
1872                        upper_row,
1873                        lower_row,
1874                        bullish_row,
1875                        bearish_row,
1876                        up_row,
1877                        down_row,
1878                    )
1879                },
1880            )
1881            .collect();
1882        for result in results {
1883            result?;
1884        }
1885    }
1886    if !parallel || cfg!(target_arch = "wasm32") {
1887        for (row, params) in combos.iter().enumerate() {
1888            let start = row * cols;
1889            let end = start + cols;
1890            let resolved = resolve_params(params)?;
1891            bulls_v_bears_compute_into(
1892                high,
1893                low,
1894                close,
1895                resolved,
1896                &mut out_value[start..end],
1897                &mut out_bull[start..end],
1898                &mut out_bear[start..end],
1899                &mut out_ma[start..end],
1900                &mut out_upper[start..end],
1901                &mut out_lower[start..end],
1902                &mut out_bullish_signal[start..end],
1903                &mut out_bearish_signal[start..end],
1904                &mut out_zero_cross_up[start..end],
1905                &mut out_zero_cross_down[start..end],
1906            )?;
1907        }
1908    }
1909
1910    Ok(combos)
1911}
1912
1913#[cfg(feature = "python")]
1914#[pyfunction(name = "bulls_v_bears")]
1915#[pyo3(signature = (
1916    high,
1917    low,
1918    close,
1919    period=14,
1920    ma_type="ema",
1921    calculation_method="normalized",
1922    normalized_bars_back=120,
1923    raw_rolling_period=50,
1924    raw_threshold_percentile=95.0,
1925    threshold_level=80.0,
1926    kernel=None
1927))]
1928pub fn bulls_v_bears_py<'py>(
1929    py: Python<'py>,
1930    high: PyReadonlyArray1<'py, f64>,
1931    low: PyReadonlyArray1<'py, f64>,
1932    close: PyReadonlyArray1<'py, f64>,
1933    period: usize,
1934    ma_type: &str,
1935    calculation_method: &str,
1936    normalized_bars_back: usize,
1937    raw_rolling_period: usize,
1938    raw_threshold_percentile: f64,
1939    threshold_level: f64,
1940    kernel: Option<&str>,
1941) -> PyResult<Bound<'py, PyDict>> {
1942    let high = high.as_slice()?;
1943    let low = low.as_slice()?;
1944    let close = close.as_slice()?;
1945    let input = BullsVBearsInput::from_slices(
1946        high,
1947        low,
1948        close,
1949        BullsVBearsParams {
1950            period: Some(period),
1951            ma_type: Some(
1952                BullsVBearsMaType::from_str(ma_type)
1953                    .map_err(|e| PyValueError::new_err(e.to_string()))?,
1954            ),
1955            calculation_method: Some(
1956                BullsVBearsCalculationMethod::from_str(calculation_method)
1957                    .map_err(|e| PyValueError::new_err(e.to_string()))?,
1958            ),
1959            normalized_bars_back: Some(normalized_bars_back),
1960            raw_rolling_period: Some(raw_rolling_period),
1961            raw_threshold_percentile: Some(raw_threshold_percentile),
1962            threshold_level: Some(threshold_level),
1963        },
1964    );
1965    let kernel = validate_kernel(kernel, false)?;
1966    let out = py
1967        .allow_threads(|| bulls_v_bears_with_kernel(&input, kernel))
1968        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1969    let dict = PyDict::new(py);
1970    dict.set_item("value", out.value.into_pyarray(py))?;
1971    dict.set_item("bull", out.bull.into_pyarray(py))?;
1972    dict.set_item("bear", out.bear.into_pyarray(py))?;
1973    dict.set_item("ma", out.ma.into_pyarray(py))?;
1974    dict.set_item("upper", out.upper.into_pyarray(py))?;
1975    dict.set_item("lower", out.lower.into_pyarray(py))?;
1976    dict.set_item("bullish_signal", out.bullish_signal.into_pyarray(py))?;
1977    dict.set_item("bearish_signal", out.bearish_signal.into_pyarray(py))?;
1978    dict.set_item("zero_cross_up", out.zero_cross_up.into_pyarray(py))?;
1979    dict.set_item("zero_cross_down", out.zero_cross_down.into_pyarray(py))?;
1980    Ok(dict)
1981}
1982
1983#[cfg(feature = "python")]
1984#[pyclass(name = "BullsVBearsStream")]
1985pub struct BullsVBearsStreamPy {
1986    stream: BullsVBearsStream,
1987}
1988
1989#[cfg(feature = "python")]
1990#[pymethods]
1991impl BullsVBearsStreamPy {
1992    #[new]
1993    #[pyo3(signature = (
1994        period=14,
1995        ma_type="ema",
1996        calculation_method="normalized",
1997        normalized_bars_back=120,
1998        raw_rolling_period=50,
1999        raw_threshold_percentile=95.0,
2000        threshold_level=80.0
2001    ))]
2002    fn new(
2003        period: usize,
2004        ma_type: &str,
2005        calculation_method: &str,
2006        normalized_bars_back: usize,
2007        raw_rolling_period: usize,
2008        raw_threshold_percentile: f64,
2009        threshold_level: f64,
2010    ) -> PyResult<Self> {
2011        let stream = BullsVBearsStream::try_new(BullsVBearsParams {
2012            period: Some(period),
2013            ma_type: Some(
2014                BullsVBearsMaType::from_str(ma_type)
2015                    .map_err(|e| PyValueError::new_err(e.to_string()))?,
2016            ),
2017            calculation_method: Some(
2018                BullsVBearsCalculationMethod::from_str(calculation_method)
2019                    .map_err(|e| PyValueError::new_err(e.to_string()))?,
2020            ),
2021            normalized_bars_back: Some(normalized_bars_back),
2022            raw_rolling_period: Some(raw_rolling_period),
2023            raw_threshold_percentile: Some(raw_threshold_percentile),
2024            threshold_level: Some(threshold_level),
2025        })
2026        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2027        Ok(Self { stream })
2028    }
2029
2030    fn update(
2031        &mut self,
2032        high: f64,
2033        low: f64,
2034        close: f64,
2035    ) -> (f64, f64, f64, f64, f64, f64, f64, f64, f64, f64) {
2036        self.stream.update(high, low, close)
2037    }
2038}
2039
2040#[cfg(feature = "python")]
2041#[pyfunction(name = "bulls_v_bears_batch")]
2042#[pyo3(signature = (
2043    high,
2044    low,
2045    close,
2046    period_range=(14,14,0),
2047    normalized_bars_back_range=(120,120,0),
2048    raw_rolling_period_range=(50,50,0),
2049    raw_threshold_percentile_range=(95.0,95.0,0.0),
2050    threshold_level_range=(80.0,80.0,0.0),
2051    ma_type="ema",
2052    calculation_method="normalized",
2053    kernel=None
2054))]
2055pub fn bulls_v_bears_batch_py<'py>(
2056    py: Python<'py>,
2057    high: PyReadonlyArray1<'py, f64>,
2058    low: PyReadonlyArray1<'py, f64>,
2059    close: PyReadonlyArray1<'py, f64>,
2060    period_range: (usize, usize, usize),
2061    normalized_bars_back_range: (usize, usize, usize),
2062    raw_rolling_period_range: (usize, usize, usize),
2063    raw_threshold_percentile_range: (f64, f64, f64),
2064    threshold_level_range: (f64, f64, f64),
2065    ma_type: &str,
2066    calculation_method: &str,
2067    kernel: Option<&str>,
2068) -> PyResult<Bound<'py, PyDict>> {
2069    let high = high.as_slice()?;
2070    let low = low.as_slice()?;
2071    let close = close.as_slice()?;
2072    let sweep = BullsVBearsBatchRange {
2073        period: period_range,
2074        normalized_bars_back: normalized_bars_back_range,
2075        raw_rolling_period: raw_rolling_period_range,
2076        raw_threshold_percentile: raw_threshold_percentile_range,
2077        threshold_level: threshold_level_range,
2078        ma_type: BullsVBearsMaType::from_str(ma_type)
2079            .map_err(|e| PyValueError::new_err(e.to_string()))?,
2080        calculation_method: BullsVBearsCalculationMethod::from_str(calculation_method)
2081            .map_err(|e| PyValueError::new_err(e.to_string()))?,
2082    };
2083    let combos =
2084        bulls_v_bears_expand_grid(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
2085    let rows = combos.len();
2086    let cols = close.len();
2087    let total = rows
2088        .checked_mul(cols)
2089        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
2090
2091    let out_value = unsafe { PyArray1::<f64>::new(py, [total], false) };
2092    let out_bull = unsafe { PyArray1::<f64>::new(py, [total], false) };
2093    let out_bear = unsafe { PyArray1::<f64>::new(py, [total], false) };
2094    let out_ma = unsafe { PyArray1::<f64>::new(py, [total], false) };
2095    let out_upper = unsafe { PyArray1::<f64>::new(py, [total], false) };
2096    let out_lower = unsafe { PyArray1::<f64>::new(py, [total], false) };
2097    let out_bullish_signal = unsafe { PyArray1::<f64>::new(py, [total], false) };
2098    let out_bearish_signal = unsafe { PyArray1::<f64>::new(py, [total], false) };
2099    let out_zero_cross_up = unsafe { PyArray1::<f64>::new(py, [total], false) };
2100    let out_zero_cross_down = unsafe { PyArray1::<f64>::new(py, [total], false) };
2101
2102    let value_slice = unsafe { out_value.as_slice_mut()? };
2103    let bull_slice = unsafe { out_bull.as_slice_mut()? };
2104    let bear_slice = unsafe { out_bear.as_slice_mut()? };
2105    let ma_slice = unsafe { out_ma.as_slice_mut()? };
2106    let upper_slice = unsafe { out_upper.as_slice_mut()? };
2107    let lower_slice = unsafe { out_lower.as_slice_mut()? };
2108    let bullish_signal_slice = unsafe { out_bullish_signal.as_slice_mut()? };
2109    let bearish_signal_slice = unsafe { out_bearish_signal.as_slice_mut()? };
2110    let zero_cross_up_slice = unsafe { out_zero_cross_up.as_slice_mut()? };
2111    let zero_cross_down_slice = unsafe { out_zero_cross_down.as_slice_mut()? };
2112    let kernel = validate_kernel(kernel, true)?;
2113
2114    py.allow_threads(|| {
2115        let batch_kernel = match kernel {
2116            Kernel::Auto => detect_best_batch_kernel(),
2117            other => other,
2118        };
2119        bulls_v_bears_batch_inner_into(
2120            high,
2121            low,
2122            close,
2123            &sweep,
2124            batch_kernel.to_non_batch(),
2125            true,
2126            value_slice,
2127            bull_slice,
2128            bear_slice,
2129            ma_slice,
2130            upper_slice,
2131            lower_slice,
2132            bullish_signal_slice,
2133            bearish_signal_slice,
2134            zero_cross_up_slice,
2135            zero_cross_down_slice,
2136        )
2137    })
2138    .map_err(|e| PyValueError::new_err(e.to_string()))?;
2139
2140    let dict = PyDict::new(py);
2141    dict.set_item("value", out_value.reshape((rows, cols))?)?;
2142    dict.set_item("bull", out_bull.reshape((rows, cols))?)?;
2143    dict.set_item("bear", out_bear.reshape((rows, cols))?)?;
2144    dict.set_item("ma", out_ma.reshape((rows, cols))?)?;
2145    dict.set_item("upper", out_upper.reshape((rows, cols))?)?;
2146    dict.set_item("lower", out_lower.reshape((rows, cols))?)?;
2147    dict.set_item("bullish_signal", out_bullish_signal.reshape((rows, cols))?)?;
2148    dict.set_item("bearish_signal", out_bearish_signal.reshape((rows, cols))?)?;
2149    dict.set_item("zero_cross_up", out_zero_cross_up.reshape((rows, cols))?)?;
2150    dict.set_item(
2151        "zero_cross_down",
2152        out_zero_cross_down.reshape((rows, cols))?,
2153    )?;
2154    dict.set_item(
2155        "periods",
2156        combos
2157            .iter()
2158            .map(|combo| combo.period.unwrap_or(DEFAULT_PERIOD))
2159            .collect::<Vec<_>>()
2160            .into_pyarray(py),
2161    )?;
2162    dict.set_item(
2163        "normalized_bars_backs",
2164        combos
2165            .iter()
2166            .map(|combo| {
2167                combo
2168                    .normalized_bars_back
2169                    .unwrap_or(DEFAULT_NORMALIZED_BARS_BACK)
2170            })
2171            .collect::<Vec<_>>()
2172            .into_pyarray(py),
2173    )?;
2174    dict.set_item(
2175        "raw_rolling_periods",
2176        combos
2177            .iter()
2178            .map(|combo| {
2179                combo
2180                    .raw_rolling_period
2181                    .unwrap_or(DEFAULT_RAW_ROLLING_PERIOD)
2182            })
2183            .collect::<Vec<_>>()
2184            .into_pyarray(py),
2185    )?;
2186    dict.set_item(
2187        "raw_threshold_percentiles",
2188        combos
2189            .iter()
2190            .map(|combo| {
2191                combo
2192                    .raw_threshold_percentile
2193                    .unwrap_or(DEFAULT_RAW_THRESHOLD_PERCENTILE)
2194            })
2195            .collect::<Vec<_>>()
2196            .into_pyarray(py),
2197    )?;
2198    dict.set_item(
2199        "threshold_levels",
2200        combos
2201            .iter()
2202            .map(|combo| combo.threshold_level.unwrap_or(DEFAULT_THRESHOLD_LEVEL))
2203            .collect::<Vec<_>>()
2204            .into_pyarray(py),
2205    )?;
2206    dict.set_item("rows", rows)?;
2207    dict.set_item("cols", cols)?;
2208    Ok(dict)
2209}
2210
2211#[cfg(feature = "python")]
2212pub fn register_bulls_v_bears_module(m: &Bound<'_, pyo3::types::PyModule>) -> PyResult<()> {
2213    m.add_function(wrap_pyfunction!(bulls_v_bears_py, m)?)?;
2214    m.add_function(wrap_pyfunction!(bulls_v_bears_batch_py, m)?)?;
2215    m.add_class::<BullsVBearsStreamPy>()?;
2216    Ok(())
2217}
2218
2219#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2220#[derive(Serialize, Deserialize)]
2221pub struct BullsVBearsJsOutput {
2222    pub value: Vec<f64>,
2223    pub bull: Vec<f64>,
2224    pub bear: Vec<f64>,
2225    pub ma: Vec<f64>,
2226    pub upper: Vec<f64>,
2227    pub lower: Vec<f64>,
2228    pub bullish_signal: Vec<f64>,
2229    pub bearish_signal: Vec<f64>,
2230    pub zero_cross_up: Vec<f64>,
2231    pub zero_cross_down: Vec<f64>,
2232}
2233
2234#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2235fn parse_ma_type(value: &str) -> Result<BullsVBearsMaType, JsValue> {
2236    BullsVBearsMaType::from_str(value).map_err(|e| JsValue::from_str(&e))
2237}
2238
2239#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2240fn parse_calculation_method(value: &str) -> Result<BullsVBearsCalculationMethod, JsValue> {
2241    BullsVBearsCalculationMethod::from_str(value).map_err(|e| JsValue::from_str(&e))
2242}
2243
2244#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2245#[wasm_bindgen(js_name = "bulls_v_bears_js")]
2246pub fn bulls_v_bears_js(
2247    high: &[f64],
2248    low: &[f64],
2249    close: &[f64],
2250    period: usize,
2251    ma_type: String,
2252    calculation_method: String,
2253    normalized_bars_back: usize,
2254    raw_rolling_period: usize,
2255    raw_threshold_percentile: f64,
2256    threshold_level: f64,
2257) -> Result<JsValue, JsValue> {
2258    let input = BullsVBearsInput::from_slices(
2259        high,
2260        low,
2261        close,
2262        BullsVBearsParams {
2263            period: Some(period),
2264            ma_type: Some(parse_ma_type(&ma_type)?),
2265            calculation_method: Some(parse_calculation_method(&calculation_method)?),
2266            normalized_bars_back: Some(normalized_bars_back),
2267            raw_rolling_period: Some(raw_rolling_period),
2268            raw_threshold_percentile: Some(raw_threshold_percentile),
2269            threshold_level: Some(threshold_level),
2270        },
2271    );
2272    let out = bulls_v_bears_with_kernel(&input, Kernel::Auto)
2273        .map_err(|e| JsValue::from_str(&e.to_string()))?;
2274    serde_wasm_bindgen::to_value(&BullsVBearsJsOutput {
2275        value: out.value,
2276        bull: out.bull,
2277        bear: out.bear,
2278        ma: out.ma,
2279        upper: out.upper,
2280        lower: out.lower,
2281        bullish_signal: out.bullish_signal,
2282        bearish_signal: out.bearish_signal,
2283        zero_cross_up: out.zero_cross_up,
2284        zero_cross_down: out.zero_cross_down,
2285    })
2286    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
2287}
2288
2289#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2290#[derive(Serialize, Deserialize)]
2291pub struct BullsVBearsBatchConfig {
2292    pub period_range: Vec<usize>,
2293    pub normalized_bars_back_range: Vec<usize>,
2294    pub raw_rolling_period_range: Vec<usize>,
2295    pub raw_threshold_percentile_range: Vec<f64>,
2296    pub threshold_level_range: Vec<f64>,
2297    pub ma_type: Option<String>,
2298    pub calculation_method: Option<String>,
2299}
2300
2301#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2302#[derive(Serialize, Deserialize)]
2303pub struct BullsVBearsBatchJsOutput {
2304    pub value: Vec<f64>,
2305    pub bull: Vec<f64>,
2306    pub bear: Vec<f64>,
2307    pub ma: Vec<f64>,
2308    pub upper: Vec<f64>,
2309    pub lower: Vec<f64>,
2310    pub bullish_signal: Vec<f64>,
2311    pub bearish_signal: Vec<f64>,
2312    pub zero_cross_up: Vec<f64>,
2313    pub zero_cross_down: Vec<f64>,
2314    pub periods: Vec<usize>,
2315    pub normalized_bars_backs: Vec<usize>,
2316    pub raw_rolling_periods: Vec<usize>,
2317    pub raw_threshold_percentiles: Vec<f64>,
2318    pub threshold_levels: Vec<f64>,
2319    pub rows: usize,
2320    pub cols: usize,
2321}
2322
2323#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2324fn js_vec3_to_usize(name: &str, values: &[usize]) -> Result<(usize, usize, usize), JsValue> {
2325    if values.len() != 3 {
2326        return Err(JsValue::from_str(&format!(
2327            "Invalid config: {name} must have exactly 3 elements [start, end, step]"
2328        )));
2329    }
2330    Ok((values[0], values[1], values[2]))
2331}
2332
2333#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2334fn js_vec3_to_f64(name: &str, values: &[f64]) -> Result<(f64, f64, f64), JsValue> {
2335    if values.len() != 3 {
2336        return Err(JsValue::from_str(&format!(
2337            "Invalid config: {name} must have exactly 3 elements [start, end, step]"
2338        )));
2339    }
2340    if !values.iter().all(|v| v.is_finite()) {
2341        return Err(JsValue::from_str(&format!(
2342            "Invalid config: {name} entries must be finite numbers"
2343        )));
2344    }
2345    Ok((values[0], values[1], values[2]))
2346}
2347
2348#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2349#[wasm_bindgen(js_name = "bulls_v_bears_batch_js")]
2350pub fn bulls_v_bears_batch_js(
2351    high: &[f64],
2352    low: &[f64],
2353    close: &[f64],
2354    config: JsValue,
2355) -> Result<JsValue, JsValue> {
2356    let config: BullsVBearsBatchConfig = serde_wasm_bindgen::from_value(config)
2357        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
2358    let sweep = BullsVBearsBatchRange {
2359        period: js_vec3_to_usize("period_range", &config.period_range)?,
2360        normalized_bars_back: js_vec3_to_usize(
2361            "normalized_bars_back_range",
2362            &config.normalized_bars_back_range,
2363        )?,
2364        raw_rolling_period: js_vec3_to_usize(
2365            "raw_rolling_period_range",
2366            &config.raw_rolling_period_range,
2367        )?,
2368        raw_threshold_percentile: js_vec3_to_f64(
2369            "raw_threshold_percentile_range",
2370            &config.raw_threshold_percentile_range,
2371        )?,
2372        threshold_level: js_vec3_to_f64("threshold_level_range", &config.threshold_level_range)?,
2373        ma_type: parse_ma_type(config.ma_type.as_deref().unwrap_or("ema"))?,
2374        calculation_method: parse_calculation_method(
2375            config.calculation_method.as_deref().unwrap_or("normalized"),
2376        )?,
2377    };
2378    let out = bulls_v_bears_batch_with_kernel(high, low, close, &sweep, Kernel::Auto)
2379        .map_err(|e| JsValue::from_str(&e.to_string()))?;
2380    let periods = out
2381        .combos
2382        .iter()
2383        .map(|combo| combo.period.unwrap_or(DEFAULT_PERIOD))
2384        .collect::<Vec<_>>();
2385    let normalized_bars_backs = out
2386        .combos
2387        .iter()
2388        .map(|combo| {
2389            combo
2390                .normalized_bars_back
2391                .unwrap_or(DEFAULT_NORMALIZED_BARS_BACK)
2392        })
2393        .collect::<Vec<_>>();
2394    let raw_rolling_periods = out
2395        .combos
2396        .iter()
2397        .map(|combo| {
2398            combo
2399                .raw_rolling_period
2400                .unwrap_or(DEFAULT_RAW_ROLLING_PERIOD)
2401        })
2402        .collect::<Vec<_>>();
2403    let raw_threshold_percentiles = out
2404        .combos
2405        .iter()
2406        .map(|combo| {
2407            combo
2408                .raw_threshold_percentile
2409                .unwrap_or(DEFAULT_RAW_THRESHOLD_PERCENTILE)
2410        })
2411        .collect::<Vec<_>>();
2412    let threshold_levels = out
2413        .combos
2414        .iter()
2415        .map(|combo| combo.threshold_level.unwrap_or(DEFAULT_THRESHOLD_LEVEL))
2416        .collect::<Vec<_>>();
2417    serde_wasm_bindgen::to_value(&BullsVBearsBatchJsOutput {
2418        value: out.value,
2419        bull: out.bull,
2420        bear: out.bear,
2421        ma: out.ma,
2422        upper: out.upper,
2423        lower: out.lower,
2424        bullish_signal: out.bullish_signal,
2425        bearish_signal: out.bearish_signal,
2426        zero_cross_up: out.zero_cross_up,
2427        zero_cross_down: out.zero_cross_down,
2428        periods,
2429        normalized_bars_backs,
2430        raw_rolling_periods,
2431        raw_threshold_percentiles,
2432        threshold_levels,
2433        rows: out.rows,
2434        cols: out.cols,
2435    })
2436    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
2437}
2438
2439#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2440#[wasm_bindgen]
2441pub fn bulls_v_bears_alloc(len: usize) -> *mut f64 {
2442    let mut vec = Vec::<f64>::with_capacity(len);
2443    let ptr = vec.as_mut_ptr();
2444    std::mem::forget(vec);
2445    ptr
2446}
2447
2448#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2449#[wasm_bindgen]
2450pub fn bulls_v_bears_free(ptr: *mut f64, len: usize) {
2451    if !ptr.is_null() {
2452        unsafe {
2453            let _ = Vec::from_raw_parts(ptr, len, len);
2454        }
2455    }
2456}
2457
2458#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2459#[allow(clippy::too_many_arguments)]
2460#[wasm_bindgen]
2461pub fn bulls_v_bears_into(
2462    high_ptr: *const f64,
2463    low_ptr: *const f64,
2464    close_ptr: *const f64,
2465    out_value_ptr: *mut f64,
2466    out_bull_ptr: *mut f64,
2467    out_bear_ptr: *mut f64,
2468    out_ma_ptr: *mut f64,
2469    out_upper_ptr: *mut f64,
2470    out_lower_ptr: *mut f64,
2471    out_bullish_signal_ptr: *mut f64,
2472    out_bearish_signal_ptr: *mut f64,
2473    out_zero_cross_up_ptr: *mut f64,
2474    out_zero_cross_down_ptr: *mut f64,
2475    len: usize,
2476    period: usize,
2477    ma_type: String,
2478    calculation_method: String,
2479    normalized_bars_back: usize,
2480    raw_rolling_period: usize,
2481    raw_threshold_percentile: f64,
2482    threshold_level: f64,
2483) -> Result<(), JsValue> {
2484    if high_ptr.is_null()
2485        || low_ptr.is_null()
2486        || close_ptr.is_null()
2487        || out_value_ptr.is_null()
2488        || out_bull_ptr.is_null()
2489        || out_bear_ptr.is_null()
2490        || out_ma_ptr.is_null()
2491        || out_upper_ptr.is_null()
2492        || out_lower_ptr.is_null()
2493        || out_bullish_signal_ptr.is_null()
2494        || out_bearish_signal_ptr.is_null()
2495        || out_zero_cross_up_ptr.is_null()
2496        || out_zero_cross_down_ptr.is_null()
2497    {
2498        return Err(JsValue::from_str(
2499            "null pointer passed to bulls_v_bears_into",
2500        ));
2501    }
2502    unsafe {
2503        let high = std::slice::from_raw_parts(high_ptr, len);
2504        let low = std::slice::from_raw_parts(low_ptr, len);
2505        let close = std::slice::from_raw_parts(close_ptr, len);
2506        let out_value = std::slice::from_raw_parts_mut(out_value_ptr, len);
2507        let out_bull = std::slice::from_raw_parts_mut(out_bull_ptr, len);
2508        let out_bear = std::slice::from_raw_parts_mut(out_bear_ptr, len);
2509        let out_ma = std::slice::from_raw_parts_mut(out_ma_ptr, len);
2510        let out_upper = std::slice::from_raw_parts_mut(out_upper_ptr, len);
2511        let out_lower = std::slice::from_raw_parts_mut(out_lower_ptr, len);
2512        let out_bullish_signal = std::slice::from_raw_parts_mut(out_bullish_signal_ptr, len);
2513        let out_bearish_signal = std::slice::from_raw_parts_mut(out_bearish_signal_ptr, len);
2514        let out_zero_cross_up = std::slice::from_raw_parts_mut(out_zero_cross_up_ptr, len);
2515        let out_zero_cross_down = std::slice::from_raw_parts_mut(out_zero_cross_down_ptr, len);
2516        bulls_v_bears_into_slice(
2517            out_value,
2518            out_bull,
2519            out_bear,
2520            out_ma,
2521            out_upper,
2522            out_lower,
2523            out_bullish_signal,
2524            out_bearish_signal,
2525            out_zero_cross_up,
2526            out_zero_cross_down,
2527            high,
2528            low,
2529            close,
2530            BullsVBearsParams {
2531                period: Some(period),
2532                ma_type: Some(parse_ma_type(&ma_type)?),
2533                calculation_method: Some(parse_calculation_method(&calculation_method)?),
2534                normalized_bars_back: Some(normalized_bars_back),
2535                raw_rolling_period: Some(raw_rolling_period),
2536                raw_threshold_percentile: Some(raw_threshold_percentile),
2537                threshold_level: Some(threshold_level),
2538            },
2539            Kernel::Auto,
2540        )
2541        .map_err(|e| JsValue::from_str(&e.to_string()))
2542    }
2543}
2544
2545#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2546#[allow(clippy::too_many_arguments)]
2547#[wasm_bindgen]
2548pub fn bulls_v_bears_batch_into(
2549    high_ptr: *const f64,
2550    low_ptr: *const f64,
2551    close_ptr: *const f64,
2552    out_value_ptr: *mut f64,
2553    out_bull_ptr: *mut f64,
2554    out_bear_ptr: *mut f64,
2555    out_ma_ptr: *mut f64,
2556    out_upper_ptr: *mut f64,
2557    out_lower_ptr: *mut f64,
2558    out_bullish_signal_ptr: *mut f64,
2559    out_bearish_signal_ptr: *mut f64,
2560    out_zero_cross_up_ptr: *mut f64,
2561    out_zero_cross_down_ptr: *mut f64,
2562    len: usize,
2563    period_start: usize,
2564    period_end: usize,
2565    period_step: usize,
2566    normalized_bars_back_start: usize,
2567    normalized_bars_back_end: usize,
2568    normalized_bars_back_step: usize,
2569    raw_rolling_period_start: usize,
2570    raw_rolling_period_end: usize,
2571    raw_rolling_period_step: usize,
2572    raw_threshold_percentile_start: f64,
2573    raw_threshold_percentile_end: f64,
2574    raw_threshold_percentile_step: f64,
2575    threshold_level_start: f64,
2576    threshold_level_end: f64,
2577    threshold_level_step: f64,
2578    ma_type: String,
2579    calculation_method: String,
2580) -> Result<usize, JsValue> {
2581    if high_ptr.is_null()
2582        || low_ptr.is_null()
2583        || close_ptr.is_null()
2584        || out_value_ptr.is_null()
2585        || out_bull_ptr.is_null()
2586        || out_bear_ptr.is_null()
2587        || out_ma_ptr.is_null()
2588        || out_upper_ptr.is_null()
2589        || out_lower_ptr.is_null()
2590        || out_bullish_signal_ptr.is_null()
2591        || out_bearish_signal_ptr.is_null()
2592        || out_zero_cross_up_ptr.is_null()
2593        || out_zero_cross_down_ptr.is_null()
2594    {
2595        return Err(JsValue::from_str(
2596            "null pointer passed to bulls_v_bears_batch_into",
2597        ));
2598    }
2599    unsafe {
2600        let high = std::slice::from_raw_parts(high_ptr, len);
2601        let low = std::slice::from_raw_parts(low_ptr, len);
2602        let close = std::slice::from_raw_parts(close_ptr, len);
2603        let sweep = BullsVBearsBatchRange {
2604            period: (period_start, period_end, period_step),
2605            normalized_bars_back: (
2606                normalized_bars_back_start,
2607                normalized_bars_back_end,
2608                normalized_bars_back_step,
2609            ),
2610            raw_rolling_period: (
2611                raw_rolling_period_start,
2612                raw_rolling_period_end,
2613                raw_rolling_period_step,
2614            ),
2615            raw_threshold_percentile: (
2616                raw_threshold_percentile_start,
2617                raw_threshold_percentile_end,
2618                raw_threshold_percentile_step,
2619            ),
2620            threshold_level: (
2621                threshold_level_start,
2622                threshold_level_end,
2623                threshold_level_step,
2624            ),
2625            ma_type: parse_ma_type(&ma_type)?,
2626            calculation_method: parse_calculation_method(&calculation_method)?,
2627        };
2628        let combos =
2629            bulls_v_bears_expand_grid(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
2630        let rows = combos.len();
2631        let total = rows
2632            .checked_mul(len)
2633            .ok_or_else(|| JsValue::from_str("rows*cols overflow in bulls_v_bears_batch_into"))?;
2634        let out_value = std::slice::from_raw_parts_mut(out_value_ptr, total);
2635        let out_bull = std::slice::from_raw_parts_mut(out_bull_ptr, total);
2636        let out_bear = std::slice::from_raw_parts_mut(out_bear_ptr, total);
2637        let out_ma = std::slice::from_raw_parts_mut(out_ma_ptr, total);
2638        let out_upper = std::slice::from_raw_parts_mut(out_upper_ptr, total);
2639        let out_lower = std::slice::from_raw_parts_mut(out_lower_ptr, total);
2640        let out_bullish_signal = std::slice::from_raw_parts_mut(out_bullish_signal_ptr, total);
2641        let out_bearish_signal = std::slice::from_raw_parts_mut(out_bearish_signal_ptr, total);
2642        let out_zero_cross_up = std::slice::from_raw_parts_mut(out_zero_cross_up_ptr, total);
2643        let out_zero_cross_down = std::slice::from_raw_parts_mut(out_zero_cross_down_ptr, total);
2644        bulls_v_bears_batch_into_slice(
2645            out_value,
2646            out_bull,
2647            out_bear,
2648            out_ma,
2649            out_upper,
2650            out_lower,
2651            out_bullish_signal,
2652            out_bearish_signal,
2653            out_zero_cross_up,
2654            out_zero_cross_down,
2655            high,
2656            low,
2657            close,
2658            &sweep,
2659            Kernel::Auto.to_non_batch(),
2660        )
2661        .map_err(|e| JsValue::from_str(&e.to_string()))?;
2662        Ok(rows)
2663    }
2664}
2665
2666#[cfg(test)]
2667mod tests {
2668    use super::*;
2669    use crate::indicators::dispatch::{
2670        compute_cpu_batch, IndicatorBatchRequest, IndicatorDataRef, IndicatorParamSet, ParamKV,
2671        ParamValue,
2672    };
2673
2674    fn sample_hlc() -> (Vec<f64>, Vec<f64>, Vec<f64>) {
2675        let close = (0..160)
2676            .map(|i| 100.0 + (i as f64 * 0.25) + ((i % 7) as f64 - 3.0) * 0.4)
2677            .collect::<Vec<_>>();
2678        let high = close.iter().map(|v| *v + 1.5).collect::<Vec<_>>();
2679        let low = close.iter().map(|v| *v - 1.25).collect::<Vec<_>>();
2680        (high, low, close)
2681    }
2682
2683    fn assert_vec_close(lhs: &[f64], rhs: &[f64]) {
2684        assert_eq!(lhs.len(), rhs.len());
2685        for (idx, (a, b)) in lhs.iter().zip(rhs.iter()).enumerate() {
2686            if a.is_nan() && b.is_nan() {
2687                continue;
2688            }
2689            let diff = (a - b).abs();
2690            assert!(diff <= 1e-10, "mismatch at {idx}: {a} vs {b}");
2691        }
2692    }
2693
2694    #[test]
2695    fn normalized_stream_matches_batch() {
2696        let (high, low, close) = sample_hlc();
2697        let params = BullsVBearsParams::default();
2698        let batch = bulls_v_bears(&BullsVBearsInput::from_slices(
2699            &high,
2700            &low,
2701            &close,
2702            params.clone(),
2703        ))
2704        .unwrap();
2705        let mut stream = BullsVBearsStream::try_new(params).unwrap();
2706        let mut value = Vec::with_capacity(close.len());
2707        let mut bull = Vec::with_capacity(close.len());
2708        let mut bear = Vec::with_capacity(close.len());
2709        let mut ma = Vec::with_capacity(close.len());
2710        let mut upper = Vec::with_capacity(close.len());
2711        let mut lower = Vec::with_capacity(close.len());
2712        let mut bullish_signal = Vec::with_capacity(close.len());
2713        let mut bearish_signal = Vec::with_capacity(close.len());
2714        let mut zero_cross_up = Vec::with_capacity(close.len());
2715        let mut zero_cross_down = Vec::with_capacity(close.len());
2716
2717        for i in 0..close.len() {
2718            let out = stream.update(high[i], low[i], close[i]);
2719            value.push(out.0);
2720            bull.push(out.1);
2721            bear.push(out.2);
2722            ma.push(out.3);
2723            upper.push(out.4);
2724            lower.push(out.5);
2725            bullish_signal.push(out.6);
2726            bearish_signal.push(out.7);
2727            zero_cross_up.push(out.8);
2728            zero_cross_down.push(out.9);
2729        }
2730
2731        assert_vec_close(&value, &batch.value);
2732        assert_vec_close(&bull, &batch.bull);
2733        assert_vec_close(&bear, &batch.bear);
2734        assert_vec_close(&ma, &batch.ma);
2735        assert_vec_close(&upper, &batch.upper);
2736        assert_vec_close(&lower, &batch.lower);
2737        assert_vec_close(&bullish_signal, &batch.bullish_signal);
2738        assert_vec_close(&bearish_signal, &batch.bearish_signal);
2739        assert_vec_close(&zero_cross_up, &batch.zero_cross_up);
2740        assert_vec_close(&zero_cross_down, &batch.zero_cross_down);
2741    }
2742
2743    #[test]
2744    fn batch_first_row_matches_single() {
2745        let (high, low, close) = sample_hlc();
2746        let single = bulls_v_bears(&BullsVBearsInput::from_slices(
2747            &high,
2748            &low,
2749            &close,
2750            BullsVBearsParams {
2751                period: Some(14),
2752                ma_type: Some(BullsVBearsMaType::Ema),
2753                calculation_method: Some(BullsVBearsCalculationMethod::Raw),
2754                normalized_bars_back: Some(120),
2755                raw_rolling_period: Some(50),
2756                raw_threshold_percentile: Some(95.0),
2757                threshold_level: Some(80.0),
2758            },
2759        ))
2760        .unwrap();
2761        let batch = bulls_v_bears_batch_slice(
2762            &high,
2763            &low,
2764            &close,
2765            &BullsVBearsBatchRange {
2766                period: (14, 16, 2),
2767                normalized_bars_back: (120, 120, 0),
2768                raw_rolling_period: (50, 50, 0),
2769                raw_threshold_percentile: (95.0, 95.0, 0.0),
2770                threshold_level: (80.0, 80.0, 0.0),
2771                ma_type: BullsVBearsMaType::Ema,
2772                calculation_method: BullsVBearsCalculationMethod::Raw,
2773            },
2774            Kernel::Auto,
2775        )
2776        .unwrap();
2777        let cols = close.len();
2778        assert_eq!(batch.rows, 2);
2779        assert_vec_close(&batch.value[..cols], &single.value);
2780        assert_vec_close(&batch.upper[..cols], &single.upper);
2781        assert_vec_close(&batch.lower[..cols], &single.lower);
2782    }
2783
2784    #[test]
2785    fn invalid_period_fails() {
2786        let (high, low, close) = sample_hlc();
2787        let err = bulls_v_bears(&BullsVBearsInput::from_slices(
2788            &high,
2789            &low,
2790            &close,
2791            BullsVBearsParams {
2792                period: Some(0),
2793                ..BullsVBearsParams::default()
2794            },
2795        ))
2796        .unwrap_err();
2797        assert!(err.to_string().contains("Invalid period"));
2798    }
2799
2800    #[test]
2801    fn cpu_dispatch_matches_direct() {
2802        let (high, low, close) = sample_hlc();
2803        let request = IndicatorBatchRequest {
2804            indicator_id: "bulls_v_bears",
2805            output_id: Some("value"),
2806            data: IndicatorDataRef::Ohlc {
2807                open: &close,
2808                high: &high,
2809                low: &low,
2810                close: &close,
2811            },
2812            combos: &[IndicatorParamSet {
2813                params: &[
2814                    ParamKV {
2815                        key: "period",
2816                        value: ParamValue::Int(14),
2817                    },
2818                    ParamKV {
2819                        key: "ma_type",
2820                        value: ParamValue::EnumString("ema"),
2821                    },
2822                    ParamKV {
2823                        key: "calculation_method",
2824                        value: ParamValue::EnumString("raw"),
2825                    },
2826                ],
2827            }],
2828            kernel: Kernel::Auto,
2829        };
2830
2831        let output = compute_cpu_batch(request).unwrap();
2832        let values = output.values_f64.unwrap();
2833        let direct = bulls_v_bears(&BullsVBearsInput::from_slices(
2834            &high,
2835            &low,
2836            &close,
2837            BullsVBearsParams {
2838                period: Some(14),
2839                ma_type: Some(BullsVBearsMaType::Ema),
2840                calculation_method: Some(BullsVBearsCalculationMethod::Raw),
2841                ..BullsVBearsParams::default()
2842            },
2843        ))
2844        .unwrap();
2845        assert_vec_close(&values[..close.len()], &direct.value);
2846    }
2847}