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}