1use crate::utilities::data_loader::{source_type, Candles};
2use crate::utilities::enums::Kernel;
3use crate::utilities::helpers::{
4 alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
5 make_uninit_matrix,
6};
7use aligned_vec::{AVec, CACHELINE_ALIGN};
8#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
9use core::arch::x86_64::*;
10#[cfg(not(target_arch = "wasm32"))]
11use rayon::prelude::*;
12use std::convert::AsRef;
13use std::error::Error;
14use thiserror::Error;
15
16#[derive(Debug, Clone)]
17pub enum ObvData<'a> {
18 Candles { candles: &'a Candles },
19 Slices { close: &'a [f64], volume: &'a [f64] },
20}
21
22#[derive(Debug, Clone)]
23pub struct ObvOutput {
24 pub values: Vec<f64>,
25}
26
27#[derive(Debug, Clone, Default)]
28pub struct ObvParams;
29
30#[derive(Debug, Clone)]
31pub struct ObvInput<'a> {
32 pub data: ObvData<'a>,
33 pub params: ObvParams,
34}
35
36impl<'a> ObvInput<'a> {
37 #[inline]
38 pub fn from_candles(candles: &'a Candles, params: ObvParams) -> Self {
39 Self {
40 data: ObvData::Candles { candles },
41 params,
42 }
43 }
44 #[inline]
45 pub fn from_slices(close: &'a [f64], volume: &'a [f64], params: ObvParams) -> Self {
46 Self {
47 data: ObvData::Slices { close, volume },
48 params,
49 }
50 }
51 #[inline]
52 pub fn with_default_candles(candles: &'a Candles) -> Self {
53 Self::from_candles(candles, ObvParams::default())
54 }
55 #[inline(always)]
56 fn as_refs(&self) -> (&'a [f64], &'a [f64]) {
57 match &self.data {
58 ObvData::Candles { candles } => (
59 source_type(candles, "close"),
60 source_type(candles, "volume"),
61 ),
62 ObvData::Slices { close, volume } => (*close, *volume),
63 }
64 }
65}
66
67#[derive(Copy, Clone, Debug)]
68pub struct ObvBuilder {
69 kernel: Kernel,
70}
71
72impl Default for ObvBuilder {
73 fn default() -> Self {
74 Self {
75 kernel: Kernel::Auto,
76 }
77 }
78}
79
80impl ObvBuilder {
81 #[inline(always)]
82 pub fn new() -> Self {
83 Self::default()
84 }
85 #[inline(always)]
86 pub fn kernel(mut self, k: Kernel) -> Self {
87 self.kernel = k;
88 self
89 }
90 #[inline(always)]
91 pub fn apply(self, candles: &Candles) -> Result<ObvOutput, ObvError> {
92 let i = ObvInput::from_candles(candles, ObvParams::default());
93 obv_with_kernel(&i, self.kernel)
94 }
95 #[inline(always)]
96 pub fn apply_slices(self, close: &[f64], volume: &[f64]) -> Result<ObvOutput, ObvError> {
97 let i = ObvInput::from_slices(close, volume, ObvParams::default());
98 obv_with_kernel(&i, self.kernel)
99 }
100 #[inline(always)]
101 pub fn into_stream(self) -> ObvStream {
102 ObvStream::new()
103 }
104}
105
106#[derive(Debug, Error)]
107pub enum ObvError {
108 #[error("obv: Input data slice is empty.")]
109 EmptyInputData,
110 #[error("obv: Data length mismatch: close_len = {close_len}, volume_len = {volume_len}")]
111 DataLengthMismatch { close_len: usize, volume_len: usize },
112 #[error("obv: All values are NaN.")]
113 AllValuesNaN,
114 #[error("obv: Output length mismatch: expected {expected}, got {got}")]
115 OutputLengthMismatch { expected: usize, got: usize },
116 #[error("obv: Invalid period: period = {period}, data length = {data_len}")]
117 InvalidPeriod { period: usize, data_len: usize },
118 #[error("obv: Not enough valid data: needed = {needed}, valid = {valid}")]
119 NotEnoughValidData { needed: usize, valid: usize },
120 #[error("obv: Invalid range: start={start}, end={end}, step={step}")]
121 InvalidRange {
122 start: String,
123 end: String,
124 step: String,
125 },
126 #[error("obv: Invalid kernel for batch: {0:?}")]
127 InvalidKernelForBatch(crate::utilities::enums::Kernel),
128}
129
130impl From<Box<dyn std::error::Error>> for ObvError {
131 fn from(_: Box<dyn std::error::Error>) -> Self {
132 ObvError::EmptyInputData
133 }
134}
135
136#[inline]
137pub fn obv(input: &ObvInput) -> Result<ObvOutput, ObvError> {
138 obv_with_kernel(input, Kernel::Auto)
139}
140
141pub fn obv_with_kernel(input: &ObvInput, kernel: Kernel) -> Result<ObvOutput, ObvError> {
142 let (close, volume) = input.as_refs();
143
144 if close.is_empty() || volume.is_empty() {
145 return Err(ObvError::EmptyInputData);
146 }
147 if close.len() != volume.len() {
148 return Err(ObvError::DataLengthMismatch {
149 close_len: close.len(),
150 volume_len: volume.len(),
151 });
152 }
153 let first = close
154 .iter()
155 .zip(volume.iter())
156 .position(|(c, v)| !c.is_nan() && !v.is_nan())
157 .ok_or(ObvError::AllValuesNaN)?;
158
159 let mut out = alloc_with_nan_prefix(close.len(), first);
160
161 let chosen = match kernel {
162 Kernel::Auto => Kernel::Scalar,
163 other => other,
164 };
165
166 unsafe {
167 match chosen {
168 Kernel::Scalar | Kernel::ScalarBatch => obv_scalar(close, volume, first, &mut out),
169 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
170 Kernel::Avx2 | Kernel::Avx2Batch => obv_avx2(close, volume, first, &mut out),
171 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
172 Kernel::Avx512 | Kernel::Avx512Batch => obv_avx512(close, volume, first, &mut out),
173 _ => unreachable!(),
174 }
175 }
176
177 Ok(ObvOutput { values: out })
178}
179
180#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
181#[inline]
182pub fn obv_into(input: &ObvInput, out: &mut [f64]) -> Result<(), ObvError> {
183 let (close, volume) = input.as_refs();
184 obv_into_slice(out, close, volume, Kernel::Auto)
185}
186
187#[inline]
188pub fn obv_scalar(close: &[f64], volume: &[f64], first_valid: usize, out: &mut [f64]) {
189 let mut prev_obv = 0.0f64;
190 let mut prev_close = close[first_valid];
191 out[first_valid] = 0.0;
192
193 let tail_close = &close[first_valid + 1..];
194 let tail_volume = &volume[first_valid + 1..];
195 let tail_out = &mut out[first_valid + 1..];
196
197 for (dst, (&c, &v)) in tail_out
198 .iter_mut()
199 .zip(tail_close.iter().zip(tail_volume.iter()))
200 {
201 let s = ((c > prev_close) as i32 - (c < prev_close) as i32) as f64;
202 prev_obv = v.mul_add(s, prev_obv);
203 *dst = prev_obv;
204 prev_close = c;
205 }
206}
207
208#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
209#[inline]
210pub unsafe fn obv_avx2(close: &[f64], volume: &[f64], first_valid: usize, out: &mut [f64]) {
211 use core::arch::x86_64::*;
212 let len = close.len();
213 let mut prev_obv = 0.0f64;
214 let mut prev_close = *close.get_unchecked(first_valid);
215 *out.get_unchecked_mut(first_valid) = 0.0;
216
217 let mut i = first_valid + 1;
218 let end = len;
219
220 let one = _mm_set1_pd(1.0);
221 let neg_one = _mm_set1_pd(-1.0);
222 let zero = _mm_setzero_pd();
223
224 while i + 1 < end {
225 let c = _mm_loadu_pd(close.as_ptr().add(i));
226
227 let prev = _mm_set_pd(*close.get_unchecked(i), prev_close);
228
229 let gt = _mm_cmpgt_pd(c, prev);
230 let lt = _mm_cmplt_pd(c, prev);
231 let pos = _mm_and_pd(gt, one);
232 let neg = _mm_and_pd(lt, neg_one);
233 let sign = _mm_add_pd(pos, neg);
234
235 let vol = _mm_loadu_pd(volume.as_ptr().add(i));
236 let dv = _mm_mul_pd(vol, sign);
237
238 let dv0 = _mm_cvtsd_f64(dv);
239 let dv1 = _mm_cvtsd_f64(_mm_unpackhi_pd(dv, dv));
240
241 let res0 = dv0 + prev_obv;
242 let res1 = dv1 + res0;
243
244 let res = _mm_set_pd(res1, res0);
245 _mm_storeu_pd(out.as_mut_ptr().add(i), res);
246
247 prev_obv = res1;
248
249 let c_hi = _mm_unpackhi_pd(c, c);
250 prev_close = _mm_cvtsd_f64(c_hi);
251
252 i += 2;
253 }
254
255 if i < end {
256 let c = *close.get_unchecked(i);
257 let v = *volume.get_unchecked(i);
258 let s = ((c > prev_close) as i32 - (c < prev_close) as i32) as f64;
259 prev_obv = v.mul_add(s, prev_obv);
260 *out.get_unchecked_mut(i) = prev_obv;
261 }
262}
263
264#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
265#[inline]
266pub unsafe fn obv_avx512(close: &[f64], volume: &[f64], first_valid: usize, out: &mut [f64]) {
267 obv_avx2(close, volume, first_valid, out)
268}
269
270#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
271#[inline]
272pub unsafe fn obv_avx512_short(close: &[f64], volume: &[f64], first_valid: usize, out: &mut [f64]) {
273 obv_avx2(close, volume, first_valid, out)
274}
275
276#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
277#[inline]
278pub unsafe fn obv_avx512_long(close: &[f64], volume: &[f64], first_valid: usize, out: &mut [f64]) {
279 obv_avx2(close, volume, first_valid, out)
280}
281
282#[inline(always)]
283pub unsafe fn obv_row_scalar(
284 close: &[f64],
285 volume: &[f64],
286 first: usize,
287 _period: usize,
288 _stride: usize,
289 _w_ptr: *const f64,
290 _inv_n: f64,
291 out: &mut [f64],
292) {
293 obv_scalar(close, volume, first, out)
294}
295
296#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
297#[inline(always)]
298pub unsafe fn obv_row_avx2(
299 close: &[f64],
300 volume: &[f64],
301 first: usize,
302 _period: usize,
303 _stride: usize,
304 _w_ptr: *const f64,
305 _inv_n: f64,
306 out: &mut [f64],
307) {
308 obv_avx2(close, volume, first, out)
309}
310
311#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
312#[inline(always)]
313pub unsafe fn obv_row_avx512(
314 close: &[f64],
315 volume: &[f64],
316 first: usize,
317 _period: usize,
318 _stride: usize,
319 _w_ptr: *const f64,
320 _inv_n: f64,
321 out: &mut [f64],
322) {
323 obv_avx512(close, volume, first, out)
324}
325
326#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
327#[inline(always)]
328pub unsafe fn obv_row_avx512_short(
329 close: &[f64],
330 volume: &[f64],
331 first: usize,
332 _period: usize,
333 _stride: usize,
334 _w_ptr: *const f64,
335 _inv_n: f64,
336 out: &mut [f64],
337) {
338 obv_avx512_short(close, volume, first, out)
339}
340
341#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
342#[inline(always)]
343pub unsafe fn obv_row_avx512_long(
344 close: &[f64],
345 volume: &[f64],
346 first: usize,
347 _period: usize,
348 _stride: usize,
349 _w_ptr: *const f64,
350 _inv_n: f64,
351 out: &mut [f64],
352) {
353 obv_avx512_long(close, volume, first, out)
354}
355
356#[derive(Clone, Debug)]
357pub struct ObvStream {
358 prev_close: f64,
359 prev_obv: f64,
360 initialized: bool,
361}
362
363impl ObvStream {
364 #[inline(always)]
365 pub fn new() -> Self {
366 Self {
367 prev_close: f64::NAN,
368 prev_obv: 0.0,
369 initialized: false,
370 }
371 }
372
373 #[inline(always)]
374 pub fn update(&mut self, close: f64, volume: f64) -> Option<f64> {
375 if !self.initialized {
376 if !close.is_nan() && !volume.is_nan() {
377 self.prev_close = close;
378 self.prev_obv = 0.0;
379 self.initialized = true;
380 return Some(0.0);
381 } else {
382 return None;
383 }
384 }
385
386 let s = ((close > self.prev_close) as i32 - (close < self.prev_close) as i32) as f64;
387
388 self.prev_obv = volume.mul_add(s, self.prev_obv);
389
390 self.prev_close = close;
391
392 Some(self.prev_obv)
393 }
394
395 #[inline(always)]
396 pub fn last(&self) -> Option<f64> {
397 if self.initialized {
398 Some(self.prev_obv)
399 } else {
400 None
401 }
402 }
403
404 #[inline(always)]
405 pub fn reset(&mut self) {
406 self.prev_close = f64::NAN;
407 self.prev_obv = 0.0;
408 self.initialized = false;
409 }
410}
411
412#[derive(Clone, Debug)]
413pub struct ObvBatchRange {
414 pub reserved: usize,
415}
416
417impl Default for ObvBatchRange {
418 fn default() -> Self {
419 Self { reserved: 1 }
420 }
421}
422
423#[derive(Clone, Debug, Default)]
424pub struct ObvBatchBuilder {
425 kernel: Kernel,
426}
427
428impl ObvBatchBuilder {
429 pub fn new() -> Self {
430 Self::default()
431 }
432 pub fn kernel(mut self, k: Kernel) -> Self {
433 self.kernel = k;
434 self
435 }
436 pub fn apply_slices(self, close: &[f64], volume: &[f64]) -> Result<ObvBatchOutput, ObvError> {
437 obv_batch_with_kernel(close, volume, self.kernel)
438 }
439 pub fn apply_candles(self, c: &Candles) -> Result<ObvBatchOutput, ObvError> {
440 let close = source_type(c, "close");
441 let volume = source_type(c, "volume");
442 self.apply_slices(close, volume)
443 }
444 pub fn with_default_candles(c: &Candles) -> Result<ObvBatchOutput, ObvError> {
445 ObvBatchBuilder::new().kernel(Kernel::Auto).apply_candles(c)
446 }
447}
448
449pub struct ObvBatchOutput {
450 pub values: Vec<f64>,
451 pub rows: usize,
452 pub cols: usize,
453}
454
455pub fn obv_batch_with_kernel(
456 close: &[f64],
457 volume: &[f64],
458 kernel: Kernel,
459) -> Result<ObvBatchOutput, ObvError> {
460 let chosen = match kernel {
461 Kernel::Auto => detect_best_batch_kernel(),
462 other if other.is_batch() => other,
463 other => return Err(ObvError::InvalidKernelForBatch(other)),
464 };
465 obv_batch_par_slice(close, volume, chosen)
466}
467
468#[inline(always)]
469pub fn obv_batch_slice(
470 close: &[f64],
471 volume: &[f64],
472 kern: Kernel,
473) -> Result<ObvBatchOutput, ObvError> {
474 obv_batch_inner(close, volume, kern, false)
475}
476
477#[inline(always)]
478pub fn obv_batch_par_slice(
479 close: &[f64],
480 volume: &[f64],
481 kern: Kernel,
482) -> Result<ObvBatchOutput, ObvError> {
483 obv_batch_inner(close, volume, kern, true)
484}
485
486#[inline(always)]
487fn obv_batch_inner(
488 close: &[f64],
489 volume: &[f64],
490 kern: Kernel,
491 _parallel: bool,
492) -> Result<ObvBatchOutput, ObvError> {
493 if close.is_empty() || volume.is_empty() {
494 return Err(ObvError::EmptyInputData);
495 }
496 if close.len() != volume.len() {
497 return Err(ObvError::DataLengthMismatch {
498 close_len: close.len(),
499 volume_len: volume.len(),
500 });
501 }
502 let first = close
503 .iter()
504 .zip(volume.iter())
505 .position(|(c, v)| !c.is_nan() && !v.is_nan())
506 .ok_or(ObvError::AllValuesNaN)?;
507
508 let rows = 1usize;
509 let cols = close.len();
510
511 let _ = rows
512 .checked_mul(cols)
513 .ok_or_else(|| ObvError::InvalidRange {
514 start: rows.to_string(),
515 end: cols.to_string(),
516 step: "rows*cols".into(),
517 })?;
518
519 let mut buf_mu = make_uninit_matrix(rows, cols);
520 init_matrix_prefixes(&mut buf_mu, cols, &[first]);
521
522 let mut guard = core::mem::ManuallyDrop::new(buf_mu);
523 let out_slice: &mut [f64] =
524 unsafe { core::slice::from_raw_parts_mut(guard.as_mut_ptr() as *mut f64, rows * cols) };
525
526 unsafe {
527 match kern {
528 Kernel::ScalarBatch | Kernel::Scalar => obv_row_scalar(
529 close,
530 volume,
531 first,
532 0,
533 0,
534 core::ptr::null(),
535 0.0,
536 out_slice,
537 ),
538 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
539 Kernel::Avx2Batch | Kernel::Avx2 => obv_row_avx2(
540 close,
541 volume,
542 first,
543 0,
544 0,
545 core::ptr::null(),
546 0.0,
547 out_slice,
548 ),
549 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
550 Kernel::Avx512Batch | Kernel::Avx512 => obv_row_avx512(
551 close,
552 volume,
553 first,
554 0,
555 0,
556 core::ptr::null(),
557 0.0,
558 out_slice,
559 ),
560 _ => unreachable!(),
561 }
562 }
563
564 let values = unsafe {
565 Vec::from_raw_parts(
566 guard.as_mut_ptr() as *mut f64,
567 rows * cols,
568 guard.capacity(),
569 )
570 };
571 Ok(ObvBatchOutput { values, rows, cols })
572}
573
574#[inline(always)]
575fn obv_batch_inner_into(
576 close: &[f64],
577 volume: &[f64],
578 kern: Kernel,
579 out: &mut [f64],
580) -> Result<(), ObvError> {
581 if close.is_empty() || volume.is_empty() {
582 return Err(ObvError::EmptyInputData);
583 }
584 if close.len() != volume.len() {
585 return Err(ObvError::DataLengthMismatch {
586 close_len: close.len(),
587 volume_len: volume.len(),
588 });
589 }
590 if out.len() != close.len() {
591 return Err(ObvError::OutputLengthMismatch {
592 expected: close.len(),
593 got: out.len(),
594 });
595 }
596 let first = close
597 .iter()
598 .zip(volume.iter())
599 .position(|(c, v)| !c.is_nan() && !v.is_nan())
600 .ok_or(ObvError::AllValuesNaN)?;
601
602 for v in &mut out[..first] {
603 *v = f64::NAN;
604 }
605
606 unsafe {
607 match kern {
608 Kernel::ScalarBatch | Kernel::Scalar => {
609 obv_row_scalar(close, volume, first, 0, 0, core::ptr::null(), 0.0, out)
610 }
611 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
612 Kernel::Avx2Batch | Kernel::Avx2 => {
613 obv_row_avx2(close, volume, first, 0, 0, core::ptr::null(), 0.0, out)
614 }
615 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
616 Kernel::Avx512Batch | Kernel::Avx512 => {
617 obv_row_avx512(close, volume, first, 0, 0, core::ptr::null(), 0.0, out)
618 }
619 _ => unreachable!(),
620 }
621 }
622 Ok(())
623}
624
625#[inline(always)]
626fn expand_grid(_r: &ObvBatchRange) -> Vec<ObvParams> {
627 vec![ObvParams::default()]
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use crate::skip_if_unsupported;
634 use crate::utilities::data_loader::read_candles_from_csv;
635
636 #[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
637 #[test]
638 fn test_obv_into_matches_api() {
639 let n = 256usize;
640 let mut close = vec![f64::NAN; n];
641 let mut volume = vec![f64::NAN; n];
642
643 for i in 0..n {
644 if i >= 5 {
645 let base = 100.0 + ((i as i32 % 11) - 5) as f64;
646 let wiggle = ((i as f64) * 0.03).sin();
647 close[i] = base + wiggle;
648 }
649 if i >= 7 {
650 let v = ((i * 37) % 1000) as f64;
651 volume[i] = if i % 10 == 0 { 0.0 } else { v + 0.5 };
652 }
653 }
654
655 let input = ObvInput::from_slices(&close, &volume, ObvParams::default());
656 let baseline = obv(&input).expect("baseline obv").values;
657
658 let mut out = vec![0.0; n];
659 obv_into(&input, &mut out).expect("obv_into");
660
661 assert_eq!(baseline.len(), out.len());
662
663 fn eq_or_both_nan(a: f64, b: f64) -> bool {
664 (a.is_nan() && b.is_nan()) || (a == b) || ((a - b).abs() <= 1e-12)
665 }
666
667 for i in 0..n {
668 assert!(
669 eq_or_both_nan(baseline[i], out[i]),
670 "Mismatch at {}: baseline={} into={}",
671 i,
672 baseline[i],
673 out[i]
674 );
675 }
676 }
677 fn check_obv_empty_data(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
678 skip_if_unsupported!(kernel, test_name);
679 let close: [f64; 0] = [];
680 let volume: [f64; 0] = [];
681 let input = ObvInput::from_slices(&close, &volume, ObvParams::default());
682 let result = obv_with_kernel(&input, kernel);
683 assert!(result.is_err(), "Expected error for empty data");
684 Ok(())
685 }
686 fn check_obv_data_length_mismatch(
687 test_name: &str,
688 kernel: Kernel,
689 ) -> Result<(), Box<dyn Error>> {
690 skip_if_unsupported!(kernel, test_name);
691 let close = [1.0, 2.0, 3.0];
692 let volume = [100.0, 200.0];
693 let input = ObvInput::from_slices(&close, &volume, ObvParams::default());
694 let result = obv_with_kernel(&input, kernel);
695 assert!(result.is_err(), "Expected error for mismatched data length");
696 Ok(())
697 }
698 fn check_obv_all_nan(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
699 skip_if_unsupported!(kernel, test_name);
700 let close = [f64::NAN, f64::NAN];
701 let volume = [f64::NAN, f64::NAN];
702 let input = ObvInput::from_slices(&close, &volume, ObvParams::default());
703 let result = obv_with_kernel(&input, kernel);
704 assert!(result.is_err(), "Expected error for all NaN data");
705 Ok(())
706 }
707 fn check_obv_csv_accuracy(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
708 skip_if_unsupported!(kernel, test_name);
709 let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
710 let candles = read_candles_from_csv(file_path)?;
711 let close = source_type(&candles, "close");
712 let volume = source_type(&candles, "volume");
713 let input = ObvInput::from_candles(&candles, ObvParams::default());
714 let obv_result = obv_with_kernel(&input, kernel)?;
715 assert_eq!(obv_result.values.len(), close.len());
716 let last_five_expected = [
717 -329661.6180239202,
718 -329767.87639284023,
719 -329889.94421654026,
720 -329801.35075036023,
721 -330218.2007503602,
722 ];
723 let start_idx = obv_result.values.len() - 5;
724 let result_tail = &obv_result.values[start_idx..];
725 for (i, &val) in result_tail.iter().enumerate() {
726 let exp_val = last_five_expected[i];
727 let diff = (val - exp_val).abs();
728 assert!(
729 diff < 1e-6,
730 "OBV mismatch at tail index {}: expected {}, got {}",
731 i,
732 exp_val,
733 val
734 );
735 }
736 Ok(())
737 }
738
739 macro_rules! generate_all_obv_tests {
740 ($($test_fn:ident),*) => {
741 paste::paste! {
742 $(
743 #[test]
744 fn [<$test_fn _scalar_f64>]() {
745 let _ = $test_fn(stringify!([<$test_fn _scalar_f64>]), Kernel::Scalar);
746 }
747 )*
748 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
749 $(
750 #[test]
751 fn [<$test_fn _avx2_f64>]() {
752 let _ = $test_fn(stringify!([<$test_fn _avx2_f64>]), Kernel::Avx2);
753 }
754 #[test]
755 fn [<$test_fn _avx512_f64>]() {
756 let _ = $test_fn(stringify!([<$test_fn _avx512_f64>]), Kernel::Avx512);
757 }
758 )*
759 }
760 }
761 }
762
763 #[cfg(debug_assertions)]
764 fn check_obv_no_poison(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
765 skip_if_unsupported!(kernel, test_name);
766
767 let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
768 let candles = read_candles_from_csv(file_path)?;
769 let close = source_type(&candles, "close");
770 let volume = source_type(&candles, "volume");
771
772 let test_params = vec![ObvParams::default()];
773
774 for (param_idx, params) in test_params.iter().enumerate() {
775 let input = ObvInput::from_candles(&candles, params.clone());
776 let output = obv_with_kernel(&input, kernel)?;
777
778 for (i, &val) in output.values.iter().enumerate() {
779 if val.is_nan() {
780 continue;
781 }
782
783 let bits = val.to_bits();
784
785 if bits == 0x11111111_11111111 {
786 panic!(
787 "[{}] Found alloc_with_nan_prefix poison value {} (0x{:016X}) at index {} \
788 with params: {:?} (param set {})",
789 test_name, val, bits, i, params, param_idx
790 );
791 }
792
793 if bits == 0x22222222_22222222 {
794 panic!(
795 "[{}] Found init_matrix_prefixes poison value {} (0x{:016X}) at index {} \
796 with params: {:?} (param set {})",
797 test_name, val, bits, i, params, param_idx
798 );
799 }
800
801 if bits == 0x33333333_33333333 {
802 panic!(
803 "[{}] Found make_uninit_matrix poison value {} (0x{:016X}) at index {} \
804 with params: {:?} (param set {})",
805 test_name, val, bits, i, params, param_idx
806 );
807 }
808 }
809 }
810
811 Ok(())
812 }
813
814 #[cfg(not(debug_assertions))]
815 fn check_obv_no_poison(_test_name: &str, _kernel: Kernel) -> Result<(), Box<dyn Error>> {
816 Ok(())
817 }
818
819 #[cfg(debug_assertions)]
820 fn check_batch_no_poison(test: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
821 skip_if_unsupported!(kernel, test);
822
823 let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
824 let c = read_candles_from_csv(file)?;
825
826 let test_configs = vec!["Testing OBV batch with default configuration"];
827
828 for (cfg_idx, _config_name) in test_configs.iter().enumerate() {
829 let output = ObvBatchBuilder::new().kernel(kernel).apply_candles(&c)?;
830
831 for (idx, &val) in output.values.iter().enumerate() {
832 if val.is_nan() {
833 continue;
834 }
835
836 let bits = val.to_bits();
837 let row = idx / output.cols;
838 let col = idx % output.cols;
839
840 if bits == 0x11111111_11111111 {
841 panic!(
842 "[{}] Config {}: Found alloc_with_nan_prefix poison value {} (0x{:016X}) \
843 at row {} col {} (flat index {}) with OBV (no params)",
844 test, cfg_idx, val, bits, row, col, idx
845 );
846 }
847
848 if bits == 0x22222222_22222222 {
849 panic!(
850 "[{}] Config {}: Found init_matrix_prefixes poison value {} (0x{:016X}) \
851 at row {} col {} (flat index {}) with OBV (no params)",
852 test, cfg_idx, val, bits, row, col, idx
853 );
854 }
855
856 if bits == 0x33333333_33333333 {
857 panic!(
858 "[{}] Config {}: Found make_uninit_matrix poison value {} (0x{:016X}) \
859 at row {} col {} (flat index {}) with OBV (no params)",
860 test, cfg_idx, val, bits, row, col, idx
861 );
862 }
863 }
864 }
865
866 Ok(())
867 }
868
869 #[cfg(not(debug_assertions))]
870 fn check_batch_no_poison(_test: &str, _kernel: Kernel) -> Result<(), Box<dyn Error>> {
871 Ok(())
872 }
873
874 #[cfg(feature = "proptest")]
875 #[allow(clippy::float_cmp)]
876 fn check_obv_property(
877 test_name: &str,
878 kernel: Kernel,
879 ) -> Result<(), Box<dyn std::error::Error>> {
880 use proptest::prelude::*;
881 skip_if_unsupported!(kernel, test_name);
882
883 let strat = prop::collection::vec(
884 (
885 (-1e6f64..1e6f64).prop_filter("finite close", |x| x.is_finite()),
886 (0f64..1e6f64)
887 .prop_filter("finite positive volume", |x| x.is_finite() && *x >= 0.0),
888 ),
889 10..400,
890 );
891
892 proptest::test_runner::TestRunner::default().run(&strat, |price_volume_pairs| {
893 let (close, volume): (Vec<f64>, Vec<f64>) = price_volume_pairs.into_iter().unzip();
894
895 let input = ObvInput::from_slices(&close, &volume, ObvParams::default());
896 let ObvOutput { values: out } = obv_with_kernel(&input, kernel)?;
897 let ObvOutput { values: ref_out } = obv_with_kernel(&input, Kernel::Scalar)?;
898
899 let first_valid = close
900 .iter()
901 .zip(volume.iter())
902 .position(|(c, v)| !c.is_nan() && !v.is_nan());
903
904 prop_assert_eq!(
905 first_valid,
906 Some(0),
907 "Expected first valid index to be 0 for finite input data"
908 );
909
910 if let Some(first_idx) = first_valid {
911 for i in 0..first_idx {
912 prop_assert!(
913 out[i].is_nan(),
914 "Expected NaN at index {} (before first_valid), got {}",
915 i,
916 out[i]
917 );
918 }
919
920 for i in first_idx..out.len() {
921 prop_assert!(
922 !out[i].is_nan(),
923 "Expected valid value at index {} (after first_valid), got NaN",
924 i
925 );
926 }
927
928 prop_assert_eq!(
929 out[first_idx],
930 0.0,
931 "First valid OBV at index {} should be 0, got {}",
932 first_idx,
933 out[first_idx]
934 );
935
936 for i in (first_idx + 1)..close.len() {
937 if !out[i].is_nan() && i > 0 && !out[i - 1].is_nan() {
938 let obv_diff = out[i] - out[i - 1];
939 let price_diff = close[i] - close[i - 1];
940
941 if price_diff > 0.0 {
942 prop_assert!(
943 (obv_diff - volume[i]).abs() < 1e-9,
944 "At index {}: OBV diff {} should equal volume {} (price increased)",
945 i,
946 obv_diff,
947 volume[i]
948 );
949 } else if price_diff < 0.0 {
950 prop_assert!(
951 (obv_diff + volume[i]).abs() < 1e-9,
952 "At index {}: OBV diff {} should equal -volume {} (price decreased)",
953 i, obv_diff, -volume[i]
954 );
955 } else {
956 prop_assert!(
957 obv_diff.abs() < 1e-9,
958 "At index {}: OBV should not change when price is unchanged, diff = {}",
959 i, obv_diff
960 );
961 }
962 }
963 }
964
965 for i in 0..out.len() {
966 if out[i].is_nan() && ref_out[i].is_nan() {
967 continue;
968 }
969 prop_assert!(
970 (out[i] - ref_out[i]).abs() < 1e-9,
971 "Kernel mismatch at index {}: {} (kernel) vs {} (scalar)",
972 i,
973 out[i],
974 ref_out[i]
975 );
976 }
977
978 for (i, &val) in out.iter().enumerate() {
979 if !val.is_nan() {
980 let bits = val.to_bits();
981 prop_assert!(
982 bits != 0x11111111_11111111
983 && bits != 0x22222222_22222222
984 && bits != 0x33333333_33333333,
985 "Found poison value at index {}: {} (0x{:016X})",
986 i,
987 val,
988 bits
989 );
990 }
991 }
992
993 if close.windows(2).all(|w| (w[0] - w[1]).abs() < 1e-9) {
994 for i in first_idx..out.len() {
995 if !out[i].is_nan() {
996 prop_assert!(
997 out[i].abs() < 1e-9,
998 "OBV should remain at 0 for constant price, got {} at index {}",
999 out[i],
1000 i
1001 );
1002 }
1003 }
1004 }
1005
1006 if close.windows(2).all(|w| w[1] > w[0]) {
1007 let mut expected_obv = 0.0;
1008 for i in first_idx..out.len() {
1009 if i > first_idx {
1010 expected_obv += volume[i];
1011 }
1012 if !out[i].is_nan() {
1013 prop_assert!(
1014 (out[i] - expected_obv).abs() < 1e-9,
1015 "For monotonic increasing price at index {}: expected OBV {}, got {}",
1016 i, expected_obv, out[i]
1017 );
1018 }
1019 }
1020 }
1021
1022 if close.windows(2).all(|w| w[1] < w[0]) {
1023 let mut expected_obv = 0.0;
1024 for i in first_idx..out.len() {
1025 if i > first_idx {
1026 expected_obv -= volume[i];
1027 }
1028 if !out[i].is_nan() {
1029 prop_assert!(
1030 (out[i] - expected_obv).abs() < 1e-9,
1031 "For monotonic decreasing price at index {}: expected OBV {}, got {}",
1032 i, expected_obv, out[i]
1033 );
1034 }
1035 }
1036 }
1037
1038 for i in (first_idx + 1)..close.len() {
1039 if volume[i] == 0.0 && i > 0 && !out[i].is_nan() && !out[i - 1].is_nan() {
1040 prop_assert!(
1041 (out[i] - out[i - 1]).abs() < 1e-9,
1042 "OBV should not change when volume is 0 at index {}, but changed from {} to {}",
1043 i, out[i - 1], out[i]
1044 );
1045 }
1046 }
1047
1048 let max_possible_obv = 1e6 * (out.len() as f64);
1049 for (i, &val) in out.iter().enumerate() {
1050 if !val.is_nan() {
1051 prop_assert!(
1052 val.abs() <= max_possible_obv,
1053 "OBV at index {} exceeds reasonable bounds: {} > {}",
1054 i,
1055 val.abs(),
1056 max_possible_obv
1057 );
1058 }
1059 }
1060 }
1061
1062 Ok(())
1063 })?;
1064
1065 Ok(())
1066 }
1067
1068 generate_all_obv_tests!(
1069 check_obv_empty_data,
1070 check_obv_data_length_mismatch,
1071 check_obv_all_nan,
1072 check_obv_csv_accuracy,
1073 check_obv_no_poison
1074 );
1075
1076 #[cfg(feature = "proptest")]
1077 generate_all_obv_tests!(check_obv_property);
1078}
1079
1080pub fn obv_into_slice(
1081 dst: &mut [f64],
1082 close: &[f64],
1083 volume: &[f64],
1084 kern: Kernel,
1085) -> Result<(), ObvError> {
1086 if close.is_empty() || volume.is_empty() {
1087 return Err(ObvError::EmptyInputData);
1088 }
1089 if close.len() != volume.len() {
1090 return Err(ObvError::DataLengthMismatch {
1091 close_len: close.len(),
1092 volume_len: volume.len(),
1093 });
1094 }
1095 if dst.len() != close.len() {
1096 return Err(ObvError::OutputLengthMismatch {
1097 expected: close.len(),
1098 got: dst.len(),
1099 });
1100 }
1101
1102 let first = close
1103 .iter()
1104 .zip(volume.iter())
1105 .position(|(c, v)| !c.is_nan() && !v.is_nan())
1106 .ok_or(ObvError::AllValuesNaN)?;
1107
1108 let chosen = match kern {
1109 Kernel::Auto => Kernel::Scalar,
1110 other => other,
1111 };
1112
1113 unsafe {
1114 match chosen {
1115 Kernel::Scalar | Kernel::ScalarBatch => obv_scalar(close, volume, first, dst),
1116 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1117 Kernel::Avx2 | Kernel::Avx2Batch => obv_avx2(close, volume, first, dst),
1118 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1119 Kernel::Avx512 | Kernel::Avx512Batch => obv_avx512(close, volume, first, dst),
1120 _ => unreachable!(),
1121 }
1122 }
1123
1124 for v in &mut dst[..first] {
1125 *v = f64::NAN;
1126 }
1127 Ok(())
1128}
1129
1130#[cfg(feature = "python")]
1131use crate::utilities::kernel_validation::validate_kernel;
1132#[cfg(feature = "python")]
1133use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
1134#[cfg(feature = "python")]
1135use pyo3::exceptions::PyValueError;
1136#[cfg(feature = "python")]
1137use pyo3::prelude::*;
1138#[cfg(feature = "python")]
1139use pyo3::types::PyDict;
1140#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1141use serde::{Deserialize, Serialize};
1142#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1143use wasm_bindgen::prelude::*;
1144
1145#[cfg(feature = "python")]
1146#[pyfunction(name = "obv")]
1147#[pyo3(signature = (close, volume, kernel=None))]
1148pub fn obv_py<'py>(
1149 py: Python<'py>,
1150 close: PyReadonlyArray1<'py, f64>,
1151 volume: PyReadonlyArray1<'py, f64>,
1152 kernel: Option<&str>,
1153) -> PyResult<Bound<'py, PyArray1<f64>>> {
1154 let close_slice: &[f64];
1155 let volume_slice: &[f64];
1156 let owned_close;
1157 let owned_volume;
1158 close_slice = if let Ok(s) = close.as_slice() {
1159 s
1160 } else {
1161 owned_close = close.to_owned_array();
1162 owned_close.as_slice().unwrap()
1163 };
1164 volume_slice = if let Ok(s) = volume.as_slice() {
1165 s
1166 } else {
1167 owned_volume = volume.to_owned_array();
1168 owned_volume.as_slice().unwrap()
1169 };
1170 let kern = validate_kernel(kernel, false)?;
1171
1172 let input = ObvInput::from_slices(close_slice, volume_slice, ObvParams::default());
1173
1174 let result_vec: Vec<f64> = py
1175 .allow_threads(|| obv_with_kernel(&input, kern).map(|o| o.values))
1176 .map_err(|e| PyValueError::new_err(e.to_string()))?;
1177
1178 Ok(result_vec.into_pyarray(py))
1179}
1180
1181#[cfg(feature = "python")]
1182#[pyclass(name = "ObvStream")]
1183pub struct ObvStreamPy {
1184 stream: ObvStream,
1185}
1186
1187#[cfg(feature = "python")]
1188#[pymethods]
1189impl ObvStreamPy {
1190 #[new]
1191 pub fn new() -> PyResult<Self> {
1192 Ok(ObvStreamPy {
1193 stream: ObvStream::new(),
1194 })
1195 }
1196
1197 pub fn update(&mut self, close: f64, volume: f64) -> Option<f64> {
1198 self.stream.update(close, volume)
1199 }
1200}
1201
1202#[cfg(feature = "python")]
1203#[pyfunction(name = "obv_batch")]
1204#[pyo3(signature = (close, volume, kernel=None))]
1205pub fn obv_batch_py<'py>(
1206 py: Python<'py>,
1207 close: PyReadonlyArray1<'py, f64>,
1208 volume: PyReadonlyArray1<'py, f64>,
1209 kernel: Option<&str>,
1210) -> PyResult<Bound<'py, PyDict>> {
1211 let close_slice: &[f64];
1212 let volume_slice: &[f64];
1213 let owned_close;
1214 let owned_volume;
1215 close_slice = if let Ok(s) = close.as_slice() {
1216 s
1217 } else {
1218 owned_close = close.to_owned_array();
1219 owned_close.as_slice().unwrap()
1220 };
1221 volume_slice = if let Ok(s) = volume.as_slice() {
1222 s
1223 } else {
1224 owned_volume = volume.to_owned_array();
1225 owned_volume.as_slice().unwrap()
1226 };
1227 let kern = validate_kernel(kernel, true)?;
1228
1229 let rows: usize = 1;
1230 let cols = close_slice.len();
1231
1232 let expected = rows
1233 .checked_mul(cols)
1234 .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
1235 let out_arr = unsafe { PyArray1::<f64>::new(py, [expected], false) };
1236 let slice_out = unsafe { out_arr.as_slice_mut()? };
1237
1238 py.allow_threads(|| {
1239 let kernel = match kern {
1240 Kernel::Auto => detect_best_batch_kernel(),
1241 k => k,
1242 };
1243
1244 obv_batch_inner_into(close_slice, volume_slice, kernel, slice_out)
1245 })
1246 .map_err(|e| PyValueError::new_err(e.to_string()))?;
1247
1248 let dict = PyDict::new(py);
1249 dict.set_item("values", out_arr.reshape((rows, cols))?)?;
1250
1251 Ok(dict)
1252}
1253
1254#[cfg(all(feature = "python", feature = "cuda"))]
1255use crate::cuda::CudaObv;
1256#[cfg(all(feature = "python", feature = "cuda"))]
1257use crate::utilities::dlpack_cuda::{make_device_array_py, DeviceArrayF32Py};
1258
1259#[cfg(all(feature = "python", feature = "cuda"))]
1260#[pyfunction(name = "obv_cuda_batch_dev")]
1261#[pyo3(signature = (close, volume, device_id=0))]
1262pub fn obv_cuda_batch_dev_py(
1263 py: Python<'_>,
1264 close: PyReadonlyArray1<'_, f32>,
1265 volume: PyReadonlyArray1<'_, f32>,
1266 device_id: usize,
1267) -> PyResult<DeviceArrayF32Py> {
1268 use crate::cuda::cuda_available;
1269 if !cuda_available() {
1270 return Err(PyValueError::new_err("CUDA not available"));
1271 }
1272
1273 let close_slice = close.as_slice()?;
1274 let volume_slice = volume.as_slice()?;
1275 if close_slice.len() != volume_slice.len() {
1276 return Err(PyValueError::new_err("mismatched input lengths"));
1277 }
1278
1279 let inner = py.allow_threads(|| {
1280 let cuda = CudaObv::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
1281 cuda.obv_batch_dev(close_slice, volume_slice)
1282 .map_err(|e| PyValueError::new_err(e.to_string()))
1283 })?;
1284
1285 let dev = make_device_array_py(device_id, inner)?;
1286 Ok(dev)
1287}
1288
1289#[cfg(all(feature = "python", feature = "cuda"))]
1290#[pyfunction(name = "obv_cuda_many_series_one_param_dev")]
1291#[pyo3(signature = (close_tm, volume_tm, cols, rows, device_id=0))]
1292pub fn obv_cuda_many_series_one_param_dev_py(
1293 py: Python<'_>,
1294 close_tm: PyReadonlyArray1<'_, f32>,
1295 volume_tm: PyReadonlyArray1<'_, f32>,
1296 cols: usize,
1297 rows: usize,
1298 device_id: usize,
1299) -> PyResult<DeviceArrayF32Py> {
1300 use crate::cuda::cuda_available;
1301 if !cuda_available() {
1302 return Err(PyValueError::new_err("CUDA not available"));
1303 }
1304
1305 let close_slice = close_tm.as_slice()?;
1306 let volume_slice = volume_tm.as_slice()?;
1307 let elems = cols
1308 .checked_mul(rows)
1309 .ok_or_else(|| PyValueError::new_err("cols*rows overflow"))?;
1310 if close_slice.len() != volume_slice.len() || close_slice.len() != elems {
1311 return Err(PyValueError::new_err("mismatched input sizes or dims"));
1312 }
1313
1314 let inner = py.allow_threads(|| {
1315 let cuda = CudaObv::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
1316 cuda.obv_many_series_one_param_time_major_dev(close_slice, volume_slice, cols, rows)
1317 .map_err(|e| PyValueError::new_err(e.to_string()))
1318 })?;
1319
1320 let dev = make_device_array_py(device_id, inner)?;
1321 Ok(dev)
1322}
1323
1324#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1325#[wasm_bindgen]
1326pub fn obv_js(close: &[f64], volume: &[f64]) -> Result<Vec<f64>, JsValue> {
1327 let mut output = vec![0.0; close.len()];
1328
1329 obv_into_slice(&mut output, close, volume, Kernel::Auto)
1330 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1331
1332 Ok(output)
1333}
1334
1335#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1336#[wasm_bindgen]
1337pub fn obv_into(
1338 close_ptr: *const f64,
1339 volume_ptr: *const f64,
1340 out_ptr: *mut f64,
1341 len: usize,
1342) -> Result<(), JsValue> {
1343 if close_ptr.is_null() || volume_ptr.is_null() || out_ptr.is_null() {
1344 return Err(JsValue::from_str("Null pointer passed to obv_into"));
1345 }
1346
1347 unsafe {
1348 let close = std::slice::from_raw_parts(close_ptr, len);
1349 let volume = std::slice::from_raw_parts(volume_ptr, len);
1350
1351 if close_ptr == out_ptr || volume_ptr == out_ptr {
1352 let mut temp = vec![0.0; len];
1353 obv_into_slice(&mut temp, close, volume, Kernel::Auto)
1354 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1355 let out = std::slice::from_raw_parts_mut(out_ptr, len);
1356 out.copy_from_slice(&temp);
1357 } else {
1358 let out = std::slice::from_raw_parts_mut(out_ptr, len);
1359 obv_into_slice(out, close, volume, Kernel::Auto)
1360 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1361 }
1362
1363 Ok(())
1364 }
1365}
1366
1367#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1368#[wasm_bindgen]
1369pub fn obv_alloc(len: usize) -> *mut f64 {
1370 let mut vec = Vec::<f64>::with_capacity(len);
1371 let ptr = vec.as_mut_ptr();
1372 std::mem::forget(vec);
1373 ptr
1374}
1375
1376#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1377#[wasm_bindgen]
1378pub fn obv_free(ptr: *mut f64, len: usize) {
1379 if !ptr.is_null() {
1380 unsafe {
1381 let _ = Vec::from_raw_parts(ptr, len, len);
1382 }
1383 }
1384}
1385
1386#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1387#[wasm_bindgen]
1388pub fn obv_batch_into(
1389 close_ptr: *const f64,
1390 volume_ptr: *const f64,
1391 out_ptr: *mut f64,
1392 len: usize,
1393) -> Result<usize, JsValue> {
1394 if close_ptr.is_null() || volume_ptr.is_null() || out_ptr.is_null() {
1395 return Err(JsValue::from_str("null pointer passed to obv_batch_into"));
1396 }
1397 unsafe {
1398 let close = std::slice::from_raw_parts(close_ptr, len);
1399 let volume = std::slice::from_raw_parts(volume_ptr, len);
1400 let out = std::slice::from_raw_parts_mut(out_ptr, len);
1401 obv_into_slice(out, close, volume, Kernel::Auto)
1402 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1403 }
1404 Ok(1)
1405}
1406
1407#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1408#[derive(Serialize, Deserialize)]
1409pub struct ObvBatchJsOutput {
1410 pub values: Vec<f64>,
1411 pub rows: usize,
1412 pub cols: usize,
1413}
1414
1415#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1416#[wasm_bindgen(js_name = obv_batch)]
1417pub fn obv_batch_js(close: &[f64], volume: &[f64]) -> Result<JsValue, JsValue> {
1418 let mut output = vec![0.0; close.len()];
1419
1420 obv_into_slice(&mut output, close, volume, Kernel::Auto)
1421 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1422
1423 let js_output = ObvBatchJsOutput {
1424 values: output,
1425 rows: 1,
1426 cols: close.len(),
1427 };
1428
1429 serde_wasm_bindgen::to_value(&js_output)
1430 .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
1431}
1432
1433#[cfg(feature = "python")]
1434pub fn register_obv_module(m: &Bound<'_, pyo3::types::PyModule>) -> PyResult<()> {
1435 m.add_function(wrap_pyfunction!(obv_py, m)?)?;
1436 m.add_function(wrap_pyfunction!(obv_batch_py, m)?)?;
1437 Ok(())
1438}