Skip to main content

vector_ta/indicators/
safezonestop.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(all(target_arch = "wasm32", feature = "wasm"))]
10use serde::{Deserialize, Serialize};
11#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
12use wasm_bindgen::prelude::*;
13
14use crate::utilities::data_loader::{source_type, Candles};
15use crate::utilities::enums::Kernel;
16use crate::utilities::helpers::{
17    alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
18    make_uninit_matrix,
19};
20#[cfg(feature = "python")]
21use crate::utilities::kernel_validation::validate_kernel;
22#[cfg(not(target_arch = "wasm32"))]
23use rayon::prelude::*;
24use std::error::Error;
25use thiserror::Error;
26
27#[inline(always)]
28fn first_valid_pair(high: &[f64], low: &[f64]) -> Option<usize> {
29    let n = high.len().min(low.len());
30    for i in 0..n {
31        if !high[i].is_nan() && !low[i].is_nan() {
32            return Some(i);
33        }
34    }
35    None
36}
37
38#[inline(always)]
39fn warm_len(first: usize, period: usize, max_lookback: usize) -> usize {
40    first + period.max(max_lookback.saturating_sub(1))
41}
42
43#[derive(Debug, Clone)]
44pub enum SafeZoneStopData<'a> {
45    Candles {
46        candles: &'a Candles,
47        direction: &'a str,
48    },
49    Slices {
50        high: &'a [f64],
51        low: &'a [f64],
52        direction: &'a str,
53    },
54}
55
56#[derive(Debug, Clone)]
57pub struct SafeZoneStopOutput {
58    pub values: Vec<f64>,
59}
60
61#[derive(Debug, Clone)]
62#[cfg_attr(
63    all(target_arch = "wasm32", feature = "wasm"),
64    derive(Serialize, Deserialize)
65)]
66pub struct SafeZoneStopParams {
67    pub period: Option<usize>,
68    pub mult: Option<f64>,
69    pub max_lookback: Option<usize>,
70}
71
72impl Default for SafeZoneStopParams {
73    fn default() -> Self {
74        Self {
75            period: Some(22),
76            mult: Some(2.5),
77            max_lookback: Some(3),
78        }
79    }
80}
81
82#[derive(Debug, Clone)]
83pub struct SafeZoneStopInput<'a> {
84    pub data: SafeZoneStopData<'a>,
85    pub params: SafeZoneStopParams,
86}
87
88impl<'a> SafeZoneStopInput<'a> {
89    #[inline]
90    pub fn from_candles(c: &'a Candles, direction: &'a str, p: SafeZoneStopParams) -> Self {
91        Self {
92            data: SafeZoneStopData::Candles {
93                candles: c,
94                direction,
95            },
96            params: p,
97        }
98    }
99    #[inline]
100    pub fn from_slices(
101        high: &'a [f64],
102        low: &'a [f64],
103        direction: &'a str,
104        p: SafeZoneStopParams,
105    ) -> Self {
106        Self {
107            data: SafeZoneStopData::Slices {
108                high,
109                low,
110                direction,
111            },
112            params: p,
113        }
114    }
115    #[inline]
116    pub fn with_default_candles(c: &'a Candles) -> Self {
117        Self::from_candles(c, "long", SafeZoneStopParams::default())
118    }
119    #[inline]
120    pub fn get_period(&self) -> usize {
121        self.params.period.unwrap_or(22)
122    }
123    #[inline]
124    pub fn get_mult(&self) -> f64 {
125        self.params.mult.unwrap_or(2.5)
126    }
127    #[inline]
128    pub fn get_max_lookback(&self) -> usize {
129        self.params.max_lookback.unwrap_or(3)
130    }
131}
132
133#[derive(Copy, Clone, Debug)]
134pub struct SafeZoneStopBuilder {
135    period: Option<usize>,
136    mult: Option<f64>,
137    max_lookback: Option<usize>,
138    direction: Option<&'static str>,
139    kernel: Kernel,
140}
141
142impl Default for SafeZoneStopBuilder {
143    fn default() -> Self {
144        Self {
145            period: None,
146            mult: None,
147            max_lookback: None,
148            direction: Some("long"),
149            kernel: Kernel::Auto,
150        }
151    }
152}
153
154impl SafeZoneStopBuilder {
155    #[inline(always)]
156    pub fn new() -> Self {
157        Self::default()
158    }
159    #[inline(always)]
160    pub fn period(mut self, n: usize) -> Self {
161        self.period = Some(n);
162        self
163    }
164    #[inline(always)]
165    pub fn mult(mut self, x: f64) -> Self {
166        self.mult = Some(x);
167        self
168    }
169    #[inline(always)]
170    pub fn max_lookback(mut self, n: usize) -> Self {
171        self.max_lookback = Some(n);
172        self
173    }
174    #[inline(always)]
175    pub fn direction(mut self, d: &'static str) -> Self {
176        self.direction = Some(d);
177        self
178    }
179    #[inline(always)]
180    pub fn kernel(mut self, k: Kernel) -> Self {
181        self.kernel = k;
182        self
183    }
184    #[inline(always)]
185    pub fn apply(self, c: &Candles) -> Result<SafeZoneStopOutput, SafeZoneStopError> {
186        let p = SafeZoneStopParams {
187            period: self.period,
188            mult: self.mult,
189            max_lookback: self.max_lookback,
190        };
191        let i = SafeZoneStopInput::from_candles(c, self.direction.unwrap_or("long"), p);
192        safezonestop_with_kernel(&i, self.kernel)
193    }
194    #[inline(always)]
195    pub fn apply_slices(
196        self,
197        high: &[f64],
198        low: &[f64],
199    ) -> Result<SafeZoneStopOutput, SafeZoneStopError> {
200        let p = SafeZoneStopParams {
201            period: self.period,
202            mult: self.mult,
203            max_lookback: self.max_lookback,
204        };
205        let i = SafeZoneStopInput::from_slices(high, low, self.direction.unwrap_or("long"), p);
206        safezonestop_with_kernel(&i, self.kernel)
207    }
208}
209
210#[derive(Debug, Error)]
211pub enum SafeZoneStopError {
212    #[error("safezonestop: Input data slice is empty.")]
213    EmptyInputData,
214    #[error("safezonestop: All values are NaN.")]
215    AllValuesNaN,
216    #[error("safezonestop: Invalid period: period = {period}, data length = {data_len}")]
217    InvalidPeriod { period: usize, data_len: usize },
218    #[error("safezonestop: Not enough valid data: needed = {needed}, valid = {valid}")]
219    NotEnoughValidData { needed: usize, valid: usize },
220    #[error("safezonestop: Output slice length mismatch: expected = {expected}, got = {got}")]
221    OutputLengthMismatch { expected: usize, got: usize },
222    #[error("safezonestop: Mismatched lengths")]
223    MismatchedLengths,
224    #[error("safezonestop: Invalid direction. Must be 'long' or 'short'.")]
225    InvalidDirection,
226    #[error("safezonestop: Invalid range: start = {start}, end = {end}, step = {step}")]
227    InvalidRange { start: f64, end: f64, step: f64 },
228    #[error("safezonestop: Invalid kernel type for batch operation: {0:?}")]
229    InvalidKernelForBatch(Kernel),
230}
231
232#[inline]
233pub fn safezonestop(input: &SafeZoneStopInput) -> Result<SafeZoneStopOutput, SafeZoneStopError> {
234    safezonestop_with_kernel(input, Kernel::Auto)
235}
236
237pub fn safezonestop_with_kernel(
238    input: &SafeZoneStopInput,
239    kernel: Kernel,
240) -> Result<SafeZoneStopOutput, SafeZoneStopError> {
241    let (high, low, direction) = match &input.data {
242        SafeZoneStopData::Candles { candles, direction } => {
243            let h = source_type(candles, "high");
244            let l = source_type(candles, "low");
245            (h, l, *direction)
246        }
247        SafeZoneStopData::Slices {
248            high,
249            low,
250            direction,
251        } => (*high, *low, *direction),
252    };
253
254    if high.len() != low.len() {
255        return Err(SafeZoneStopError::MismatchedLengths);
256    }
257
258    let period = input.get_period();
259    let mult = input.get_mult();
260    let max_lookback = input.get_max_lookback();
261    let len = high.len();
262
263    if len == 0 {
264        return Err(SafeZoneStopError::EmptyInputData);
265    }
266
267    if period == 0 || period > len {
268        return Err(SafeZoneStopError::InvalidPeriod {
269            period,
270            data_len: len,
271        });
272    }
273    if direction != "long" && direction != "short" {
274        return Err(SafeZoneStopError::InvalidDirection);
275    }
276
277    let first = first_valid_pair(high, low).ok_or(SafeZoneStopError::AllValuesNaN)?;
278    let needed = (period + 1).max(max_lookback);
279    if len - first < needed {
280        return Err(SafeZoneStopError::NotEnoughValidData {
281            needed,
282            valid: len - first,
283        });
284    }
285
286    let chosen = match kernel {
287        Kernel::Auto => Kernel::Scalar,
288        other => other,
289    };
290
291    let warm = warm_len(first, period, max_lookback);
292    let mut out = alloc_with_nan_prefix(len, warm);
293
294    unsafe {
295        match chosen {
296            Kernel::Scalar | Kernel::ScalarBatch => safezonestop_scalar(
297                high,
298                low,
299                period,
300                mult,
301                max_lookback,
302                direction,
303                first,
304                &mut out,
305            ),
306            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
307            Kernel::Avx2 | Kernel::Avx2Batch => safezonestop_avx2(
308                high,
309                low,
310                period,
311                mult,
312                max_lookback,
313                direction,
314                first,
315                &mut out,
316            ),
317            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
318            Kernel::Avx512 | Kernel::Avx512Batch => safezonestop_avx512(
319                high,
320                low,
321                period,
322                mult,
323                max_lookback,
324                direction,
325                first,
326                &mut out,
327            ),
328            _ => unreachable!(),
329        }
330    }
331
332    Ok(SafeZoneStopOutput { values: out })
333}
334
335#[inline]
336pub fn safezonestop_into_slice(
337    dst: &mut [f64],
338    input: &SafeZoneStopInput,
339    kern: Kernel,
340) -> Result<(), SafeZoneStopError> {
341    let (high, low, direction) = match &input.data {
342        SafeZoneStopData::Candles { candles, direction } => (
343            source_type(candles, "high"),
344            source_type(candles, "low"),
345            *direction,
346        ),
347        SafeZoneStopData::Slices {
348            high,
349            low,
350            direction,
351        } => (*high, *low, *direction),
352    };
353
354    let len = high.len();
355    if len != low.len() {
356        return Err(SafeZoneStopError::MismatchedLengths);
357    }
358    if dst.len() != len {
359        return Err(SafeZoneStopError::OutputLengthMismatch {
360            expected: len,
361            got: dst.len(),
362        });
363    }
364
365    let period = input.get_period();
366    let mult = input.get_mult();
367    let max_lookback = input.get_max_lookback();
368    if period == 0 || period > len {
369        return Err(SafeZoneStopError::InvalidPeriod {
370            period,
371            data_len: len,
372        });
373    }
374    if direction != "long" && direction != "short" {
375        return Err(SafeZoneStopError::InvalidDirection);
376    }
377
378    let first = first_valid_pair(high, low).ok_or(SafeZoneStopError::AllValuesNaN)?;
379    let needed = (period + 1).max(max_lookback);
380    if len - first < needed {
381        return Err(SafeZoneStopError::NotEnoughValidData {
382            needed,
383            valid: len - first,
384        });
385    }
386
387    let chosen = match kern {
388        Kernel::Auto => Kernel::Scalar,
389        other => other,
390    };
391
392    unsafe {
393        match chosen {
394            Kernel::Scalar | Kernel::ScalarBatch => {
395                safezonestop_scalar(high, low, period, mult, max_lookback, direction, first, dst)
396            }
397            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
398            Kernel::Avx2 | Kernel::Avx2Batch => {
399                safezonestop_avx2(high, low, period, mult, max_lookback, direction, first, dst)
400            }
401            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
402            Kernel::Avx512 | Kernel::Avx512Batch => {
403                safezonestop_avx512(high, low, period, mult, max_lookback, direction, first, dst)
404            }
405            _ => unreachable!(),
406        }
407    }
408
409    let warm_end = warm_len(first, period, max_lookback).min(dst.len());
410    for v in &mut dst[..warm_end] {
411        *v = f64::NAN;
412    }
413
414    Ok(())
415}
416
417#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
418#[inline]
419pub fn safezonestop_into(
420    input: &SafeZoneStopInput,
421    out: &mut [f64],
422) -> Result<(), SafeZoneStopError> {
423    let (high, low, direction) = match &input.data {
424        SafeZoneStopData::Candles { candles, direction } => (
425            source_type(candles, "high"),
426            source_type(candles, "low"),
427            *direction,
428        ),
429        SafeZoneStopData::Slices {
430            high,
431            low,
432            direction,
433        } => (*high, *low, *direction),
434    };
435
436    if high.len() != low.len() {
437        return Err(SafeZoneStopError::MismatchedLengths);
438    }
439    let len = high.len();
440    if len == 0 {
441        return Err(SafeZoneStopError::EmptyInputData);
442    }
443    if out.len() != len {
444        return Err(SafeZoneStopError::OutputLengthMismatch {
445            expected: len,
446            got: out.len(),
447        });
448    }
449
450    let period = input.get_period();
451    let max_lookback = input.get_max_lookback();
452    if period == 0 || period > len {
453        return Err(SafeZoneStopError::InvalidPeriod {
454            period,
455            data_len: len,
456        });
457    }
458    if direction != "long" && direction != "short" {
459        return Err(SafeZoneStopError::InvalidDirection);
460    }
461
462    let first = first_valid_pair(high, low).ok_or(SafeZoneStopError::AllValuesNaN)?;
463    let needed = (period + 1).max(max_lookback);
464    if len - first < needed {
465        return Err(SafeZoneStopError::NotEnoughValidData {
466            needed,
467            valid: len - first,
468        });
469    }
470
471    let warm = warm_len(first, period, max_lookback).min(out.len());
472    let qnan = f64::from_bits(0x7ff8_0000_0000_0000);
473    for v in &mut out[..warm] {
474        *v = qnan;
475    }
476
477    safezonestop_into_slice(out, input, Kernel::Auto)
478}
479
480pub unsafe fn safezonestop_scalar(
481    high: &[f64],
482    low: &[f64],
483    period: usize,
484    mult: f64,
485    max_lookback: usize,
486    direction: &str,
487    first: usize,
488    out: &mut [f64],
489) {
490    let len = high.len();
491    if len == 0 {
492        return;
493    }
494
495    let warm = first + period.max(max_lookback) - 1;
496    let warm_end = warm.min(len);
497    for k in 0..warm_end {
498        *out.get_unchecked_mut(k) = f64::NAN;
499    }
500    if warm >= len {
501        return;
502    }
503
504    let dir_long = direction
505        .as_bytes()
506        .get(0)
507        .map(|&b| b == b'l')
508        .unwrap_or(true);
509
510    const LB_DEQUE_THRESHOLD: usize = 32;
511    let end0 = first + period;
512
513    if max_lookback > LB_DEQUE_THRESHOLD {
514        #[inline(always)]
515        fn ring_dec(x: usize, cap: usize) -> usize {
516            if x == 0 {
517                cap - 1
518            } else {
519                x - 1
520            }
521        }
522        #[inline(always)]
523        fn ring_inc(x: usize, cap: usize) -> usize {
524            let y = x + 1;
525            if y == cap {
526                0
527            } else {
528                y
529            }
530        }
531
532        let cap = max_lookback.max(1) + 1;
533        let mut q_idx = vec![0usize; cap];
534        let mut q_val = vec![0.0f64; cap];
535        let mut q_head: usize = 0;
536        let mut q_tail: usize = 0;
537        let mut q_len: usize = 0;
538
539        let mut prev_high = *high.get_unchecked(first);
540        let mut prev_low = *low.get_unchecked(first);
541        let mut dm_prev = 0.0f64;
542        let mut dm_ready = false;
543        let mut boot_n = 0usize;
544        let mut boot_sum = 0.0f64;
545        let alpha = 1.0 - 1.0 / (period as f64);
546
547        for i in (first + 1)..len {
548            let h = *high.get_unchecked(i);
549            let l = *low.get_unchecked(i);
550            let up = h - prev_high;
551            let dn = prev_low - l;
552            let up_pos = if up > 0.0 { up } else { 0.0 };
553            let dn_pos = if dn > 0.0 { dn } else { 0.0 };
554            let dm_raw = if dir_long {
555                if dn_pos > up_pos {
556                    dn_pos
557                } else {
558                    0.0
559                }
560            } else {
561                if up_pos > dn_pos {
562                    up_pos
563                } else {
564                    0.0
565                }
566            };
567
568            if !dm_ready {
569                boot_n += 1;
570                boot_sum += dm_raw;
571                if boot_n == period {
572                    dm_prev = boot_sum;
573                    dm_ready = true;
574                }
575            } else {
576                dm_prev = alpha.mul_add(dm_prev, dm_raw);
577            }
578
579            if dm_ready {
580                let cand = if dir_long {
581                    (-mult).mul_add(dm_prev, prev_low)
582                } else {
583                    mult.mul_add(dm_prev, prev_high)
584                };
585
586                let start = i.saturating_add(1).saturating_sub(max_lookback);
587                while q_len > 0 {
588                    let idx_front = *q_idx.get_unchecked(q_head);
589                    if idx_front < start {
590                        q_head = ring_inc(q_head, cap);
591                        q_len -= 1;
592                    } else {
593                        break;
594                    }
595                }
596                while q_len > 0 {
597                    let last = ring_dec(q_tail, cap);
598                    let back_val = *q_val.get_unchecked(last);
599                    let pop = if dir_long {
600                        back_val <= cand
601                    } else {
602                        back_val >= cand
603                    };
604                    if pop {
605                        q_tail = last;
606                        q_len -= 1;
607                    } else {
608                        break;
609                    }
610                }
611                *q_idx.get_unchecked_mut(q_tail) = i;
612                *q_val.get_unchecked_mut(q_tail) = cand;
613                q_tail = ring_inc(q_tail, cap);
614                q_len += 1;
615            }
616
617            if i >= warm {
618                *out.get_unchecked_mut(i) = if q_len > 0 {
619                    *q_val.get_unchecked(q_head)
620                } else {
621                    f64::NAN
622                };
623            }
624
625            prev_high = h;
626            prev_low = l;
627        }
628    } else if end0 < len {
629        let mut dm_smooth = vec![0.0f64; len];
630
631        let mut prev_h = *high.get_unchecked(first);
632        let mut prev_l = *low.get_unchecked(first);
633        let mut sum = 0.0;
634        for i in (first + 1)..=end0 {
635            let h = *high.get_unchecked(i);
636            let l = *low.get_unchecked(i);
637            let up = h - prev_h;
638            let dn = prev_l - l;
639            let up_pos = if up > 0.0 { up } else { 0.0 };
640            let dn_pos = if dn > 0.0 { dn } else { 0.0 };
641            let dm = if dir_long {
642                if dn_pos > up_pos {
643                    dn_pos
644                } else {
645                    0.0
646                }
647            } else {
648                if up_pos > dn_pos {
649                    up_pos
650                } else {
651                    0.0
652                }
653            };
654            sum += dm;
655            prev_h = h;
656            prev_l = l;
657        }
658        *dm_smooth.get_unchecked_mut(end0) = sum;
659
660        let alpha = 1.0 - 1.0 / (period as f64);
661        for i in (end0 + 1)..len {
662            let h = *high.get_unchecked(i);
663            let l = *low.get_unchecked(i);
664            let up = h - prev_h;
665            let dn = prev_l - l;
666            let up_pos = if up > 0.0 { up } else { 0.0 };
667            let dn_pos = if dn > 0.0 { dn } else { 0.0 };
668            let dm = if dir_long {
669                if dn_pos > up_pos {
670                    dn_pos
671                } else {
672                    0.0
673                }
674            } else {
675                if up_pos > dn_pos {
676                    up_pos
677                } else {
678                    0.0
679                }
680            };
681            let prev = *dm_smooth.get_unchecked(i - 1);
682            *dm_smooth.get_unchecked_mut(i) = alpha.mul_add(prev, dm);
683            prev_h = h;
684            prev_l = l;
685        }
686
687        if dir_long {
688            for i in warm..len {
689                let start_idx = i + 1 - max_lookback;
690                let j0 = start_idx.max(end0);
691                if j0 > i {
692                    *out.get_unchecked_mut(i) = f64::NAN;
693                    continue;
694                }
695                let mut mx = f64::NEG_INFINITY;
696                for j in j0..=i {
697                    let val =
698                        (-mult).mul_add(*dm_smooth.get_unchecked(j), *low.get_unchecked(j - 1));
699                    if val > mx {
700                        mx = val;
701                    }
702                }
703                *out.get_unchecked_mut(i) = mx;
704            }
705        } else {
706            for i in warm..len {
707                let start_idx = i + 1 - max_lookback;
708                let j0 = start_idx.max(end0);
709                if j0 > i {
710                    *out.get_unchecked_mut(i) = f64::NAN;
711                    continue;
712                }
713                let mut mn = f64::INFINITY;
714                for j in j0..=i {
715                    let val = mult.mul_add(*dm_smooth.get_unchecked(j), *high.get_unchecked(j - 1));
716                    if val < mn {
717                        mn = val;
718                    }
719                }
720                *out.get_unchecked_mut(i) = mn;
721            }
722        }
723    }
724}
725
726#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
727#[inline(always)]
728pub unsafe fn safezonestop_avx512(
729    high: &[f64],
730    low: &[f64],
731    period: usize,
732    mult: f64,
733    max_lookback: usize,
734    direction: &str,
735    first: usize,
736    out: &mut [f64],
737) {
738    if period <= 32 {
739        safezonestop_avx512_short(high, low, period, mult, max_lookback, direction, first, out);
740    } else {
741        safezonestop_avx512_long(high, low, period, mult, max_lookback, direction, first, out);
742    }
743}
744
745#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
746#[inline(always)]
747pub unsafe fn safezonestop_avx512_short(
748    high: &[f64],
749    low: &[f64],
750    period: usize,
751    mult: f64,
752    max_lookback: usize,
753    direction: &str,
754    first: usize,
755    out: &mut [f64],
756) {
757    safezonestop_scalar(high, low, period, mult, max_lookback, direction, first, out)
758}
759
760#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
761#[inline(always)]
762pub unsafe fn safezonestop_avx512_long(
763    high: &[f64],
764    low: &[f64],
765    period: usize,
766    mult: f64,
767    max_lookback: usize,
768    direction: &str,
769    first: usize,
770    out: &mut [f64],
771) {
772    safezonestop_scalar(high, low, period, mult, max_lookback, direction, first, out)
773}
774
775#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
776#[inline(always)]
777pub unsafe fn safezonestop_avx2(
778    high: &[f64],
779    low: &[f64],
780    period: usize,
781    mult: f64,
782    max_lookback: usize,
783    direction: &str,
784    first: usize,
785    out: &mut [f64],
786) {
787    safezonestop_scalar(high, low, period, mult, max_lookback, direction, first, out)
788}
789
790#[derive(Debug, Clone)]
791pub struct SafeZoneStopStream {
792    period: usize,
793    mult: f64,
794    max_lookback: usize,
795    dir_long: bool,
796
797    inv_p: f64,
798    alpha: f64,
799    warm_i: usize,
800
801    i: usize,
802
803    have_prev: bool,
804    prev_high: f64,
805    prev_low: f64,
806
807    boot_n: usize,
808    boot_sum: f64,
809    dm_prev: f64,
810    dm_ready: bool,
811
812    cap: usize,
813    q_idx: Vec<usize>,
814    q_val: Vec<f64>,
815    q_head: usize,
816    q_tail: usize,
817    q_len: usize,
818}
819
820impl SafeZoneStopStream {
821    #[inline]
822    fn ring_inc(&self, x: usize) -> usize {
823        let y = x + 1;
824        if y == self.cap {
825            0
826        } else {
827            y
828        }
829    }
830    #[inline]
831    fn ring_dec(&self, x: usize) -> usize {
832        if x == 0 {
833            self.cap - 1
834        } else {
835            x - 1
836        }
837    }
838
839    pub fn try_new(params: SafeZoneStopParams, direction: &str) -> Result<Self, SafeZoneStopError> {
840        let period = params.period.unwrap_or(22);
841        let mult = params.mult.unwrap_or(2.5);
842        let max_lookback = params.max_lookback.unwrap_or(3);
843        if period == 0 {
844            return Err(SafeZoneStopError::InvalidPeriod {
845                period,
846                data_len: 0,
847            });
848        }
849        if direction != "long" && direction != "short" {
850            return Err(SafeZoneStopError::InvalidDirection);
851        }
852        let dir_long = direction.as_bytes()[0] == b'l';
853        let inv_p = 1.0 / (period as f64);
854        let alpha = 1.0 - inv_p;
855
856        let warm_i = period.max(max_lookback).saturating_sub(1);
857
858        let cap = max_lookback.max(1) + 1;
859        let q_idx = vec![0usize; cap];
860        let q_val = vec![0.0f64; cap];
861
862        Ok(Self {
863            period,
864            mult,
865            max_lookback,
866            dir_long,
867            inv_p,
868            alpha,
869            warm_i,
870            i: 0,
871            have_prev: false,
872            prev_high: f64::NAN,
873            prev_low: f64::NAN,
874            boot_n: 0,
875            boot_sum: 0.0,
876            dm_prev: f64::NAN,
877            dm_ready: false,
878            cap,
879            q_idx,
880            q_val,
881            q_head: 0,
882            q_tail: 0,
883            q_len: 0,
884        })
885    }
886
887    #[inline]
888    fn reset_on_nan(&mut self) {
889        self.have_prev = false;
890        self.boot_n = 0;
891        self.boot_sum = 0.0;
892        self.dm_prev = f64::NAN;
893        self.dm_ready = false;
894        self.q_head = 0;
895        self.q_tail = 0;
896        self.q_len = 0;
897    }
898
899    #[inline]
900    fn push_candidate(&mut self, j: usize, cand: f64) {
901        let start = j.saturating_add(1).saturating_sub(self.max_lookback);
902        while self.q_len > 0 {
903            let idx_front = self.q_idx[self.q_head];
904            if idx_front < start {
905                self.q_head = self.ring_inc(self.q_head);
906                self.q_len -= 1;
907            } else {
908                break;
909            }
910        }
911
912        if self.dir_long {
913            while self.q_len > 0 {
914                let last = self.ring_dec(self.q_tail);
915                if self.q_val[last] <= cand {
916                    self.q_tail = last;
917                    self.q_len -= 1;
918                } else {
919                    break;
920                }
921            }
922        } else {
923            while self.q_len > 0 {
924                let last = self.ring_dec(self.q_tail);
925                if self.q_val[last] >= cand {
926                    self.q_tail = last;
927                    self.q_len -= 1;
928                } else {
929                    break;
930                }
931            }
932        }
933
934        self.q_idx[self.q_tail] = j;
935        self.q_val[self.q_tail] = cand;
936        self.q_tail = self.ring_inc(self.q_tail);
937        self.q_len += 1;
938    }
939
940    #[inline]
941    pub fn update(&mut self, high: f64, low: f64) -> Option<f64> {
942        if !high.is_finite() || !low.is_finite() {
943            self.reset_on_nan();
944            return None;
945        }
946        if !self.have_prev {
947            self.prev_high = high;
948            self.prev_low = low;
949            self.have_prev = true;
950            self.i = 0;
951            return None;
952        }
953
954        let up = high - self.prev_high;
955        let dn = self.prev_low - low;
956        let up_pos = if up > 0.0 { up } else { 0.0 };
957        let dn_pos = if dn > 0.0 { dn } else { 0.0 };
958        let dm_raw = if self.dir_long {
959            if dn_pos > up_pos {
960                dn_pos
961            } else {
962                0.0
963            }
964        } else {
965            if up_pos > dn_pos {
966                up_pos
967            } else {
968                0.0
969            }
970        };
971
972        let j = self.i + 1;
973
974        if !self.dm_ready {
975            self.boot_n += 1;
976            self.boot_sum += dm_raw;
977            if self.boot_n == self.period {
978                self.dm_prev = self.boot_sum;
979                self.dm_ready = true;
980            }
981        } else {
982            self.dm_prev = self.alpha.mul_add(self.dm_prev, dm_raw);
983        }
984
985        if self.dm_ready {
986            let cand = if self.dir_long {
987                (-self.mult).mul_add(self.dm_prev, self.prev_low)
988            } else {
989                self.mult.mul_add(self.dm_prev, self.prev_high)
990            };
991            self.push_candidate(j, cand);
992        }
993
994        self.prev_high = high;
995        self.prev_low = low;
996        self.i = j;
997
998        if self.dm_ready && self.i >= self.warm_i && self.q_len > 0 {
999            Some(self.q_val[self.q_head])
1000        } else {
1001            None
1002        }
1003    }
1004}
1005
1006#[derive(Clone, Debug)]
1007pub struct SafeZoneStopBatchRange {
1008    pub period: (usize, usize, usize),
1009    pub mult: (f64, f64, f64),
1010    pub max_lookback: (usize, usize, usize),
1011}
1012
1013impl Default for SafeZoneStopBatchRange {
1014    fn default() -> Self {
1015        Self {
1016            period: (22, 271, 1),
1017            mult: (2.5, 2.5, 0.0),
1018            max_lookback: (3, 3, 0),
1019        }
1020    }
1021}
1022
1023#[derive(Clone, Debug)]
1024pub struct SafeZoneStopBatchBuilder {
1025    range: SafeZoneStopBatchRange,
1026    direction: &'static str,
1027    kernel: Kernel,
1028}
1029
1030impl Default for SafeZoneStopBatchBuilder {
1031    fn default() -> Self {
1032        Self {
1033            range: SafeZoneStopBatchRange::default(),
1034            direction: "long",
1035            kernel: Kernel::Auto,
1036        }
1037    }
1038}
1039
1040impl SafeZoneStopBatchBuilder {
1041    pub fn new() -> Self {
1042        Self::default()
1043    }
1044    pub fn kernel(mut self, k: Kernel) -> Self {
1045        self.kernel = k;
1046        self
1047    }
1048    #[inline]
1049    pub fn period_range(mut self, start: usize, end: usize, step: usize) -> Self {
1050        self.range.period = (start, end, step);
1051        self
1052    }
1053    #[inline]
1054    pub fn mult_range(mut self, start: f64, end: f64, step: f64) -> Self {
1055        self.range.mult = (start, end, step);
1056        self
1057    }
1058    #[inline]
1059    pub fn max_lookback_range(mut self, start: usize, end: usize, step: usize) -> Self {
1060        self.range.max_lookback = (start, end, step);
1061        self
1062    }
1063    #[inline]
1064    pub fn direction(mut self, d: &'static str) -> Self {
1065        self.direction = d;
1066        self
1067    }
1068    pub fn apply_slices(
1069        self,
1070        high: &[f64],
1071        low: &[f64],
1072    ) -> Result<SafeZoneStopBatchOutput, SafeZoneStopError> {
1073        safezonestop_batch_with_kernel(high, low, &self.range, self.direction, self.kernel)
1074    }
1075    pub fn with_default_slices(
1076        high: &[f64],
1077        low: &[f64],
1078        direction: &'static str,
1079        k: Kernel,
1080    ) -> Result<SafeZoneStopBatchOutput, SafeZoneStopError> {
1081        SafeZoneStopBatchBuilder::new()
1082            .kernel(k)
1083            .direction(direction)
1084            .apply_slices(high, low)
1085    }
1086}
1087
1088pub fn safezonestop_batch_with_kernel(
1089    high: &[f64],
1090    low: &[f64],
1091    sweep: &SafeZoneStopBatchRange,
1092    direction: &str,
1093    k: Kernel,
1094) -> Result<SafeZoneStopBatchOutput, SafeZoneStopError> {
1095    let kernel = match k {
1096        Kernel::Auto => Kernel::ScalarBatch,
1097        other if other.is_batch() => other,
1098        other => return Err(SafeZoneStopError::InvalidKernelForBatch(other)),
1099    };
1100    let simd = match kernel {
1101        Kernel::Avx512Batch => Kernel::Avx512,
1102        Kernel::Avx2Batch => Kernel::Avx2,
1103        Kernel::ScalarBatch => Kernel::Scalar,
1104        _ => unreachable!(),
1105    };
1106    safezonestop_batch_par_slice(high, low, sweep, direction, simd)
1107}
1108
1109#[derive(Clone, Debug)]
1110pub struct SafeZoneStopBatchOutput {
1111    pub values: Vec<f64>,
1112    pub combos: Vec<SafeZoneStopParams>,
1113    pub rows: usize,
1114    pub cols: usize,
1115}
1116impl SafeZoneStopBatchOutput {
1117    pub fn row_for_params(&self, p: &SafeZoneStopParams) -> Option<usize> {
1118        self.combos.iter().position(|c| {
1119            c.period.unwrap_or(22) == p.period.unwrap_or(22)
1120                && (c.mult.unwrap_or(2.5) - p.mult.unwrap_or(2.5)).abs() < 1e-12
1121                && c.max_lookback.unwrap_or(3) == p.max_lookback.unwrap_or(3)
1122        })
1123    }
1124    pub fn values_for(&self, p: &SafeZoneStopParams) -> Option<&[f64]> {
1125        self.row_for_params(p).map(|row| {
1126            let start = row * self.cols;
1127            &self.values[start..start + self.cols]
1128        })
1129    }
1130}
1131
1132#[inline(always)]
1133fn expand_grid(r: &SafeZoneStopBatchRange) -> Result<Vec<SafeZoneStopParams>, SafeZoneStopError> {
1134    fn axis_usize(
1135        (start, end, step): (usize, usize, usize),
1136    ) -> Result<Vec<usize>, SafeZoneStopError> {
1137        if step == 0 || start == end {
1138            return Ok(vec![start]);
1139        }
1140        let mut vals = Vec::new();
1141        if start < end {
1142            let mut x = start;
1143            while x <= end {
1144                vals.push(x);
1145                x = x.checked_add(step).ok_or(SafeZoneStopError::InvalidRange {
1146                    start: start as f64,
1147                    end: end as f64,
1148                    step: step as f64,
1149                })?;
1150            }
1151        } else {
1152            let mut x = start;
1153            while x >= end {
1154                vals.push(x);
1155                if x == end {
1156                    break;
1157                }
1158                x = x.checked_sub(step).ok_or(SafeZoneStopError::InvalidRange {
1159                    start: start as f64,
1160                    end: end as f64,
1161                    step: step as f64,
1162                })?;
1163            }
1164        }
1165        if vals.is_empty() {
1166            return Err(SafeZoneStopError::InvalidRange {
1167                start: start as f64,
1168                end: end as f64,
1169                step: step as f64,
1170            });
1171        }
1172        Ok(vals)
1173    }
1174    fn axis_f64((start, end, step): (f64, f64, f64)) -> Result<Vec<f64>, SafeZoneStopError> {
1175        if step.abs() < 1e-12 || (start - end).abs() < 1e-12 {
1176            return Ok(vec![start]);
1177        }
1178        let mut v = Vec::new();
1179        let mut x = start;
1180        if step > 0.0 {
1181            while x <= end + 1e-12 {
1182                v.push(x);
1183                x += step;
1184            }
1185        } else {
1186            while x >= end - 1e-12 {
1187                v.push(x);
1188                x += step;
1189            }
1190        }
1191        if v.is_empty() {
1192            return Err(SafeZoneStopError::InvalidRange { start, end, step });
1193        }
1194        Ok(v)
1195    }
1196    let periods = axis_usize(r.period)?;
1197    let mults = axis_f64(r.mult)?;
1198    let lookbacks = axis_usize(r.max_lookback)?;
1199    let cap = periods
1200        .len()
1201        .checked_mul(mults.len())
1202        .and_then(|v| v.checked_mul(lookbacks.len()))
1203        .ok_or(SafeZoneStopError::InvalidRange {
1204            start: r.period.0 as f64,
1205            end: r.period.1 as f64,
1206            step: r.period.2 as f64,
1207        })?;
1208    let mut out = Vec::with_capacity(cap);
1209    for &p in &periods {
1210        for &m in &mults {
1211            for &l in &lookbacks {
1212                out.push(SafeZoneStopParams {
1213                    period: Some(p),
1214                    mult: Some(m),
1215                    max_lookback: Some(l),
1216                });
1217            }
1218        }
1219    }
1220    Ok(out)
1221}
1222
1223#[inline(always)]
1224pub fn safezonestop_batch_slice(
1225    high: &[f64],
1226    low: &[f64],
1227    sweep: &SafeZoneStopBatchRange,
1228    direction: &str,
1229    kern: Kernel,
1230) -> Result<SafeZoneStopBatchOutput, SafeZoneStopError> {
1231    safezonestop_batch_inner(high, low, sweep, direction, kern, false)
1232}
1233
1234#[inline(always)]
1235pub fn safezonestop_batch_par_slice(
1236    high: &[f64],
1237    low: &[f64],
1238    sweep: &SafeZoneStopBatchRange,
1239    direction: &str,
1240    kern: Kernel,
1241) -> Result<SafeZoneStopBatchOutput, SafeZoneStopError> {
1242    safezonestop_batch_inner(high, low, sweep, direction, kern, true)
1243}
1244
1245#[inline(always)]
1246fn safezonestop_batch_inner(
1247    high: &[f64],
1248    low: &[f64],
1249    sweep: &SafeZoneStopBatchRange,
1250    direction: &str,
1251    kern: Kernel,
1252    parallel: bool,
1253) -> Result<SafeZoneStopBatchOutput, SafeZoneStopError> {
1254    let combos = expand_grid(sweep)?;
1255    if direction != "long" && direction != "short" {
1256        return Err(SafeZoneStopError::InvalidDirection);
1257    }
1258    if high.len() != low.len() {
1259        return Err(SafeZoneStopError::MismatchedLengths);
1260    }
1261    let len = high.len();
1262    if len == 0 {
1263        return Err(SafeZoneStopError::EmptyInputData);
1264    }
1265    for c in combos.iter() {
1266        let p = c.period.unwrap();
1267        if p == 0 || p > len {
1268            return Err(SafeZoneStopError::InvalidPeriod {
1269                period: p,
1270                data_len: len,
1271            });
1272        }
1273    }
1274    let first = first_valid_pair(high, low).ok_or(SafeZoneStopError::AllValuesNaN)?;
1275    let max_need = combos
1276        .iter()
1277        .map(|c| (c.period.unwrap() + 1).max(c.max_lookback.unwrap()))
1278        .max()
1279        .unwrap();
1280    if len - first < max_need {
1281        return Err(SafeZoneStopError::NotEnoughValidData {
1282            needed: max_need,
1283            valid: len - first,
1284        });
1285    }
1286    let rows = combos.len();
1287    let cols = len;
1288
1289    let mut buf_uninit = make_uninit_matrix(rows, cols);
1290    let warm_prefixes: Vec<usize> = combos
1291        .iter()
1292        .map(|c| warm_len(first, c.period.unwrap(), c.max_lookback.unwrap()))
1293        .collect();
1294    init_matrix_prefixes(&mut buf_uninit, cols, &warm_prefixes);
1295
1296    let mut guard = core::mem::ManuallyDrop::new(buf_uninit);
1297    let values_slice: &mut [f64] =
1298        unsafe { core::slice::from_raw_parts_mut(guard.as_mut_ptr() as *mut f64, guard.len()) };
1299
1300    let dir_long = direction
1301        .as_bytes()
1302        .get(0)
1303        .map(|&b| b == b'l')
1304        .unwrap_or(true);
1305    let mut dm_raw = vec![0.0f64; len];
1306    {
1307        let mut prev_h = unsafe { *high.get_unchecked(first) };
1308        let mut prev_l = unsafe { *low.get_unchecked(first) };
1309        for i in (first + 1)..len {
1310            let h = unsafe { *high.get_unchecked(i) };
1311            let l = unsafe { *low.get_unchecked(i) };
1312            let up = h - prev_h;
1313            let dn = prev_l - l;
1314            let up_pos = if up > 0.0 { up } else { 0.0 };
1315            let dn_pos = if dn > 0.0 { dn } else { 0.0 };
1316            let v = if dir_long {
1317                if dn_pos > up_pos {
1318                    dn_pos
1319                } else {
1320                    0.0
1321                }
1322            } else {
1323                if up_pos > dn_pos {
1324                    up_pos
1325                } else {
1326                    0.0
1327                }
1328            };
1329            dm_raw[i] = v;
1330            prev_h = h;
1331            prev_l = l;
1332        }
1333    }
1334
1335    let do_row = |row: usize, out_row: &mut [f64]| unsafe {
1336        let p = combos[row].period.unwrap();
1337        let m = combos[row].mult.unwrap();
1338        let lb = combos[row].max_lookback.unwrap();
1339        match kern {
1340            Kernel::Scalar => safezonestop_row_scalar_with_dmraw(
1341                high, low, p, m, lb, dir_long, first, &dm_raw, out_row,
1342            ),
1343            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1344            Kernel::Avx2 => safezonestop_row_avx2(high, low, p, m, lb, direction, first, out_row),
1345            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1346            Kernel::Avx512 => {
1347                safezonestop_row_avx512(high, low, p, m, lb, direction, first, out_row)
1348            }
1349            _ => unreachable!(),
1350        }
1351    };
1352
1353    if parallel {
1354        #[cfg(not(target_arch = "wasm32"))]
1355        {
1356            values_slice
1357                .par_chunks_mut(cols)
1358                .enumerate()
1359                .for_each(|(row, slice)| do_row(row, slice));
1360        }
1361
1362        #[cfg(target_arch = "wasm32")]
1363        {
1364            for (row, slice) in values_slice.chunks_mut(cols).enumerate() {
1365                do_row(row, slice);
1366            }
1367        }
1368    } else {
1369        for (row, slice) in values_slice.chunks_mut(cols).enumerate() {
1370            do_row(row, slice);
1371        }
1372    }
1373
1374    let values = unsafe {
1375        Vec::from_raw_parts(
1376            guard.as_mut_ptr() as *mut f64,
1377            guard.len(),
1378            guard.capacity(),
1379        )
1380    };
1381
1382    Ok(SafeZoneStopBatchOutput {
1383        values,
1384        combos,
1385        rows,
1386        cols,
1387    })
1388}
1389
1390#[inline(always)]
1391pub fn safezonestop_batch_inner_into(
1392    high: &[f64],
1393    low: &[f64],
1394    sweep: &SafeZoneStopBatchRange,
1395    direction: &str,
1396    kern: Kernel,
1397    parallel: bool,
1398    out: &mut [f64],
1399) -> Result<Vec<SafeZoneStopParams>, SafeZoneStopError> {
1400    let combos = expand_grid(sweep)?;
1401    if direction != "long" && direction != "short" {
1402        return Err(SafeZoneStopError::InvalidDirection);
1403    }
1404    if high.len() != low.len() {
1405        return Err(SafeZoneStopError::MismatchedLengths);
1406    }
1407    let len = high.len();
1408    if len == 0 {
1409        return Err(SafeZoneStopError::EmptyInputData);
1410    }
1411    for c in combos.iter() {
1412        let p = c.period.unwrap();
1413        if p == 0 || p > len {
1414            return Err(SafeZoneStopError::InvalidPeriod {
1415                period: p,
1416                data_len: len,
1417            });
1418        }
1419    }
1420    let first = first_valid_pair(high, low).ok_or(SafeZoneStopError::AllValuesNaN)?;
1421    let max_need = combos
1422        .iter()
1423        .map(|c| (c.period.unwrap() + 1).max(c.max_lookback.unwrap()))
1424        .max()
1425        .unwrap();
1426    if len - first < max_need {
1427        return Err(SafeZoneStopError::NotEnoughValidData {
1428            needed: max_need,
1429            valid: len - first,
1430        });
1431    }
1432    let rows = combos.len();
1433    let cols = len;
1434    let expected = rows
1435        .checked_mul(cols)
1436        .ok_or(SafeZoneStopError::InvalidRange {
1437            start: sweep.period.0 as f64,
1438            end: sweep.period.1 as f64,
1439            step: sweep.period.2 as f64,
1440        })?;
1441    if out.len() != expected {
1442        return Err(SafeZoneStopError::OutputLengthMismatch {
1443            expected,
1444            got: out.len(),
1445        });
1446    }
1447
1448    let out_uninit = unsafe {
1449        core::slice::from_raw_parts_mut(
1450            out.as_mut_ptr() as *mut core::mem::MaybeUninit<f64>,
1451            out.len(),
1452        )
1453    };
1454    let warm_prefixes: Vec<usize> = combos
1455        .iter()
1456        .map(|c| warm_len(first, c.period.unwrap(), c.max_lookback.unwrap()))
1457        .collect();
1458    init_matrix_prefixes(out_uninit, cols, &warm_prefixes);
1459
1460    let dir_long = direction
1461        .as_bytes()
1462        .get(0)
1463        .map(|&b| b == b'l')
1464        .unwrap_or(true);
1465    let mut dm_raw = vec![0.0f64; len];
1466    {
1467        let mut prev_h = unsafe { *high.get_unchecked(first) };
1468        let mut prev_l = unsafe { *low.get_unchecked(first) };
1469        for i in (first + 1)..len {
1470            let h = unsafe { *high.get_unchecked(i) };
1471            let l = unsafe { *low.get_unchecked(i) };
1472            let up = h - prev_h;
1473            let dn = prev_l - l;
1474            let up_pos = if up > 0.0 { up } else { 0.0 };
1475            let dn_pos = if dn > 0.0 { dn } else { 0.0 };
1476            let v = if dir_long {
1477                if dn_pos > up_pos {
1478                    dn_pos
1479                } else {
1480                    0.0
1481                }
1482            } else {
1483                if up_pos > dn_pos {
1484                    up_pos
1485                } else {
1486                    0.0
1487                }
1488            };
1489            dm_raw[i] = v;
1490            prev_h = h;
1491            prev_l = l;
1492        }
1493    }
1494
1495    let do_row = |row: usize, out_row_mu: &mut [core::mem::MaybeUninit<f64>]| unsafe {
1496        let out_row =
1497            core::slice::from_raw_parts_mut(out_row_mu.as_mut_ptr() as *mut f64, out_row_mu.len());
1498        let p = combos[row].period.unwrap();
1499        let m = combos[row].mult.unwrap();
1500        let lb = combos[row].max_lookback.unwrap();
1501        match kern {
1502            Kernel::Scalar => safezonestop_row_scalar_with_dmraw(
1503                high, low, p, m, lb, dir_long, first, &dm_raw, out_row,
1504            ),
1505            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1506            Kernel::Avx2 => safezonestop_row_avx2(high, low, p, m, lb, direction, first, out_row),
1507            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1508            Kernel::Avx512 => {
1509                safezonestop_row_avx512(high, low, p, m, lb, direction, first, out_row)
1510            }
1511            _ => unreachable!(),
1512        }
1513    };
1514
1515    if parallel {
1516        #[cfg(not(target_arch = "wasm32"))]
1517        {
1518            out_uninit
1519                .par_chunks_mut(cols)
1520                .enumerate()
1521                .for_each(|(row, slice)| do_row(row, slice));
1522        }
1523
1524        #[cfg(target_arch = "wasm32")]
1525        {
1526            for (row, slice) in out_uninit.chunks_mut(cols).enumerate() {
1527                do_row(row, slice);
1528            }
1529        }
1530    } else {
1531        for (row, slice) in out_uninit.chunks_mut(cols).enumerate() {
1532            do_row(row, slice);
1533        }
1534    }
1535
1536    Ok(combos)
1537}
1538
1539pub unsafe fn safezonestop_row_scalar(
1540    high: &[f64],
1541    low: &[f64],
1542    period: usize,
1543    mult: f64,
1544    max_lookback: usize,
1545    direction: &str,
1546    first: usize,
1547    out: &mut [f64],
1548) {
1549    safezonestop_scalar(high, low, period, mult, max_lookback, direction, first, out)
1550}
1551
1552#[inline(always)]
1553unsafe fn safezonestop_row_scalar_with_dmraw(
1554    high: &[f64],
1555    low: &[f64],
1556    period: usize,
1557    mult: f64,
1558    max_lookback: usize,
1559    dir_long: bool,
1560    first: usize,
1561    dm_raw: &[f64],
1562    out: &mut [f64],
1563) {
1564    let len = high.len();
1565    if len == 0 {
1566        return;
1567    }
1568
1569    let warm = first + period.max(max_lookback) - 1;
1570    let warm_end = warm.min(len);
1571    for k in 0..warm_end {
1572        *out.get_unchecked_mut(k) = f64::NAN;
1573    }
1574    if warm >= len {
1575        return;
1576    }
1577
1578    let end0 = first + period;
1579    if end0 < len {
1580        const LB_DEQUE_THRESHOLD: usize = 32;
1581        if max_lookback > LB_DEQUE_THRESHOLD {
1582            #[inline(always)]
1583            fn ring_dec(x: usize, cap: usize) -> usize {
1584                if x == 0 {
1585                    cap - 1
1586                } else {
1587                    x - 1
1588                }
1589            }
1590            #[inline(always)]
1591            fn ring_inc(x: usize, cap: usize) -> usize {
1592                let y = x + 1;
1593                if y == cap {
1594                    0
1595                } else {
1596                    y
1597                }
1598            }
1599            let cap = max_lookback.max(1) + 1;
1600            let mut q_idx = vec![0usize; cap];
1601            let mut q_val = vec![0.0f64; cap];
1602            let mut q_head: usize = 0;
1603            let mut q_tail: usize = 0;
1604            let mut q_len: usize = 0;
1605
1606            let mut boot_sum = 0.0;
1607            for i in (first + 1)..=end0 {
1608                boot_sum += *dm_raw.get_unchecked(i);
1609            }
1610            let mut dm_prev = boot_sum;
1611            let alpha = 1.0 - 1.0 / (period as f64);
1612
1613            let cand0 = if dir_long {
1614                (-mult).mul_add(dm_prev, *low.get_unchecked(end0 - 1))
1615            } else {
1616                mult.mul_add(dm_prev, *high.get_unchecked(end0 - 1))
1617            };
1618            *q_idx.get_unchecked_mut(q_tail) = end0;
1619            *q_val.get_unchecked_mut(q_tail) = cand0;
1620            q_tail = ring_inc(q_tail, cap);
1621            q_len = 1;
1622            if end0 >= warm {
1623                *out.get_unchecked_mut(end0) = cand0;
1624            }
1625
1626            for i in (end0 + 1)..len {
1627                dm_prev = alpha.mul_add(dm_prev, *dm_raw.get_unchecked(i));
1628
1629                let cand = if dir_long {
1630                    (-mult).mul_add(dm_prev, *low.get_unchecked(i - 1))
1631                } else {
1632                    mult.mul_add(dm_prev, *high.get_unchecked(i - 1))
1633                };
1634
1635                let start = i.saturating_add(1).saturating_sub(max_lookback);
1636                while q_len > 0 {
1637                    let idx_front = *q_idx.get_unchecked(q_head);
1638                    if idx_front < start {
1639                        q_head = ring_inc(q_head, cap);
1640                        q_len -= 1;
1641                    } else {
1642                        break;
1643                    }
1644                }
1645                while q_len > 0 {
1646                    let last = ring_dec(q_tail, cap);
1647                    let back_val = *q_val.get_unchecked(last);
1648                    let pop = if dir_long {
1649                        back_val <= cand
1650                    } else {
1651                        back_val >= cand
1652                    };
1653                    if pop {
1654                        q_tail = last;
1655                        q_len -= 1;
1656                    } else {
1657                        break;
1658                    }
1659                }
1660                *q_idx.get_unchecked_mut(q_tail) = i;
1661                *q_val.get_unchecked_mut(q_tail) = cand;
1662                q_tail = ring_inc(q_tail, cap);
1663                q_len += 1;
1664
1665                if i >= warm {
1666                    *out.get_unchecked_mut(i) = if q_len > 0 {
1667                        *q_val.get_unchecked(q_head)
1668                    } else {
1669                        f64::NAN
1670                    };
1671                }
1672            }
1673        } else {
1674            let mut dm_smooth = vec![0.0f64; len];
1675            let mut sum = 0.0;
1676            for i in (first + 1)..=end0 {
1677                sum += *dm_raw.get_unchecked(i);
1678            }
1679            *dm_smooth.get_unchecked_mut(end0) = sum;
1680            let alpha = 1.0 - 1.0 / (period as f64);
1681            for i in (end0 + 1)..len {
1682                let prev = *dm_smooth.get_unchecked(i - 1);
1683                *dm_smooth.get_unchecked_mut(i) = alpha.mul_add(prev, *dm_raw.get_unchecked(i));
1684            }
1685            if dir_long {
1686                for i in warm..len {
1687                    let start_idx = i + 1 - max_lookback;
1688                    let j0 = start_idx.max(end0);
1689                    if j0 > i {
1690                        *out.get_unchecked_mut(i) = f64::NAN;
1691                        continue;
1692                    }
1693                    let mut mx = f64::NEG_INFINITY;
1694                    for j in j0..=i {
1695                        let val =
1696                            (-mult).mul_add(*dm_smooth.get_unchecked(j), *low.get_unchecked(j - 1));
1697                        if val > mx {
1698                            mx = val;
1699                        }
1700                    }
1701                    *out.get_unchecked_mut(i) = mx;
1702                }
1703            } else {
1704                for i in warm..len {
1705                    let start_idx = i + 1 - max_lookback;
1706                    let j0 = start_idx.max(end0);
1707                    if j0 > i {
1708                        *out.get_unchecked_mut(i) = f64::NAN;
1709                        continue;
1710                    }
1711                    let mut mn = f64::INFINITY;
1712                    for j in j0..=i {
1713                        let val =
1714                            mult.mul_add(*dm_smooth.get_unchecked(j), *high.get_unchecked(j - 1));
1715                        if val < mn {
1716                            mn = val;
1717                        }
1718                    }
1719                    *out.get_unchecked_mut(i) = mn;
1720                }
1721            }
1722        }
1723    }
1724}
1725
1726#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1727#[inline(always)]
1728pub unsafe fn safezonestop_row_avx2(
1729    high: &[f64],
1730    low: &[f64],
1731    period: usize,
1732    mult: f64,
1733    max_lookback: usize,
1734    direction: &str,
1735    first: usize,
1736    out: &mut [f64],
1737) {
1738    safezonestop_scalar(high, low, period, mult, max_lookback, direction, first, out)
1739}
1740
1741#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1742#[inline(always)]
1743pub unsafe fn safezonestop_row_avx512(
1744    high: &[f64],
1745    low: &[f64],
1746    period: usize,
1747    mult: f64,
1748    max_lookback: usize,
1749    direction: &str,
1750    first: usize,
1751    out: &mut [f64],
1752) {
1753    if period <= 32 {
1754        safezonestop_row_avx512_short(high, low, period, mult, max_lookback, direction, first, out)
1755    } else {
1756        safezonestop_row_avx512_long(high, low, period, mult, max_lookback, direction, first, out)
1757    }
1758}
1759
1760#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1761#[inline(always)]
1762pub unsafe fn safezonestop_row_avx512_short(
1763    high: &[f64],
1764    low: &[f64],
1765    period: usize,
1766    mult: f64,
1767    max_lookback: usize,
1768    direction: &str,
1769    first: usize,
1770    out: &mut [f64],
1771) {
1772    safezonestop_scalar(high, low, period, mult, max_lookback, direction, first, out)
1773}
1774
1775#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1776#[inline(always)]
1777pub unsafe fn safezonestop_row_avx512_long(
1778    high: &[f64],
1779    low: &[f64],
1780    period: usize,
1781    mult: f64,
1782    max_lookback: usize,
1783    direction: &str,
1784    first: usize,
1785    out: &mut [f64],
1786) {
1787    safezonestop_scalar(high, low, period, mult, max_lookback, direction, first, out)
1788}
1789
1790#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1791#[wasm_bindgen]
1792pub fn safezonestop_js(
1793    high: &[f64],
1794    low: &[f64],
1795    period: usize,
1796    mult: f64,
1797    max_lookback: usize,
1798    direction: &str,
1799) -> Result<Vec<f64>, JsValue> {
1800    let params = SafeZoneStopParams {
1801        period: Some(period),
1802        mult: Some(mult),
1803        max_lookback: Some(max_lookback),
1804    };
1805    let input = SafeZoneStopInput::from_slices(high, low, direction, params);
1806
1807    let mut output = vec![0.0; high.len()];
1808
1809    safezonestop_into_slice(&mut output, &input, Kernel::Auto)
1810        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1811
1812    Ok(output)
1813}
1814
1815#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1816#[wasm_bindgen]
1817pub fn safezonestop_into(
1818    high_ptr: *const f64,
1819    low_ptr: *const f64,
1820    out_ptr: *mut f64,
1821    len: usize,
1822    period: usize,
1823    mult: f64,
1824    max_lookback: usize,
1825    direction: &str,
1826) -> Result<(), JsValue> {
1827    if high_ptr.is_null() || low_ptr.is_null() || out_ptr.is_null() {
1828        return Err(JsValue::from_str("Null pointer provided"));
1829    }
1830
1831    unsafe {
1832        let high = std::slice::from_raw_parts(high_ptr, len);
1833        let low = std::slice::from_raw_parts(low_ptr, len);
1834
1835        let params = SafeZoneStopParams {
1836            period: Some(period),
1837            mult: Some(mult),
1838            max_lookback: Some(max_lookback),
1839        };
1840        let input = SafeZoneStopInput::from_slices(high, low, direction, params);
1841
1842        if high_ptr == out_ptr || low_ptr == out_ptr {
1843            let mut temp = vec![0.0; len];
1844            safezonestop_into_slice(&mut temp, &input, Kernel::Auto)
1845                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1846            let out = std::slice::from_raw_parts_mut(out_ptr, len);
1847            out.copy_from_slice(&temp);
1848        } else {
1849            let out = std::slice::from_raw_parts_mut(out_ptr, len);
1850            safezonestop_into_slice(out, &input, Kernel::Auto)
1851                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1852        }
1853        Ok(())
1854    }
1855}
1856
1857#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1858#[wasm_bindgen]
1859pub fn safezonestop_alloc(len: usize) -> *mut f64 {
1860    let mut vec = Vec::<f64>::with_capacity(len);
1861    let ptr = vec.as_mut_ptr();
1862    std::mem::forget(vec);
1863    ptr
1864}
1865
1866#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1867#[wasm_bindgen]
1868pub fn safezonestop_free(ptr: *mut f64, len: usize) {
1869    if !ptr.is_null() {
1870        unsafe {
1871            let _ = Vec::from_raw_parts(ptr, len, len);
1872        }
1873    }
1874}
1875
1876#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1877#[derive(Serialize, Deserialize)]
1878pub struct SafeZoneStopBatchConfig {
1879    pub period_range: (usize, usize, usize),
1880    pub mult_range: (f64, f64, f64),
1881    pub max_lookback_range: (usize, usize, usize),
1882    pub direction: String,
1883}
1884
1885#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1886#[derive(Serialize, Deserialize)]
1887pub struct SafeZoneStopBatchJsOutput {
1888    pub values: Vec<f64>,
1889    pub combos: Vec<SafeZoneStopParams>,
1890    pub rows: usize,
1891    pub cols: usize,
1892}
1893
1894#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1895#[wasm_bindgen(js_name = safezonestop_batch)]
1896pub fn safezonestop_batch_unified_js(
1897    high: &[f64],
1898    low: &[f64],
1899    config: JsValue,
1900) -> Result<JsValue, JsValue> {
1901    let config: SafeZoneStopBatchConfig = serde_wasm_bindgen::from_value(config)
1902        .map_err(|e| JsValue::from_str(&format!("Invalid config: {}", e)))?;
1903
1904    let sweep = SafeZoneStopBatchRange {
1905        period: config.period_range,
1906        mult: config.mult_range,
1907        max_lookback: config.max_lookback_range,
1908    };
1909
1910    let row_kernel = match detect_best_batch_kernel() {
1911        Kernel::Avx512Batch => Kernel::Avx512,
1912        Kernel::Avx2Batch => Kernel::Avx2,
1913        _ => Kernel::Scalar,
1914    };
1915
1916    let output = safezonestop_batch_inner(high, low, &sweep, &config.direction, row_kernel, false)
1917        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1918
1919    let js_output = SafeZoneStopBatchJsOutput {
1920        values: output.values,
1921        combos: output.combos,
1922        rows: output.rows,
1923        cols: output.cols,
1924    };
1925
1926    serde_wasm_bindgen::to_value(&js_output)
1927        .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
1928}
1929
1930#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1931#[wasm_bindgen]
1932pub fn safezonestop_batch_into(
1933    high_ptr: *const f64,
1934    low_ptr: *const f64,
1935    out_ptr: *mut f64,
1936    len: usize,
1937    period_start: usize,
1938    period_end: usize,
1939    period_step: usize,
1940    mult_start: f64,
1941    mult_end: f64,
1942    mult_step: f64,
1943    max_lookback_start: usize,
1944    max_lookback_end: usize,
1945    max_lookback_step: usize,
1946    direction: &str,
1947) -> Result<usize, JsValue> {
1948    if high_ptr.is_null() || low_ptr.is_null() || out_ptr.is_null() {
1949        return Err(JsValue::from_str(
1950            "null pointer passed to safezonestop_batch_into",
1951        ));
1952    }
1953
1954    unsafe {
1955        let high = std::slice::from_raw_parts(high_ptr, len);
1956        let low = std::slice::from_raw_parts(low_ptr, len);
1957
1958        let sweep = SafeZoneStopBatchRange {
1959            period: (period_start, period_end, period_step),
1960            mult: (mult_start, mult_end, mult_step),
1961            max_lookback: (max_lookback_start, max_lookback_end, max_lookback_step),
1962        };
1963
1964        let combos = expand_grid(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
1965        let total_size = combos
1966            .len()
1967            .checked_mul(len)
1968            .ok_or_else(|| JsValue::from_str("safezonestop_batch_into: rows * cols overflow"))?;
1969        let out = std::slice::from_raw_parts_mut(out_ptr, total_size);
1970
1971        let row_kernel = match detect_best_batch_kernel() {
1972            Kernel::Avx512Batch => Kernel::Avx512,
1973            Kernel::Avx2Batch => Kernel::Avx2,
1974            _ => Kernel::Scalar,
1975        };
1976
1977        safezonestop_batch_inner_into(high, low, &sweep, direction, row_kernel, false, out)
1978            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1979
1980        Ok(combos.len())
1981    }
1982}
1983
1984#[cfg(test)]
1985mod tests {
1986    use super::*;
1987    use crate::skip_if_unsupported;
1988    use crate::utilities::data_loader::read_candles_from_csv;
1989
1990    fn check_safezonestop_partial_params(
1991        test_name: &str,
1992        kernel: Kernel,
1993    ) -> Result<(), Box<dyn Error>> {
1994        skip_if_unsupported!(kernel, test_name);
1995        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1996        let candles = read_candles_from_csv(file_path)?;
1997        let params = SafeZoneStopParams {
1998            period: Some(14),
1999            mult: None,
2000            max_lookback: None,
2001        };
2002        let input = SafeZoneStopInput::from_candles(&candles, "short", params);
2003        let output = safezonestop_with_kernel(&input, kernel)?;
2004        assert_eq!(output.values.len(), candles.close.len());
2005        Ok(())
2006    }
2007
2008    fn check_safezonestop_accuracy(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
2009        skip_if_unsupported!(kernel, test_name);
2010        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2011        let candles = read_candles_from_csv(file_path)?;
2012        let params = SafeZoneStopParams {
2013            period: Some(22),
2014            mult: Some(2.5),
2015            max_lookback: Some(3),
2016        };
2017        let input = SafeZoneStopInput::from_candles(&candles, "long", params);
2018        let output = safezonestop_with_kernel(&input, kernel)?;
2019        let expected = [
2020            45331.180007991,
2021            45712.94455308232,
2022            46019.94707339676,
2023            46461.767660969635,
2024            46461.767660969635,
2025        ];
2026        let start = output.values.len().saturating_sub(5);
2027        for (i, &val) in output.values[start..].iter().enumerate() {
2028            let diff = (val - expected[i]).abs();
2029            assert!(
2030                diff < 1e-4,
2031                "[{}] SafeZoneStop {:?} mismatch at idx {}: got {}, expected {}",
2032                test_name,
2033                kernel,
2034                i,
2035                val,
2036                expected[i]
2037            );
2038        }
2039        Ok(())
2040    }
2041
2042    fn check_safezonestop_default_candles(
2043        test_name: &str,
2044        kernel: Kernel,
2045    ) -> Result<(), Box<dyn Error>> {
2046        skip_if_unsupported!(kernel, test_name);
2047        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2048        let candles = read_candles_from_csv(file_path)?;
2049        let input = SafeZoneStopInput::with_default_candles(&candles);
2050        let output = safezonestop_with_kernel(&input, kernel)?;
2051        assert_eq!(output.values.len(), candles.close.len());
2052        Ok(())
2053    }
2054
2055    fn check_safezonestop_zero_period(
2056        test_name: &str,
2057        kernel: Kernel,
2058    ) -> Result<(), Box<dyn Error>> {
2059        skip_if_unsupported!(kernel, test_name);
2060        let high = [10.0, 20.0, 30.0];
2061        let low = [5.0, 15.0, 25.0];
2062        let params = SafeZoneStopParams {
2063            period: Some(0),
2064            mult: Some(2.5),
2065            max_lookback: Some(3),
2066        };
2067        let input = SafeZoneStopInput::from_slices(&high, &low, "long", params);
2068        let res = safezonestop_with_kernel(&input, kernel);
2069        assert!(
2070            res.is_err(),
2071            "[{}] SafeZoneStop should fail with zero period",
2072            test_name
2073        );
2074        Ok(())
2075    }
2076
2077    fn check_safezonestop_mismatched_lengths(
2078        test_name: &str,
2079        kernel: Kernel,
2080    ) -> Result<(), Box<dyn Error>> {
2081        skip_if_unsupported!(kernel, test_name);
2082        let high = [10.0, 20.0, 30.0];
2083        let low = [5.0, 15.0];
2084        let params = SafeZoneStopParams::default();
2085        let input = SafeZoneStopInput::from_slices(&high, &low, "long", params);
2086        let res = safezonestop_with_kernel(&input, kernel);
2087        assert!(
2088            res.is_err(),
2089            "[{}] SafeZoneStop should fail with mismatched lengths",
2090            test_name
2091        );
2092        Ok(())
2093    }
2094
2095    fn check_safezonestop_nan_handling(
2096        test_name: &str,
2097        kernel: Kernel,
2098    ) -> Result<(), Box<dyn Error>> {
2099        skip_if_unsupported!(kernel, test_name);
2100        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2101        let candles = read_candles_from_csv(file_path)?;
2102        let input = SafeZoneStopInput::with_default_candles(&candles);
2103        let res = safezonestop_with_kernel(&input, kernel)?;
2104        assert_eq!(res.values.len(), candles.close.len());
2105        if res.values.len() > 240 {
2106            for (i, &val) in res.values[240..].iter().enumerate() {
2107                assert!(
2108                    !val.is_nan(),
2109                    "[{}] Found unexpected NaN at out-index {}",
2110                    test_name,
2111                    240 + i
2112                );
2113            }
2114        }
2115        Ok(())
2116    }
2117
2118    #[cfg(debug_assertions)]
2119    fn check_safezonestop_no_poison(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
2120        skip_if_unsupported!(kernel, test_name);
2121
2122        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2123        let candles = read_candles_from_csv(file_path)?;
2124
2125        let test_params = vec![
2126            (SafeZoneStopParams::default(), "long"),
2127            (SafeZoneStopParams::default(), "short"),
2128            (
2129                SafeZoneStopParams {
2130                    period: Some(2),
2131                    mult: Some(2.5),
2132                    max_lookback: Some(3),
2133                },
2134                "long",
2135            ),
2136            (
2137                SafeZoneStopParams {
2138                    period: Some(5),
2139                    mult: Some(1.0),
2140                    max_lookback: Some(2),
2141                },
2142                "long",
2143            ),
2144            (
2145                SafeZoneStopParams {
2146                    period: Some(5),
2147                    mult: Some(2.5),
2148                    max_lookback: Some(3),
2149                },
2150                "short",
2151            ),
2152            (
2153                SafeZoneStopParams {
2154                    period: Some(10),
2155                    mult: Some(3.0),
2156                    max_lookback: Some(5),
2157                },
2158                "long",
2159            ),
2160            (
2161                SafeZoneStopParams {
2162                    period: Some(14),
2163                    mult: Some(2.0),
2164                    max_lookback: Some(4),
2165                },
2166                "short",
2167            ),
2168            (
2169                SafeZoneStopParams {
2170                    period: Some(22),
2171                    mult: Some(1.5),
2172                    max_lookback: Some(2),
2173                },
2174                "long",
2175            ),
2176            (
2177                SafeZoneStopParams {
2178                    period: Some(22),
2179                    mult: Some(5.0),
2180                    max_lookback: Some(10),
2181                },
2182                "short",
2183            ),
2184            (
2185                SafeZoneStopParams {
2186                    period: Some(50),
2187                    mult: Some(2.5),
2188                    max_lookback: Some(5),
2189                },
2190                "long",
2191            ),
2192            (
2193                SafeZoneStopParams {
2194                    period: Some(100),
2195                    mult: Some(3.0),
2196                    max_lookback: Some(10),
2197                },
2198                "short",
2199            ),
2200            (
2201                SafeZoneStopParams {
2202                    period: Some(2),
2203                    mult: Some(0.5),
2204                    max_lookback: Some(1),
2205                },
2206                "long",
2207            ),
2208            (
2209                SafeZoneStopParams {
2210                    period: Some(30),
2211                    mult: Some(10.0),
2212                    max_lookback: Some(15),
2213                },
2214                "short",
2215            ),
2216        ];
2217
2218        for (param_idx, (params, direction)) in test_params.iter().enumerate() {
2219            let input = SafeZoneStopInput::from_candles(&candles, direction, params.clone());
2220            let output = safezonestop_with_kernel(&input, kernel)?;
2221
2222            for (i, &val) in output.values.iter().enumerate() {
2223                if val.is_nan() {
2224                    continue;
2225                }
2226
2227                let bits = val.to_bits();
2228
2229                if bits == 0x11111111_11111111 {
2230                    panic!(
2231                        "[{}] Found alloc_with_nan_prefix poison value {} (0x{:016X}) at index {} \
2232						 with params: period={}, mult={}, max_lookback={}, direction='{}' (param set {})",
2233                        test_name,
2234                        val,
2235                        bits,
2236                        i,
2237                        params.period.unwrap_or(22),
2238                        params.mult.unwrap_or(2.5),
2239                        params.max_lookback.unwrap_or(3),
2240                        direction,
2241                        param_idx
2242                    );
2243                }
2244
2245                if bits == 0x22222222_22222222 {
2246                    panic!(
2247                        "[{}] Found init_matrix_prefixes poison value {} (0x{:016X}) at index {} \
2248						 with params: period={}, mult={}, max_lookback={}, direction='{}' (param set {})",
2249                        test_name,
2250                        val,
2251                        bits,
2252                        i,
2253                        params.period.unwrap_or(22),
2254                        params.mult.unwrap_or(2.5),
2255                        params.max_lookback.unwrap_or(3),
2256                        direction,
2257                        param_idx
2258                    );
2259                }
2260
2261                if bits == 0x33333333_33333333 {
2262                    panic!(
2263                        "[{}] Found make_uninit_matrix poison value {} (0x{:016X}) at index {} \
2264						 with params: period={}, mult={}, max_lookback={}, direction='{}' (param set {})",
2265                        test_name,
2266                        val,
2267                        bits,
2268                        i,
2269                        params.period.unwrap_or(22),
2270                        params.mult.unwrap_or(2.5),
2271                        params.max_lookback.unwrap_or(3),
2272                        direction,
2273                        param_idx
2274                    );
2275                }
2276            }
2277        }
2278
2279        Ok(())
2280    }
2281
2282    #[cfg(not(debug_assertions))]
2283    fn check_safezonestop_no_poison(
2284        _test_name: &str,
2285        _kernel: Kernel,
2286    ) -> Result<(), Box<dyn Error>> {
2287        Ok(())
2288    }
2289
2290    #[cfg(feature = "proptest")]
2291    #[allow(clippy::float_cmp)]
2292    fn check_safezonestop_property(
2293        test_name: &str,
2294        kernel: Kernel,
2295    ) -> Result<(), Box<dyn std::error::Error>> {
2296        use proptest::prelude::*;
2297        skip_if_unsupported!(kernel, test_name);
2298
2299        let strat = (1usize..=64)
2300            .prop_flat_map(|period| {
2301                let len = (period + 1).max(10)..400;
2302                (
2303                    100.0f64..1000.0f64,
2304                    prop::collection::vec(-0.05f64..0.05f64, len.clone()),
2305                    prop::collection::vec(0.001f64..0.02f64, len),
2306                    Just(period),
2307                    0.5f64..5.0f64,
2308                    1usize..10,
2309                    prop::bool::ANY,
2310                )
2311            })
2312            .prop_map(
2313                |(start_price, returns, spreads, period, mult, max_lookback, is_long)| {
2314                    let len = returns.len().min(spreads.len());
2315                    let mut low = Vec::with_capacity(len);
2316                    let mut high = Vec::with_capacity(len);
2317                    let mut price = start_price;
2318
2319                    for i in 0..len {
2320                        price *= 1.0 + returns[i];
2321                        price = price.max(1.0);
2322
2323                        let spread = price * spreads[i];
2324                        low.push(price - spread / 2.0);
2325                        high.push(price + spread / 2.0);
2326                    }
2327
2328                    (high, low, period, mult, max_lookback, is_long)
2329                },
2330            );
2331
2332        proptest::test_runner::TestRunner::default()
2333            .run(
2334                &strat,
2335                |(high, low, period, mult, max_lookback, is_long)| {
2336                    let len = high.len();
2337                    let direction = if is_long { "long" } else { "short" };
2338
2339                    let params = SafeZoneStopParams {
2340                        period: Some(period),
2341                        mult: Some(mult),
2342                        max_lookback: Some(max_lookback),
2343                    };
2344                    let input =
2345                        SafeZoneStopInput::from_slices(&high, &low, direction, params.clone());
2346
2347                    let output = safezonestop_with_kernel(&input, kernel).unwrap();
2348                    let ref_output = safezonestop_with_kernel(&input, Kernel::Scalar).unwrap();
2349
2350                    let warmup_period =
2351                        period.saturating_sub(1).max(max_lookback.saturating_sub(1));
2352
2353                    for i in 0..warmup_period.min(len) {
2354                        prop_assert!(
2355                            output.values[i].is_nan(),
2356                            "Expected NaN during warmup at idx {}, got {}",
2357                            i,
2358                            output.values[i]
2359                        );
2360                    }
2361
2362                    if len > warmup_period + max_lookback {
2363                        let opposite_dir = if is_long { "short" } else { "long" };
2364                        let opposite_input = SafeZoneStopInput::from_slices(
2365                            &high,
2366                            &low,
2367                            opposite_dir,
2368                            params.clone(),
2369                        );
2370                        let opposite_output =
2371                            safezonestop_with_kernel(&opposite_input, kernel).unwrap();
2372
2373                        let mut found_difference = false;
2374                        for i in (warmup_period + max_lookback)..len {
2375                            if !output.values[i].is_nan() && !opposite_output.values[i].is_nan() {
2376                                if (output.values[i] - opposite_output.values[i]).abs() > 1e-10 {
2377                                    found_difference = true;
2378                                    break;
2379                                }
2380                            }
2381                        }
2382                        prop_assert!(
2383                            found_difference || len < warmup_period + max_lookback + 5,
2384                            "Long and short directions should produce different stop values"
2385                        );
2386                    }
2387
2388                    for i in warmup_period..len {
2389                        let val = output.values[i];
2390                        if !val.is_nan() {
2391                            prop_assert!(
2392                                val.is_finite(),
2393                                "SafeZone value at idx {} is not finite: {}",
2394                                i,
2395                                val
2396                            );
2397
2398                            let lookback_start = i.saturating_sub(period + max_lookback);
2399                            let recent_high = high[lookback_start..=i]
2400                                .iter()
2401                                .cloned()
2402                                .fold(f64::NEG_INFINITY, f64::max);
2403                            let recent_low = low[lookback_start..=i]
2404                                .iter()
2405                                .cloned()
2406                                .fold(f64::INFINITY, f64::min);
2407                            let recent_range = recent_high - recent_low;
2408
2409                            let max_deviation = recent_range * mult * 5.0 + recent_high * 0.5;
2410
2411                            prop_assert!(
2412							val >= -max_deviation && val <= recent_high + max_deviation,
2413							"SafeZone value {} at idx {} outside reasonable bounds [{}, {}] based on recent prices",
2414							val, i, -max_deviation, recent_high + max_deviation
2415						);
2416
2417                            if recent_range < 1.0 {
2418                                if is_long {
2419                                    prop_assert!(
2420                                        val <= recent_high + recent_range * mult * 3.0,
2421                                        "Long stop {} at idx {} too far above recent high {}",
2422                                        val,
2423                                        i,
2424                                        recent_high
2425                                    );
2426                                } else {
2427                                    prop_assert!(
2428                                        val >= recent_low - recent_range * mult * 3.0,
2429                                        "Short stop {} at idx {} too far below recent low {}",
2430                                        val,
2431                                        i,
2432                                        recent_low
2433                                    );
2434                                }
2435                            }
2436                        }
2437                    }
2438
2439                    if len > warmup_period + period * 2 {
2440                        let mid_point = len / 2;
2441                        if mid_point > warmup_period + period {
2442                            let first_half_spread: f64 = (warmup_period..mid_point)
2443                                .map(|i| high[i] - low[i])
2444                                .sum::<f64>()
2445                                / (mid_point - warmup_period) as f64;
2446
2447                            let second_half_spread: f64 =
2448                                (mid_point..len).map(|i| high[i] - low[i]).sum::<f64>()
2449                                    / (len - mid_point) as f64;
2450
2451                            if first_half_spread > 0.0 && second_half_spread > 0.0 {
2452                                let volatility_ratio = first_half_spread / second_half_spread;
2453                                if volatility_ratio > 2.0 || volatility_ratio < 0.5 {
2454                                    let first_half_stops: Vec<f64> = output.values
2455                                        [warmup_period + period..mid_point]
2456                                        .iter()
2457                                        .filter(|v| !v.is_nan())
2458                                        .copied()
2459                                        .collect();
2460
2461                                    let second_half_stops: Vec<f64> = output.values[mid_point..len]
2462                                        .iter()
2463                                        .filter(|v| !v.is_nan())
2464                                        .copied()
2465                                        .collect();
2466
2467                                    if !first_half_stops.is_empty() && !second_half_stops.is_empty()
2468                                    {
2469                                        prop_assert!(
2470										first_half_stops.len() > 0 && second_half_stops.len() > 0,
2471										"Should have valid stops in both halves for volatility test"
2472									);
2473                                    }
2474                                }
2475                            }
2476                        }
2477                    }
2478
2479                    for i in 0..len {
2480                        let y = output.values[i];
2481                        let r = ref_output.values[i];
2482
2483                        if !y.is_finite() || !r.is_finite() {
2484                            prop_assert!(
2485                                y.to_bits() == r.to_bits(),
2486                                "NaN/inf mismatch at idx {}: {} vs {}",
2487                                i,
2488                                y,
2489                                r
2490                            );
2491                            continue;
2492                        }
2493
2494                        let y_bits = y.to_bits();
2495                        let r_bits = r.to_bits();
2496                        let ulp_diff = y_bits.abs_diff(r_bits);
2497
2498                        prop_assert!(
2499                            (y - r).abs() <= 1e-9 || ulp_diff <= 4,
2500                            "Kernel mismatch at idx {}: {} vs {} (ULP={})",
2501                            i,
2502                            y,
2503                            r,
2504                            ulp_diff
2505                        );
2506                    }
2507
2508                    if period == 1 && max_lookback == 1 {
2509                        prop_assert!(
2510                            output.values[0].is_nan(),
2511                            "First value should be NaN with period=1, max_lookback=1"
2512                        );
2513                    }
2514
2515                    if high.windows(2).all(|w| (w[0] - w[1]).abs() < 1e-12)
2516                        && low.windows(2).all(|w| (w[0] - w[1]).abs() < 1e-12)
2517                    {
2518                        let stable_start = (warmup_period + period * 2).min(len - 1);
2519                        if stable_start < len - 1 {
2520                            let stable_values: Vec<f64> = output.values[stable_start..]
2521                                .iter()
2522                                .filter(|v| !v.is_nan())
2523                                .copied()
2524                                .collect();
2525
2526                            if stable_values.len() > 1 {
2527                                let first_stable = stable_values[0];
2528                                for val in &stable_values[1..] {
2529                                    prop_assert!(
2530									(val - first_stable).abs() < 1e-6,
2531									"Constant data should produce stable SafeZone values: {} vs {}", val, first_stable
2532								);
2533                                }
2534                            }
2535                        }
2536                    }
2537
2538                    Ok(())
2539                },
2540            )
2541            .unwrap();
2542
2543        Ok(())
2544    }
2545
2546    macro_rules! generate_all_safezonestop_tests {
2547        ($($test_fn:ident),*) => {
2548            paste::paste! {
2549                $(
2550                    #[test]
2551                    fn [<$test_fn _scalar_f64>]() {
2552                        let _ = $test_fn(stringify!([<$test_fn _scalar_f64>]), Kernel::Scalar);
2553                    }
2554                )*
2555                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
2556                $(
2557                    #[test]
2558                    fn [<$test_fn _avx2_f64>]() {
2559                        let _ = $test_fn(stringify!([<$test_fn _avx2_f64>]), Kernel::Avx2);
2560                    }
2561                    #[test]
2562                    fn [<$test_fn _avx512_f64>]() {
2563                        let _ = $test_fn(stringify!([<$test_fn _avx512_f64>]), Kernel::Avx512);
2564                    }
2565                )*
2566            }
2567        }
2568    }
2569    generate_all_safezonestop_tests!(
2570        check_safezonestop_partial_params,
2571        check_safezonestop_accuracy,
2572        check_safezonestop_default_candles,
2573        check_safezonestop_zero_period,
2574        check_safezonestop_mismatched_lengths,
2575        check_safezonestop_nan_handling,
2576        check_safezonestop_no_poison
2577    );
2578
2579    #[cfg(feature = "proptest")]
2580    generate_all_safezonestop_tests!(check_safezonestop_property);
2581
2582    #[test]
2583    fn test_safezonestop_into_matches_api() -> Result<(), Box<dyn Error>> {
2584        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2585        let candles = read_candles_from_csv(file_path)?;
2586        let input = SafeZoneStopInput::with_default_candles(&candles);
2587
2588        let baseline = safezonestop(&input)?.values;
2589
2590        let mut out = vec![0.0f64; candles.close.len()];
2591        #[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
2592        {
2593            safezonestop_into(&input, &mut out)?;
2594        }
2595        #[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2596        {
2597            return Ok(());
2598        }
2599
2600        assert_eq!(baseline.len(), out.len());
2601
2602        #[inline]
2603        fn eq_or_both_nan(a: f64, b: f64) -> bool {
2604            (a.is_nan() && b.is_nan()) || (a == b)
2605        }
2606
2607        for i in 0..baseline.len() {
2608            assert!(
2609                eq_or_both_nan(baseline[i], out[i]),
2610                "divergence at index {}: baseline={}, into={}",
2611                i,
2612                baseline[i],
2613                out[i]
2614            );
2615        }
2616
2617        Ok(())
2618    }
2619
2620    fn check_batch_default_row(test: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
2621        skip_if_unsupported!(kernel, test);
2622
2623        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2624        let c = read_candles_from_csv(file)?;
2625
2626        let high = source_type(&c, "high");
2627        let low = source_type(&c, "low");
2628
2629        let output = SafeZoneStopBatchBuilder::new()
2630            .kernel(kernel)
2631            .apply_slices(high, low)?;
2632
2633        let def = SafeZoneStopParams::default();
2634        let row = output.values_for(&def).expect("default row missing");
2635
2636        assert_eq!(row.len(), c.close.len());
2637
2638        let expected = [
2639            45331.180007991,
2640            45712.94455308232,
2641            46019.94707339676,
2642            46461.767660969635,
2643            46461.767660969635,
2644        ];
2645        let start = row.len() - 5;
2646        for (i, &v) in row[start..].iter().enumerate() {
2647            assert!(
2648                (v - expected[i]).abs() < 1e-4,
2649                "[{test}] default-row mismatch at idx {i}: {v} vs {expected:?}"
2650            );
2651        }
2652        Ok(())
2653    }
2654
2655    #[cfg(debug_assertions)]
2656    fn check_batch_no_poison(test: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
2657        skip_if_unsupported!(kernel, test);
2658
2659        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2660        let c = read_candles_from_csv(file)?;
2661
2662        let high = source_type(&c, "high");
2663        let low = source_type(&c, "low");
2664
2665        let test_configs = vec![
2666            (2, 10, 2, 1.0, 3.0, 0.5, 1, 5, 1, "long"),
2667            (5, 25, 5, 2.5, 2.5, 0.0, 3, 3, 0, "short"),
2668            (10, 10, 0, 1.5, 5.0, 0.5, 2, 8, 2, "long"),
2669            (2, 5, 1, 0.5, 2.0, 0.5, 1, 3, 1, "short"),
2670            (30, 60, 15, 2.0, 4.0, 1.0, 5, 10, 5, "long"),
2671            (22, 22, 0, 1.0, 5.0, 1.0, 3, 3, 0, "short"),
2672            (8, 12, 1, 2.5, 3.5, 0.25, 2, 6, 1, "long"),
2673        ];
2674
2675        for (
2676            cfg_idx,
2677            &(p_start, p_end, p_step, m_start, m_end, m_step, l_start, l_end, l_step, direction),
2678        ) in test_configs.iter().enumerate()
2679        {
2680            let output = SafeZoneStopBatchBuilder::new()
2681                .kernel(kernel)
2682                .period_range(p_start, p_end, p_step)
2683                .mult_range(m_start, m_end, m_step)
2684                .max_lookback_range(l_start, l_end, l_step)
2685                .direction(direction)
2686                .apply_slices(high, low)?;
2687
2688            for (idx, &val) in output.values.iter().enumerate() {
2689                if val.is_nan() {
2690                    continue;
2691                }
2692
2693                let bits = val.to_bits();
2694                let row = idx / output.cols;
2695                let col = idx % output.cols;
2696                let combo = &output.combos[row];
2697
2698                if bits == 0x11111111_11111111 {
2699                    panic!(
2700						"[{}] Config {}: Found alloc_with_nan_prefix poison value {} (0x{:016X}) \
2701						 at row {} col {} (flat index {}) with params: period={}, mult={}, max_lookback={}, direction='{}'",
2702						test, cfg_idx, val, bits, row, col, idx,
2703						combo.period.unwrap_or(22),
2704						combo.mult.unwrap_or(2.5),
2705						combo.max_lookback.unwrap_or(3),
2706						direction
2707					);
2708                }
2709
2710                if bits == 0x22222222_22222222 {
2711                    panic!(
2712						"[{}] Config {}: Found init_matrix_prefixes poison value {} (0x{:016X}) \
2713						 at row {} col {} (flat index {}) with params: period={}, mult={}, max_lookback={}, direction='{}'",
2714						test, cfg_idx, val, bits, row, col, idx,
2715						combo.period.unwrap_or(22),
2716						combo.mult.unwrap_or(2.5),
2717						combo.max_lookback.unwrap_or(3),
2718						direction
2719					);
2720                }
2721
2722                if bits == 0x33333333_33333333 {
2723                    panic!(
2724						"[{}] Config {}: Found make_uninit_matrix poison value {} (0x{:016X}) \
2725						 at row {} col {} (flat index {}) with params: period={}, mult={}, max_lookback={}, direction='{}'",
2726						test, cfg_idx, val, bits, row, col, idx,
2727						combo.period.unwrap_or(22),
2728						combo.mult.unwrap_or(2.5),
2729						combo.max_lookback.unwrap_or(3),
2730						direction
2731					);
2732                }
2733            }
2734        }
2735
2736        Ok(())
2737    }
2738
2739    #[cfg(not(debug_assertions))]
2740    fn check_batch_no_poison(_test: &str, _kernel: Kernel) -> Result<(), Box<dyn Error>> {
2741        Ok(())
2742    }
2743
2744    macro_rules! gen_batch_tests {
2745        ($fn_name:ident) => {
2746            paste::paste! {
2747                #[test] fn [<$fn_name _scalar>]()      {
2748                    let _ = $fn_name(stringify!([<$fn_name _scalar>]), Kernel::ScalarBatch);
2749                }
2750                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
2751                #[test] fn [<$fn_name _avx2>]()        {
2752                    let _ = $fn_name(stringify!([<$fn_name _avx2>]), Kernel::Avx2Batch);
2753                }
2754                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
2755                #[test] fn [<$fn_name _avx512>]()      {
2756                    let _ = $fn_name(stringify!([<$fn_name _avx512>]), Kernel::Avx512Batch);
2757                }
2758                #[test] fn [<$fn_name _auto_detect>]() {
2759                    let _ = $fn_name(stringify!([<$fn_name _auto_detect>]), Kernel::Auto);
2760                }
2761            }
2762        };
2763    }
2764    gen_batch_tests!(check_batch_default_row);
2765    gen_batch_tests!(check_batch_no_poison);
2766}
2767
2768#[cfg(feature = "python")]
2769#[pyfunction(name = "safezonestop")]
2770#[pyo3(signature = (high, low, period, mult, max_lookback, direction, kernel=None))]
2771pub fn safezonestop_py<'py>(
2772    py: Python<'py>,
2773    high: PyReadonlyArray1<'py, f64>,
2774    low: PyReadonlyArray1<'py, f64>,
2775    period: usize,
2776    mult: f64,
2777    max_lookback: usize,
2778    direction: &str,
2779    kernel: Option<&str>,
2780) -> PyResult<Bound<'py, PyArray1<f64>>> {
2781    use numpy::{IntoPyArray, PyArrayMethods};
2782
2783    let high_slice = high.as_slice()?;
2784    let low_slice = low.as_slice()?;
2785    let kern = validate_kernel(kernel, false)?;
2786
2787    let params = SafeZoneStopParams {
2788        period: Some(period),
2789        mult: Some(mult),
2790        max_lookback: Some(max_lookback),
2791    };
2792    let input = SafeZoneStopInput::from_slices(high_slice, low_slice, direction, params);
2793
2794    let result_vec: Vec<f64> = py
2795        .allow_threads(|| safezonestop_with_kernel(&input, kern).map(|o| o.values))
2796        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2797
2798    Ok(result_vec.into_pyarray(py))
2799}
2800
2801#[cfg(feature = "python")]
2802#[pyclass(name = "SafeZoneStopStream")]
2803pub struct SafeZoneStopStreamPy {
2804    stream: SafeZoneStopStream,
2805}
2806
2807#[cfg(feature = "python")]
2808#[pymethods]
2809impl SafeZoneStopStreamPy {
2810    #[new]
2811    fn new(period: usize, mult: f64, max_lookback: usize, direction: &str) -> PyResult<Self> {
2812        let params = SafeZoneStopParams {
2813            period: Some(period),
2814            mult: Some(mult),
2815            max_lookback: Some(max_lookback),
2816        };
2817        let stream = SafeZoneStopStream::try_new(params, direction)
2818            .map_err(|e| PyValueError::new_err(e.to_string()))?;
2819        Ok(SafeZoneStopStreamPy { stream })
2820    }
2821
2822    fn update(&mut self, high: f64, low: f64) -> Option<f64> {
2823        self.stream.update(high, low)
2824    }
2825}
2826
2827#[cfg(feature = "python")]
2828#[pyfunction(name = "safezonestop_batch")]
2829#[pyo3(signature = (high, low, period_range, mult_range, max_lookback_range, direction, kernel=None))]
2830pub fn safezonestop_batch_py<'py>(
2831    py: Python<'py>,
2832    high: PyReadonlyArray1<'py, f64>,
2833    low: PyReadonlyArray1<'py, f64>,
2834    period_range: (usize, usize, usize),
2835    mult_range: (f64, f64, f64),
2836    max_lookback_range: (usize, usize, usize),
2837    direction: &str,
2838    kernel: Option<&str>,
2839) -> PyResult<Bound<'py, PyDict>> {
2840    use numpy::{IntoPyArray, PyArray1, PyArrayMethods};
2841    use pyo3::types::PyDict;
2842
2843    let high_slice = high.as_slice()?;
2844    let low_slice = low.as_slice()?;
2845
2846    let sweep = SafeZoneStopBatchRange {
2847        period: period_range,
2848        mult: mult_range,
2849        max_lookback: max_lookback_range,
2850    };
2851
2852    let combos = expand_grid(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
2853    let rows = combos.len();
2854    let cols = high_slice.len();
2855    let total = rows.checked_mul(cols).ok_or_else(|| {
2856        PyValueError::new_err(
2857            SafeZoneStopError::InvalidRange {
2858                start: period_range.0 as f64,
2859                end: period_range.1 as f64,
2860                step: period_range.2 as f64,
2861            }
2862            .to_string(),
2863        )
2864    })?;
2865
2866    let out_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2867    let slice_out = unsafe { out_arr.as_slice_mut()? };
2868
2869    let kern = validate_kernel(kernel, true)?;
2870
2871    let combos = py
2872        .allow_threads(|| {
2873            let kernel = match kern {
2874                Kernel::Auto => detect_best_batch_kernel(),
2875                k => k,
2876            };
2877            let simd = match kernel {
2878                Kernel::Avx512Batch => Kernel::Avx512,
2879                Kernel::Avx2Batch => Kernel::Avx2,
2880                Kernel::ScalarBatch => Kernel::Scalar,
2881                _ => unreachable!(),
2882            };
2883
2884            safezonestop_batch_inner_into(
2885                high_slice, low_slice, &sweep, direction, simd, true, slice_out,
2886            )
2887        })
2888        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2889
2890    let dict = PyDict::new(py);
2891    dict.set_item("values", out_arr.reshape((rows, cols))?)?;
2892    dict.set_item(
2893        "periods",
2894        combos
2895            .iter()
2896            .map(|p| p.period.unwrap() as u64)
2897            .collect::<Vec<_>>()
2898            .into_pyarray(py),
2899    )?;
2900    dict.set_item(
2901        "mults",
2902        combos
2903            .iter()
2904            .map(|p| p.mult.unwrap())
2905            .collect::<Vec<_>>()
2906            .into_pyarray(py),
2907    )?;
2908    dict.set_item(
2909        "max_lookbacks",
2910        combos
2911            .iter()
2912            .map(|p| p.max_lookback.unwrap() as u64)
2913            .collect::<Vec<_>>()
2914            .into_pyarray(py),
2915    )?;
2916
2917    Ok(dict)
2918}
2919
2920#[cfg(all(feature = "python", feature = "cuda"))]
2921use crate::cuda::cuda_available;
2922#[cfg(all(feature = "python", feature = "cuda"))]
2923use crate::cuda::CudaSafeZoneStop;
2924#[cfg(all(feature = "python", feature = "cuda"))]
2925use crate::utilities::dlpack_cuda::{make_device_array_py, DeviceArrayF32Py};
2926
2927#[cfg(all(feature = "python", feature = "cuda"))]
2928#[pyfunction(name = "safezonestop_cuda_batch_dev")]
2929#[pyo3(signature = (high_f32, low_f32, period_range, mult_range, max_lookback_range, direction, device_id=0))]
2930pub fn safezonestop_cuda_batch_dev_py<'py>(
2931    py: Python<'py>,
2932    high_f32: numpy::PyReadonlyArray1<'py, f32>,
2933    low_f32: numpy::PyReadonlyArray1<'py, f32>,
2934    period_range: (usize, usize, usize),
2935    mult_range: (f64, f64, f64),
2936    max_lookback_range: (usize, usize, usize),
2937    direction: &str,
2938    device_id: usize,
2939) -> PyResult<(DeviceArrayF32Py, Bound<'py, PyDict>)> {
2940    if !cuda_available() {
2941        return Err(PyValueError::new_err("CUDA not available"));
2942    }
2943    let high = high_f32.as_slice()?;
2944    let low = low_f32.as_slice()?;
2945    let sweep = SafeZoneStopBatchRange {
2946        period: period_range,
2947        mult: mult_range,
2948        max_lookback: max_lookback_range,
2949    };
2950    let (inner, combos) = py.allow_threads(|| {
2951        let cuda =
2952            CudaSafeZoneStop::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
2953        cuda.safezonestop_batch_dev(high, low, direction, &sweep)
2954            .map_err(|e| PyValueError::new_err(e.to_string()))
2955    })?;
2956
2957    let dict = PyDict::new(py);
2958    let periods: Vec<u64> = combos.iter().map(|p| p.period.unwrap() as u64).collect();
2959    let mults: Vec<f64> = combos.iter().map(|p| p.mult.unwrap()).collect();
2960    let looks: Vec<u64> = combos
2961        .iter()
2962        .map(|p| p.max_lookback.unwrap() as u64)
2963        .collect();
2964    dict.set_item("periods", periods.into_pyarray(py))?;
2965    dict.set_item("mults", mults.into_pyarray(py))?;
2966    dict.set_item("max_lookbacks", looks.into_pyarray(py))?;
2967
2968    let handle = make_device_array_py(device_id, inner)?;
2969
2970    Ok((handle, dict))
2971}
2972
2973#[cfg(all(feature = "python", feature = "cuda"))]
2974#[pyfunction(name = "safezonestop_cuda_many_series_one_param_dev")]
2975#[pyo3(signature = (high_tm_f32, low_tm_f32, cols, rows, period, mult, max_lookback, direction, device_id=0))]
2976pub fn safezonestop_cuda_many_series_one_param_dev_py<'py>(
2977    py: Python<'py>,
2978    high_tm_f32: numpy::PyReadonlyArray1<'py, f32>,
2979    low_tm_f32: numpy::PyReadonlyArray1<'py, f32>,
2980    cols: usize,
2981    rows: usize,
2982    period: usize,
2983    mult: f32,
2984    max_lookback: usize,
2985    direction: &str,
2986    device_id: usize,
2987) -> PyResult<DeviceArrayF32Py> {
2988    if !cuda_available() {
2989        return Err(PyValueError::new_err("CUDA not available"));
2990    }
2991    let high = high_tm_f32.as_slice()?;
2992    let low = low_tm_f32.as_slice()?;
2993    let inner = py.allow_threads(|| {
2994        let cuda =
2995            CudaSafeZoneStop::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
2996        cuda.safezonestop_many_series_one_param_time_major_dev(
2997            high,
2998            low,
2999            cols,
3000            rows,
3001            period,
3002            mult,
3003            max_lookback,
3004            direction,
3005        )
3006        .map_err(|e| PyValueError::new_err(e.to_string()))
3007    })?;
3008
3009    make_device_array_py(device_id, inner)
3010}