Skip to main content

vector_ta/indicators/
bop.rs

1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7#[cfg(feature = "python")]
8use pyo3::types::{PyAny, PyDict, PyList};
9
10#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
11use serde::{Deserialize, Serialize};
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use wasm_bindgen::prelude::*;
14
15use crate::utilities::data_loader::{source_type, Candles};
16use crate::utilities::enums::Kernel;
17use crate::utilities::helpers::{
18    alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
19    make_uninit_matrix,
20};
21#[cfg(feature = "python")]
22use crate::utilities::kernel_validation::validate_kernel;
23#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
24use core::arch::x86_64::*;
25use thiserror::Error;
26
27#[cfg(all(feature = "python", feature = "cuda"))]
28use crate::cuda::oscillators::CudaBop;
29#[cfg(all(feature = "python", feature = "cuda"))]
30use crate::utilities::dlpack_cuda::export_f32_cuda_dlpack_2d;
31#[cfg(all(feature = "python", feature = "cuda"))]
32use cust::context::Context;
33#[cfg(all(feature = "python", feature = "cuda"))]
34use cust::memory::DeviceBuffer;
35#[cfg(all(feature = "python", feature = "cuda"))]
36use std::sync::Arc;
37
38#[derive(Debug, Clone)]
39pub enum BopData<'a> {
40    Candles {
41        candles: &'a Candles,
42    },
43    Slices {
44        open: &'a [f64],
45        high: &'a [f64],
46        low: &'a [f64],
47        close: &'a [f64],
48    },
49}
50
51#[derive(Debug, Clone, Default, PartialEq)]
52pub struct BopParams {}
53
54#[derive(Debug, Clone)]
55pub struct BopInput<'a> {
56    pub data: BopData<'a>,
57    pub params: BopParams,
58}
59
60impl<'a> BopInput<'a> {
61    #[inline]
62    pub fn from_candles(candles: &'a Candles, params: BopParams) -> Self {
63        Self {
64            data: BopData::Candles { candles },
65            params,
66        }
67    }
68    #[inline]
69    pub fn from_slices(
70        open: &'a [f64],
71        high: &'a [f64],
72        low: &'a [f64],
73        close: &'a [f64],
74        params: BopParams,
75    ) -> Self {
76        Self {
77            data: BopData::Slices {
78                open,
79                high,
80                low,
81                close,
82            },
83            params,
84        }
85    }
86    #[inline]
87    pub fn with_default_candles(candles: &'a Candles) -> Self {
88        Self {
89            data: BopData::Candles { candles },
90            params: BopParams::default(),
91        }
92    }
93}
94
95#[derive(Debug, Clone)]
96pub struct BopOutput {
97    pub values: Vec<f64>,
98}
99
100#[derive(Debug, Error, Clone, Copy)]
101pub enum BopError {
102    #[error("bop: Input data is empty")]
103    EmptyInputData,
104    #[error(
105        "bop: Input lengths mismatch - open={open_len} high={high_len} low={low_len} close={close_len}"
106    )]
107    InputLengthsMismatch {
108        open_len: usize,
109        high_len: usize,
110        low_len: usize,
111        close_len: usize,
112    },
113    #[error("bop: output length mismatch: expected={expected} got={got}")]
114    OutputLengthMismatch { expected: usize, got: usize },
115    #[error("bop: all values are NaN")]
116    AllValuesNaN,
117    #[error("bop: not enough valid data (needed={needed}, valid={valid})")]
118    NotEnoughValidData { needed: usize, valid: usize },
119    #[error("bop: invalid range (start={start}, end={end}, step={step})")]
120    InvalidRange {
121        start: usize,
122        end: usize,
123        step: usize,
124    },
125    #[error("bop: invalid kernel for batch path: {0:?}")]
126    InvalidKernelForBatch(Kernel),
127}
128
129#[derive(Copy, Clone, Debug)]
130pub struct BopBuilder {
131    kernel: Kernel,
132}
133
134impl Default for BopBuilder {
135    fn default() -> Self {
136        Self {
137            kernel: Kernel::Auto,
138        }
139    }
140}
141
142impl BopBuilder {
143    #[inline(always)]
144    pub fn new() -> Self {
145        Self::default()
146    }
147    #[inline(always)]
148    pub fn kernel(mut self, k: Kernel) -> Self {
149        self.kernel = k;
150        self
151    }
152    #[inline(always)]
153    pub fn apply(self, c: &Candles) -> Result<BopOutput, BopError> {
154        let i = BopInput::from_candles(c, BopParams::default());
155        bop_with_kernel(&i, self.kernel)
156    }
157    #[inline(always)]
158    pub fn apply_slices(
159        self,
160        open: &[f64],
161        high: &[f64],
162        low: &[f64],
163        close: &[f64],
164    ) -> Result<BopOutput, BopError> {
165        let i = BopInput::from_slices(open, high, low, close, BopParams::default());
166        bop_with_kernel(&i, self.kernel)
167    }
168
169    #[inline(always)]
170    pub fn into_stream(self) -> Result<BopStream, BopError> {
171        BopStream::try_new()
172    }
173}
174
175#[inline]
176pub fn bop(input: &BopInput) -> Result<BopOutput, BopError> {
177    bop_with_kernel(input, Kernel::Auto)
178}
179
180pub fn bop_with_kernel(input: &BopInput, kernel: Kernel) -> Result<BopOutput, BopError> {
181    let (open, high, low, close): (&[f64], &[f64], &[f64], &[f64]) = match &input.data {
182        BopData::Candles { candles } => (
183            source_type(candles, "open"),
184            source_type(candles, "high"),
185            source_type(candles, "low"),
186            source_type(candles, "close"),
187        ),
188        BopData::Slices {
189            open,
190            high,
191            low,
192            close,
193        } => (open, high, low, close),
194    };
195
196    let len = open.len();
197    if len == 0 {
198        return Err(BopError::EmptyInputData);
199    }
200    if high.len() != len || low.len() != len || close.len() != len {
201        return Err(BopError::InputLengthsMismatch {
202            open_len: len,
203            high_len: high.len(),
204            low_len: low.len(),
205            close_len: close.len(),
206        });
207    }
208
209    let first = (0..len)
210        .find(|&i| !open[i].is_nan() && !high[i].is_nan() && !low[i].is_nan() && !close[i].is_nan())
211        .unwrap_or(len);
212
213    let chosen = match kernel {
214        Kernel::Auto => Kernel::Scalar,
215        k => k,
216    };
217
218    let mut out = alloc_with_nan_prefix(len, first);
219    unsafe {
220        match chosen {
221            Kernel::Scalar | Kernel::ScalarBatch => {
222                bop_scalar_from(open, high, low, close, first, &mut out)
223            }
224            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
225            Kernel::Avx2 | Kernel::Avx2Batch => bop_avx2(open, high, low, close, &mut out),
226            #[cfg(not(all(feature = "nightly-avx", target_arch = "x86_64")))]
227            Kernel::Avx2 | Kernel::Avx2Batch => {
228                bop_scalar_from(open, high, low, close, first, &mut out)
229            }
230            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
231            Kernel::Avx512 | Kernel::Avx512Batch => bop_avx512(open, high, low, close, &mut out),
232            #[cfg(not(all(feature = "nightly-avx", target_arch = "x86_64")))]
233            Kernel::Avx512 | Kernel::Avx512Batch => {
234                bop_scalar_from(open, high, low, close, first, &mut out)
235            }
236            _ => unreachable!(),
237        }
238    }
239    Ok(BopOutput { values: out })
240}
241
242#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
243pub fn bop_into(input: &BopInput, out: &mut [f64]) -> Result<(), BopError> {
244    let (open, high, low, close): (&[f64], &[f64], &[f64], &[f64]) = match &input.data {
245        BopData::Candles { candles } => (
246            source_type(candles, "open"),
247            source_type(candles, "high"),
248            source_type(candles, "low"),
249            source_type(candles, "close"),
250        ),
251        BopData::Slices {
252            open,
253            high,
254            low,
255            close,
256        } => (open, high, low, close),
257    };
258
259    bop_into_slice(out, open, high, low, close, Kernel::Auto)
260}
261
262#[inline(always)]
263unsafe fn bop_scalar_from(
264    open: &[f64],
265    high: &[f64],
266    low: &[f64],
267    close: &[f64],
268    first: usize,
269    out: &mut [f64],
270) {
271    let len = open.len();
272    if first >= len {
273        return;
274    }
275
276    let mut po = open.as_ptr().add(first);
277    let mut ph = high.as_ptr().add(first);
278    let mut pl = low.as_ptr().add(first);
279    let mut pc = close.as_ptr().add(first);
280    let mut pd = out.as_mut_ptr().add(first);
281    let end = open.as_ptr().add(len);
282
283    while po < end {
284        let denom = *ph - *pl;
285        *pd = if denom <= 0.0 {
286            0.0
287        } else {
288            (*pc - *po) / denom
289        };
290
291        po = po.add(1);
292        ph = ph.add(1);
293        pl = pl.add(1);
294        pc = pc.add(1);
295        pd = pd.add(1);
296    }
297}
298
299pub unsafe fn bop_scalar(open: &[f64], high: &[f64], low: &[f64], close: &[f64], out: &mut [f64]) {
300    let first = (0..open.len())
301        .find(|&i| !open[i].is_nan() && !high[i].is_nan() && !low[i].is_nan() && !close[i].is_nan())
302        .unwrap_or(open.len());
303    bop_scalar_from(open, high, low, close, first, out);
304}
305
306#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
307#[inline]
308pub unsafe fn bop_avx2(open: &[f64], high: &[f64], low: &[f64], close: &[f64], out: &mut [f64]) {
309    use core::arch::x86_64::*;
310
311    debug_assert_eq!(open.len(), high.len());
312    debug_assert_eq!(open.len(), low.len());
313    debug_assert_eq!(open.len(), close.len());
314    debug_assert!(out.len() >= open.len());
315
316    let len = open.len();
317    let first = (0..len)
318        .find(|&i| !open[i].is_nan() && !high[i].is_nan() && !low[i].is_nan() && !close[i].is_nan())
319        .unwrap_or(len);
320    if first >= len {
321        return;
322    }
323
324    let mut po = open.as_ptr().add(first);
325    let mut ph = high.as_ptr().add(first);
326    let mut pl = low.as_ptr().add(first);
327    let mut pc = close.as_ptr().add(first);
328    let mut pd = out.as_mut_ptr().add(first);
329
330    let n = len - first;
331    let vz = _mm256_set1_pd(0.0);
332
333    let mut i = 0usize;
334    while i + 4 <= n {
335        let vo = _mm256_loadu_pd(po);
336        let vh = _mm256_loadu_pd(ph);
337        let vl = _mm256_loadu_pd(pl);
338        let vc = _mm256_loadu_pd(pc);
339
340        let vnum = _mm256_sub_pd(vc, vo);
341        let vden = _mm256_sub_pd(vh, vl);
342        let vres = _mm256_div_pd(vnum, vden);
343
344        let mask = _mm256_cmp_pd::<{ _CMP_LE_OQ }>(vden, vz);
345        let vout = _mm256_blendv_pd(vres, vz, mask);
346
347        _mm256_storeu_pd(pd, vout);
348
349        po = po.add(4);
350        ph = ph.add(4);
351        pl = pl.add(4);
352        pc = pc.add(4);
353        pd = pd.add(4);
354        i += 4;
355    }
356
357    while i < n {
358        let den = *ph - *pl;
359        let num = *pc - *po;
360        *pd = if den <= 0.0 { 0.0 } else { num / den };
361
362        po = po.add(1);
363        ph = ph.add(1);
364        pl = pl.add(1);
365        pc = pc.add(1);
366        pd = pd.add(1);
367        i += 1;
368    }
369}
370
371#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
372#[inline]
373pub unsafe fn bop_avx512(open: &[f64], high: &[f64], low: &[f64], close: &[f64], out: &mut [f64]) {
374    use core::arch::x86_64::*;
375
376    debug_assert_eq!(open.len(), high.len());
377    debug_assert_eq!(open.len(), low.len());
378    debug_assert_eq!(open.len(), close.len());
379    debug_assert!(out.len() >= open.len());
380
381    let len = open.len();
382    let first = (0..len)
383        .find(|&i| !open[i].is_nan() && !high[i].is_nan() && !low[i].is_nan() && !close[i].is_nan())
384        .unwrap_or(len);
385    if first >= len {
386        return;
387    }
388
389    let mut po = open.as_ptr().add(first);
390    let mut ph = high.as_ptr().add(first);
391    let mut pl = low.as_ptr().add(first);
392    let mut pc = close.as_ptr().add(first);
393    let mut pd = out.as_mut_ptr().add(first);
394
395    let n = len - first;
396    let vz = _mm512_set1_pd(0.0);
397
398    let mut i = 0usize;
399    while i + 8 <= n {
400        let vo = _mm512_loadu_pd(po);
401        let vh = _mm512_loadu_pd(ph);
402        let vl = _mm512_loadu_pd(pl);
403        let vc = _mm512_loadu_pd(pc);
404
405        let vnum = _mm512_sub_pd(vc, vo);
406        let vden = _mm512_sub_pd(vh, vl);
407        let vres = _mm512_div_pd(vnum, vden);
408
409        let m = _mm512_cmp_pd_mask::<{ _CMP_LE_OQ }>(vden, vz);
410        let vout = _mm512_mask_blend_pd(m, vres, vz);
411
412        _mm512_storeu_pd(pd, vout);
413
414        po = po.add(8);
415        ph = ph.add(8);
416        pl = pl.add(8);
417        pc = pc.add(8);
418        pd = pd.add(8);
419        i += 8;
420    }
421
422    let rem = n - i;
423    if rem != 0 {
424        let mask: __mmask8 = (1u16 << rem) as __mmask8 - 1;
425        let vo = _mm512_maskz_loadu_pd(mask, po);
426        let vh = _mm512_maskz_loadu_pd(mask, ph);
427        let vl = _mm512_maskz_loadu_pd(mask, pl);
428        let vc = _mm512_maskz_loadu_pd(mask, pc);
429
430        let vnum = _mm512_sub_pd(vc, vo);
431        let vden = _mm512_sub_pd(vh, vl);
432        let vres = _mm512_div_pd(vnum, vden);
433        let m = _mm512_cmp_pd_mask::<{ _CMP_LE_OQ }>(vden, vz);
434        let vout = _mm512_mask_blend_pd(m, vres, vz);
435
436        _mm512_mask_storeu_pd(pd, mask, vout);
437    }
438}
439
440#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
441#[inline]
442pub unsafe fn bop_avx512_short(
443    open: &[f64],
444    high: &[f64],
445    low: &[f64],
446    close: &[f64],
447    out: &mut [f64],
448) {
449    bop_avx512(open, high, low, close, out)
450}
451
452#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
453#[inline]
454pub unsafe fn bop_avx512_long(
455    open: &[f64],
456    high: &[f64],
457    low: &[f64],
458    close: &[f64],
459    out: &mut [f64],
460) {
461    bop_avx512(open, high, low, close, out)
462}
463
464#[inline]
465pub fn bop_row_scalar(open: &[f64], high: &[f64], low: &[f64], close: &[f64], out: &mut [f64]) {
466    unsafe { bop_scalar(open, high, low, close, out) }
467}
468
469#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
470#[inline]
471pub fn bop_row_avx2(open: &[f64], high: &[f64], low: &[f64], close: &[f64], out: &mut [f64]) {
472    unsafe { bop_avx2(open, high, low, close, out) }
473}
474
475#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
476#[inline]
477pub fn bop_row_avx512(open: &[f64], high: &[f64], low: &[f64], close: &[f64], out: &mut [f64]) {
478    unsafe { bop_avx512(open, high, low, close, out) }
479}
480
481#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
482#[inline]
483pub fn bop_row_avx512_short(
484    open: &[f64],
485    high: &[f64],
486    low: &[f64],
487    close: &[f64],
488    out: &mut [f64],
489) {
490    unsafe { bop_avx512_short(open, high, low, close, out) }
491}
492
493#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
494#[inline]
495pub fn bop_row_avx512_long(
496    open: &[f64],
497    high: &[f64],
498    low: &[f64],
499    close: &[f64],
500    out: &mut [f64],
501) {
502    unsafe { bop_avx512_long(open, high, low, close, out) }
503}
504
505#[derive(Clone, Debug)]
506pub struct BopStream {
507    pub last: Option<f64>,
508}
509
510impl BopStream {
511    #[inline]
512    pub fn try_new() -> Result<Self, BopError> {
513        Ok(Self { last: None })
514    }
515
516    #[inline(always)]
517    pub fn update(&mut self, open: f64, high: f64, low: f64, close: f64) -> f64 {
518        let den = high - low;
519        if den <= 0.0 {
520            return self.cold_zero();
521        }
522
523        let val = (close - open) / den;
524        self.last = Some(val);
525        val
526    }
527
528    #[inline(always)]
529    pub fn update_unchecked(&mut self, open: f64, high: f64, low: f64, close: f64) -> f64 {
530        debug_assert!(high > low, "BOP update_unchecked requires high > low");
531        let inv = (high - low).recip();
532        let val = (close - open) * inv;
533        self.last = Some(val);
534        val
535    }
536
537    #[inline(always)]
538    pub fn last_value(&self) -> Option<f64> {
539        self.last
540    }
541
542    #[cold]
543    #[inline(never)]
544    fn cold_zero(&mut self) -> f64 {
545        self.last = Some(0.0);
546        0.0
547    }
548}
549
550#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
551#[inline(always)]
552unsafe fn recip14_nr2(x: f64) -> f64 {
553    use core::arch::x86_64::*;
554
555    let r0 = _mm_rcp14_sd(_mm_setzero_pd(), _mm_set_sd(x));
556    let mut r = _mm_cvtsd_f64(r0);
557
558    r = r * (2.0 - x * r);
559    r = r * (2.0 - x * r);
560    r
561}
562
563#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
564impl BopStream {
565    #[inline(always)]
566    pub fn update_fast(&mut self, open: f64, high: f64, low: f64, close: f64) -> f64 {
567        let den = high - low;
568        if den <= 0.0 {
569            return self.cold_zero();
570        }
571        let inv = unsafe { recip14_nr2(den) };
572        let val = (close - open) * inv;
573        self.last = Some(val);
574        val
575    }
576}
577
578#[derive(Clone, Debug)]
579pub struct BopBatchRange {}
580
581impl Default for BopBatchRange {
582    fn default() -> Self {
583        Self {}
584    }
585}
586
587#[derive(Clone, Debug, Default)]
588pub struct BopBatchBuilder {
589    range: BopBatchRange,
590    kernel: Kernel,
591}
592
593impl BopBatchBuilder {
594    pub fn new() -> Self {
595        Self::default()
596    }
597    pub fn kernel(mut self, k: Kernel) -> Self {
598        self.kernel = k;
599        self
600    }
601    pub fn apply_slices(
602        self,
603        open: &[f64],
604        high: &[f64],
605        low: &[f64],
606        close: &[f64],
607    ) -> Result<BopBatchOutput, BopError> {
608        bop_batch_with_kernel(open, high, low, close, self.kernel)
609    }
610    pub fn apply_candles(self, c: &Candles) -> Result<BopBatchOutput, BopError> {
611        let open = source_type(c, "open");
612        let high = source_type(c, "high");
613        let low = source_type(c, "low");
614        let close = source_type(c, "close");
615        self.apply_slices(open, high, low, close)
616    }
617    pub fn with_default_candles(c: &Candles) -> Result<BopBatchOutput, BopError> {
618        BopBatchBuilder::new().kernel(Kernel::Auto).apply_candles(c)
619    }
620}
621
622#[derive(Clone, Debug)]
623pub struct BopBatchOutput {
624    pub values: Vec<f64>,
625    pub rows: usize,
626    pub cols: usize,
627}
628
629pub fn bop_batch_with_kernel(
630    open: &[f64],
631    high: &[f64],
632    low: &[f64],
633    close: &[f64],
634    kernel: Kernel,
635) -> Result<BopBatchOutput, BopError> {
636    let len = open.len();
637    if len == 0 {
638        return Err(BopError::EmptyInputData);
639    }
640    if high.len() != len || low.len() != len || close.len() != len {
641        return Err(BopError::InputLengthsMismatch {
642            open_len: len,
643            high_len: high.len(),
644            low_len: low.len(),
645            close_len: close.len(),
646        });
647    }
648
649    let first = (0..len)
650        .find(|&i| !open[i].is_nan() && !high[i].is_nan() && !low[i].is_nan() && !close[i].is_nan())
651        .unwrap_or(len);
652
653    let rows = 1usize;
654    let cols = len;
655    let _total = rows.checked_mul(cols).ok_or(BopError::InvalidRange {
656        start: rows,
657        end: cols,
658        step: 1,
659    })?;
660
661    let mut buf_mu = make_uninit_matrix(rows, cols);
662    init_matrix_prefixes(&mut buf_mu, cols, &[first]);
663
664    use core::mem::ManuallyDrop;
665    let mut guard = ManuallyDrop::new(buf_mu);
666    let out_f64: &mut [f64] =
667        unsafe { core::slice::from_raw_parts_mut(guard.as_mut_ptr() as *mut f64, guard.len()) };
668
669    if !matches!(
670        kernel,
671        Kernel::Auto | Kernel::ScalarBatch | Kernel::Avx2Batch | Kernel::Avx512Batch
672    ) {
673        return Err(BopError::InvalidKernelForBatch(kernel));
674    }
675
676    let chosen = match kernel {
677        Kernel::Auto => Kernel::ScalarBatch,
678        k => k,
679    };
680    unsafe {
681        match chosen {
682            Kernel::Scalar | Kernel::ScalarBatch => {
683                bop_scalar_from(open, high, low, close, first, out_f64)
684            }
685            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
686            Kernel::Avx2 | Kernel::Avx2Batch => bop_avx2(open, high, low, close, out_f64),
687            #[cfg(not(all(feature = "nightly-avx", target_arch = "x86_64")))]
688            Kernel::Avx2 | Kernel::Avx2Batch => {
689                bop_scalar_from(open, high, low, close, first, out_f64)
690            }
691            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
692            Kernel::Avx512 | Kernel::Avx512Batch => bop_avx512(open, high, low, close, out_f64),
693            #[cfg(not(all(feature = "nightly-avx", target_arch = "x86_64")))]
694            Kernel::Avx512 | Kernel::Avx512Batch => {
695                bop_scalar_from(open, high, low, close, first, out_f64)
696            }
697            _ => unreachable!(),
698        }
699    }
700
701    let values = unsafe {
702        Vec::from_raw_parts(
703            guard.as_mut_ptr() as *mut f64,
704            guard.len(),
705            guard.capacity(),
706        )
707    };
708    core::mem::forget(guard);
709
710    Ok(BopBatchOutput { values, rows, cols })
711}
712
713#[inline(always)]
714fn bop_batch_inner_into(
715    open: &[f64],
716    high: &[f64],
717    low: &[f64],
718    close: &[f64],
719    kernel: Kernel,
720    out: &mut [f64],
721) -> Result<(), BopError> {
722    let len = open.len();
723    if len == 0 {
724        return Err(BopError::EmptyInputData);
725    }
726    if high.len() != len || low.len() != len || close.len() != len {
727        return Err(BopError::InputLengthsMismatch {
728            open_len: len,
729            high_len: high.len(),
730            low_len: low.len(),
731            close_len: close.len(),
732        });
733    }
734    if out.len() != len {
735        return Err(BopError::OutputLengthMismatch {
736            expected: len,
737            got: out.len(),
738        });
739    }
740
741    let warmup_period = (0..len)
742        .find(|&i| !open[i].is_nan() && !high[i].is_nan() && !low[i].is_nan() && !close[i].is_nan())
743        .unwrap_or(len);
744
745    out[..warmup_period].fill(f64::NAN);
746
747    if !matches!(
748        kernel,
749        Kernel::Auto | Kernel::ScalarBatch | Kernel::Avx2Batch | Kernel::Avx512Batch
750    ) {
751        return Err(BopError::InvalidKernelForBatch(kernel));
752    }
753
754    let chosen = match kernel {
755        Kernel::Auto => Kernel::ScalarBatch,
756        k => k,
757    };
758
759    unsafe {
760        match chosen {
761            Kernel::Scalar | Kernel::ScalarBatch => bop_scalar(open, high, low, close, out),
762            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
763            Kernel::Avx2 | Kernel::Avx2Batch => bop_avx2(open, high, low, close, out),
764            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
765            Kernel::Avx512 | Kernel::Avx512Batch => bop_avx512(open, high, low, close, out),
766            _ => unreachable!(),
767        }
768    }
769    Ok(())
770}
771
772#[inline(always)]
773pub fn bop_batch_slice(
774    open: &[f64],
775    high: &[f64],
776    low: &[f64],
777    close: &[f64],
778    kern: Kernel,
779) -> Result<BopBatchOutput, BopError> {
780    bop_batch_with_kernel(open, high, low, close, kern)
781}
782
783#[inline(always)]
784pub fn bop_batch_par_slice(
785    open: &[f64],
786    high: &[f64],
787    low: &[f64],
788    close: &[f64],
789    kern: Kernel,
790) -> Result<BopBatchOutput, BopError> {
791    bop_batch_with_kernel(open, high, low, close, kern)
792}
793
794#[inline(always)]
795fn expand_grid(_r: &BopBatchRange) -> Vec<BopParams> {
796    vec![BopParams {}]
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use crate::skip_if_unsupported;
803    use crate::utilities::data_loader::read_candles_from_csv;
804
805    fn check_bop_partial_params(
806        test_name: &str,
807        kernel: Kernel,
808    ) -> Result<(), Box<dyn std::error::Error>> {
809        skip_if_unsupported!(kernel, test_name);
810        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
811        let candles = read_candles_from_csv(file_path)?;
812
813        let input = BopInput::with_default_candles(&candles);
814        let output = bop_with_kernel(&input, kernel)?;
815        assert_eq!(output.values.len(), candles.close.len());
816        Ok(())
817    }
818
819    fn check_bop_accuracy(
820        test_name: &str,
821        kernel: Kernel,
822    ) -> Result<(), Box<dyn std::error::Error>> {
823        skip_if_unsupported!(kernel, test_name);
824        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
825        let candles = read_candles_from_csv(file_path)?;
826
827        let input = BopInput::with_default_candles(&candles);
828        let bop_result = bop_with_kernel(&input, kernel)?;
829
830        let expected_last_five = [
831            0.045454545454545456,
832            -0.32398753894080995,
833            -0.3844086021505376,
834            0.3547400611620795,
835            -0.5336179295624333,
836        ];
837        let start_index = bop_result.values.len().saturating_sub(5);
838        let result_last_five = &bop_result.values[start_index..];
839        for (i, &v) in result_last_five.iter().enumerate() {
840            assert!(
841                (v - expected_last_five[i]).abs() < 1e-10,
842                "[{}] BOP mismatch at idx {}: got {}, expected {}",
843                test_name,
844                i,
845                v,
846                expected_last_five[i]
847            );
848        }
849        Ok(())
850    }
851
852    fn check_bop_default_candles(
853        test_name: &str,
854        kernel: Kernel,
855    ) -> Result<(), Box<dyn std::error::Error>> {
856        skip_if_unsupported!(kernel, test_name);
857        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
858        let candles = read_candles_from_csv(file_path)?;
859        let input = BopInput::with_default_candles(&candles);
860        match input.data {
861            BopData::Candles { .. } => {}
862            _ => panic!("Expected BopData::Candles"),
863        }
864        let output = bop_with_kernel(&input, kernel)?;
865        assert_eq!(output.values.len(), candles.close.len());
866        Ok(())
867    }
868
869    fn check_bop_with_empty_data(
870        test_name: &str,
871        kernel: Kernel,
872    ) -> Result<(), Box<dyn std::error::Error>> {
873        skip_if_unsupported!(kernel, test_name);
874        let empty: [f64; 0] = [];
875        let params = BopParams::default();
876        let input = BopInput::from_slices(&empty, &empty, &empty, &empty, params);
877        let result = bop_with_kernel(&input, kernel);
878        assert!(
879            result.is_err(),
880            "[{}] Expected an error for empty data",
881            test_name
882        );
883        Ok(())
884    }
885
886    fn check_bop_with_inconsistent_lengths(
887        test_name: &str,
888        kernel: Kernel,
889    ) -> Result<(), Box<dyn std::error::Error>> {
890        skip_if_unsupported!(kernel, test_name);
891        let open = [1.0, 2.0, 3.0];
892        let high = [1.5, 2.5];
893        let low = [0.8, 1.8, 2.8];
894        let close = [1.2, 2.2, 3.2];
895        let params = BopParams::default();
896        let input = BopInput::from_slices(&open, &high, &low, &close, params);
897        let result = bop_with_kernel(&input, kernel);
898        assert!(
899            result.is_err(),
900            "[{}] Expected an error for inconsistent input lengths",
901            test_name
902        );
903        Ok(())
904    }
905
906    fn check_bop_very_small_dataset(
907        test_name: &str,
908        kernel: Kernel,
909    ) -> Result<(), Box<dyn std::error::Error>> {
910        skip_if_unsupported!(kernel, test_name);
911        let open = [10.0];
912        let high = [12.0];
913        let low = [9.5];
914        let close = [11.0];
915        let params = BopParams::default();
916        let input = BopInput::from_slices(&open, &high, &low, &close, params);
917        let result = bop_with_kernel(&input, kernel)?;
918        assert_eq!(result.values.len(), 1);
919        assert!((result.values[0] - 0.4).abs() < 1e-10);
920        Ok(())
921    }
922
923    fn check_bop_with_slice_data_reinput(
924        test_name: &str,
925        kernel: Kernel,
926    ) -> Result<(), Box<dyn std::error::Error>> {
927        skip_if_unsupported!(kernel, test_name);
928        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
929        let candles = read_candles_from_csv(file_path)?;
930
931        let first_input = BopInput::with_default_candles(&candles);
932        let first_result = bop_with_kernel(&first_input, kernel)?;
933
934        let dummy = vec![0.0; first_result.values.len()];
935        let second_input = BopInput::from_slices(
936            &dummy,
937            &dummy,
938            &dummy,
939            &first_result.values,
940            BopParams::default(),
941        );
942        let second_result = bop_with_kernel(&second_input, kernel)?;
943        assert_eq!(second_result.values.len(), first_result.values.len());
944        for (i, &val) in second_result.values.iter().enumerate() {
945            assert!(
946                (val - 0.0).abs() < f64::EPSILON,
947                "[{}] Expected BOP=0.0 for dummy data at idx {}, got {}",
948                test_name,
949                i,
950                val
951            );
952        }
953        Ok(())
954    }
955
956    fn check_bop_nan_handling(
957        test_name: &str,
958        kernel: Kernel,
959    ) -> Result<(), Box<dyn std::error::Error>> {
960        skip_if_unsupported!(kernel, test_name);
961        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
962        let candles = read_candles_from_csv(file_path)?;
963        let input = BopInput::with_default_candles(&candles);
964        let bop_result = bop_with_kernel(&input, kernel)?;
965        if bop_result.values.len() > 240 {
966            for i in 240..bop_result.values.len() {
967                assert!(
968                    !bop_result.values[i].is_nan(),
969                    "[{}] Found NaN at idx {}",
970                    test_name,
971                    i
972                );
973            }
974        }
975        Ok(())
976    }
977
978    fn check_bop_streaming(
979        _test_name: &str,
980        _kernel: Kernel,
981    ) -> Result<(), Box<dyn std::error::Error>> {
982        let open = [10.0, 5.0, 6.0, 10.0, 11.0];
983        let high = [15.0, 6.0, 9.0, 20.0, 13.0];
984        let low = [10.0, 5.0, 4.0, 10.0, 11.0];
985        let close = [14.0, 6.0, 7.0, 12.0, 12.0];
986        let mut s = BopStream::try_new()?;
987        for i in 0..open.len() {
988            let val = s.update(open[i], high[i], low[i], close[i]);
989            let denom = high[i] - low[i];
990            let expected = if denom <= 0.0 {
991                0.0
992            } else {
993                (close[i] - open[i]) / denom
994            };
995            assert!((val - expected).abs() < 1e-12, "stream mismatch");
996        }
997        Ok(())
998    }
999
1000    #[cfg(debug_assertions)]
1001    fn check_bop_no_poison(
1002        test_name: &str,
1003        kernel: Kernel,
1004    ) -> Result<(), Box<dyn std::error::Error>> {
1005        skip_if_unsupported!(kernel, test_name);
1006
1007        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1008        let candles = read_candles_from_csv(file_path)?;
1009
1010        let input = BopInput::with_default_candles(&candles);
1011        let output = bop_with_kernel(&input, kernel)?;
1012
1013        for (i, &val) in output.values.iter().enumerate() {
1014            if val.is_nan() {
1015                continue;
1016            }
1017
1018            let bits = val.to_bits();
1019
1020            if bits == 0x11111111_11111111 {
1021                panic!(
1022                    "[{}] Found alloc_with_nan_prefix poison value {} (0x{:016X}) at index {}",
1023                    test_name, val, bits, i
1024                );
1025            }
1026
1027            if bits == 0x22222222_22222222 {
1028                panic!(
1029                    "[{}] Found init_matrix_prefixes poison value {} (0x{:016X}) at index {}",
1030                    test_name, val, bits, i
1031                );
1032            }
1033
1034            if bits == 0x33333333_33333333 {
1035                panic!(
1036                    "[{}] Found make_uninit_matrix poison value {} (0x{:016X}) at index {}",
1037                    test_name, val, bits, i
1038                );
1039            }
1040        }
1041
1042        Ok(())
1043    }
1044
1045    #[cfg(not(debug_assertions))]
1046    fn check_bop_no_poison(
1047        _test_name: &str,
1048        _kernel: Kernel,
1049    ) -> Result<(), Box<dyn std::error::Error>> {
1050        Ok(())
1051    }
1052
1053    macro_rules! generate_all_bop_tests {
1054        ($($test_fn:ident),*) => {
1055            paste::paste! {
1056                $( #[test] fn [<$test_fn _scalar_f64>]() {
1057                    let _ = $test_fn(stringify!([<$test_fn _scalar_f64>]), Kernel::Scalar);
1058                })*
1059                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1060                $( #[test] fn [<$test_fn _avx2_f64>]() {
1061                    let _ = $test_fn(stringify!([<$test_fn _avx2_f64>]), Kernel::Avx2);
1062                })*
1063                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1064                $( #[test] fn [<$test_fn _avx512_f64>]() {
1065                    let _ = $test_fn(stringify!([<$test_fn _avx512_f64>]), Kernel::Avx512);
1066                })*
1067            }
1068        }
1069    }
1070
1071    #[cfg(feature = "proptest")]
1072    fn check_bop_property(
1073        test_name: &str,
1074        kernel: Kernel,
1075    ) -> Result<(), Box<dyn std::error::Error>> {
1076        use proptest::prelude::*;
1077        skip_if_unsupported!(kernel, test_name);
1078
1079        let strat = (50usize..400).prop_flat_map(|size| {
1080            (
1081                10.0f64..1000.0f64,
1082                0.0f64..0.1f64,
1083                -0.02f64..0.02f64,
1084                prop::collection::vec((0.0f64..1.0, 0.0f64..1.0, 0.0f64..1.0), size),
1085                0u8..5,
1086                Just(size),
1087            )
1088        });
1089
1090        proptest::test_runner::TestRunner::default()
1091            .run(
1092                &strat,
1093                |(base_price, volatility, trend, random_factors, market_type, size)| {
1094                    let mut open = Vec::with_capacity(size);
1095                    let mut high = Vec::with_capacity(size);
1096                    let mut low = Vec::with_capacity(size);
1097                    let mut close = Vec::with_capacity(size);
1098
1099                    let mut current_price = base_price;
1100
1101                    for i in 0..size {
1102                        let range = base_price * volatility;
1103                        let (r1, r2, r3) = random_factors[i];
1104
1105                        let (o, h, l, c) = match market_type {
1106                            0 => {
1107                                let wave = (i as f64 * 0.2).sin();
1108                                let o = current_price + wave * range;
1109                                let movement = range * (r1 - 0.5);
1110                                let c = o + movement;
1111                                let h = o.max(c) + range * r2 * 0.5;
1112                                let l = o.min(c) - range * r3 * 0.5;
1113                                (o, h, l, c)
1114                            }
1115                            1 => {
1116                                let o = current_price;
1117                                current_price *= 1.0 + trend.abs();
1118                                let c = current_price + range * r1;
1119                                let h = c + range * r2;
1120                                let l = o - range * r3 * 0.3;
1121                                (o, h.max(c), l.min(o), c)
1122                            }
1123                            2 => {
1124                                let o = current_price;
1125                                current_price *= 1.0 - trend.abs();
1126                                let c = current_price - range * r1;
1127                                let h = o + range * r2 * 0.3;
1128                                let l = c - range * r3;
1129                                (o, h.max(o), l.min(c), c)
1130                            }
1131                            3 => {
1132                                if r1 < 0.3 {
1133                                    let price = current_price;
1134                                    (price, price, price, price)
1135                                } else {
1136                                    let tiny_move = range * 0.01 * (r2 - 0.5);
1137                                    let o = current_price;
1138                                    let c = current_price + tiny_move;
1139                                    let h = o.max(c) + tiny_move.abs() * 0.1;
1140                                    let l = o.min(c) - tiny_move.abs() * 0.1;
1141                                    (o, h, l, c)
1142                                }
1143                            }
1144                            _ => {
1145                                let o = current_price;
1146                                let big_move = range * 2.0 * (r1 - 0.5);
1147                                let c = current_price + big_move;
1148                                let h = o.max(c) + range * r2 * 2.0;
1149                                let l = o.min(c) - range * r3 * 2.0;
1150                                current_price = c;
1151                                (o, h, l.max(0.1), c)
1152                            }
1153                        };
1154
1155                        let h_final = h.max(o.max(c));
1156                        let l_final = l.min(o.min(c));
1157
1158                        open.push(o);
1159                        high.push(h_final);
1160                        low.push(l_final);
1161                        close.push(c);
1162                    }
1163
1164                    let params = BopParams::default();
1165                    let input = BopInput::from_slices(&open, &high, &low, &close, params);
1166
1167                    let output = bop_with_kernel(&input, kernel).unwrap();
1168                    let ref_output = bop_with_kernel(&input, Kernel::Scalar).unwrap();
1169
1170                    for i in 0..size {
1171                        let y = output.values[i];
1172                        let r = ref_output.values[i];
1173
1174                        if y.is_finite() {
1175                            prop_assert!(
1176                                y >= -1.0 - 1e-9 && y <= 1.0 + 1e-9,
1177                                "[{}] BOP out of range at idx {}: {} (should be in [-1, 1])",
1178                                test_name,
1179                                i,
1180                                y
1181                            );
1182                        }
1183
1184                        let denom = high[i] - low[i];
1185                        if denom <= 0.0 || denom.abs() < f64::EPSILON {
1186                            prop_assert!(
1187                                y.abs() < 1e-9,
1188                                "[{}] BOP should be 0 when High==Low at idx {}: got {}",
1189                                test_name,
1190                                i,
1191                                y
1192                            );
1193                        }
1194
1195                        if (close[i] - open[i]).abs() < f64::EPSILON && denom > f64::EPSILON {
1196                            prop_assert!(
1197                                y.abs() < 1e-9,
1198                                "[{}] BOP should be 0 when Close==Open at idx {}: got {}",
1199                                test_name,
1200                                i,
1201                                y
1202                            );
1203                        }
1204
1205                        if denom > f64::EPSILON {
1206                            let expected = (close[i] - open[i]) / denom;
1207                            prop_assert!(
1208                                (y - expected).abs() < 1e-9,
1209                                "[{}] BOP formula mismatch at idx {}: got {}, expected {}",
1210                                test_name,
1211                                i,
1212                                y,
1213                                expected
1214                            );
1215                        }
1216
1217                        if !y.is_finite() || !r.is_finite() {
1218                            prop_assert!(
1219                                y.to_bits() == r.to_bits(),
1220                                "[{}] Finite/NaN mismatch at idx {}: {} vs {}",
1221                                test_name,
1222                                i,
1223                                y,
1224                                r
1225                            );
1226                        } else {
1227                            let ulp_diff = y.to_bits().abs_diff(r.to_bits());
1228                            prop_assert!(
1229                                (y - r).abs() <= 1e-9 || ulp_diff <= 4,
1230                                "[{}] Kernel mismatch at idx {}: {} vs {} (ULP={})",
1231                                test_name,
1232                                i,
1233                                y,
1234                                r,
1235                                ulp_diff
1236                            );
1237                        }
1238
1239                        if (open[i] - high[i]).abs() < f64::EPSILON
1240                            && (open[i] - low[i]).abs() < f64::EPSILON
1241                            && (open[i] - close[i]).abs() < f64::EPSILON
1242                        {
1243                            prop_assert!(
1244                                y.abs() < 1e-9,
1245                                "[{}] BOP should be 0 for flat candle at idx {}: got {}",
1246                                test_name,
1247                                i,
1248                                y
1249                            );
1250                        }
1251
1252                        if denom > f64::EPSILON {
1253                            let numerator = close[i] - open[i];
1254                            if numerator > f64::EPSILON {
1255                                prop_assert!(
1256								y >= -1e-9,
1257								"[{}] BOP should be non-negative when Close > Open at idx {}: got {}",
1258								test_name, i, y
1259							);
1260                            } else if numerator < -f64::EPSILON {
1261                                prop_assert!(
1262								y <= 1e-9,
1263								"[{}] BOP should be non-positive when Close < Open at idx {}: got {}",
1264								test_name, i, y
1265							);
1266                            }
1267                        }
1268
1269                        if denom > f64::EPSILON {
1270                            if (close[i] - high[i]).abs() < 1e-9 && (open[i] - low[i]).abs() < 1e-9
1271                            {
1272                                prop_assert!(
1273								y >= 1.0 - 1e-6,
1274								"[{}] BOP should approach 1 when Close≈High and Open≈Low at idx {}: got {}",
1275								test_name, i, y
1276							);
1277                            }
1278
1279                            if (close[i] - low[i]).abs() < 1e-9 && (open[i] - high[i]).abs() < 1e-9
1280                            {
1281                                prop_assert!(
1282								y <= -1.0 + 1e-6,
1283								"[{}] BOP should approach -1 when Close≈Low and Open≈High at idx {}: got {}",
1284								test_name, i, y
1285							);
1286                            }
1287                        }
1288                    }
1289
1290                    Ok(())
1291                },
1292            )
1293            .unwrap();
1294
1295        Ok(())
1296    }
1297
1298    generate_all_bop_tests!(
1299        check_bop_partial_params,
1300        check_bop_accuracy,
1301        check_bop_default_candles,
1302        check_bop_with_empty_data,
1303        check_bop_with_inconsistent_lengths,
1304        check_bop_very_small_dataset,
1305        check_bop_with_slice_data_reinput,
1306        check_bop_nan_handling,
1307        check_bop_streaming,
1308        check_bop_no_poison
1309    );
1310
1311    #[cfg(feature = "proptest")]
1312    generate_all_bop_tests!(check_bop_property);
1313    fn check_batch_default_row(
1314        test: &str,
1315        kernel: Kernel,
1316    ) -> Result<(), Box<dyn std::error::Error>> {
1317        skip_if_unsupported!(kernel, test);
1318
1319        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1320        let c = read_candles_from_csv(file)?;
1321
1322        let open = source_type(&c, "open");
1323        let high = source_type(&c, "high");
1324        let low = source_type(&c, "low");
1325        let close = source_type(&c, "close");
1326
1327        let batch_output = BopBatchBuilder::new()
1328            .kernel(kernel)
1329            .apply_slices(open, high, low, close)?;
1330
1331        assert_eq!(batch_output.cols, c.close.len());
1332        assert_eq!(batch_output.rows, 1);
1333
1334        let input = BopInput::from_slices(open, high, low, close, BopParams::default());
1335        let scalar = bop_with_kernel(&input, kernel)?;
1336
1337        for (i, &v) in batch_output.values.iter().enumerate() {
1338            assert!(
1339                (v - scalar.values[i]).abs() < 1e-12,
1340                "[{test}] batch value mismatch at idx {i}: {v} vs {scalar:?}"
1341            );
1342        }
1343        Ok(())
1344    }
1345
1346    macro_rules! gen_batch_tests {
1347        ($fn_name:ident) => {
1348            paste::paste! {
1349                #[test] fn [<$fn_name _scalar>]()      {
1350                    let _ = $fn_name(stringify!([<$fn_name _scalar>]), Kernel::ScalarBatch);
1351                }
1352                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1353                #[test] fn [<$fn_name _avx2>]()        {
1354                    let _ = $fn_name(stringify!([<$fn_name _avx2>]), Kernel::Avx2Batch);
1355                }
1356                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1357                #[test] fn [<$fn_name _avx512>]()      {
1358                    let _ = $fn_name(stringify!([<$fn_name _avx512>]), Kernel::Avx512Batch);
1359                }
1360                #[test] fn [<$fn_name _auto_detect>]() {
1361                    let _ = $fn_name(stringify!([<$fn_name _auto_detect>]), Kernel::Auto);
1362                }
1363            }
1364        };
1365    }
1366    gen_batch_tests!(check_batch_default_row);
1367
1368    #[cfg(debug_assertions)]
1369    fn check_batch_no_poison(test: &str, kernel: Kernel) -> Result<(), Box<dyn std::error::Error>> {
1370        skip_if_unsupported!(kernel, test);
1371
1372        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1373        let c = read_candles_from_csv(file)?;
1374
1375        let open = source_type(&c, "open");
1376        let high = source_type(&c, "high");
1377        let low = source_type(&c, "low");
1378        let close = source_type(&c, "close");
1379
1380        let output = BopBatchBuilder::new()
1381            .kernel(kernel)
1382            .apply_slices(open, high, low, close)?;
1383
1384        for (idx, &val) in output.values.iter().enumerate() {
1385            if val.is_nan() {
1386                continue;
1387            }
1388
1389            let bits = val.to_bits();
1390            let row = idx / output.cols;
1391            let col = idx % output.cols;
1392
1393            if bits == 0x11111111_11111111 {
1394                panic!(
1395					"[{}] Found alloc_with_nan_prefix poison value {} (0x{:016X}) at row {} col {} (flat index {})",
1396					test, val, bits, row, col, idx
1397				);
1398            }
1399
1400            if bits == 0x22222222_22222222 {
1401                panic!(
1402					"[{}] Found init_matrix_prefixes poison value {} (0x{:016X}) at row {} col {} (flat index {})",
1403					test, val, bits, row, col, idx
1404				);
1405            }
1406
1407            if bits == 0x33333333_33333333 {
1408                panic!(
1409					"[{}] Found make_uninit_matrix poison value {} (0x{:016X}) at row {} col {} (flat index {})",
1410					test, val, bits, row, col, idx
1411				);
1412            }
1413        }
1414
1415        Ok(())
1416    }
1417
1418    #[cfg(not(debug_assertions))]
1419    fn check_batch_no_poison(
1420        _test: &str,
1421        _kernel: Kernel,
1422    ) -> Result<(), Box<dyn std::error::Error>> {
1423        Ok(())
1424    }
1425
1426    gen_batch_tests!(check_batch_no_poison);
1427
1428    #[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
1429    #[test]
1430    fn test_bop_into_matches_api() -> Result<(), Box<dyn std::error::Error>> {
1431        let n = 256usize;
1432        let mut ts = Vec::with_capacity(n);
1433        let mut open = Vec::with_capacity(n);
1434        let mut high = Vec::with_capacity(n);
1435        let mut low = Vec::with_capacity(n);
1436        let mut close = Vec::with_capacity(n);
1437        let mut vol = Vec::with_capacity(n);
1438
1439        for i in 0..n {
1440            ts.push(i as i64);
1441            let base = 100.0 + (i as f64) * 0.1;
1442            let o = base + (i as f64).sin() * 0.01;
1443            let c = base + (i as f64).cos() * 0.02;
1444            let h = base + 1.0;
1445            let l = base - 1.0;
1446
1447            open.push(o);
1448            high.push(h);
1449            low.push(l);
1450            close.push(c);
1451            vol.push(1_000.0 + i as f64);
1452        }
1453
1454        open[0] = f64::NAN;
1455        high[0] = f64::NAN;
1456        low[0] = f64::NAN;
1457        close[0] = f64::NAN;
1458
1459        let candles = crate::utilities::data_loader::Candles::new(ts, open, high, low, close, vol);
1460        let input = BopInput::with_default_candles(&candles);
1461
1462        let baseline = bop(&input)?;
1463
1464        let mut out = vec![0.0f64; baseline.values.len()];
1465        bop_into(&input, &mut out)?;
1466
1467        fn eq_or_both_nan(a: f64, b: f64) -> bool {
1468            (a.is_nan() && b.is_nan()) || (a == b)
1469        }
1470
1471        assert_eq!(out.len(), baseline.values.len());
1472        for i in 0..out.len() {
1473            assert!(
1474                eq_or_both_nan(out[i], baseline.values[i]),
1475                "mismatch at index {}: into={} vs api={}",
1476                i,
1477                out[i],
1478                baseline.values[i]
1479            );
1480        }
1481
1482        Ok(())
1483    }
1484}
1485
1486#[cfg(feature = "python")]
1487#[pyfunction(name = "bop")]
1488#[pyo3(signature = (open, high, low, close, *, kernel=None))]
1489pub fn bop_py<'py>(
1490    py: Python<'py>,
1491    open: numpy::PyReadonlyArray1<'py, f64>,
1492    high: numpy::PyReadonlyArray1<'py, f64>,
1493    low: numpy::PyReadonlyArray1<'py, f64>,
1494    close: numpy::PyReadonlyArray1<'py, f64>,
1495    kernel: Option<&str>,
1496) -> PyResult<Bound<'py, numpy::PyArray1<f64>>> {
1497    use numpy::{IntoPyArray, PyArrayMethods};
1498
1499    let open_slice = open.as_slice()?;
1500    let high_slice = high.as_slice()?;
1501    let low_slice = low.as_slice()?;
1502    let close_slice = close.as_slice()?;
1503
1504    let kern = validate_kernel(kernel, false)?;
1505
1506    let params = BopParams::default();
1507    let input = BopInput::from_slices(open_slice, high_slice, low_slice, close_slice, params);
1508
1509    let result_vec: Vec<f64> = py
1510        .allow_threads(|| bop_with_kernel(&input, kern).map(|o| o.values))
1511        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1512
1513    Ok(result_vec.into_pyarray(py))
1514}
1515
1516#[cfg(feature = "python")]
1517#[pyclass(name = "BopStream")]
1518pub struct BopStreamPy {
1519    stream: BopStream,
1520}
1521
1522#[cfg(feature = "python")]
1523#[pymethods]
1524impl BopStreamPy {
1525    #[new]
1526    fn new() -> PyResult<Self> {
1527        let stream = BopStream::try_new().map_err(|e| PyValueError::new_err(e.to_string()))?;
1528        Ok(BopStreamPy { stream })
1529    }
1530
1531    fn update(&mut self, open: f64, high: f64, low: f64, close: f64) -> f64 {
1532        self.stream.update(open, high, low, close)
1533    }
1534}
1535
1536#[cfg(all(feature = "python", feature = "cuda"))]
1537#[pyclass(module = "ta_indicators.cuda", unsendable)]
1538pub struct BopDeviceArrayF32Py {
1539    pub(crate) buf: Option<DeviceBuffer<f32>>,
1540    pub(crate) rows: usize,
1541    pub(crate) cols: usize,
1542    pub(crate) _ctx: Arc<Context>,
1543    pub(crate) device_id: u32,
1544}
1545
1546#[cfg(all(feature = "python", feature = "cuda"))]
1547#[pymethods]
1548impl BopDeviceArrayF32Py {
1549    #[inline]
1550    fn device_ptr(&self) -> u64 {
1551        self.buf
1552            .as_ref()
1553            .map(|b| b.as_device_ptr().as_raw() as u64)
1554            .unwrap_or(0)
1555    }
1556
1557    #[getter]
1558    fn __cuda_array_interface__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
1559        let d = PyDict::new(py);
1560        d.set_item("shape", (self.rows, self.cols))?;
1561        d.set_item("typestr", "<f4")?;
1562        d.set_item(
1563            "strides",
1564            (
1565                self.cols * std::mem::size_of::<f32>(),
1566                std::mem::size_of::<f32>(),
1567            ),
1568        )?;
1569        let buf = self
1570            .buf
1571            .as_ref()
1572            .ok_or_else(|| PyValueError::new_err("buffer already exported via __dlpack__"))?;
1573        let ptr = buf.as_device_ptr().as_raw() as usize;
1574        d.set_item("data", (ptr, false))?;
1575
1576        d.set_item("version", 3)?;
1577        Ok(d)
1578    }
1579
1580    fn __dlpack_device__(&self) -> (i32, i32) {
1581        (2, self.device_id as i32)
1582    }
1583
1584    #[pyo3(signature = (stream=None, max_version=None, dl_device=None, copy=None))]
1585    fn __dlpack__<'py>(
1586        &mut self,
1587        py: Python<'py>,
1588        stream: Option<pyo3::PyObject>,
1589        max_version: Option<pyo3::PyObject>,
1590        dl_device: Option<pyo3::PyObject>,
1591        copy: Option<pyo3::PyObject>,
1592    ) -> PyResult<PyObject> {
1593        let (kdl, alloc_dev) = self.__dlpack_device__();
1594        if let Some(dev_obj) = dl_device.as_ref() {
1595            if let Ok((dev_ty, dev_id)) = dev_obj.extract::<(i32, i32)>(py) {
1596                if dev_ty != kdl || dev_id != alloc_dev {
1597                    let wants_copy = copy
1598                        .as_ref()
1599                        .and_then(|c| c.extract::<bool>(py).ok())
1600                        .unwrap_or(false);
1601                    if wants_copy {
1602                        return Err(PyValueError::new_err(
1603                            "device copy not implemented for __dlpack__",
1604                        ));
1605                    } else {
1606                        return Err(PyValueError::new_err("dl_device mismatch for __dlpack__"));
1607                    }
1608                }
1609            }
1610        }
1611        let _ = stream;
1612
1613        let buf = self
1614            .buf
1615            .take()
1616            .ok_or_else(|| PyValueError::new_err("buffer already exported via __dlpack__"))?;
1617
1618        let rows = self.rows;
1619        let cols = self.cols;
1620        let max_version_bound = max_version.map(|obj| obj.into_bound(py));
1621
1622        export_f32_cuda_dlpack_2d(py, buf, rows, cols, alloc_dev, max_version_bound)
1623    }
1624}
1625
1626#[cfg(feature = "python")]
1627#[pyfunction(name = "bop_batch")]
1628#[pyo3(signature = (open, high, low, close, *, kernel=None))]
1629pub fn bop_batch_py<'py>(
1630    py: Python<'py>,
1631    open: numpy::PyReadonlyArray1<'py, f64>,
1632    high: numpy::PyReadonlyArray1<'py, f64>,
1633    low: numpy::PyReadonlyArray1<'py, f64>,
1634    close: numpy::PyReadonlyArray1<'py, f64>,
1635    kernel: Option<&str>,
1636) -> PyResult<Bound<'py, pyo3::types::PyDict>> {
1637    use numpy::{IntoPyArray, PyArray1, PyArrayMethods};
1638    use pyo3::types::{PyDict, PyList};
1639
1640    let open_slice = open.as_slice()?;
1641    let high_slice = high.as_slice()?;
1642    let low_slice = low.as_slice()?;
1643    let close_slice = close.as_slice()?;
1644
1645    let kern = validate_kernel(kernel, true)?;
1646
1647    let rows = 1usize;
1648    let cols = open_slice.len();
1649    let total = rows
1650        .checked_mul(cols)
1651        .ok_or_else(|| PyValueError::new_err("bop_batch: rows*cols overflow"))?;
1652
1653    let out_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1654    let slice_out = unsafe { out_arr.as_slice_mut()? };
1655
1656    let result = py.allow_threads(|| {
1657        bop_batch_inner_into(
1658            open_slice,
1659            high_slice,
1660            low_slice,
1661            close_slice,
1662            kern,
1663            slice_out,
1664        )
1665    });
1666    if let Err(e) = result {
1667        let msg = match e {
1668            BopError::EmptyInputData => "Input data is empty".to_string(),
1669            BopError::InputLengthsMismatch { .. } => "Input lengths mismatch".to_string(),
1670            _ => e.to_string(),
1671        };
1672        return Err(PyValueError::new_err(msg));
1673    }
1674
1675    let d = PyDict::new(py);
1676    d.set_item("values", out_arr.reshape((rows, cols))?)?;
1677
1678    d.set_item("rows", rows)?;
1679    d.set_item("cols", cols)?;
1680    d.set_item("params", Vec::<f64>::new().into_pyarray(py))?;
1681
1682    d.set_item("periods", Vec::<u64>::new().into_pyarray(py))?;
1683    d.set_item("offsets", Vec::<f64>::new().into_pyarray(py))?;
1684    d.set_item("sigmas", Vec::<f64>::new().into_pyarray(py))?;
1685    Ok(d)
1686}
1687
1688pub fn bop_into_slice(
1689    dst: &mut [f64],
1690    open: &[f64],
1691    high: &[f64],
1692    low: &[f64],
1693    close: &[f64],
1694    kern: Kernel,
1695) -> Result<(), BopError> {
1696    let len = open.len();
1697    if len == 0 {
1698        return Err(BopError::EmptyInputData);
1699    }
1700    if high.len() != len || low.len() != len || close.len() != len {
1701        return Err(BopError::InputLengthsMismatch {
1702            open_len: len,
1703            high_len: high.len(),
1704            low_len: low.len(),
1705            close_len: close.len(),
1706        });
1707    }
1708    if dst.len() != len {
1709        return Err(BopError::OutputLengthMismatch {
1710            expected: len,
1711            got: dst.len(),
1712        });
1713    }
1714
1715    let first = (0..len)
1716        .find(|&i| !open[i].is_nan() && !high[i].is_nan() && !low[i].is_nan() && !close[i].is_nan())
1717        .unwrap_or(len);
1718
1719    let chosen = match kern {
1720        Kernel::Auto => Kernel::Scalar,
1721        k => k,
1722    };
1723
1724    unsafe {
1725        match chosen {
1726            Kernel::Scalar | Kernel::ScalarBatch => {
1727                bop_scalar_from(open, high, low, close, first, dst)
1728            }
1729            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1730            Kernel::Avx2 | Kernel::Avx2Batch => bop_avx2(open, high, low, close, dst),
1731            #[cfg(not(all(feature = "nightly-avx", target_arch = "x86_64")))]
1732            Kernel::Avx2 | Kernel::Avx2Batch => bop_scalar_from(open, high, low, close, first, dst),
1733            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1734            Kernel::Avx512 | Kernel::Avx512Batch => bop_avx512(open, high, low, close, dst),
1735            #[cfg(not(all(feature = "nightly-avx", target_arch = "x86_64")))]
1736            Kernel::Avx512 | Kernel::Avx512Batch => {
1737                bop_scalar_from(open, high, low, close, first, dst)
1738            }
1739            _ => unreachable!(),
1740        }
1741    }
1742
1743    for v in &mut dst[..first] {
1744        *v = f64::NAN;
1745    }
1746    Ok(())
1747}
1748
1749#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1750#[wasm_bindgen]
1751pub fn bop_js(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Result<Vec<f64>, JsValue> {
1752    let mut output = vec![0.0; open.len()];
1753
1754    bop_into_slice(&mut output, open, high, low, close, Kernel::Auto)
1755        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1756
1757    Ok(output)
1758}
1759
1760#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1761#[wasm_bindgen]
1762pub fn bop_into(
1763    open_ptr: *const f64,
1764    high_ptr: *const f64,
1765    low_ptr: *const f64,
1766    close_ptr: *const f64,
1767    out_ptr: *mut f64,
1768    len: usize,
1769) -> Result<(), JsValue> {
1770    if open_ptr.is_null()
1771        || high_ptr.is_null()
1772        || low_ptr.is_null()
1773        || close_ptr.is_null()
1774        || out_ptr.is_null()
1775    {
1776        return Err(JsValue::from_str("Null pointer provided"));
1777    }
1778
1779    unsafe {
1780        let open = std::slice::from_raw_parts(open_ptr, len);
1781        let high = std::slice::from_raw_parts(high_ptr, len);
1782        let low = std::slice::from_raw_parts(low_ptr, len);
1783        let close = std::slice::from_raw_parts(close_ptr, len);
1784
1785        if open_ptr == out_ptr || high_ptr == out_ptr || low_ptr == out_ptr || close_ptr == out_ptr
1786        {
1787            let out = std::slice::from_raw_parts_mut(out_ptr, len);
1788
1789            let warmup_period = (0..len)
1790                .find(|&i| {
1791                    !open[i].is_nan() && !high[i].is_nan() && !low[i].is_nan() && !close[i].is_nan()
1792                })
1793                .unwrap_or(len);
1794
1795            for v in &mut out[..warmup_period] {
1796                *v = f64::NAN;
1797            }
1798
1799            for i in warmup_period..len {
1800                let denom = high[i] - low[i];
1801                out[i] = if denom <= 0.0 {
1802                    0.0
1803                } else {
1804                    (close[i] - open[i]) / denom
1805                };
1806            }
1807        } else {
1808            let out = std::slice::from_raw_parts_mut(out_ptr, len);
1809            bop_into_slice(out, open, high, low, close, Kernel::Auto)
1810                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1811        }
1812
1813        Ok(())
1814    }
1815}
1816
1817#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1818#[wasm_bindgen]
1819pub fn bop_alloc(len: usize) -> *mut f64 {
1820    let mut vec = Vec::<f64>::with_capacity(len);
1821    let ptr = vec.as_mut_ptr();
1822    std::mem::forget(vec);
1823    ptr
1824}
1825
1826#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1827#[wasm_bindgen]
1828pub fn bop_free(ptr: *mut f64, len: usize) {
1829    if !ptr.is_null() {
1830        unsafe {
1831            let _ = Vec::from_raw_parts(ptr, len, len);
1832        }
1833    }
1834}
1835
1836#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1837#[wasm_bindgen]
1838pub fn bop_batch_js(
1839    open: &[f64],
1840    high: &[f64],
1841    low: &[f64],
1842    close: &[f64],
1843) -> Result<Vec<f64>, JsValue> {
1844    let mut output = vec![0.0; open.len()];
1845
1846    bop_into_slice(&mut output, open, high, low, close, Kernel::Auto)
1847        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1848
1849    Ok(output)
1850}
1851
1852#[cfg(all(feature = "python", feature = "cuda"))]
1853#[pyfunction(name = "bop_cuda_batch_dev")]
1854#[pyo3(signature = (open, high, low, close, device_id=0))]
1855pub fn bop_cuda_batch_dev_py(
1856    py: Python<'_>,
1857    open: numpy::PyReadonlyArray1<'_, f32>,
1858    high: numpy::PyReadonlyArray1<'_, f32>,
1859    low: numpy::PyReadonlyArray1<'_, f32>,
1860    close: numpy::PyReadonlyArray1<'_, f32>,
1861    device_id: usize,
1862) -> PyResult<Py<BopDeviceArrayF32Py>> {
1863    use crate::cuda::cuda_available;
1864    if !cuda_available() {
1865        return Err(PyValueError::new_err("CUDA not available"));
1866    }
1867    let open_slice = open.as_slice()?;
1868    let high_slice = high.as_slice()?;
1869    let low_slice = low.as_slice()?;
1870    let close_slice = close.as_slice()?;
1871    if open_slice.len() == 0
1872        || high_slice.len() != open_slice.len()
1873        || low_slice.len() != open_slice.len()
1874        || close_slice.len() != open_slice.len()
1875    {
1876        return Err(PyValueError::new_err("empty or mismatched OHLC lengths"));
1877    }
1878    let (inner, ctx, dev_id) = py.allow_threads(|| {
1879        let cuda = CudaBop::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
1880        let ctx = cuda.context_arc();
1881        let dev_id = cuda.device_id();
1882        let dev = cuda
1883            .bop_batch_dev(open_slice, high_slice, low_slice, close_slice)
1884            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1885        Ok::<_, PyErr>((dev, ctx, dev_id))
1886    })?;
1887    let handle = BopDeviceArrayF32Py {
1888        buf: Some(inner.buf),
1889        rows: inner.rows,
1890        cols: inner.cols,
1891        _ctx: ctx,
1892        device_id: dev_id,
1893    };
1894    let obj = Py::new(py, handle)?;
1895    Ok(obj)
1896}
1897
1898#[cfg(all(feature = "python", feature = "cuda"))]
1899#[pyfunction(name = "bop_cuda_many_series_one_param_dev")]
1900#[pyo3(signature = (open_tm, high_tm, low_tm, close_tm, cols, rows, device_id=0))]
1901pub fn bop_cuda_many_series_one_param_dev_py(
1902    py: Python<'_>,
1903    open_tm: numpy::PyReadonlyArray1<'_, f32>,
1904    high_tm: numpy::PyReadonlyArray1<'_, f32>,
1905    low_tm: numpy::PyReadonlyArray1<'_, f32>,
1906    close_tm: numpy::PyReadonlyArray1<'_, f32>,
1907    cols: usize,
1908    rows: usize,
1909    device_id: usize,
1910) -> PyResult<Py<BopDeviceArrayF32Py>> {
1911    use crate::cuda::cuda_available;
1912    if !cuda_available() {
1913        return Err(PyValueError::new_err("CUDA not available"));
1914    }
1915    let open_slice = open_tm.as_slice()?;
1916    let high_slice = high_tm.as_slice()?;
1917    let low_slice = low_tm.as_slice()?;
1918    let close_slice = close_tm.as_slice()?;
1919    let expected = cols
1920        .checked_mul(rows)
1921        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
1922    if open_slice.len() != expected
1923        || high_slice.len() != expected
1924        || low_slice.len() != expected
1925        || close_slice.len() != expected
1926    {
1927        return Err(PyValueError::new_err("time-major input length mismatch"));
1928    }
1929    let (inner, ctx, dev_id) = py.allow_threads(|| {
1930        let cuda = CudaBop::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
1931        let ctx = cuda.context_arc();
1932        let dev_id = cuda.device_id();
1933        let dev = cuda
1934            .bop_many_series_one_param_time_major_dev(
1935                open_slice,
1936                high_slice,
1937                low_slice,
1938                close_slice,
1939                cols,
1940                rows,
1941            )
1942            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1943        Ok::<_, PyErr>((dev, ctx, dev_id))
1944    })?;
1945    let handle = BopDeviceArrayF32Py {
1946        buf: Some(inner.buf),
1947        rows: inner.rows,
1948        cols: inner.cols,
1949        _ctx: ctx,
1950        device_id: dev_id,
1951    };
1952    let obj = Py::new(py, handle)?;
1953    Ok(obj)
1954}
1955
1956#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1957#[wasm_bindgen]
1958pub fn bop_batch_into(
1959    open_ptr: *const f64,
1960    high_ptr: *const f64,
1961    low_ptr: *const f64,
1962    close_ptr: *const f64,
1963    out_ptr: *mut f64,
1964    len: usize,
1965) -> Result<usize, JsValue> {
1966    if open_ptr.is_null()
1967        || high_ptr.is_null()
1968        || low_ptr.is_null()
1969        || close_ptr.is_null()
1970        || out_ptr.is_null()
1971    {
1972        return Err(JsValue::from_str("Null pointer provided"));
1973    }
1974
1975    unsafe {
1976        let open = std::slice::from_raw_parts(open_ptr, len);
1977        let high = std::slice::from_raw_parts(high_ptr, len);
1978        let low = std::slice::from_raw_parts(low_ptr, len);
1979        let close = std::slice::from_raw_parts(close_ptr, len);
1980        let out = std::slice::from_raw_parts_mut(out_ptr, len);
1981
1982        bop_into_slice(out, open, high, low, close, Kernel::Auto)
1983            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1984
1985        Ok(1)
1986    }
1987}
1988
1989#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1990#[wasm_bindgen]
1991pub fn bop_batch_metadata_js() -> Result<Vec<f64>, JsValue> {
1992    Ok(Vec::new())
1993}
1994
1995#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1996#[derive(Serialize, Deserialize)]
1997pub struct BopBatchConfig {}
1998
1999#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2000#[derive(Serialize, Deserialize)]
2001pub struct BopBatchJsOutput {
2002    pub values: Vec<f64>,
2003    pub rows: usize,
2004    pub cols: usize,
2005}
2006
2007#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2008#[wasm_bindgen(js_name = bop_batch)]
2009pub fn bop_batch_unified_js(
2010    open: &[f64],
2011    high: &[f64],
2012    low: &[f64],
2013    close: &[f64],
2014    _config: JsValue,
2015) -> Result<JsValue, JsValue> {
2016    let out = bop_batch_with_kernel(open, high, low, close, Kernel::Auto)
2017        .map_err(|e| JsValue::from_str(&e.to_string()))?;
2018    let js = BopBatchJsOutput {
2019        values: out.values,
2020        rows: out.rows,
2021        cols: out.cols,
2022    };
2023    serde_wasm_bindgen::to_value(&js)
2024        .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
2025}