Skip to main content

vector_ta/indicators/
range_breakout_signals.rs

1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArrayMethods, PyReadonlyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7#[cfg(feature = "python")]
8use pyo3::types::PyDict;
9
10#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
11use serde::{Deserialize, Serialize};
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use wasm_bindgen::prelude::*;
14
15use crate::utilities::data_loader::Candles;
16use crate::utilities::enums::Kernel;
17use crate::utilities::helpers::{
18    alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, make_uninit_matrix,
19};
20#[cfg(feature = "python")]
21use crate::utilities::kernel_validation::validate_kernel;
22#[cfg(not(target_arch = "wasm32"))]
23use rayon::prelude::*;
24#[cfg(test)]
25use std::error::Error as StdError;
26use std::mem::ManuallyDrop;
27use thiserror::Error;
28
29const DEFAULT_RANGE_LENGTH: usize = 20;
30const DEFAULT_CONFIRMATION_LENGTH: usize = 5;
31const ATR_LENGTH: usize = 14;
32const ATR_MULTIPLIER: f64 = 1.2;
33const VOLATILITY_THRESHOLD: f64 = 1.2;
34const BULLISH_LOCATION_WEIGHT: f64 = 0.15;
35const BEARISH_LOCATION_WEIGHT: f64 = 0.85;
36
37#[inline(always)]
38fn open_source(candles: &Candles) -> &[f64] {
39    &candles.open
40}
41
42#[inline(always)]
43fn high_source(candles: &Candles) -> &[f64] {
44    &candles.high
45}
46
47#[inline(always)]
48fn low_source(candles: &Candles) -> &[f64] {
49    &candles.low
50}
51
52#[inline(always)]
53fn close_source(candles: &Candles) -> &[f64] {
54    &candles.close
55}
56
57#[inline(always)]
58fn volume_source(candles: &Candles) -> &[f64] {
59    &candles.volume
60}
61
62#[derive(Debug, Clone)]
63pub enum RangeBreakoutSignalsData<'a> {
64    Candles {
65        candles: &'a Candles,
66    },
67    Slices {
68        open: &'a [f64],
69        high: &'a [f64],
70        low: &'a [f64],
71        close: &'a [f64],
72        volume: &'a [f64],
73    },
74}
75
76#[derive(Debug, Clone)]
77#[cfg_attr(
78    all(target_arch = "wasm32", feature = "wasm"),
79    derive(Serialize, Deserialize)
80)]
81pub struct RangeBreakoutSignalsOutput {
82    pub range_top: Vec<f64>,
83    pub range_bottom: Vec<f64>,
84    pub bullish: Vec<f64>,
85    pub extra_bullish: Vec<f64>,
86    pub bearish: Vec<f64>,
87    pub extra_bearish: Vec<f64>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91#[cfg_attr(
92    all(target_arch = "wasm32", feature = "wasm"),
93    derive(Serialize, Deserialize)
94)]
95pub struct RangeBreakoutSignalsParams {
96    pub range_length: Option<usize>,
97    pub confirmation_length: Option<usize>,
98}
99
100impl Default for RangeBreakoutSignalsParams {
101    fn default() -> Self {
102        Self {
103            range_length: Some(DEFAULT_RANGE_LENGTH),
104            confirmation_length: Some(DEFAULT_CONFIRMATION_LENGTH),
105        }
106    }
107}
108
109#[derive(Debug, Clone)]
110pub struct RangeBreakoutSignalsInput<'a> {
111    pub data: RangeBreakoutSignalsData<'a>,
112    pub params: RangeBreakoutSignalsParams,
113}
114
115impl<'a> RangeBreakoutSignalsInput<'a> {
116    #[inline(always)]
117    pub fn from_candles(candles: &'a Candles, params: RangeBreakoutSignalsParams) -> Self {
118        Self {
119            data: RangeBreakoutSignalsData::Candles { candles },
120            params,
121        }
122    }
123
124    #[inline(always)]
125    pub fn from_slices(
126        open: &'a [f64],
127        high: &'a [f64],
128        low: &'a [f64],
129        close: &'a [f64],
130        volume: &'a [f64],
131        params: RangeBreakoutSignalsParams,
132    ) -> Self {
133        Self {
134            data: RangeBreakoutSignalsData::Slices {
135                open,
136                high,
137                low,
138                close,
139                volume,
140            },
141            params,
142        }
143    }
144
145    #[inline(always)]
146    pub fn with_default_candles(candles: &'a Candles) -> Self {
147        Self::from_candles(candles, RangeBreakoutSignalsParams::default())
148    }
149
150    #[inline(always)]
151    pub fn get_range_length(&self) -> usize {
152        self.params.range_length.unwrap_or(DEFAULT_RANGE_LENGTH)
153    }
154
155    #[inline(always)]
156    pub fn get_confirmation_length(&self) -> usize {
157        self.params
158            .confirmation_length
159            .unwrap_or(DEFAULT_CONFIRMATION_LENGTH)
160    }
161
162    #[inline(always)]
163    fn as_ohlcv(&self) -> (&'a [f64], &'a [f64], &'a [f64], &'a [f64], &'a [f64]) {
164        match &self.data {
165            RangeBreakoutSignalsData::Candles { candles } => (
166                open_source(candles),
167                high_source(candles),
168                low_source(candles),
169                close_source(candles),
170                volume_source(candles),
171            ),
172            RangeBreakoutSignalsData::Slices {
173                open,
174                high,
175                low,
176                close,
177                volume,
178            } => (*open, *high, *low, *close, *volume),
179        }
180    }
181}
182
183impl<'a> AsRef<[f64]> for RangeBreakoutSignalsInput<'a> {
184    #[inline(always)]
185    fn as_ref(&self) -> &[f64] {
186        self.as_ohlcv().3
187    }
188}
189
190#[derive(Clone, Debug)]
191pub struct RangeBreakoutSignalsBuilder {
192    range_length: Option<usize>,
193    confirmation_length: Option<usize>,
194    kernel: Kernel,
195}
196
197impl Default for RangeBreakoutSignalsBuilder {
198    fn default() -> Self {
199        Self {
200            range_length: None,
201            confirmation_length: None,
202            kernel: Kernel::Auto,
203        }
204    }
205}
206
207impl RangeBreakoutSignalsBuilder {
208    #[inline(always)]
209    pub fn new() -> Self {
210        Self::default()
211    }
212
213    #[inline(always)]
214    pub fn range_length(mut self, value: usize) -> Self {
215        self.range_length = Some(value);
216        self
217    }
218
219    #[inline(always)]
220    pub fn confirmation_length(mut self, value: usize) -> Self {
221        self.confirmation_length = Some(value);
222        self
223    }
224
225    #[inline(always)]
226    pub fn kernel(mut self, kernel: Kernel) -> Self {
227        self.kernel = kernel;
228        self
229    }
230
231    #[inline(always)]
232    fn params(self) -> RangeBreakoutSignalsParams {
233        RangeBreakoutSignalsParams {
234            range_length: self.range_length,
235            confirmation_length: self.confirmation_length,
236        }
237    }
238
239    #[inline(always)]
240    pub fn apply(
241        self,
242        candles: &Candles,
243    ) -> Result<RangeBreakoutSignalsOutput, RangeBreakoutSignalsError> {
244        let kernel = self.kernel;
245        let input = RangeBreakoutSignalsInput::from_candles(candles, self.params());
246        range_breakout_signals_with_kernel(&input, kernel)
247    }
248
249    #[inline(always)]
250    pub fn apply_slices(
251        self,
252        open: &[f64],
253        high: &[f64],
254        low: &[f64],
255        close: &[f64],
256        volume: &[f64],
257    ) -> Result<RangeBreakoutSignalsOutput, RangeBreakoutSignalsError> {
258        let kernel = self.kernel;
259        let input =
260            RangeBreakoutSignalsInput::from_slices(open, high, low, close, volume, self.params());
261        range_breakout_signals_with_kernel(&input, kernel)
262    }
263
264    #[inline(always)]
265    pub fn into_stream(self) -> Result<RangeBreakoutSignalsStream, RangeBreakoutSignalsError> {
266        RangeBreakoutSignalsStream::try_new(self.params())
267    }
268}
269
270#[derive(Debug, Error)]
271pub enum RangeBreakoutSignalsError {
272    #[error("range_breakout_signals: input data slice is empty.")]
273    EmptyInputData,
274    #[error("range_breakout_signals: all values are NaN.")]
275    AllValuesNaN,
276    #[error(
277        "range_breakout_signals: inconsistent slice lengths: open={open_len}, high={high_len}, low={low_len}, close={close_len}, volume={volume_len}"
278    )]
279    InconsistentSliceLengths {
280        open_len: usize,
281        high_len: usize,
282        low_len: usize,
283        close_len: usize,
284        volume_len: usize,
285    },
286    #[error(
287        "range_breakout_signals: invalid range_length: range_length = {range_length}, data length = {data_len}"
288    )]
289    InvalidRangeLength {
290        range_length: usize,
291        data_len: usize,
292    },
293    #[error(
294        "range_breakout_signals: invalid confirmation_length: confirmation_length = {confirmation_length}"
295    )]
296    InvalidConfirmationLength { confirmation_length: usize },
297    #[error("range_breakout_signals: not enough valid data: needed = {needed}, valid = {valid}")]
298    NotEnoughValidData { needed: usize, valid: usize },
299    #[error("range_breakout_signals: output length mismatch: expected = {expected}, got = {got}")]
300    OutputLengthMismatch { expected: usize, got: usize },
301    #[error("range_breakout_signals: invalid range for {axis}: start = {start}, end = {end}, step = {step}")]
302    InvalidRange {
303        axis: &'static str,
304        start: String,
305        end: String,
306        step: String,
307    },
308    #[error("range_breakout_signals: invalid kernel for batch: {0:?}")]
309    InvalidKernelForBatch(Kernel),
310}
311
312#[derive(Clone, Copy, Debug)]
313struct PreparedRangeBreakoutSignals<'a> {
314    open: &'a [f64],
315    high: &'a [f64],
316    low: &'a [f64],
317    close: &'a [f64],
318    volume: &'a [f64],
319    range_length: usize,
320    confirmation_length: usize,
321    warmup: usize,
322}
323
324#[derive(Clone, Copy, Debug)]
325struct ActiveRange {
326    top: f64,
327    bottom: f64,
328}
329
330#[derive(Clone, Debug)]
331struct MedianSmaWindow {
332    len: usize,
333    ring: Vec<f64>,
334    sorted: Vec<f64>,
335    head: usize,
336    count: usize,
337    sum: f64,
338}
339
340impl MedianSmaWindow {
341    #[inline(always)]
342    fn new(len: usize) -> Self {
343        Self {
344            len,
345            ring: vec![0.0; len],
346            sorted: Vec::with_capacity(len),
347            head: 0,
348            count: 0,
349            sum: 0.0,
350        }
351    }
352
353    #[inline(always)]
354    fn reset(&mut self) {
355        self.sorted.clear();
356        self.head = 0;
357        self.count = 0;
358        self.sum = 0.0;
359    }
360
361    #[inline(always)]
362    fn push(&mut self, value: f64) -> Option<(f64, f64)> {
363        if self.count == self.len {
364            let old = self.ring[self.head];
365            self.sum -= old;
366            if let Some(index) = self.sorted.iter().position(|probe| *probe == old) {
367                self.sorted.remove(index);
368            }
369            self.ring[self.head] = value;
370            self.head = (self.head + 1) % self.len;
371        } else {
372            self.ring[self.count] = value;
373            self.count += 1;
374            if self.count == self.len {
375                self.head = 0;
376            }
377        }
378
379        self.sum += value;
380        let index = self.sorted.partition_point(|probe| *probe <= value);
381        self.sorted.insert(index, value);
382
383        if self.count < self.len {
384            return None;
385        }
386
387        let median = if self.len & 1 == 1 {
388            self.sorted[self.len >> 1]
389        } else {
390            let upper = self.len >> 1;
391            (self.sorted[upper - 1] + self.sorted[upper]) * 0.5
392        };
393        Some((median, self.sum / self.len as f64))
394    }
395}
396
397#[derive(Clone, Debug)]
398struct AtrState {
399    len: usize,
400    count: usize,
401    sum: f64,
402    value: f64,
403    prev_close: f64,
404    have_prev_close: bool,
405}
406
407impl AtrState {
408    #[inline(always)]
409    fn new(len: usize) -> Self {
410        Self {
411            len,
412            count: 0,
413            sum: 0.0,
414            value: f64::NAN,
415            prev_close: f64::NAN,
416            have_prev_close: false,
417        }
418    }
419
420    #[inline(always)]
421    fn reset(&mut self) {
422        self.count = 0;
423        self.sum = 0.0;
424        self.value = f64::NAN;
425        self.prev_close = f64::NAN;
426        self.have_prev_close = false;
427    }
428
429    #[inline(always)]
430    fn update(&mut self, high: f64, low: f64, close: f64) -> Option<f64> {
431        let prev_close = if self.have_prev_close {
432            self.prev_close
433        } else {
434            close
435        };
436        let tr = (high - low)
437            .max((high - prev_close).abs())
438            .max((low - prev_close).abs());
439        self.prev_close = close;
440        self.have_prev_close = true;
441
442        if self.count < self.len {
443            self.count += 1;
444            self.sum += tr;
445            if self.count < self.len {
446                return None;
447            }
448            self.value = self.sum / self.len as f64;
449            return Some(self.value);
450        }
451
452        self.value = ((self.value * (self.len - 1) as f64) + tr) / self.len as f64;
453        Some(self.value)
454    }
455}
456
457#[derive(Clone, Debug)]
458struct VolumeWindow {
459    len: usize,
460    up_ring: Vec<f64>,
461    down_ring: Vec<f64>,
462    head: usize,
463    count: usize,
464    up_sum: f64,
465    down_sum: f64,
466}
467
468impl VolumeWindow {
469    #[inline(always)]
470    fn new(len: usize) -> Self {
471        Self {
472            len,
473            up_ring: vec![0.0; len],
474            down_ring: vec![0.0; len],
475            head: 0,
476            count: 0,
477            up_sum: 0.0,
478            down_sum: 0.0,
479        }
480    }
481
482    #[inline(always)]
483    fn reset(&mut self) {
484        self.head = 0;
485        self.count = 0;
486        self.up_sum = 0.0;
487        self.down_sum = 0.0;
488    }
489
490    #[inline(always)]
491    fn push(&mut self, up: f64, down: f64) {
492        if self.count == self.len {
493            self.up_sum -= self.up_ring[self.head];
494            self.down_sum -= self.down_ring[self.head];
495            self.up_ring[self.head] = up;
496            self.down_ring[self.head] = down;
497            self.head = (self.head + 1) % self.len;
498        } else {
499            self.up_ring[self.count] = up;
500            self.down_ring[self.count] = down;
501            self.count += 1;
502            if self.count == self.len {
503                self.head = 0;
504            }
505        }
506        self.up_sum += up;
507        self.down_sum += down;
508    }
509
510    #[inline(always)]
511    fn is_full(&self) -> bool {
512        self.count == self.len
513    }
514}
515
516#[derive(Clone, Debug)]
517struct BoolWindow {
518    ring: Vec<bool>,
519    head: usize,
520    count: usize,
521}
522
523impl BoolWindow {
524    #[inline(always)]
525    fn new(len: usize) -> Self {
526        Self {
527            ring: vec![false; len],
528            head: 0,
529            count: 0,
530        }
531    }
532
533    #[inline(always)]
534    fn reset(&mut self) {
535        self.head = 0;
536        self.count = 0;
537    }
538
539    #[inline(always)]
540    fn push(&mut self, value: bool) {
541        if self.count == self.ring.len() {
542            self.ring[self.head] = value;
543            self.head = (self.head + 1) % self.ring.len();
544        } else {
545            self.ring[self.count] = value;
546            self.count += 1;
547            if self.count == self.ring.len() {
548                self.head = 0;
549            }
550        }
551    }
552
553    #[inline(always)]
554    fn get_ago(&self, ago: usize) -> Option<bool> {
555        if ago >= self.count {
556            return None;
557        }
558        if self.count < self.ring.len() {
559            return Some(self.ring[self.count - 1 - ago]);
560        }
561        let len = self.ring.len();
562        let latest = if self.head == 0 {
563            len - 1
564        } else {
565            self.head - 1
566        };
567        Some(self.ring[(latest + len - ago) % len])
568    }
569
570    #[inline(always)]
571    fn is_full(&self) -> bool {
572        self.count == self.ring.len()
573    }
574}
575
576#[derive(Clone, Debug)]
577struct RangeBreakoutSignalsState {
578    confirmation_length: usize,
579    dist_window: MedianSmaWindow,
580    atr_state: AtrState,
581    volume_window: VolumeWindow,
582    under_window: BoolWindow,
583    prev_volatility: f64,
584    active_range: Option<ActiveRange>,
585}
586
587impl RangeBreakoutSignalsState {
588    #[inline(always)]
589    fn new(range_length: usize, confirmation_length: usize) -> Self {
590        Self {
591            confirmation_length,
592            dist_window: MedianSmaWindow::new(range_length),
593            atr_state: AtrState::new(ATR_LENGTH),
594            volume_window: VolumeWindow::new(confirmation_length + 1),
595            under_window: BoolWindow::new(confirmation_length + 1),
596            prev_volatility: f64::NAN,
597            active_range: None,
598        }
599    }
600
601    #[inline(always)]
602    fn reset(&mut self) {
603        self.dist_window.reset();
604        self.atr_state.reset();
605        self.volume_window.reset();
606        self.under_window.reset();
607        self.prev_volatility = f64::NAN;
608        self.active_range = None;
609    }
610
611    #[inline(always)]
612    fn split_volume(open: f64, close: f64, volume: f64) -> (f64, f64) {
613        if close > open {
614            (volume, 0.0)
615        } else if close < open {
616            (0.0, volume)
617        } else {
618            let half = volume * 0.5;
619            (half, half)
620        }
621    }
622
623    #[inline(always)]
624    fn location(range: ActiveRange, bullish: bool) -> f64 {
625        let span = range.top - range.bottom;
626        let weight = if bullish {
627            BULLISH_LOCATION_WEIGHT
628        } else {
629            BEARISH_LOCATION_WEIGHT
630        };
631        range.bottom + span * weight
632    }
633
634    #[inline(always)]
635    fn update(
636        &mut self,
637        open: f64,
638        high: f64,
639        low: f64,
640        close: f64,
641        volume: f64,
642    ) -> Option<(f64, f64, f64, f64, f64, f64)> {
643        if !open.is_finite()
644            || !high.is_finite()
645            || !low.is_finite()
646            || !close.is_finite()
647            || !volume.is_finite()
648        {
649            self.reset();
650            return None;
651        }
652
653        let previous_volatility = self.prev_volatility;
654        let atr = self.atr_state.update(high, low, close);
655        let volatility = self
656            .dist_window
657            .push((close - open).abs())
658            .and_then(|(median, mean)| (median > 0.0).then_some(mean / median));
659
660        let current_isunder = volatility.is_some_and(|value| value < VOLATILITY_THRESHOLD);
661        let (up_volume, down_volume) = Self::split_volume(open, close, volume);
662        self.volume_window.push(up_volume, down_volume);
663        self.under_window.push(current_isunder);
664
665        let ready = volatility.is_some()
666            && atr.is_some()
667            && previous_volatility.is_finite()
668            && self.volume_window.is_full()
669            && self.under_window.is_full();
670
671        if ready {
672            let under_ago = self
673                .under_window
674                .get_ago(self.confirmation_length)
675                .unwrap_or(false);
676            let current_volatility = volatility.unwrap_or(f64::NAN);
677            let crossed_under = previous_volatility >= VOLATILITY_THRESHOLD
678                && current_volatility < VOLATILITY_THRESHOLD;
679            if self.active_range.is_none() && crossed_under && current_isunder && under_ago {
680                let offset = atr.unwrap_or(f64::NAN) * ATR_MULTIPLIER;
681                self.active_range = Some(ActiveRange {
682                    top: close + offset,
683                    bottom: close - offset,
684                });
685            }
686        }
687
688        let mut range_top = f64::NAN;
689        let mut range_bottom = f64::NAN;
690        let mut bullish = f64::NAN;
691        let mut extra_bullish = f64::NAN;
692        let mut bearish = f64::NAN;
693        let mut extra_bearish = f64::NAN;
694
695        if let Some(range) = self.active_range {
696            range_top = range.top;
697            range_bottom = range.bottom;
698
699            if close > range.top || close < range.bottom {
700                let bullish_break = close > range.top;
701                let location = Self::location(range, bullish_break);
702                let bullish_volume = self.volume_window.up_sum > self.volume_window.down_sum;
703
704                if bullish_break {
705                    bullish = location;
706                    if bullish_volume {
707                        extra_bullish = location;
708                    }
709                } else {
710                    bearish = location;
711                    if !bullish_volume {
712                        extra_bearish = location;
713                    }
714                }
715
716                self.active_range = None;
717            }
718        }
719
720        self.prev_volatility = volatility.unwrap_or(f64::NAN);
721
722        ready.then_some((
723            range_top,
724            range_bottom,
725            bullish,
726            extra_bullish,
727            bearish,
728            extra_bearish,
729        ))
730    }
731}
732
733#[derive(Clone, Debug)]
734pub struct RangeBreakoutSignalsStream {
735    params: RangeBreakoutSignalsParams,
736    state: RangeBreakoutSignalsState,
737}
738
739impl RangeBreakoutSignalsStream {
740    #[inline(always)]
741    pub fn try_new(params: RangeBreakoutSignalsParams) -> Result<Self, RangeBreakoutSignalsError> {
742        let range_length = params.range_length.unwrap_or(DEFAULT_RANGE_LENGTH);
743        let confirmation_length = params
744            .confirmation_length
745            .unwrap_or(DEFAULT_CONFIRMATION_LENGTH);
746        validate_params(range_length, confirmation_length, usize::MAX)?;
747        Ok(Self {
748            params,
749            state: RangeBreakoutSignalsState::new(range_length, confirmation_length),
750        })
751    }
752
753    #[inline(always)]
754    pub fn update(
755        &mut self,
756        open: f64,
757        high: f64,
758        low: f64,
759        close: f64,
760        volume: f64,
761    ) -> Option<(f64, f64, f64, f64, f64, f64)> {
762        self.state.update(open, high, low, close, volume)
763    }
764
765    #[inline(always)]
766    pub fn reset(&mut self) {
767        self.state = RangeBreakoutSignalsState::new(
768            self.params.range_length.unwrap_or(DEFAULT_RANGE_LENGTH),
769            self.params
770                .confirmation_length
771                .unwrap_or(DEFAULT_CONFIRMATION_LENGTH),
772        );
773    }
774}
775
776#[inline(always)]
777fn required_valid_bars(range_length: usize, confirmation_length: usize) -> usize {
778    (range_length + 1)
779        .max(ATR_LENGTH)
780        .max(confirmation_length + 1)
781}
782
783#[inline(always)]
784fn validate_params(
785    range_length: usize,
786    confirmation_length: usize,
787    data_len: usize,
788) -> Result<(), RangeBreakoutSignalsError> {
789    if range_length == 0 {
790        return Err(RangeBreakoutSignalsError::InvalidRangeLength {
791            range_length,
792            data_len,
793        });
794    }
795    if confirmation_length == 0 {
796        return Err(RangeBreakoutSignalsError::InvalidConfirmationLength {
797            confirmation_length,
798        });
799    }
800    if data_len != usize::MAX && range_length > data_len {
801        return Err(RangeBreakoutSignalsError::InvalidRangeLength {
802            range_length,
803            data_len,
804        });
805    }
806    Ok(())
807}
808
809fn analyze_valid_segments(
810    open: &[f64],
811    high: &[f64],
812    low: &[f64],
813    close: &[f64],
814    volume: &[f64],
815) -> Result<(usize, usize), RangeBreakoutSignalsError> {
816    if open.is_empty() {
817        return Err(RangeBreakoutSignalsError::EmptyInputData);
818    }
819    if open.len() != high.len()
820        || open.len() != low.len()
821        || open.len() != close.len()
822        || open.len() != volume.len()
823    {
824        return Err(RangeBreakoutSignalsError::InconsistentSliceLengths {
825            open_len: open.len(),
826            high_len: high.len(),
827            low_len: low.len(),
828            close_len: close.len(),
829            volume_len: volume.len(),
830        });
831    }
832
833    let mut run = 0usize;
834    let mut max_run = 0usize;
835    let mut valid = 0usize;
836    for i in 0..open.len() {
837        if open[i].is_finite()
838            && high[i].is_finite()
839            && low[i].is_finite()
840            && close[i].is_finite()
841            && volume[i].is_finite()
842        {
843            valid += 1;
844            run += 1;
845            max_run = max_run.max(run);
846        } else {
847            run = 0;
848        }
849    }
850
851    if valid == 0 {
852        return Err(RangeBreakoutSignalsError::AllValuesNaN);
853    }
854
855    Ok((valid, max_run))
856}
857
858fn prepare_input<'a>(
859    input: &'a RangeBreakoutSignalsInput<'a>,
860    kernel: Kernel,
861) -> Result<PreparedRangeBreakoutSignals<'a>, RangeBreakoutSignalsError> {
862    if matches!(kernel, Kernel::Auto) {
863        let _ = detect_best_kernel();
864    }
865
866    let (open, high, low, close, volume) = input.as_ohlcv();
867    let range_length = input.get_range_length();
868    let confirmation_length = input.get_confirmation_length();
869    validate_params(range_length, confirmation_length, close.len())?;
870
871    let (_, max_run) = analyze_valid_segments(open, high, low, close, volume)?;
872    let needed = required_valid_bars(range_length, confirmation_length);
873    if max_run < needed {
874        return Err(RangeBreakoutSignalsError::NotEnoughValidData {
875            needed,
876            valid: max_run,
877        });
878    }
879
880    Ok(PreparedRangeBreakoutSignals {
881        open,
882        high,
883        low,
884        close,
885        volume,
886        range_length,
887        confirmation_length,
888        warmup: needed - 1,
889    })
890}
891
892fn compute_row(
893    open: &[f64],
894    high: &[f64],
895    low: &[f64],
896    close: &[f64],
897    volume: &[f64],
898    range_length: usize,
899    confirmation_length: usize,
900    range_top_out: &mut [f64],
901    range_bottom_out: &mut [f64],
902    bullish_out: &mut [f64],
903    extra_bullish_out: &mut [f64],
904    bearish_out: &mut [f64],
905    extra_bearish_out: &mut [f64],
906) -> Result<(), RangeBreakoutSignalsError> {
907    let expected = close.len();
908    for out in [
909        &mut *range_top_out,
910        &mut *range_bottom_out,
911        &mut *bullish_out,
912        &mut *extra_bullish_out,
913        &mut *bearish_out,
914        &mut *extra_bearish_out,
915    ] {
916        if out.len() != expected {
917            return Err(RangeBreakoutSignalsError::OutputLengthMismatch {
918                expected,
919                got: out.len(),
920            });
921        }
922    }
923
924    let mut state = RangeBreakoutSignalsState::new(range_length, confirmation_length);
925    for i in 0..expected {
926        if let Some((rt, rb, b, eb, br, ebr)) =
927            state.update(open[i], high[i], low[i], close[i], volume[i])
928        {
929            range_top_out[i] = rt;
930            range_bottom_out[i] = rb;
931            bullish_out[i] = b;
932            extra_bullish_out[i] = eb;
933            bearish_out[i] = br;
934            extra_bearish_out[i] = ebr;
935        } else {
936            range_top_out[i] = f64::NAN;
937            range_bottom_out[i] = f64::NAN;
938            bullish_out[i] = f64::NAN;
939            extra_bullish_out[i] = f64::NAN;
940            bearish_out[i] = f64::NAN;
941            extra_bearish_out[i] = f64::NAN;
942        }
943    }
944
945    Ok(())
946}
947
948#[inline]
949pub fn range_breakout_signals(
950    input: &RangeBreakoutSignalsInput,
951) -> Result<RangeBreakoutSignalsOutput, RangeBreakoutSignalsError> {
952    range_breakout_signals_with_kernel(input, Kernel::Auto)
953}
954
955pub fn range_breakout_signals_with_kernel(
956    input: &RangeBreakoutSignalsInput,
957    kernel: Kernel,
958) -> Result<RangeBreakoutSignalsOutput, RangeBreakoutSignalsError> {
959    let prepared = prepare_input(input, kernel)?;
960    let len = prepared.close.len();
961    let warmup = prepared.warmup;
962    let mut range_top = alloc_with_nan_prefix(len, warmup);
963    let mut range_bottom = alloc_with_nan_prefix(len, warmup);
964    let mut bullish = alloc_with_nan_prefix(len, warmup);
965    let mut extra_bullish = alloc_with_nan_prefix(len, warmup);
966    let mut bearish = alloc_with_nan_prefix(len, warmup);
967    let mut extra_bearish = alloc_with_nan_prefix(len, warmup);
968
969    compute_row(
970        prepared.open,
971        prepared.high,
972        prepared.low,
973        prepared.close,
974        prepared.volume,
975        prepared.range_length,
976        prepared.confirmation_length,
977        &mut range_top,
978        &mut range_bottom,
979        &mut bullish,
980        &mut extra_bullish,
981        &mut bearish,
982        &mut extra_bearish,
983    )?;
984
985    Ok(RangeBreakoutSignalsOutput {
986        range_top,
987        range_bottom,
988        bullish,
989        extra_bullish,
990        bearish,
991        extra_bearish,
992    })
993}
994
995#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
996pub fn range_breakout_signals_into(
997    range_top_out: &mut [f64],
998    range_bottom_out: &mut [f64],
999    bullish_out: &mut [f64],
1000    extra_bullish_out: &mut [f64],
1001    bearish_out: &mut [f64],
1002    extra_bearish_out: &mut [f64],
1003    input: &RangeBreakoutSignalsInput,
1004) -> Result<(), RangeBreakoutSignalsError> {
1005    range_breakout_signals_into_slice(
1006        range_top_out,
1007        range_bottom_out,
1008        bullish_out,
1009        extra_bullish_out,
1010        bearish_out,
1011        extra_bearish_out,
1012        input,
1013        Kernel::Auto,
1014    )
1015}
1016
1017pub fn range_breakout_signals_into_slice(
1018    range_top_out: &mut [f64],
1019    range_bottom_out: &mut [f64],
1020    bullish_out: &mut [f64],
1021    extra_bullish_out: &mut [f64],
1022    bearish_out: &mut [f64],
1023    extra_bearish_out: &mut [f64],
1024    input: &RangeBreakoutSignalsInput,
1025    kernel: Kernel,
1026) -> Result<(), RangeBreakoutSignalsError> {
1027    let prepared = prepare_input(input, kernel)?;
1028    compute_row(
1029        prepared.open,
1030        prepared.high,
1031        prepared.low,
1032        prepared.close,
1033        prepared.volume,
1034        prepared.range_length,
1035        prepared.confirmation_length,
1036        range_top_out,
1037        range_bottom_out,
1038        bullish_out,
1039        extra_bullish_out,
1040        bearish_out,
1041        extra_bearish_out,
1042    )
1043}
1044
1045#[derive(Clone, Debug)]
1046pub struct RangeBreakoutSignalsBatchRange {
1047    pub range_length: (usize, usize, usize),
1048    pub confirmation_length: (usize, usize, usize),
1049}
1050
1051impl Default for RangeBreakoutSignalsBatchRange {
1052    fn default() -> Self {
1053        Self {
1054            range_length: (DEFAULT_RANGE_LENGTH, DEFAULT_RANGE_LENGTH, 0),
1055            confirmation_length: (DEFAULT_CONFIRMATION_LENGTH, DEFAULT_CONFIRMATION_LENGTH, 0),
1056        }
1057    }
1058}
1059
1060#[derive(Clone, Debug, Default)]
1061pub struct RangeBreakoutSignalsBatchBuilder {
1062    range: RangeBreakoutSignalsBatchRange,
1063    kernel: Kernel,
1064}
1065
1066impl RangeBreakoutSignalsBatchBuilder {
1067    #[inline(always)]
1068    pub fn new() -> Self {
1069        Self::default()
1070    }
1071
1072    #[inline(always)]
1073    pub fn kernel(mut self, kernel: Kernel) -> Self {
1074        self.kernel = kernel;
1075        self
1076    }
1077
1078    #[inline(always)]
1079    pub fn range_length_range(mut self, start: usize, end: usize, step: usize) -> Self {
1080        self.range.range_length = (start, end, step);
1081        self
1082    }
1083
1084    #[inline(always)]
1085    pub fn confirmation_length_range(mut self, start: usize, end: usize, step: usize) -> Self {
1086        self.range.confirmation_length = (start, end, step);
1087        self
1088    }
1089}
1090
1091#[derive(Debug, Clone)]
1092#[cfg_attr(
1093    all(target_arch = "wasm32", feature = "wasm"),
1094    derive(Serialize, Deserialize)
1095)]
1096pub struct RangeBreakoutSignalsBatchOutput {
1097    pub range_top: Vec<f64>,
1098    pub range_bottom: Vec<f64>,
1099    pub bullish: Vec<f64>,
1100    pub extra_bullish: Vec<f64>,
1101    pub bearish: Vec<f64>,
1102    pub extra_bearish: Vec<f64>,
1103    pub combos: Vec<RangeBreakoutSignalsParams>,
1104    pub rows: usize,
1105    pub cols: usize,
1106}
1107
1108#[inline(always)]
1109fn axis_usize(
1110    axis: &'static str,
1111    (start, end, step): (usize, usize, usize),
1112) -> Result<Vec<usize>, RangeBreakoutSignalsError> {
1113    if start == end || step == 0 {
1114        return Ok(vec![start]);
1115    }
1116    let mut out = Vec::new();
1117    if start < end {
1118        let mut value = start;
1119        while value <= end {
1120            out.push(value);
1121            value =
1122                value
1123                    .checked_add(step)
1124                    .ok_or_else(|| RangeBreakoutSignalsError::InvalidRange {
1125                        axis,
1126                        start: start.to_string(),
1127                        end: end.to_string(),
1128                        step: step.to_string(),
1129                    })?;
1130        }
1131    } else {
1132        let mut value = start;
1133        while value >= end {
1134            out.push(value);
1135            value =
1136                value
1137                    .checked_sub(step)
1138                    .ok_or_else(|| RangeBreakoutSignalsError::InvalidRange {
1139                        axis,
1140                        start: start.to_string(),
1141                        end: end.to_string(),
1142                        step: step.to_string(),
1143                    })?;
1144        }
1145    }
1146    if out.is_empty() || *out.last().unwrap() != end {
1147        return Err(RangeBreakoutSignalsError::InvalidRange {
1148            axis,
1149            start: start.to_string(),
1150            end: end.to_string(),
1151            step: step.to_string(),
1152        });
1153    }
1154    Ok(out)
1155}
1156
1157pub fn expand_grid_range_breakout_signals(
1158    sweep: &RangeBreakoutSignalsBatchRange,
1159) -> Result<Vec<RangeBreakoutSignalsParams>, RangeBreakoutSignalsError> {
1160    let range_lengths = axis_usize("range_length", sweep.range_length)?;
1161    let confirmation_lengths = axis_usize("confirmation_length", sweep.confirmation_length)?;
1162    let mut out = Vec::with_capacity(range_lengths.len() * confirmation_lengths.len());
1163    for &range_length in &range_lengths {
1164        for &confirmation_length in &confirmation_lengths {
1165            out.push(RangeBreakoutSignalsParams {
1166                range_length: Some(range_length),
1167                confirmation_length: Some(confirmation_length),
1168            });
1169        }
1170    }
1171    Ok(out)
1172}
1173
1174fn batch_inner_into(
1175    open: &[f64],
1176    high: &[f64],
1177    low: &[f64],
1178    close: &[f64],
1179    volume: &[f64],
1180    sweep: &RangeBreakoutSignalsBatchRange,
1181    parallel: bool,
1182    range_top_out: &mut [f64],
1183    range_bottom_out: &mut [f64],
1184    bullish_out: &mut [f64],
1185    extra_bullish_out: &mut [f64],
1186    bearish_out: &mut [f64],
1187    extra_bearish_out: &mut [f64],
1188) -> Result<Vec<RangeBreakoutSignalsParams>, RangeBreakoutSignalsError> {
1189    let (_, max_run) = analyze_valid_segments(open, high, low, close, volume)?;
1190    let combos = expand_grid_range_breakout_signals(sweep)?;
1191    let rows = combos.len();
1192    let cols = close.len();
1193    let expected = rows * cols;
1194
1195    for out in [
1196        &mut *range_top_out,
1197        &mut *range_bottom_out,
1198        &mut *bullish_out,
1199        &mut *extra_bullish_out,
1200        &mut *bearish_out,
1201        &mut *extra_bearish_out,
1202    ] {
1203        if out.len() != expected {
1204            return Err(RangeBreakoutSignalsError::OutputLengthMismatch {
1205                expected,
1206                got: out.len(),
1207            });
1208        }
1209    }
1210
1211    for params in &combos {
1212        let needed = required_valid_bars(
1213            params.range_length.unwrap_or(DEFAULT_RANGE_LENGTH),
1214            params
1215                .confirmation_length
1216                .unwrap_or(DEFAULT_CONFIRMATION_LENGTH),
1217        );
1218        if max_run < needed {
1219            return Err(RangeBreakoutSignalsError::NotEnoughValidData {
1220                needed,
1221                valid: max_run,
1222            });
1223        }
1224    }
1225
1226    let do_row = |row: usize,
1227                  range_top_row: &mut [f64],
1228                  range_bottom_row: &mut [f64],
1229                  bullish_row: &mut [f64],
1230                  extra_bullish_row: &mut [f64],
1231                  bearish_row: &mut [f64],
1232                  extra_bearish_row: &mut [f64]| {
1233        let params = &combos[row];
1234        compute_row(
1235            open,
1236            high,
1237            low,
1238            close,
1239            volume,
1240            params.range_length.unwrap_or(DEFAULT_RANGE_LENGTH),
1241            params
1242                .confirmation_length
1243                .unwrap_or(DEFAULT_CONFIRMATION_LENGTH),
1244            range_top_row,
1245            range_bottom_row,
1246            bullish_row,
1247            extra_bullish_row,
1248            bearish_row,
1249            extra_bearish_row,
1250        )
1251    };
1252
1253    if parallel {
1254        #[cfg(not(target_arch = "wasm32"))]
1255        {
1256            range_top_out
1257                .par_chunks_mut(cols)
1258                .zip(range_bottom_out.par_chunks_mut(cols))
1259                .zip(bullish_out.par_chunks_mut(cols))
1260                .zip(extra_bullish_out.par_chunks_mut(cols))
1261                .zip(bearish_out.par_chunks_mut(cols))
1262                .zip(extra_bearish_out.par_chunks_mut(cols))
1263                .enumerate()
1264                .try_for_each(
1265                    |(
1266                        row,
1267                        (
1268                            (
1269                                (
1270                                    ((range_top_row, range_bottom_row), bullish_row),
1271                                    extra_bullish_row,
1272                                ),
1273                                bearish_row,
1274                            ),
1275                            extra_bearish_row,
1276                        ),
1277                    )| {
1278                        do_row(
1279                            row,
1280                            range_top_row,
1281                            range_bottom_row,
1282                            bullish_row,
1283                            extra_bullish_row,
1284                            bearish_row,
1285                            extra_bearish_row,
1286                        )
1287                    },
1288                )?;
1289        }
1290        #[cfg(target_arch = "wasm32")]
1291        {
1292            for row in 0..rows {
1293                let start = row * cols;
1294                let end = start + cols;
1295                do_row(
1296                    row,
1297                    &mut range_top_out[start..end],
1298                    &mut range_bottom_out[start..end],
1299                    &mut bullish_out[start..end],
1300                    &mut extra_bullish_out[start..end],
1301                    &mut bearish_out[start..end],
1302                    &mut extra_bearish_out[start..end],
1303                )?;
1304            }
1305        }
1306    } else {
1307        for row in 0..rows {
1308            let start = row * cols;
1309            let end = start + cols;
1310            do_row(
1311                row,
1312                &mut range_top_out[start..end],
1313                &mut range_bottom_out[start..end],
1314                &mut bullish_out[start..end],
1315                &mut extra_bullish_out[start..end],
1316                &mut bearish_out[start..end],
1317                &mut extra_bearish_out[start..end],
1318            )?;
1319        }
1320    }
1321
1322    Ok(combos)
1323}
1324
1325pub fn range_breakout_signals_batch_with_kernel(
1326    open: &[f64],
1327    high: &[f64],
1328    low: &[f64],
1329    close: &[f64],
1330    volume: &[f64],
1331    sweep: &RangeBreakoutSignalsBatchRange,
1332    kernel: Kernel,
1333) -> Result<RangeBreakoutSignalsBatchOutput, RangeBreakoutSignalsError> {
1334    match kernel {
1335        Kernel::Auto => {
1336            let _ = detect_best_batch_kernel();
1337        }
1338        k if !k.is_batch() => return Err(RangeBreakoutSignalsError::InvalidKernelForBatch(k)),
1339        _ => {}
1340    }
1341
1342    let rows = expand_grid_range_breakout_signals(sweep)?.len();
1343    let cols = close.len();
1344    let mut top_guard = ManuallyDrop::new(make_uninit_matrix(rows, cols));
1345    let mut bottom_guard = ManuallyDrop::new(make_uninit_matrix(rows, cols));
1346    let mut bullish_guard = ManuallyDrop::new(make_uninit_matrix(rows, cols));
1347    let mut extra_bullish_guard = ManuallyDrop::new(make_uninit_matrix(rows, cols));
1348    let mut bearish_guard = ManuallyDrop::new(make_uninit_matrix(rows, cols));
1349    let mut extra_bearish_guard = ManuallyDrop::new(make_uninit_matrix(rows, cols));
1350
1351    let top: &mut [f64] = unsafe {
1352        core::slice::from_raw_parts_mut(top_guard.as_mut_ptr() as *mut f64, top_guard.len())
1353    };
1354    let bottom: &mut [f64] = unsafe {
1355        core::slice::from_raw_parts_mut(bottom_guard.as_mut_ptr() as *mut f64, bottom_guard.len())
1356    };
1357    let bullish: &mut [f64] = unsafe {
1358        core::slice::from_raw_parts_mut(bullish_guard.as_mut_ptr() as *mut f64, bullish_guard.len())
1359    };
1360    let extra_bullish: &mut [f64] = unsafe {
1361        core::slice::from_raw_parts_mut(
1362            extra_bullish_guard.as_mut_ptr() as *mut f64,
1363            extra_bullish_guard.len(),
1364        )
1365    };
1366    let bearish: &mut [f64] = unsafe {
1367        core::slice::from_raw_parts_mut(bearish_guard.as_mut_ptr() as *mut f64, bearish_guard.len())
1368    };
1369    let extra_bearish: &mut [f64] = unsafe {
1370        core::slice::from_raw_parts_mut(
1371            extra_bearish_guard.as_mut_ptr() as *mut f64,
1372            extra_bearish_guard.len(),
1373        )
1374    };
1375
1376    let combos = batch_inner_into(
1377        open,
1378        high,
1379        low,
1380        close,
1381        volume,
1382        sweep,
1383        !cfg!(target_arch = "wasm32"),
1384        top,
1385        bottom,
1386        bullish,
1387        extra_bullish,
1388        bearish,
1389        extra_bearish,
1390    )?;
1391
1392    Ok(RangeBreakoutSignalsBatchOutput {
1393        range_top: unsafe {
1394            Vec::from_raw_parts(
1395                top_guard.as_mut_ptr() as *mut f64,
1396                top_guard.len(),
1397                top_guard.capacity(),
1398            )
1399        },
1400        range_bottom: unsafe {
1401            Vec::from_raw_parts(
1402                bottom_guard.as_mut_ptr() as *mut f64,
1403                bottom_guard.len(),
1404                bottom_guard.capacity(),
1405            )
1406        },
1407        bullish: unsafe {
1408            Vec::from_raw_parts(
1409                bullish_guard.as_mut_ptr() as *mut f64,
1410                bullish_guard.len(),
1411                bullish_guard.capacity(),
1412            )
1413        },
1414        extra_bullish: unsafe {
1415            Vec::from_raw_parts(
1416                extra_bullish_guard.as_mut_ptr() as *mut f64,
1417                extra_bullish_guard.len(),
1418                extra_bullish_guard.capacity(),
1419            )
1420        },
1421        bearish: unsafe {
1422            Vec::from_raw_parts(
1423                bearish_guard.as_mut_ptr() as *mut f64,
1424                bearish_guard.len(),
1425                bearish_guard.capacity(),
1426            )
1427        },
1428        extra_bearish: unsafe {
1429            Vec::from_raw_parts(
1430                extra_bearish_guard.as_mut_ptr() as *mut f64,
1431                extra_bearish_guard.len(),
1432                extra_bearish_guard.capacity(),
1433            )
1434        },
1435        combos,
1436        rows,
1437        cols,
1438    })
1439}
1440
1441pub fn range_breakout_signals_batch_slice(
1442    open: &[f64],
1443    high: &[f64],
1444    low: &[f64],
1445    close: &[f64],
1446    volume: &[f64],
1447    sweep: &RangeBreakoutSignalsBatchRange,
1448    kernel: Kernel,
1449) -> Result<RangeBreakoutSignalsBatchOutput, RangeBreakoutSignalsError> {
1450    range_breakout_signals_batch_with_kernel(open, high, low, close, volume, sweep, kernel)
1451}
1452
1453pub fn range_breakout_signals_batch_par_slice(
1454    open: &[f64],
1455    high: &[f64],
1456    low: &[f64],
1457    close: &[f64],
1458    volume: &[f64],
1459    sweep: &RangeBreakoutSignalsBatchRange,
1460    kernel: Kernel,
1461) -> Result<RangeBreakoutSignalsBatchOutput, RangeBreakoutSignalsError> {
1462    range_breakout_signals_batch_with_kernel(open, high, low, close, volume, sweep, kernel)
1463}
1464
1465#[cfg(feature = "python")]
1466#[pyfunction(name = "range_breakout_signals")]
1467#[pyo3(signature = (open, high, low, close, volume, range_length=DEFAULT_RANGE_LENGTH, confirmation_length=DEFAULT_CONFIRMATION_LENGTH, kernel=None))]
1468pub fn range_breakout_signals_py<'py>(
1469    py: Python<'py>,
1470    open: PyReadonlyArray1<'py, f64>,
1471    high: PyReadonlyArray1<'py, f64>,
1472    low: PyReadonlyArray1<'py, f64>,
1473    close: PyReadonlyArray1<'py, f64>,
1474    volume: PyReadonlyArray1<'py, f64>,
1475    range_length: usize,
1476    confirmation_length: usize,
1477    kernel: Option<&str>,
1478) -> PyResult<Bound<'py, PyDict>> {
1479    let kernel = validate_kernel(kernel, false)?;
1480    let input = RangeBreakoutSignalsInput::from_slices(
1481        open.as_slice()?,
1482        high.as_slice()?,
1483        low.as_slice()?,
1484        close.as_slice()?,
1485        volume.as_slice()?,
1486        RangeBreakoutSignalsParams {
1487            range_length: Some(range_length),
1488            confirmation_length: Some(confirmation_length),
1489        },
1490    );
1491    let out = py
1492        .allow_threads(|| range_breakout_signals_with_kernel(&input, kernel))
1493        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1494    let dict = PyDict::new(py);
1495    dict.set_item("range_top", out.range_top.into_pyarray(py))?;
1496    dict.set_item("range_bottom", out.range_bottom.into_pyarray(py))?;
1497    dict.set_item("bullish", out.bullish.into_pyarray(py))?;
1498    dict.set_item("extra_bullish", out.extra_bullish.into_pyarray(py))?;
1499    dict.set_item("bearish", out.bearish.into_pyarray(py))?;
1500    dict.set_item("extra_bearish", out.extra_bearish.into_pyarray(py))?;
1501    Ok(dict)
1502}
1503
1504#[cfg(feature = "python")]
1505#[pyfunction(name = "range_breakout_signals_batch")]
1506#[pyo3(signature = (open, high, low, close, volume, range_length_range=(DEFAULT_RANGE_LENGTH, DEFAULT_RANGE_LENGTH, 0), confirmation_length_range=(DEFAULT_CONFIRMATION_LENGTH, DEFAULT_CONFIRMATION_LENGTH, 0), kernel=None))]
1507pub fn range_breakout_signals_batch_py<'py>(
1508    py: Python<'py>,
1509    open: PyReadonlyArray1<'py, f64>,
1510    high: PyReadonlyArray1<'py, f64>,
1511    low: PyReadonlyArray1<'py, f64>,
1512    close: PyReadonlyArray1<'py, f64>,
1513    volume: PyReadonlyArray1<'py, f64>,
1514    range_length_range: (usize, usize, usize),
1515    confirmation_length_range: (usize, usize, usize),
1516    kernel: Option<&str>,
1517) -> PyResult<Bound<'py, PyDict>> {
1518    let kernel = validate_kernel(kernel, true)?;
1519    let out = range_breakout_signals_batch_with_kernel(
1520        open.as_slice()?,
1521        high.as_slice()?,
1522        low.as_slice()?,
1523        close.as_slice()?,
1524        volume.as_slice()?,
1525        &RangeBreakoutSignalsBatchRange {
1526            range_length: range_length_range,
1527            confirmation_length: confirmation_length_range,
1528        },
1529        kernel,
1530    )
1531    .map_err(|e| PyValueError::new_err(e.to_string()))?;
1532    let dict = PyDict::new(py);
1533    dict.set_item(
1534        "range_top",
1535        out.range_top
1536            .into_pyarray(py)
1537            .reshape((out.rows, out.cols))?,
1538    )?;
1539    dict.set_item(
1540        "range_bottom",
1541        out.range_bottom
1542            .into_pyarray(py)
1543            .reshape((out.rows, out.cols))?,
1544    )?;
1545    dict.set_item(
1546        "bullish",
1547        out.bullish.into_pyarray(py).reshape((out.rows, out.cols))?,
1548    )?;
1549    dict.set_item(
1550        "extra_bullish",
1551        out.extra_bullish
1552            .into_pyarray(py)
1553            .reshape((out.rows, out.cols))?,
1554    )?;
1555    dict.set_item(
1556        "bearish",
1557        out.bearish.into_pyarray(py).reshape((out.rows, out.cols))?,
1558    )?;
1559    dict.set_item(
1560        "extra_bearish",
1561        out.extra_bearish
1562            .into_pyarray(py)
1563            .reshape((out.rows, out.cols))?,
1564    )?;
1565    dict.set_item(
1566        "range_lengths",
1567        out.combos
1568            .iter()
1569            .map(|combo| combo.range_length.unwrap_or(DEFAULT_RANGE_LENGTH))
1570            .collect::<Vec<_>>()
1571            .into_pyarray(py),
1572    )?;
1573    dict.set_item(
1574        "confirmation_lengths",
1575        out.combos
1576            .iter()
1577            .map(|combo| {
1578                combo
1579                    .confirmation_length
1580                    .unwrap_or(DEFAULT_CONFIRMATION_LENGTH)
1581            })
1582            .collect::<Vec<_>>()
1583            .into_pyarray(py),
1584    )?;
1585    dict.set_item("rows", out.rows)?;
1586    dict.set_item("cols", out.cols)?;
1587    Ok(dict)
1588}
1589
1590#[cfg(feature = "python")]
1591#[pyclass(name = "RangeBreakoutSignalsStream")]
1592pub struct RangeBreakoutSignalsStreamPy {
1593    inner: RangeBreakoutSignalsStream,
1594}
1595
1596#[cfg(feature = "python")]
1597#[pymethods]
1598impl RangeBreakoutSignalsStreamPy {
1599    #[new]
1600    #[pyo3(signature = (range_length=None, confirmation_length=None))]
1601    pub fn new(range_length: Option<usize>, confirmation_length: Option<usize>) -> PyResult<Self> {
1602        let inner = RangeBreakoutSignalsStream::try_new(RangeBreakoutSignalsParams {
1603            range_length,
1604            confirmation_length,
1605        })
1606        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1607        Ok(Self { inner })
1608    }
1609
1610    pub fn update(
1611        &mut self,
1612        open: f64,
1613        high: f64,
1614        low: f64,
1615        close: f64,
1616        volume: f64,
1617    ) -> Option<(f64, f64, f64, f64, f64, f64)> {
1618        self.inner.update(open, high, low, close, volume)
1619    }
1620}
1621
1622#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1623#[derive(Serialize, Deserialize)]
1624pub struct RangeBreakoutSignalsBatchConfig {
1625    pub range_length_range: (usize, usize, usize),
1626    pub confirmation_length_range: (usize, usize, usize),
1627}
1628
1629#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1630#[wasm_bindgen]
1631pub fn range_breakout_signals_js(
1632    open: &[f64],
1633    high: &[f64],
1634    low: &[f64],
1635    close: &[f64],
1636    volume: &[f64],
1637    range_length: usize,
1638    confirmation_length: usize,
1639) -> Result<JsValue, JsValue> {
1640    let input = RangeBreakoutSignalsInput::from_slices(
1641        open,
1642        high,
1643        low,
1644        close,
1645        volume,
1646        RangeBreakoutSignalsParams {
1647            range_length: Some(range_length),
1648            confirmation_length: Some(confirmation_length),
1649        },
1650    );
1651    let out = range_breakout_signals_with_kernel(&input, Kernel::Auto)
1652        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1653    serde_wasm_bindgen::to_value(&out).map_err(|e| JsValue::from_str(&e.to_string()))
1654}
1655
1656#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1657#[wasm_bindgen]
1658pub fn range_breakout_signals_alloc(len: usize) -> *mut f64 {
1659    let mut values = Vec::<f64>::with_capacity(len);
1660    let ptr = values.as_mut_ptr();
1661    std::mem::forget(values);
1662    ptr
1663}
1664
1665#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1666#[wasm_bindgen]
1667pub fn range_breakout_signals_free(ptr: *mut f64, len: usize) {
1668    if !ptr.is_null() {
1669        unsafe {
1670            let _ = Vec::from_raw_parts(ptr, len, len);
1671        }
1672    }
1673}
1674
1675#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1676#[wasm_bindgen]
1677pub fn range_breakout_signals_into(
1678    open_ptr: *const f64,
1679    high_ptr: *const f64,
1680    low_ptr: *const f64,
1681    close_ptr: *const f64,
1682    volume_ptr: *const f64,
1683    range_top_ptr: *mut f64,
1684    range_bottom_ptr: *mut f64,
1685    bullish_ptr: *mut f64,
1686    extra_bullish_ptr: *mut f64,
1687    bearish_ptr: *mut f64,
1688    extra_bearish_ptr: *mut f64,
1689    len: usize,
1690    range_length: usize,
1691    confirmation_length: usize,
1692) -> Result<(), JsValue> {
1693    if open_ptr.is_null()
1694        || high_ptr.is_null()
1695        || low_ptr.is_null()
1696        || close_ptr.is_null()
1697        || volume_ptr.is_null()
1698        || range_top_ptr.is_null()
1699        || range_bottom_ptr.is_null()
1700        || bullish_ptr.is_null()
1701        || extra_bullish_ptr.is_null()
1702        || bearish_ptr.is_null()
1703        || extra_bearish_ptr.is_null()
1704    {
1705        return Err(JsValue::from_str("Null pointer provided"));
1706    }
1707
1708    unsafe {
1709        let input = RangeBreakoutSignalsInput::from_slices(
1710            std::slice::from_raw_parts(open_ptr, len),
1711            std::slice::from_raw_parts(high_ptr, len),
1712            std::slice::from_raw_parts(low_ptr, len),
1713            std::slice::from_raw_parts(close_ptr, len),
1714            std::slice::from_raw_parts(volume_ptr, len),
1715            RangeBreakoutSignalsParams {
1716                range_length: Some(range_length),
1717                confirmation_length: Some(confirmation_length),
1718            },
1719        );
1720        let out = range_breakout_signals_with_kernel(&input, Kernel::Auto)
1721            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1722        std::slice::from_raw_parts_mut(range_top_ptr, len).copy_from_slice(&out.range_top);
1723        std::slice::from_raw_parts_mut(range_bottom_ptr, len).copy_from_slice(&out.range_bottom);
1724        std::slice::from_raw_parts_mut(bullish_ptr, len).copy_from_slice(&out.bullish);
1725        std::slice::from_raw_parts_mut(extra_bullish_ptr, len).copy_from_slice(&out.extra_bullish);
1726        std::slice::from_raw_parts_mut(bearish_ptr, len).copy_from_slice(&out.bearish);
1727        std::slice::from_raw_parts_mut(extra_bearish_ptr, len).copy_from_slice(&out.extra_bearish);
1728    }
1729    Ok(())
1730}
1731
1732#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1733#[wasm_bindgen(js_name = range_breakout_signals_batch)]
1734pub fn range_breakout_signals_batch_unified_js(
1735    open: &[f64],
1736    high: &[f64],
1737    low: &[f64],
1738    close: &[f64],
1739    volume: &[f64],
1740    config: JsValue,
1741) -> Result<JsValue, JsValue> {
1742    let config: RangeBreakoutSignalsBatchConfig =
1743        serde_wasm_bindgen::from_value(config).map_err(|e| JsValue::from_str(&e.to_string()))?;
1744    let out = range_breakout_signals_batch_with_kernel(
1745        open,
1746        high,
1747        low,
1748        close,
1749        volume,
1750        &RangeBreakoutSignalsBatchRange {
1751            range_length: config.range_length_range,
1752            confirmation_length: config.confirmation_length_range,
1753        },
1754        Kernel::Auto,
1755    )
1756    .map_err(|e| JsValue::from_str(&e.to_string()))?;
1757    serde_wasm_bindgen::to_value(&out).map_err(|e| JsValue::from_str(&e.to_string()))
1758}
1759
1760#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1761#[wasm_bindgen(js_name = range_breakout_signals_batch_into)]
1762pub fn range_breakout_signals_batch_into(
1763    open_ptr: *const f64,
1764    high_ptr: *const f64,
1765    low_ptr: *const f64,
1766    close_ptr: *const f64,
1767    volume_ptr: *const f64,
1768    range_top_ptr: *mut f64,
1769    range_bottom_ptr: *mut f64,
1770    bullish_ptr: *mut f64,
1771    extra_bullish_ptr: *mut f64,
1772    bearish_ptr: *mut f64,
1773    extra_bearish_ptr: *mut f64,
1774    len: usize,
1775    range_length_start: usize,
1776    range_length_end: usize,
1777    range_length_step: usize,
1778    confirmation_length_start: usize,
1779    confirmation_length_end: usize,
1780    confirmation_length_step: usize,
1781) -> Result<usize, JsValue> {
1782    let sweep = RangeBreakoutSignalsBatchRange {
1783        range_length: (range_length_start, range_length_end, range_length_step),
1784        confirmation_length: (
1785            confirmation_length_start,
1786            confirmation_length_end,
1787            confirmation_length_step,
1788        ),
1789    };
1790    let rows = expand_grid_range_breakout_signals(&sweep)
1791        .map_err(|e| JsValue::from_str(&e.to_string()))?
1792        .len();
1793    let total = rows
1794        .checked_mul(len)
1795        .ok_or_else(|| JsValue::from_str("rows * cols overflow"))?;
1796
1797    unsafe {
1798        let out = range_breakout_signals_batch_with_kernel(
1799            std::slice::from_raw_parts(open_ptr, len),
1800            std::slice::from_raw_parts(high_ptr, len),
1801            std::slice::from_raw_parts(low_ptr, len),
1802            std::slice::from_raw_parts(close_ptr, len),
1803            std::slice::from_raw_parts(volume_ptr, len),
1804            &sweep,
1805            Kernel::Auto,
1806        )
1807        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1808        std::slice::from_raw_parts_mut(range_top_ptr, total).copy_from_slice(&out.range_top);
1809        std::slice::from_raw_parts_mut(range_bottom_ptr, total).copy_from_slice(&out.range_bottom);
1810        std::slice::from_raw_parts_mut(bullish_ptr, total).copy_from_slice(&out.bullish);
1811        std::slice::from_raw_parts_mut(extra_bullish_ptr, total)
1812            .copy_from_slice(&out.extra_bullish);
1813        std::slice::from_raw_parts_mut(bearish_ptr, total).copy_from_slice(&out.bearish);
1814        std::slice::from_raw_parts_mut(extra_bearish_ptr, total)
1815            .copy_from_slice(&out.extra_bearish);
1816    }
1817
1818    Ok(rows)
1819}
1820
1821#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1822#[wasm_bindgen]
1823pub struct RangeBreakoutSignalsStreamWasm {
1824    inner: RangeBreakoutSignalsStream,
1825}
1826
1827#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1828#[wasm_bindgen]
1829impl RangeBreakoutSignalsStreamWasm {
1830    #[wasm_bindgen(constructor)]
1831    pub fn new(
1832        range_length: Option<usize>,
1833        confirmation_length: Option<usize>,
1834    ) -> Result<RangeBreakoutSignalsStreamWasm, JsValue> {
1835        let inner = RangeBreakoutSignalsStream::try_new(RangeBreakoutSignalsParams {
1836            range_length,
1837            confirmation_length,
1838        })
1839        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1840        Ok(Self { inner })
1841    }
1842
1843    pub fn update(
1844        &mut self,
1845        open: f64,
1846        high: f64,
1847        low: f64,
1848        close: f64,
1849        volume: f64,
1850    ) -> Result<JsValue, JsValue> {
1851        serde_wasm_bindgen::to_value(&self.inner.update(open, high, low, close, volume))
1852            .map_err(|e| JsValue::from_str(&e.to_string()))
1853    }
1854}
1855
1856#[cfg(test)]
1857mod tests {
1858    use super::*;
1859
1860    fn sample_ohlcv() -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
1861        let mut open = Vec::with_capacity(96);
1862        let mut high = Vec::with_capacity(96);
1863        let mut low = Vec::with_capacity(96);
1864        let mut close = Vec::with_capacity(96);
1865        let mut volume = Vec::with_capacity(96);
1866
1867        for i in 0..24 {
1868            let base = 100.0 + i as f64 * 0.35;
1869            let o = base - 1.4;
1870            let c = base + if i & 1 == 0 { 1.7 } else { -1.6 };
1871            open.push(o);
1872            close.push(c);
1873            high.push(o.max(c) + 1.1);
1874            low.push(o.min(c) - 1.0);
1875            volume.push(900.0 + (i as f64) * 8.0);
1876        }
1877
1878        for i in 24..36 {
1879            let base = 108.5 + ((i - 24) as f64) * 0.03;
1880            open.push(base - 0.03);
1881            close.push(base + 0.03);
1882            high.push(base + 0.18);
1883            low.push(base - 0.18);
1884            volume.push(1600.0 + (i as f64) * 6.0);
1885        }
1886
1887        open.push(109.2);
1888        high.push(112.4);
1889        low.push(109.0);
1890        close.push(112.1);
1891        volume.push(2200.0);
1892
1893        for i in 37..60 {
1894            let base = 111.8 - ((i - 37) as f64) * 0.08;
1895            let o = base + 0.8;
1896            let c = base - 0.9;
1897            open.push(o);
1898            close.push(c);
1899            high.push(o.max(c) + 0.9);
1900            low.push(o.min(c) - 0.8);
1901            volume.push(1100.0 + (i as f64) * 5.0);
1902        }
1903
1904        for i in 60..72 {
1905            let base = 104.7 - ((i - 60) as f64) * 0.02;
1906            open.push(base + 0.02);
1907            close.push(base - 0.02);
1908            high.push(base + 0.16);
1909            low.push(base - 0.16);
1910            volume.push(1750.0 + (i as f64) * 5.0);
1911        }
1912
1913        open.push(103.9);
1914        high.push(104.0);
1915        low.push(100.6);
1916        close.push(100.9);
1917        volume.push(2400.0);
1918
1919        for i in 73..96 {
1920            let base = 101.4 + ((i - 73) as f64) * 0.06;
1921            let o = base - 0.3;
1922            let c = base + 0.25;
1923            open.push(o);
1924            close.push(c);
1925            high.push(o.max(c) + 0.45);
1926            low.push(o.min(c) - 0.45);
1927            volume.push(1200.0 + (i as f64) * 3.0);
1928        }
1929
1930        (open, high, low, close, volume)
1931    }
1932
1933    #[test]
1934    fn range_breakout_signals_outputs_present() -> Result<(), Box<dyn StdError>> {
1935        let (open, high, low, close, volume) = sample_ohlcv();
1936        let out = range_breakout_signals(&RangeBreakoutSignalsInput::from_slices(
1937            &open,
1938            &high,
1939            &low,
1940            &close,
1941            &volume,
1942            RangeBreakoutSignalsParams::default(),
1943        ))?;
1944        assert!(out.range_top.iter().any(|value| value.is_finite()));
1945        assert!(out.range_bottom.iter().any(|value| value.is_finite()));
1946        Ok(())
1947    }
1948}