Skip to main content

vector_ta/indicators/
obv.rs

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}