Skip to main content

vector_ta/indicators/
pvi.rs

1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7#[cfg(feature = "python")]
8use pyo3::types::PyDict;
9#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
10use serde::{Deserialize, Serialize};
11#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
12use wasm_bindgen::prelude::*;
13
14use crate::utilities::data_loader::{source_type, Candles};
15use crate::utilities::enums::Kernel;
16use crate::utilities::helpers::{
17    alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
18    make_uninit_matrix,
19};
20#[cfg(feature = "python")]
21use crate::utilities::kernel_validation::validate_kernel;
22use aligned_vec::{AVec, CACHELINE_ALIGN};
23#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
24use core::arch::x86_64::*;
25#[cfg(not(target_arch = "wasm32"))]
26use rayon::prelude::*;
27use std::convert::AsRef;
28use thiserror::Error;
29
30impl<'a> AsRef<[f64]> for PviInput<'a> {
31    #[inline(always)]
32    fn as_ref(&self) -> &[f64] {
33        match &self.data {
34            PviData::Slices { close, .. } => close,
35            PviData::Candles {
36                candles,
37                close_source,
38                ..
39            } => source_type(candles, close_source),
40        }
41    }
42}
43
44#[derive(Debug, Clone)]
45pub enum PviData<'a> {
46    Candles {
47        candles: &'a Candles,
48        close_source: &'a str,
49        volume_source: &'a str,
50    },
51    Slices {
52        close: &'a [f64],
53        volume: &'a [f64],
54    },
55}
56
57#[derive(Debug, Clone)]
58pub struct PviOutput {
59    pub values: Vec<f64>,
60}
61
62#[derive(Debug, Clone)]
63#[cfg_attr(
64    all(target_arch = "wasm32", feature = "wasm"),
65    derive(Serialize, Deserialize)
66)]
67pub struct PviParams {
68    pub initial_value: Option<f64>,
69}
70
71impl Default for PviParams {
72    fn default() -> Self {
73        Self {
74            initial_value: Some(1000.0),
75        }
76    }
77}
78
79#[derive(Debug, Clone)]
80pub struct PviInput<'a> {
81    pub data: PviData<'a>,
82    pub params: PviParams,
83}
84
85impl<'a> PviInput<'a> {
86    #[inline]
87    pub fn from_candles(
88        candles: &'a Candles,
89        close_source: &'a str,
90        volume_source: &'a str,
91        params: PviParams,
92    ) -> Self {
93        Self {
94            data: PviData::Candles {
95                candles,
96                close_source,
97                volume_source,
98            },
99            params,
100        }
101    }
102    #[inline]
103    pub fn from_slices(close: &'a [f64], volume: &'a [f64], params: PviParams) -> Self {
104        Self {
105            data: PviData::Slices { close, volume },
106            params,
107        }
108    }
109    #[inline]
110    pub fn with_default_candles(candles: &'a Candles) -> Self {
111        Self::from_candles(candles, "close", "volume", PviParams::default())
112    }
113    #[inline]
114    pub fn get_initial_value(&self) -> f64 {
115        self.params.initial_value.unwrap_or(1000.0)
116    }
117}
118
119#[derive(Copy, Clone, Debug)]
120pub struct PviBuilder {
121    initial_value: Option<f64>,
122    kernel: Kernel,
123}
124
125impl Default for PviBuilder {
126    fn default() -> Self {
127        Self {
128            initial_value: None,
129            kernel: Kernel::Auto,
130        }
131    }
132}
133
134impl PviBuilder {
135    #[inline(always)]
136    pub fn new() -> Self {
137        Self::default()
138    }
139    #[inline(always)]
140    pub fn initial_value(mut self, v: f64) -> Self {
141        self.initial_value = Some(v);
142        self
143    }
144    #[inline(always)]
145    pub fn kernel(mut self, k: Kernel) -> Self {
146        self.kernel = k;
147        self
148    }
149    #[inline(always)]
150    pub fn apply(self, c: &Candles) -> Result<PviOutput, PviError> {
151        let p = PviParams {
152            initial_value: self.initial_value,
153        };
154        let i = PviInput::from_candles(c, "close", "volume", p);
155        pvi_with_kernel(&i, self.kernel)
156    }
157    #[inline(always)]
158    pub fn apply_slice(self, close: &[f64], volume: &[f64]) -> Result<PviOutput, PviError> {
159        let p = PviParams {
160            initial_value: self.initial_value,
161        };
162        let i = PviInput::from_slices(close, volume, p);
163        pvi_with_kernel(&i, self.kernel)
164    }
165    #[inline(always)]
166    pub fn into_stream(self) -> Result<PviStream, PviError> {
167        let p = PviParams {
168            initial_value: self.initial_value,
169        };
170        PviStream::try_new(p)
171    }
172}
173
174#[derive(Debug, Error)]
175pub enum PviError {
176    #[error("pvi: Empty data provided.")]
177    EmptyInputData,
178    #[error("pvi: All values are NaN.")]
179    AllValuesNaN,
180    #[error("pvi: close and volume data have different lengths")]
181    MismatchedLength,
182    #[error("pvi: Not enough valid data: needed at least {needed} valid data points, got {valid}")]
183    NotEnoughValidData { needed: usize, valid: usize },
184    #[error("pvi: output length mismatch: expected {expected}, got {got}")]
185    OutputLengthMismatch { expected: usize, got: usize },
186    #[error("pvi: invalid range: start={start}, end={end}, step={step}")]
187    InvalidRange { start: f64, end: f64, step: f64 },
188    #[error("pvi: invalid kernel for batch: {0:?}")]
189    InvalidKernelForBatch(Kernel),
190    #[error("pvi: invalid input: {0}")]
191    InvalidInput(String),
192}
193
194#[inline]
195pub fn pvi(input: &PviInput) -> Result<PviOutput, PviError> {
196    pvi_with_kernel(input, Kernel::Auto)
197}
198
199pub fn pvi_with_kernel(input: &PviInput, kernel: Kernel) -> Result<PviOutput, PviError> {
200    let (close, volume) = match &input.data {
201        PviData::Candles {
202            candles,
203            close_source,
204            volume_source,
205        } => {
206            let c = source_type(candles, close_source);
207            let v = source_type(candles, volume_source);
208            (c, v)
209        }
210        PviData::Slices { close, volume } => (*close, *volume),
211    };
212
213    if close.is_empty() || volume.is_empty() {
214        return Err(PviError::EmptyInputData);
215    }
216    if close.len() != volume.len() {
217        return Err(PviError::MismatchedLength);
218    }
219    let first_valid_idx = close
220        .iter()
221        .zip(volume.iter())
222        .position(|(&c, &v)| !c.is_nan() && !v.is_nan())
223        .ok_or(PviError::AllValuesNaN)?;
224    let valid = close.len() - first_valid_idx;
225    if valid < 2 {
226        return Err(PviError::NotEnoughValidData { needed: 2, valid });
227    }
228
229    let mut out = alloc_with_nan_prefix(close.len(), first_valid_idx);
230    let chosen = match kernel {
231        Kernel::Auto => match detect_best_kernel() {
232            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
233            Kernel::Avx512 => Kernel::Avx2,
234            other => other,
235        },
236        other => other,
237    };
238    unsafe {
239        match chosen {
240            Kernel::Scalar | Kernel::ScalarBatch => pvi_scalar(
241                close,
242                volume,
243                first_valid_idx,
244                input.get_initial_value(),
245                &mut out,
246            ),
247            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
248            Kernel::Avx2 | Kernel::Avx2Batch => pvi_avx2(
249                close,
250                volume,
251                first_valid_idx,
252                input.get_initial_value(),
253                &mut out,
254            ),
255            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
256            Kernel::Avx512 | Kernel::Avx512Batch => pvi_avx512(
257                close,
258                volume,
259                first_valid_idx,
260                input.get_initial_value(),
261                &mut out,
262            ),
263            _ => unreachable!(),
264        }
265    }
266    Ok(PviOutput { values: out })
267}
268
269#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
270#[inline]
271pub fn pvi_into(input: &PviInput, out: &mut [f64]) -> Result<(), PviError> {
272    let (close, volume) = match &input.data {
273        PviData::Candles {
274            candles,
275            close_source,
276            volume_source,
277        } => {
278            let c = source_type(candles, close_source);
279            let v = source_type(candles, volume_source);
280            (c, v)
281        }
282        PviData::Slices { close, volume } => (*close, *volume),
283    };
284
285    if close.is_empty() || volume.is_empty() {
286        return Err(PviError::EmptyInputData);
287    }
288    if close.len() != volume.len() {
289        return Err(PviError::MismatchedLength);
290    }
291    if out.len() != close.len() {
292        return Err(PviError::OutputLengthMismatch {
293            expected: close.len(),
294            got: out.len(),
295        });
296    }
297
298    let first_valid_idx = close
299        .iter()
300        .zip(volume.iter())
301        .position(|(&c, &v)| !c.is_nan() && !v.is_nan())
302        .ok_or(PviError::AllValuesNaN)?;
303    let valid = close.len() - first_valid_idx;
304    if valid < 2 {
305        return Err(PviError::NotEnoughValidData { needed: 2, valid });
306    }
307
308    let qnan = f64::from_bits(0x7ff8_0000_0000_0000);
309    let warm = first_valid_idx.min(out.len());
310    for v in &mut out[..warm] {
311        *v = qnan;
312    }
313
314    let chosen = match Kernel::Auto {
315        Kernel::Auto => match detect_best_kernel() {
316            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
317            Kernel::Avx512 => Kernel::Avx2,
318            other => other,
319        },
320        other => other,
321    };
322    let initial = input.get_initial_value();
323    unsafe {
324        match chosen {
325            Kernel::Scalar | Kernel::ScalarBatch => {
326                pvi_scalar(close, volume, first_valid_idx, initial, out)
327            }
328            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
329            Kernel::Avx2 | Kernel::Avx2Batch => {
330                pvi_avx2(close, volume, first_valid_idx, initial, out)
331            }
332            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
333            Kernel::Avx512 | Kernel::Avx512Batch => {
334                pvi_avx512(close, volume, first_valid_idx, initial, out)
335            }
336            _ => unreachable!(),
337        }
338    }
339
340    Ok(())
341}
342
343#[inline]
344pub fn pvi_into_slice(dst: &mut [f64], input: &PviInput, kern: Kernel) -> Result<(), PviError> {
345    let (close, volume) = match &input.data {
346        PviData::Candles {
347            candles,
348            close_source,
349            volume_source,
350        } => {
351            let c = source_type(candles, close_source);
352            let v = source_type(candles, volume_source);
353            (c, v)
354        }
355        PviData::Slices { close, volume } => (*close, *volume),
356    };
357
358    if close.is_empty() || volume.is_empty() {
359        return Err(PviError::EmptyInputData);
360    }
361    if close.len() != volume.len() {
362        return Err(PviError::MismatchedLength);
363    }
364    if dst.len() != close.len() {
365        return Err(PviError::OutputLengthMismatch {
366            expected: close.len(),
367            got: dst.len(),
368        });
369    }
370
371    let first_valid_idx = close
372        .iter()
373        .zip(volume.iter())
374        .position(|(&c, &v)| !c.is_nan() && !v.is_nan())
375        .ok_or(PviError::AllValuesNaN)?;
376    let valid = close.len() - first_valid_idx;
377    if valid < 2 {
378        return Err(PviError::NotEnoughValidData { needed: 2, valid });
379    }
380
381    let chosen = match kern {
382        Kernel::Auto => match detect_best_kernel() {
383            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
384            Kernel::Avx512 => Kernel::Avx2,
385            other => other,
386        },
387        other => other,
388    };
389
390    let initial_value = input.get_initial_value();
391
392    unsafe {
393        match chosen {
394            Kernel::Scalar | Kernel::ScalarBatch => {
395                pvi_scalar(close, volume, first_valid_idx, initial_value, dst)
396            }
397            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
398            Kernel::Avx2 | Kernel::Avx2Batch => {
399                pvi_avx2(close, volume, first_valid_idx, initial_value, dst)
400            }
401            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
402            Kernel::Avx512 | Kernel::Avx512Batch => {
403                pvi_avx512(close, volume, first_valid_idx, initial_value, dst)
404            }
405            _ => unreachable!(),
406        }
407    }
408
409    for v in &mut dst[..first_valid_idx] {
410        *v = f64::NAN;
411    }
412
413    Ok(())
414}
415
416#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
417#[inline]
418pub fn pvi_avx512(
419    close: &[f64],
420    volume: &[f64],
421    first_valid: usize,
422    initial: f64,
423    out: &mut [f64],
424) {
425    unsafe {
426        if close.len() <= 32 {
427            pvi_avx512_short(close, volume, first_valid, initial, out)
428        } else {
429            pvi_avx512_long(close, volume, first_valid, initial, out)
430        }
431    }
432}
433
434#[inline]
435pub fn pvi_scalar(
436    close: &[f64],
437    volume: &[f64],
438    first_valid: usize,
439    initial: f64,
440    out: &mut [f64],
441) {
442    debug_assert_eq!(close.len(), volume.len());
443    debug_assert_eq!(close.len(), out.len());
444    let n = close.len();
445    if n == 0 {
446        return;
447    }
448
449    let mut pvi = initial;
450    out[first_valid] = pvi;
451
452    let mut prev_close = close[first_valid];
453    let mut prev_vol = volume[first_valid];
454
455    for i in (first_valid + 1)..n {
456        let c = close[i];
457        let v = volume[i];
458        if c.is_nan() || v.is_nan() || prev_close.is_nan() || prev_vol.is_nan() {
459            out[i] = f64::NAN;
460            if !c.is_nan() && !v.is_nan() {
461                prev_close = c;
462                prev_vol = v;
463            }
464            continue;
465        }
466        if v > prev_vol {
467            let r = (c - prev_close) / prev_close;
468            pvi += r * pvi;
469        }
470        out[i] = pvi;
471        prev_close = c;
472        prev_vol = v;
473    }
474}
475
476#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
477#[inline(always)]
478pub fn pvi_avx2(close: &[f64], volume: &[f64], first_valid: usize, initial: f64, out: &mut [f64]) {
479    debug_assert_eq!(close.len(), volume.len());
480    debug_assert_eq!(close.len(), out.len());
481    let n = close.len();
482    if n == 0 {
483        return;
484    }
485
486    #[inline(always)]
487    fn not_nan(x: f64) -> bool {
488        x == x
489    }
490
491    unsafe {
492        let mut pvi = initial;
493        *out.get_unchecked_mut(first_valid) = pvi;
494
495        let mut prev_close = *close.get_unchecked(first_valid);
496        let mut prev_vol = *volume.get_unchecked(first_valid);
497
498        let cptr = close.as_ptr().add(first_valid + 1);
499        let vptr = volume.as_ptr().add(first_valid + 1);
500        let optr = out.as_mut_ptr().add(first_valid + 1);
501
502        let mut j = 0usize;
503        let rem = n - (first_valid + 1);
504
505        while j + 1 < rem {
506            let c0 = *cptr.add(j);
507            let v0 = *vptr.add(j);
508            if not_nan(c0) && not_nan(v0) && not_nan(prev_close) && not_nan(prev_vol) {
509                if v0 > prev_vol {
510                    let r = (c0 - prev_close) / prev_close;
511                    pvi = f64::mul_add(r, pvi, pvi);
512                }
513                *optr.add(j) = pvi;
514                prev_close = c0;
515                prev_vol = v0;
516            } else {
517                *optr.add(j) = f64::NAN;
518                if not_nan(c0) && not_nan(v0) {
519                    prev_close = c0;
520                    prev_vol = v0;
521                }
522            }
523
524            let c1 = *cptr.add(j + 1);
525            let v1 = *vptr.add(j + 1);
526            if not_nan(c1) && not_nan(v1) && not_nan(prev_close) && not_nan(prev_vol) {
527                if v1 > prev_vol {
528                    let r = (c1 - prev_close) / prev_close;
529                    pvi = f64::mul_add(r, pvi, pvi);
530                }
531                *optr.add(j + 1) = pvi;
532                prev_close = c1;
533                prev_vol = v1;
534            } else {
535                *optr.add(j + 1) = f64::NAN;
536                if not_nan(c1) && not_nan(v1) {
537                    prev_close = c1;
538                    prev_vol = v1;
539                }
540            }
541
542            j += 2;
543        }
544
545        if j < rem {
546            let c = *cptr.add(j);
547            let v = *vptr.add(j);
548            if not_nan(c) && not_nan(v) && not_nan(prev_close) && not_nan(prev_vol) {
549                if v > prev_vol {
550                    let r = (c - prev_close) / prev_close;
551                    pvi = f64::mul_add(r, pvi, pvi);
552                }
553                *optr.add(j) = pvi;
554            } else {
555                *optr.add(j) = f64::NAN;
556            }
557        }
558    }
559}
560
561#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
562#[inline(always)]
563pub fn pvi_avx512_short(
564    close: &[f64],
565    volume: &[f64],
566    first_valid: usize,
567    initial: f64,
568    out: &mut [f64],
569) {
570    pvi_avx2(close, volume, first_valid, initial, out)
571}
572
573#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
574#[inline(always)]
575pub fn pvi_avx512_long(
576    close: &[f64],
577    volume: &[f64],
578    first_valid: usize,
579    initial: f64,
580    out: &mut [f64],
581) {
582    pvi_avx2(close, volume, first_valid, initial, out)
583}
584
585#[derive(Debug, Clone)]
586pub struct PviStream {
587    initial_value: f64,
588    last_close: f64,
589    last_volume: f64,
590    curr: f64,
591    state: StreamState,
592}
593
594#[derive(Clone, Copy, Debug, PartialEq, Eq)]
595enum StreamState {
596    Init,
597    Valid,
598}
599
600impl PviStream {
601    #[inline]
602    pub fn try_new(params: PviParams) -> Result<Self, PviError> {
603        let initial = params.initial_value.unwrap_or(1000.0);
604        Ok(Self {
605            initial_value: initial,
606            last_close: f64::NAN,
607            last_volume: f64::NAN,
608            curr: initial,
609            state: StreamState::Init,
610        })
611    }
612
613    #[inline(always)]
614    pub fn update(&mut self, close: f64, volume: f64) -> Option<f64> {
615        if let StreamState::Init = self.state {
616            return self.init_or_none(close, volume);
617        }
618
619        if close.is_nan() | volume.is_nan() {
620            return self.cold_invalid(close, volume);
621        }
622
623        if volume > self.last_volume {
624            let prev = self.last_close;
625            let r = (close - prev) / prev;
626            self.curr += r * self.curr;
627        }
628
629        self.last_close = close;
630        self.last_volume = volume;
631
632        Some(self.curr)
633    }
634
635    #[inline(always)]
636    fn init_or_none(&mut self, close: f64, volume: f64) -> Option<f64> {
637        if close.is_nan() || volume.is_nan() {
638            return None;
639        }
640        self.last_close = close;
641        self.last_volume = volume;
642        self.curr = self.initial_value;
643        self.state = StreamState::Valid;
644        Some(self.curr)
645    }
646
647    #[cold]
648    #[inline(never)]
649    fn cold_invalid(&mut self, _close: f64, _volume: f64) -> Option<f64> {
650        None
651    }
652
653    #[inline(always)]
654    pub fn update_unchecked_finite(&mut self, close: f64, volume: f64) -> f64 {
655        debug_assert!(self.state == StreamState::Valid);
656        if volume > self.last_volume {
657            let prev = self.last_close;
658            let r = (close - prev) / prev;
659            self.curr += r * self.curr;
660        }
661        self.last_close = close;
662        self.last_volume = volume;
663        self.curr
664    }
665}
666
667#[derive(Clone, Debug)]
668pub struct PviBatchRange {
669    pub initial_value: (f64, f64, f64),
670}
671
672impl Default for PviBatchRange {
673    fn default() -> Self {
674        Self {
675            initial_value: (1000.0, 1249.0, 1.0),
676        }
677    }
678}
679
680#[derive(Clone, Debug, Default)]
681pub struct PviBatchBuilder {
682    range: PviBatchRange,
683    kernel: Kernel,
684}
685
686impl PviBatchBuilder {
687    pub fn new() -> Self {
688        Self::default()
689    }
690    pub fn kernel(mut self, k: Kernel) -> Self {
691        self.kernel = k;
692        self
693    }
694    #[inline]
695    pub fn initial_value_range(mut self, start: f64, end: f64, step: f64) -> Self {
696        self.range.initial_value = (start, end, step);
697        self
698    }
699    #[inline]
700    pub fn initial_value_static(mut self, v: f64) -> Self {
701        self.range.initial_value = (v, v, 0.0);
702        self
703    }
704    pub fn apply_slices(self, close: &[f64], volume: &[f64]) -> Result<PviBatchOutput, PviError> {
705        pvi_batch_with_kernel(close, volume, &self.range, self.kernel)
706    }
707    pub fn with_default_slices(
708        close: &[f64],
709        volume: &[f64],
710        k: Kernel,
711    ) -> Result<PviBatchOutput, PviError> {
712        PviBatchBuilder::new().kernel(k).apply_slices(close, volume)
713    }
714    pub fn apply_candles(
715        self,
716        c: &Candles,
717        close_src: &str,
718        vol_src: &str,
719    ) -> Result<PviBatchOutput, PviError> {
720        let close = source_type(c, close_src);
721        let vol = source_type(c, vol_src);
722        self.apply_slices(close, vol)
723    }
724    pub fn with_default_candles(c: &Candles) -> Result<PviBatchOutput, PviError> {
725        PviBatchBuilder::new()
726            .kernel(Kernel::Auto)
727            .apply_candles(c, "close", "volume")
728    }
729}
730
731pub fn pvi_batch_with_kernel(
732    close: &[f64],
733    volume: &[f64],
734    sweep: &PviBatchRange,
735    k: Kernel,
736) -> Result<PviBatchOutput, PviError> {
737    let kernel = match k {
738        Kernel::Auto => detect_best_batch_kernel(),
739        other if other.is_batch() => other,
740        other => return Err(PviError::InvalidKernelForBatch(other)),
741    };
742    let simd = match kernel {
743        Kernel::Avx512Batch => Kernel::Avx512,
744        Kernel::Avx2Batch => Kernel::Avx2,
745        Kernel::ScalarBatch => Kernel::Scalar,
746        _ => unreachable!(),
747    };
748    pvi_batch_par_slice(close, volume, sweep, simd)
749}
750
751#[derive(Clone, Debug)]
752pub struct PviBatchOutput {
753    pub values: Vec<f64>,
754    pub combos: Vec<PviParams>,
755    pub rows: usize,
756    pub cols: usize,
757}
758impl PviBatchOutput {
759    pub fn row_for_params(&self, p: &PviParams) -> Option<usize> {
760        self.combos.iter().position(|c| {
761            (c.initial_value.unwrap_or(1000.0) - p.initial_value.unwrap_or(1000.0)).abs() < 1e-12
762        })
763    }
764    pub fn values_for(&self, p: &PviParams) -> Option<&[f64]> {
765        self.row_for_params(p).map(|row| {
766            let start = row * self.cols;
767            &self.values[start..start + self.cols]
768        })
769    }
770}
771
772#[inline(always)]
773fn expand_grid(r: &PviBatchRange) -> Result<Vec<PviParams>, PviError> {
774    fn axis_f64((start, end, step): (f64, f64, f64)) -> Result<Vec<f64>, PviError> {
775        if step.abs() < 1e-12 || (start - end).abs() < 1e-12 {
776            return Ok(vec![start]);
777        }
778        let mut vals = Vec::new();
779        if start <= end {
780            let mut x = start;
781            loop {
782                vals.push(x);
783                if x >= end {
784                    break;
785                }
786                let next = x + step;
787                if !next.is_finite() || next == x {
788                    break;
789                }
790                x = next;
791                if x > end + 1e-12 {
792                    break;
793                }
794            }
795        } else {
796            let mut x = start;
797            loop {
798                vals.push(x);
799                if x <= end {
800                    break;
801                }
802                let next = x - step.abs();
803                if !next.is_finite() || next == x {
804                    break;
805                }
806                x = next;
807                if x < end - 1e-12 {
808                    break;
809                }
810            }
811        }
812        if vals.is_empty() {
813            return Err(PviError::InvalidRange { start, end, step });
814        }
815        Ok(vals)
816    }
817
818    let initials = axis_f64(r.initial_value)?;
819    let mut out = Vec::with_capacity(initials.len());
820    for &v in &initials {
821        out.push(PviParams {
822            initial_value: Some(v),
823        });
824    }
825    if out.is_empty() {
826        return Err(PviError::InvalidRange {
827            start: r.initial_value.0,
828            end: r.initial_value.1,
829            step: r.initial_value.2,
830        });
831    }
832    Ok(out)
833}
834
835#[inline(always)]
836pub fn pvi_batch_slice(
837    close: &[f64],
838    volume: &[f64],
839    sweep: &PviBatchRange,
840    kern: Kernel,
841) -> Result<PviBatchOutput, PviError> {
842    pvi_batch_inner(close, volume, sweep, kern, false)
843}
844
845#[inline(always)]
846pub fn pvi_batch_par_slice(
847    close: &[f64],
848    volume: &[f64],
849    sweep: &PviBatchRange,
850    kern: Kernel,
851) -> Result<PviBatchOutput, PviError> {
852    pvi_batch_inner(close, volume, sweep, kern, true)
853}
854
855#[inline(always)]
856fn pvi_batch_inner(
857    close: &[f64],
858    volume: &[f64],
859    sweep: &PviBatchRange,
860    kern: Kernel,
861    parallel: bool,
862) -> Result<PviBatchOutput, PviError> {
863    let combos = expand_grid(sweep)?;
864    if close.is_empty() || volume.is_empty() {
865        return Err(PviError::EmptyInputData);
866    }
867    if close.len() != volume.len() {
868        return Err(PviError::MismatchedLength);
869    }
870
871    let first_valid_idx = close
872        .iter()
873        .zip(volume.iter())
874        .position(|(&c, &v)| !c.is_nan() && !v.is_nan())
875        .ok_or(PviError::AllValuesNaN)?;
876    let valid = close.len() - first_valid_idx;
877    if valid < 2 {
878        return Err(PviError::NotEnoughValidData { needed: 2, valid });
879    }
880
881    let rows = combos.len();
882    let cols = close.len();
883
884    let mut buf_mu = make_uninit_matrix(rows, cols);
885
886    if rows <= 32 {
887        let mut warm = [0usize; 32];
888        for i in 0..rows {
889            warm[i] = first_valid_idx;
890        }
891        init_matrix_prefixes(&mut buf_mu, cols, &warm[..rows]);
892    } else {
893        let warm = vec![first_valid_idx; rows];
894        init_matrix_prefixes(&mut buf_mu, cols, &warm);
895    }
896
897    let mut guard = core::mem::ManuallyDrop::new(buf_mu);
898
899    let out_f: &mut [f64] =
900        unsafe { core::slice::from_raw_parts_mut(guard.as_mut_ptr() as *mut f64, guard.len()) };
901    pvi_batch_inner_into(close, volume, sweep, kern, parallel, out_f)?;
902
903    let values = unsafe {
904        Vec::from_raw_parts(
905            guard.as_mut_ptr() as *mut f64,
906            guard.len(),
907            guard.capacity(),
908        )
909    };
910
911    Ok(PviBatchOutput {
912        values,
913        combos,
914        rows,
915        cols,
916    })
917}
918
919#[inline(always)]
920fn pvi_batch_inner_into(
921    close: &[f64],
922    volume: &[f64],
923    sweep: &PviBatchRange,
924    kern: Kernel,
925    parallel: bool,
926    out: &mut [f64],
927) -> Result<Vec<PviParams>, PviError> {
928    let combos = expand_grid(sweep)?;
929    if close.is_empty() || volume.is_empty() {
930        return Err(PviError::EmptyInputData);
931    }
932    if close.len() != volume.len() {
933        return Err(PviError::MismatchedLength);
934    }
935
936    let first_valid_idx = close
937        .iter()
938        .zip(volume.iter())
939        .position(|(&c, &v)| !c.is_nan() && !v.is_nan())
940        .ok_or(PviError::AllValuesNaN)?;
941    let valid = close.len() - first_valid_idx;
942    if valid < 2 {
943        return Err(PviError::NotEnoughValidData { needed: 2, valid });
944    }
945
946    let rows = combos.len();
947    let cols = close.len();
948    let expected = rows
949        .checked_mul(cols)
950        .ok_or_else(|| PviError::InvalidInput("rows*cols overflow".into()))?;
951    if out.len() != expected {
952        return Err(PviError::OutputLengthMismatch {
953            expected,
954            got: out.len(),
955        });
956    }
957
958    let out_mu: &mut [core::mem::MaybeUninit<f64>] = unsafe {
959        core::slice::from_raw_parts_mut(
960            out.as_mut_ptr() as *mut core::mem::MaybeUninit<f64>,
961            out.len(),
962        )
963    };
964
965    let mut scale = vec![f64::NAN; cols];
966    scale[first_valid_idx] = 1.0;
967
968    #[inline(always)]
969    fn not_nan(x: f64) -> bool {
970        x == x
971    }
972
973    unsafe {
974        let mut prev_close = *close.get_unchecked(first_valid_idx);
975        let mut prev_vol = *volume.get_unchecked(first_valid_idx);
976        let mut accum = 1.0f64;
977
978        let cptr = close.as_ptr();
979        let vptr = volume.as_ptr();
980        let mut i = first_valid_idx + 1;
981        while i < cols {
982            let c = *cptr.add(i);
983            let v = *vptr.add(i);
984            if not_nan(c) && not_nan(v) && not_nan(prev_close) && not_nan(prev_vol) {
985                if v > prev_vol {
986                    let r = (c - prev_close) / prev_close;
987                    accum = f64::mul_add(r, accum, accum);
988                }
989                scale[i] = accum;
990                prev_close = c;
991                prev_vol = v;
992            } else {
993                scale[i] = f64::NAN;
994                if not_nan(c) && not_nan(v) {
995                    prev_close = c;
996                    prev_vol = v;
997                }
998            }
999            i += 1;
1000        }
1001    }
1002
1003    let do_row = |row: usize, dst_row_mu: &mut [core::mem::MaybeUninit<f64>]| unsafe {
1004        let iv = combos[row].initial_value.unwrap_or(1000.0);
1005
1006        let dst_row: &mut [f64] =
1007            core::slice::from_raw_parts_mut(dst_row_mu.as_mut_ptr() as *mut f64, dst_row_mu.len());
1008
1009        *dst_row.get_unchecked_mut(first_valid_idx) = iv;
1010
1011        let mut j = first_valid_idx + 1;
1012        while j < cols {
1013            let s = *scale.get_unchecked(j);
1014            if s == s {
1015                *dst_row.get_unchecked_mut(j) = iv * s;
1016            } else {
1017                *dst_row.get_unchecked_mut(j) = f64::NAN;
1018            }
1019            j += 1;
1020        }
1021    };
1022
1023    if parallel {
1024        #[cfg(not(target_arch = "wasm32"))]
1025        {
1026            use rayon::prelude::*;
1027            out_mu
1028                .par_chunks_mut(cols)
1029                .enumerate()
1030                .for_each(|(r, row)| do_row(r, row));
1031        }
1032        #[cfg(target_arch = "wasm32")]
1033        for (r, row) in out_mu.chunks_mut(cols).enumerate() {
1034            do_row(r, row);
1035        }
1036    } else {
1037        for (r, row) in out_mu.chunks_mut(cols).enumerate() {
1038            do_row(r, row);
1039        }
1040    }
1041
1042    Ok(combos)
1043}
1044
1045#[inline(always)]
1046unsafe fn pvi_row_scalar(
1047    close: &[f64],
1048    volume: &[f64],
1049    first: usize,
1050    initial: f64,
1051    out: &mut [f64],
1052) {
1053    pvi_scalar(close, volume, first, initial, out)
1054}
1055
1056#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1057#[inline(always)]
1058unsafe fn pvi_row_avx2(close: &[f64], volume: &[f64], first: usize, initial: f64, out: &mut [f64]) {
1059    pvi_scalar(close, volume, first, initial, out)
1060}
1061
1062#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1063#[inline(always)]
1064unsafe fn pvi_row_avx512(
1065    close: &[f64],
1066    volume: &[f64],
1067    first: usize,
1068    initial: f64,
1069    out: &mut [f64],
1070) {
1071    if close.len() <= 32 {
1072        pvi_row_avx512_short(close, volume, first, initial, out);
1073    } else {
1074        pvi_row_avx512_long(close, volume, first, initial, out);
1075    }
1076}
1077
1078#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1079#[inline(always)]
1080unsafe fn pvi_row_avx512_short(
1081    close: &[f64],
1082    volume: &[f64],
1083    first: usize,
1084    initial: f64,
1085    out: &mut [f64],
1086) {
1087    pvi_scalar(close, volume, first, initial, out)
1088}
1089
1090#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1091#[inline(always)]
1092unsafe fn pvi_row_avx512_long(
1093    close: &[f64],
1094    volume: &[f64],
1095    first: usize,
1096    initial: f64,
1097    out: &mut [f64],
1098) {
1099    pvi_scalar(close, volume, first, initial, out)
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104    use super::*;
1105    use crate::skip_if_unsupported;
1106    use crate::utilities::data_loader::read_candles_from_csv;
1107
1108    fn check_pvi_partial_params(
1109        test_name: &str,
1110        kernel: Kernel,
1111    ) -> Result<(), Box<dyn std::error::Error>> {
1112        skip_if_unsupported!(kernel, test_name);
1113        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1114        let candles = read_candles_from_csv(file_path)?;
1115        let default_params = PviParams {
1116            initial_value: None,
1117        };
1118        let input = PviInput::from_candles(&candles, "close", "volume", default_params);
1119        let output = pvi_with_kernel(&input, kernel)?;
1120        assert_eq!(output.values.len(), candles.close.len());
1121        Ok(())
1122    }
1123
1124    fn check_pvi_accuracy(
1125        test_name: &str,
1126        kernel: Kernel,
1127    ) -> Result<(), Box<dyn std::error::Error>> {
1128        skip_if_unsupported!(kernel, test_name);
1129        let close_data = [100.0, 102.0, 101.0, 103.0, 103.0, 105.0];
1130        let volume_data = [500.0, 600.0, 500.0, 700.0, 680.0, 900.0];
1131        let params = PviParams {
1132            initial_value: Some(1000.0),
1133        };
1134        let input = PviInput::from_slices(&close_data, &volume_data, params);
1135        let output = pvi_with_kernel(&input, kernel)?;
1136        assert_eq!(output.values.len(), close_data.len());
1137        assert!((output.values[0] - 1000.0).abs() < 1e-6);
1138        Ok(())
1139    }
1140
1141    fn check_pvi_default_candles(
1142        test_name: &str,
1143        kernel: Kernel,
1144    ) -> Result<(), Box<dyn std::error::Error>> {
1145        skip_if_unsupported!(kernel, test_name);
1146        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1147        let candles = read_candles_from_csv(file_path)?;
1148        let input = PviInput::with_default_candles(&candles);
1149        let output = pvi_with_kernel(&input, kernel)?;
1150        assert_eq!(output.values.len(), candles.close.len());
1151        Ok(())
1152    }
1153
1154    fn check_pvi_empty_data(
1155        test_name: &str,
1156        kernel: Kernel,
1157    ) -> Result<(), Box<dyn std::error::Error>> {
1158        skip_if_unsupported!(kernel, test_name);
1159        let close_data = [];
1160        let volume_data = [];
1161        let params = PviParams::default();
1162        let input = PviInput::from_slices(&close_data, &volume_data, params);
1163        let result = pvi_with_kernel(&input, kernel);
1164        assert!(result.is_err());
1165        Ok(())
1166    }
1167
1168    fn check_pvi_mismatched_length(
1169        test_name: &str,
1170        kernel: Kernel,
1171    ) -> Result<(), Box<dyn std::error::Error>> {
1172        skip_if_unsupported!(kernel, test_name);
1173        let close_data = [100.0, 101.0];
1174        let volume_data = [500.0];
1175        let params = PviParams::default();
1176        let input = PviInput::from_slices(&close_data, &volume_data, params);
1177        let result = pvi_with_kernel(&input, kernel);
1178        assert!(result.is_err());
1179        Ok(())
1180    }
1181
1182    fn check_pvi_all_values_nan(
1183        test_name: &str,
1184        kernel: Kernel,
1185    ) -> Result<(), Box<dyn std::error::Error>> {
1186        skip_if_unsupported!(kernel, test_name);
1187        let close_data = [f64::NAN, f64::NAN, f64::NAN];
1188        let volume_data = [f64::NAN, f64::NAN, f64::NAN];
1189        let params = PviParams::default();
1190        let input = PviInput::from_slices(&close_data, &volume_data, params);
1191        let result = pvi_with_kernel(&input, kernel);
1192        assert!(result.is_err());
1193        Ok(())
1194    }
1195
1196    fn check_pvi_not_enough_valid_data(
1197        test_name: &str,
1198        kernel: Kernel,
1199    ) -> Result<(), Box<dyn std::error::Error>> {
1200        skip_if_unsupported!(kernel, test_name);
1201        let close_data = [f64::NAN, 100.0];
1202        let volume_data = [f64::NAN, 500.0];
1203        let params = PviParams::default();
1204        let input = PviInput::from_slices(&close_data, &volume_data, params);
1205        let result = pvi_with_kernel(&input, kernel);
1206        assert!(result.is_err());
1207        Ok(())
1208    }
1209
1210    fn check_pvi_streaming(
1211        test_name: &str,
1212        kernel: Kernel,
1213    ) -> Result<(), Box<dyn std::error::Error>> {
1214        skip_if_unsupported!(kernel, test_name);
1215        let close_data = [100.0, 102.0, 101.0, 103.0, 103.0, 105.0];
1216        let volume_data = [500.0, 600.0, 500.0, 700.0, 680.0, 900.0];
1217        let params = PviParams {
1218            initial_value: Some(1000.0),
1219        };
1220        let input = PviInput::from_slices(&close_data, &volume_data, params.clone());
1221        let batch_output = pvi_with_kernel(&input, kernel)?.values;
1222
1223        let mut stream = PviStream::try_new(params)?;
1224        let mut stream_values = Vec::with_capacity(close_data.len());
1225        for (&close, &vol) in close_data.iter().zip(volume_data.iter()) {
1226            match stream.update(close, vol) {
1227                Some(val) => stream_values.push(val),
1228                None => stream_values.push(f64::NAN),
1229            }
1230        }
1231        assert_eq!(batch_output.len(), stream_values.len());
1232        for (i, (&b, &s)) in batch_output.iter().zip(stream_values.iter()).enumerate() {
1233            if b.is_nan() && s.is_nan() {
1234                continue;
1235            }
1236            let diff = (b - s).abs();
1237            assert!(
1238                diff < 1e-9,
1239                "[{}] PVI streaming mismatch at idx {}: batch={}, stream={}, diff={}",
1240                test_name,
1241                i,
1242                b,
1243                s,
1244                diff
1245            );
1246        }
1247        Ok(())
1248    }
1249
1250    macro_rules! generate_all_pvi_tests {
1251        ($($test_fn:ident),*) => {
1252            paste::paste! {
1253                $(
1254                    #[test]
1255                    fn [<$test_fn _scalar_f64>]() {
1256                        let _ = $test_fn(stringify!([<$test_fn _scalar_f64>]), Kernel::Scalar);
1257                    }
1258                )*
1259                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1260                $(
1261                    #[test]
1262                    fn [<$test_fn _avx2_f64>]() {
1263                        let _ = $test_fn(stringify!([<$test_fn _avx2_f64>]), Kernel::Avx2);
1264                    }
1265                    #[test]
1266                    fn [<$test_fn _avx512_f64>]() {
1267                        let _ = $test_fn(stringify!([<$test_fn _avx512_f64>]), Kernel::Avx512);
1268                    }
1269                )*
1270            }
1271        }
1272    }
1273
1274    #[cfg(debug_assertions)]
1275    fn check_pvi_no_poison(
1276        test_name: &str,
1277        kernel: Kernel,
1278    ) -> Result<(), Box<dyn std::error::Error>> {
1279        skip_if_unsupported!(kernel, test_name);
1280
1281        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1282        let candles = read_candles_from_csv(file_path)?;
1283
1284        let test_params = vec![
1285            PviParams::default(),
1286            PviParams {
1287                initial_value: Some(100.0),
1288            },
1289            PviParams {
1290                initial_value: Some(500.0),
1291            },
1292            PviParams {
1293                initial_value: Some(5000.0),
1294            },
1295            PviParams {
1296                initial_value: Some(10000.0),
1297            },
1298            PviParams {
1299                initial_value: Some(0.0),
1300            },
1301            PviParams {
1302                initial_value: Some(1.0),
1303            },
1304            PviParams {
1305                initial_value: Some(-1000.0),
1306            },
1307            PviParams {
1308                initial_value: Some(999999.0),
1309            },
1310            PviParams {
1311                initial_value: None,
1312            },
1313        ];
1314
1315        for (param_idx, params) in test_params.iter().enumerate() {
1316            let input = PviInput::from_candles(&candles, "close", "volume", params.clone());
1317            let output = pvi_with_kernel(&input, kernel)?;
1318
1319            for (i, &val) in output.values.iter().enumerate() {
1320                if val.is_nan() {
1321                    continue;
1322                }
1323
1324                let bits = val.to_bits();
1325
1326                if bits == 0x11111111_11111111 {
1327                    panic!(
1328                        "[{}] Found alloc_with_nan_prefix poison value {} (0x{:016X}) at index {} \
1329						 with params: initial_value={:?} (param set {})",
1330                        test_name, val, bits, i, params.initial_value, param_idx
1331                    );
1332                }
1333
1334                if bits == 0x22222222_22222222 {
1335                    panic!(
1336                        "[{}] Found init_matrix_prefixes poison value {} (0x{:016X}) at index {} \
1337						 with params: initial_value={:?} (param set {})",
1338                        test_name, val, bits, i, params.initial_value, param_idx
1339                    );
1340                }
1341
1342                if bits == 0x33333333_33333333 {
1343                    panic!(
1344                        "[{}] Found make_uninit_matrix poison value {} (0x{:016X}) at index {} \
1345						 with params: initial_value={:?} (param set {})",
1346                        test_name, val, bits, i, params.initial_value, param_idx
1347                    );
1348                }
1349            }
1350        }
1351
1352        Ok(())
1353    }
1354
1355    #[cfg(not(debug_assertions))]
1356    fn check_pvi_no_poison(
1357        _test_name: &str,
1358        _kernel: Kernel,
1359    ) -> Result<(), Box<dyn std::error::Error>> {
1360        Ok(())
1361    }
1362
1363    #[test]
1364    fn test_pvi_into_matches_api() {
1365        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1366        let candles = read_candles_from_csv(file_path).expect("load candles");
1367
1368        let input = PviInput::from_candles(&candles, "close", "volume", PviParams::default());
1369
1370        let baseline = pvi(&input).expect("pvi baseline").values;
1371
1372        let mut into_out = vec![0.0f64; baseline.len()];
1373        #[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
1374        {
1375            pvi_into(&input, &mut into_out).expect("pvi_into");
1376        }
1377        #[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1378        {
1379            pvi_into_slice(&mut into_out, &input, Kernel::Auto).expect("pvi_into_slice");
1380        }
1381
1382        assert_eq!(baseline.len(), into_out.len());
1383
1384        fn eq_or_both_nan(a: f64, b: f64) -> bool {
1385            (a.is_nan() && b.is_nan()) || (a == b) || ((a - b).abs() <= 1e-12)
1386        }
1387        for i in 0..baseline.len() {
1388            assert!(
1389                eq_or_both_nan(baseline[i], into_out[i]),
1390                "Mismatch at index {}: got {}, expected {}",
1391                i,
1392                into_out[i],
1393                baseline[i]
1394            );
1395        }
1396    }
1397
1398    #[cfg(feature = "proptest")]
1399    #[allow(clippy::float_cmp)]
1400    fn check_pvi_property(
1401        test_name: &str,
1402        kernel: Kernel,
1403    ) -> Result<(), Box<dyn std::error::Error>> {
1404        use proptest::prelude::*;
1405        skip_if_unsupported!(kernel, test_name);
1406
1407        let strat = (
1408            prop::collection::vec(
1409                (-1e6f64..1e6f64).prop_filter("finite close", |x| x.is_finite() && x.abs() > 1e-10),
1410                10..400,
1411            ),
1412            prop::collection::vec(
1413                (0f64..1e6f64).prop_filter("finite volume", |x| x.is_finite()),
1414                10..400,
1415            ),
1416            100f64..10000f64,
1417        )
1418            .prop_filter("same length", |(close, volume, _)| {
1419                close.len() == volume.len()
1420            });
1421
1422        proptest::test_runner::TestRunner::default().run(
1423            &strat,
1424            |(close_data, volume_data, initial_value)| {
1425                let params = PviParams {
1426                    initial_value: Some(initial_value),
1427                };
1428                let input = PviInput::from_slices(&close_data, &volume_data, params);
1429
1430                let output = match pvi_with_kernel(&input, kernel) {
1431                    Ok(o) => o,
1432                    Err(_) => return Ok(()),
1433                };
1434                let out = &output.values;
1435
1436                let scalar_output = match pvi_with_kernel(&input, Kernel::Scalar) {
1437                    Ok(o) => o,
1438                    Err(_) => return Ok(()),
1439                };
1440                let ref_out = &scalar_output.values;
1441
1442                let first_valid_idx = close_data
1443                    .iter()
1444                    .zip(volume_data.iter())
1445                    .position(|(&c, &v)| !c.is_nan() && !v.is_nan());
1446
1447                if let Some(first_idx) = first_valid_idx {
1448                    if !out[first_idx].is_nan() {
1449                        prop_assert!(
1450                            (out[first_idx] - initial_value).abs() < 1e-9,
1451                            "First valid PVI value {} should equal initial_value {} at index {}",
1452                            out[first_idx],
1453                            initial_value,
1454                            first_idx
1455                        );
1456                    }
1457
1458                    for i in (first_idx + 1)..close_data.len() {
1459                        if !out[i].is_nan() && i > 0 && !out[i - 1].is_nan() {
1460                            if !volume_data[i].is_nan() && !volume_data[i - 1].is_nan() {
1461                                if volume_data[i] <= volume_data[i - 1] {
1462                                    prop_assert!(
1463										(out[i] - out[i - 1]).abs() < 1e-9,
1464										"PVI should remain constant when volume doesn't increase: {} != {} at index {}",
1465										out[i], out[i - 1], i
1466									);
1467                                }
1468                            }
1469                        }
1470                    }
1471
1472                    for i in (first_idx + 1)..close_data.len() {
1473                        if !out[i].is_nan() && i > 0 && !out[i - 1].is_nan() {
1474                            if !volume_data[i].is_nan() && !volume_data[i - 1].is_nan() {
1475                                let volume_increased = volume_data[i] > volume_data[i - 1];
1476                                let pvi_changed = (out[i] - out[i - 1]).abs() > 1e-9;
1477
1478                                if pvi_changed {
1479                                    prop_assert!(
1480										volume_increased,
1481										"PVI changed without volume increase at index {}: vol[{}]={} <= vol[{}]={}",
1482										i, i, volume_data[i], i - 1, volume_data[i - 1]
1483									);
1484                                }
1485                            }
1486                        }
1487                    }
1488
1489                    for i in (first_idx + 1)..close_data.len() {
1490                        if !out[i].is_nan()
1491                            && i > 0
1492                            && !out[i - 1].is_nan()
1493                            && !close_data[i].is_nan()
1494                            && !close_data[i - 1].is_nan()
1495                            && !volume_data[i].is_nan()
1496                            && !volume_data[i - 1].is_nan()
1497                        {
1498                            if volume_data[i] > volume_data[i - 1]
1499                                && close_data[i - 1].abs() > 1e-10
1500                            {
1501                                let expected_change = ((close_data[i] - close_data[i - 1])
1502                                    / close_data[i - 1])
1503                                    * out[i - 1];
1504                                let expected_pvi = out[i - 1] + expected_change;
1505                                prop_assert!(
1506                                    (out[i] - expected_pvi).abs() < 1e-9,
1507                                    "PVI calculation error at index {}: expected {} but got {}",
1508                                    i,
1509                                    expected_pvi,
1510                                    out[i]
1511                                );
1512                            }
1513                        }
1514                    }
1515
1516                    for i in 0..out.len() {
1517                        if out[i].is_nan() && ref_out[i].is_nan() {
1518                            continue;
1519                        }
1520                        prop_assert!(
1521                            (out[i] - ref_out[i]).abs() < 1e-9,
1522                            "Kernel mismatch at index {}: {} ({:?}) vs {} (Scalar)",
1523                            i,
1524                            out[i],
1525                            kernel,
1526                            ref_out[i]
1527                        );
1528                    }
1529
1530                    for (i, &val) in out.iter().enumerate() {
1531                        if !val.is_nan() {
1532                            let bits = val.to_bits();
1533                            prop_assert!(
1534                                bits != 0x11111111_11111111
1535                                    && bits != 0x22222222_22222222
1536                                    && bits != 0x33333333_33333333,
1537                                "Found poison value {} (0x{:016X}) at index {}",
1538                                val,
1539                                bits,
1540                                i
1541                            );
1542                        }
1543                    }
1544
1545                    if volume_data.windows(2).all(|w| (w[0] - w[1]).abs() < 1e-10) {
1546                        for &val in out.iter().skip(first_idx) {
1547                            if !val.is_nan() {
1548                                prop_assert!(
1549									(val - initial_value).abs() < 1e-9,
1550									"PVI should remain at initial_value {} with constant volume, but got {}",
1551									initial_value, val
1552								);
1553                            }
1554                        }
1555                    }
1556
1557                    let is_monotonic_increasing = volume_data
1558                        .windows(2)
1559                        .all(|w| !w[0].is_nan() && !w[1].is_nan() && w[1] > w[0]);
1560
1561                    if is_monotonic_increasing && close_data.len() > first_idx + 2 {
1562                        let mut last_valid_pvi = out[first_idx];
1563                        for i in (first_idx + 1)..out.len() {
1564                            if !out[i].is_nan()
1565                                && !close_data[i].is_nan()
1566                                && !close_data[i - 1].is_nan()
1567                            {
1568                                if (close_data[i] - close_data[i - 1]).abs() > 1e-10 {
1569                                    prop_assert!(
1570										(out[i] - last_valid_pvi).abs() > 1e-10,
1571										"PVI should change with monotonic increasing volume and price change at index {}",
1572										i
1573									);
1574                                }
1575                                last_valid_pvi = out[i];
1576                            }
1577                        }
1578                    }
1579
1580                    for i in (first_idx + 1)..close_data.len() {
1581                        if !volume_data[i].is_nan()
1582                            && !volume_data[i - 1].is_nan()
1583                            && volume_data[i] > volume_data[i - 1]
1584                            && !close_data[i].is_nan()
1585                            && !close_data[i - 1].is_nan()
1586                            && close_data[i - 1].abs() > 1e-10
1587                        {
1588                            if !out[i].is_nan() && i > 0 && !out[i - 1].is_nan() {
1589                                let expected_change = ((close_data[i] - close_data[i - 1])
1590                                    / close_data[i - 1])
1591                                    * out[i - 1];
1592                                let expected_pvi = out[i - 1] + expected_change;
1593
1594                                prop_assert!(
1595									(out[i] - expected_pvi).abs() < 1e-9 || out[i].is_infinite(),
1596									"PVI calculation should be correct or handle extreme values at index {}",
1597									i
1598								);
1599                            }
1600                        }
1601                    }
1602
1603                    for (i, &val) in out.iter().enumerate() {
1604                        if !val.is_nan() {
1605                            prop_assert!(
1606                                val.is_finite(),
1607                                "PVI should be finite, but got {} at index {}",
1608                                val,
1609                                i
1610                            );
1611
1612                            if initial_value > 0.0
1613                                && val.is_finite()
1614                                && val.abs() < initial_value * 100.0
1615                            {
1616                                prop_assert!(
1617									val >= 0.0 || close_data[..i].iter().any(|&c| c < 0.0),
1618									"PVI unexpectedly negative ({}) with positive initial value {} at index {}",
1619									val, initial_value, i
1620								);
1621                            }
1622                        }
1623                    }
1624                }
1625
1626                Ok(())
1627            },
1628        )?;
1629
1630        Ok(())
1631    }
1632
1633    #[cfg(feature = "proptest")]
1634    generate_all_pvi_tests!(
1635        check_pvi_partial_params,
1636        check_pvi_accuracy,
1637        check_pvi_default_candles,
1638        check_pvi_empty_data,
1639        check_pvi_mismatched_length,
1640        check_pvi_all_values_nan,
1641        check_pvi_not_enough_valid_data,
1642        check_pvi_streaming,
1643        check_pvi_no_poison,
1644        check_pvi_property
1645    );
1646
1647    #[cfg(not(feature = "proptest"))]
1648    generate_all_pvi_tests!(
1649        check_pvi_partial_params,
1650        check_pvi_accuracy,
1651        check_pvi_default_candles,
1652        check_pvi_empty_data,
1653        check_pvi_mismatched_length,
1654        check_pvi_all_values_nan,
1655        check_pvi_not_enough_valid_data,
1656        check_pvi_streaming,
1657        check_pvi_no_poison
1658    );
1659
1660    fn check_batch_default_row(
1661        test: &str,
1662        kernel: Kernel,
1663    ) -> Result<(), Box<dyn std::error::Error>> {
1664        skip_if_unsupported!(kernel, test);
1665        let close_data = [100.0, 102.0, 101.0, 103.0, 103.0, 105.0];
1666        let volume_data = [500.0, 600.0, 500.0, 700.0, 680.0, 900.0];
1667        let output = PviBatchBuilder::new()
1668            .kernel(kernel)
1669            .apply_slices(&close_data, &volume_data)?;
1670        let def = PviParams::default();
1671        let row = output.values_for(&def).expect("default row missing");
1672        assert_eq!(row.len(), close_data.len());
1673        Ok(())
1674    }
1675
1676    macro_rules! gen_batch_tests {
1677        ($fn_name:ident) => {
1678            paste::paste! {
1679                #[test] fn [<$fn_name _scalar>]()      {
1680                    let _ = $fn_name(stringify!([<$fn_name _scalar>]), Kernel::ScalarBatch);
1681                }
1682                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1683                #[test] fn [<$fn_name _avx2>]()        {
1684                    let _ = $fn_name(stringify!([<$fn_name _avx2>]), Kernel::Avx2Batch);
1685                }
1686                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1687                #[test] fn [<$fn_name _avx512>]()      {
1688                    let _ = $fn_name(stringify!([<$fn_name _avx512>]), Kernel::Avx512Batch);
1689                }
1690                #[test] fn [<$fn_name _auto_detect>]() {
1691                    let _ = $fn_name(stringify!([<$fn_name _auto_detect>]), Kernel::Auto);
1692                }
1693            }
1694        };
1695    }
1696    #[cfg(debug_assertions)]
1697    fn check_batch_no_poison(test: &str, kernel: Kernel) -> Result<(), Box<dyn std::error::Error>> {
1698        skip_if_unsupported!(kernel, test);
1699
1700        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1701        let c = read_candles_from_csv(file)?;
1702
1703        let test_configs = vec![
1704            (100.0, 500.0, 100.0),
1705            (1000.0, 5000.0, 1000.0),
1706            (10000.0, 50000.0, 10000.0),
1707            (900.0, 1100.0, 50.0),
1708            (0.0, 100.0, 25.0),
1709            (-1000.0, 1000.0, 500.0),
1710            (1.0, 10.0, 1.0),
1711            (999999.0, 1000001.0, 1.0),
1712        ];
1713
1714        for (cfg_idx, &(start, end, step)) in test_configs.iter().enumerate() {
1715            let output = PviBatchBuilder::new()
1716                .kernel(kernel)
1717                .initial_value_range(start, end, step)
1718                .apply_candles(&c, "close", "volume")?;
1719
1720            for (idx, &val) in output.values.iter().enumerate() {
1721                if val.is_nan() {
1722                    continue;
1723                }
1724
1725                let bits = val.to_bits();
1726                let row = idx / output.cols;
1727                let col = idx % output.cols;
1728                let combo = &output.combos[row];
1729
1730                if bits == 0x11111111_11111111 {
1731                    panic!(
1732                        "[{}] Config {}: Found alloc_with_nan_prefix poison value {} (0x{:016X}) \
1733						 at row {} col {} (flat index {}) with params: initial_value={}",
1734                        test,
1735                        cfg_idx,
1736                        val,
1737                        bits,
1738                        row,
1739                        col,
1740                        idx,
1741                        combo.initial_value.unwrap_or(1000.0)
1742                    );
1743                }
1744
1745                if bits == 0x22222222_22222222 {
1746                    panic!(
1747                        "[{}] Config {}: Found init_matrix_prefixes poison value {} (0x{:016X}) \
1748						 at row {} col {} (flat index {}) with params: initial_value={}",
1749                        test,
1750                        cfg_idx,
1751                        val,
1752                        bits,
1753                        row,
1754                        col,
1755                        idx,
1756                        combo.initial_value.unwrap_or(1000.0)
1757                    );
1758                }
1759
1760                if bits == 0x33333333_33333333 {
1761                    panic!(
1762                        "[{}] Config {}: Found make_uninit_matrix poison value {} (0x{:016X}) \
1763						 at row {} col {} (flat index {}) with params: initial_value={}",
1764                        test,
1765                        cfg_idx,
1766                        val,
1767                        bits,
1768                        row,
1769                        col,
1770                        idx,
1771                        combo.initial_value.unwrap_or(1000.0)
1772                    );
1773                }
1774            }
1775        }
1776
1777        Ok(())
1778    }
1779
1780    #[cfg(not(debug_assertions))]
1781    fn check_batch_no_poison(
1782        _test: &str,
1783        _kernel: Kernel,
1784    ) -> Result<(), Box<dyn std::error::Error>> {
1785        Ok(())
1786    }
1787
1788    gen_batch_tests!(check_batch_default_row);
1789    gen_batch_tests!(check_batch_no_poison);
1790}
1791
1792#[cfg(feature = "python")]
1793#[pyfunction(name = "pvi")]
1794#[pyo3(signature = (close, volume, initial_value=None, kernel=None))]
1795pub fn pvi_py<'py>(
1796    py: Python<'py>,
1797    close: PyReadonlyArray1<'py, f64>,
1798    volume: PyReadonlyArray1<'py, f64>,
1799    initial_value: Option<f64>,
1800    kernel: Option<&str>,
1801) -> PyResult<Bound<'py, PyArray1<f64>>> {
1802    use numpy::{IntoPyArray, PyArrayMethods};
1803
1804    let close_slice = close.as_slice()?;
1805    let volume_slice = volume.as_slice()?;
1806    let kern = validate_kernel(kernel, false)?;
1807
1808    let params = PviParams { initial_value };
1809    let input = PviInput::from_slices(close_slice, volume_slice, params);
1810
1811    let result_vec: Vec<f64> = py
1812        .allow_threads(|| pvi_with_kernel(&input, kern).map(|o| o.values))
1813        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1814
1815    Ok(result_vec.into_pyarray(py))
1816}
1817
1818#[cfg(feature = "python")]
1819#[pyfunction(name = "pvi_batch")]
1820#[pyo3(signature = (close, volume, initial_value_range, kernel=None))]
1821pub fn pvi_batch_py<'py>(
1822    py: Python<'py>,
1823    close: PyReadonlyArray1<'py, f64>,
1824    volume: PyReadonlyArray1<'py, f64>,
1825    initial_value_range: (f64, f64, f64),
1826    kernel: Option<&str>,
1827) -> PyResult<Bound<'py, PyDict>> {
1828    use numpy::{IntoPyArray, PyArray1, PyArrayMethods};
1829
1830    let close_slice = close.as_slice()?;
1831    let volume_slice = volume.as_slice()?;
1832
1833    let sweep = PviBatchRange {
1834        initial_value: initial_value_range,
1835    };
1836
1837    let combos = expand_grid(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
1838    let rows = combos.len();
1839    let cols = close_slice.len();
1840
1841    let out_arr = unsafe { PyArray1::<f64>::new(py, [rows * cols], false) };
1842    let slice_out = unsafe { out_arr.as_slice_mut()? };
1843
1844    let kern = validate_kernel(kernel, true)?;
1845
1846    let combos = py
1847        .allow_threads(|| {
1848            let kernel = match kern {
1849                Kernel::Auto => detect_best_batch_kernel(),
1850                k => k,
1851            };
1852            let simd = match kernel {
1853                Kernel::Avx512Batch => Kernel::Avx512,
1854                Kernel::Avx2Batch => Kernel::Avx2,
1855                Kernel::ScalarBatch => Kernel::Scalar,
1856                _ => unreachable!(),
1857            };
1858            pvi_batch_inner_into(close_slice, volume_slice, &sweep, simd, true, slice_out)
1859        })
1860        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1861
1862    let dict = PyDict::new(py);
1863    dict.set_item("values", out_arr.reshape((rows, cols))?)?;
1864    dict.set_item(
1865        "initial_values",
1866        combos
1867            .iter()
1868            .map(|p| p.initial_value.unwrap_or(1000.0))
1869            .collect::<Vec<_>>()
1870            .into_pyarray(py),
1871    )?;
1872
1873    Ok(dict)
1874}
1875
1876#[cfg(feature = "python")]
1877#[pyclass(name = "PviStream")]
1878pub struct PviStreamPy {
1879    stream: PviStream,
1880}
1881
1882#[cfg(feature = "python")]
1883#[pymethods]
1884impl PviStreamPy {
1885    #[new]
1886    #[pyo3(signature = (initial_value=None))]
1887    fn new(initial_value: Option<f64>) -> PyResult<Self> {
1888        let params = PviParams { initial_value };
1889        let stream =
1890            PviStream::try_new(params).map_err(|e| PyValueError::new_err(e.to_string()))?;
1891        Ok(PviStreamPy { stream })
1892    }
1893
1894    fn update(&mut self, close: f64, volume: f64) -> Option<f64> {
1895        self.stream.update(close, volume)
1896    }
1897}
1898
1899#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1900#[wasm_bindgen]
1901pub fn pvi_js(close: &[f64], volume: &[f64], initial_value: f64) -> Result<Vec<f64>, JsValue> {
1902    let params = PviParams {
1903        initial_value: Some(initial_value),
1904    };
1905    let input = PviInput::from_slices(close, volume, params);
1906
1907    let mut output = vec![0.0; close.len()];
1908    pvi_into_slice(&mut output, &input, Kernel::Auto)
1909        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1910
1911    Ok(output)
1912}
1913
1914#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1915#[wasm_bindgen]
1916pub fn pvi_into(
1917    close_ptr: *const f64,
1918    volume_ptr: *const f64,
1919    out_ptr: *mut f64,
1920    len: usize,
1921    initial_value: f64,
1922) -> Result<(), JsValue> {
1923    if close_ptr.is_null() || volume_ptr.is_null() || out_ptr.is_null() {
1924        return Err(JsValue::from_str("Null pointer provided"));
1925    }
1926
1927    unsafe {
1928        let close = std::slice::from_raw_parts(close_ptr, len);
1929        let volume = std::slice::from_raw_parts(volume_ptr, len);
1930
1931        let params = PviParams {
1932            initial_value: Some(initial_value),
1933        };
1934        let input = PviInput::from_slices(close, volume, params);
1935
1936        if close_ptr == out_ptr || volume_ptr == out_ptr {
1937            let mut temp = vec![0.0; len];
1938            pvi_into_slice(&mut temp, &input, Kernel::Auto)
1939                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1940            let out = std::slice::from_raw_parts_mut(out_ptr, len);
1941            out.copy_from_slice(&temp);
1942        } else {
1943            let out = std::slice::from_raw_parts_mut(out_ptr, len);
1944            pvi_into_slice(out, &input, Kernel::Auto)
1945                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1946        }
1947        Ok(())
1948    }
1949}
1950
1951#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1952#[wasm_bindgen]
1953pub fn pvi_alloc(len: usize) -> *mut f64 {
1954    let mut vec = Vec::<f64>::with_capacity(len);
1955    let ptr = vec.as_mut_ptr();
1956    std::mem::forget(vec);
1957    ptr
1958}
1959
1960#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1961#[wasm_bindgen]
1962pub fn pvi_free(ptr: *mut f64, len: usize) {
1963    if !ptr.is_null() {
1964        unsafe {
1965            let _ = Vec::from_raw_parts(ptr, len, len);
1966        }
1967    }
1968}
1969
1970#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1971#[derive(Serialize, Deserialize)]
1972pub struct PviBatchConfig {
1973    pub initial_value_range: (f64, f64, f64),
1974}
1975
1976#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1977#[derive(Serialize, Deserialize)]
1978pub struct PviBatchJsOutput {
1979    pub values: Vec<f64>,
1980    pub combos: Vec<PviParams>,
1981    pub rows: usize,
1982    pub cols: usize,
1983}
1984
1985#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1986#[wasm_bindgen(js_name = pvi_batch)]
1987pub fn pvi_batch_js(close: &[f64], volume: &[f64], config: JsValue) -> Result<JsValue, JsValue> {
1988    let config: PviBatchConfig = serde_wasm_bindgen::from_value(config)
1989        .map_err(|e| JsValue::from_str(&format!("Invalid config: {}", e)))?;
1990
1991    let sweep = PviBatchRange {
1992        initial_value: config.initial_value_range,
1993    };
1994
1995    let output = pvi_batch_with_kernel(close, volume, &sweep, Kernel::Auto)
1996        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1997
1998    let js_output = PviBatchJsOutput {
1999        values: output.values,
2000        combos: output.combos,
2001        rows: output.rows,
2002        cols: output.cols,
2003    };
2004
2005    serde_wasm_bindgen::to_value(&js_output)
2006        .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
2007}
2008
2009#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2010#[wasm_bindgen]
2011pub fn pvi_batch_into(
2012    close_ptr: *const f64,
2013    volume_ptr: *const f64,
2014    out_ptr: *mut f64,
2015    len: usize,
2016    initial_value_start: f64,
2017    initial_value_end: f64,
2018    initial_value_step: f64,
2019) -> Result<usize, JsValue> {
2020    if close_ptr.is_null() || volume_ptr.is_null() || out_ptr.is_null() {
2021        return Err(JsValue::from_str("null pointer passed to pvi_batch_into"));
2022    }
2023
2024    unsafe {
2025        let close = std::slice::from_raw_parts(close_ptr, len);
2026        let volume = std::slice::from_raw_parts(volume_ptr, len);
2027
2028        let sweep = PviBatchRange {
2029            initial_value: (initial_value_start, initial_value_end, initial_value_step),
2030        };
2031
2032        let combos = expand_grid(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
2033        let rows = combos.len();
2034        let total = rows
2035            .checked_mul(len)
2036            .ok_or_else(|| JsValue::from_str("pvi_batch_into: rows*len overflow"))?;
2037        let out = std::slice::from_raw_parts_mut(out_ptr, total);
2038
2039        let kernel = detect_best_batch_kernel();
2040        let simd = match kernel {
2041            Kernel::Avx512Batch => Kernel::Avx512,
2042            Kernel::Avx2Batch => Kernel::Avx2,
2043            Kernel::ScalarBatch => Kernel::Scalar,
2044            _ => unreachable!(),
2045        };
2046
2047        pvi_batch_inner_into(close, volume, &sweep, simd, true, out)
2048            .map_err(|e| JsValue::from_str(&e.to_string()))?;
2049
2050        Ok(rows)
2051    }
2052}
2053
2054#[cfg(all(feature = "python", feature = "cuda"))]
2055use crate::cuda::cuda_available;
2056#[cfg(all(feature = "python", feature = "cuda"))]
2057use crate::cuda::moving_averages::DeviceArrayF32;
2058#[cfg(all(feature = "python", feature = "cuda"))]
2059use crate::cuda::CudaPvi;
2060#[cfg(all(feature = "python", feature = "cuda"))]
2061use crate::utilities::dlpack_cuda::export_f32_cuda_dlpack_2d;
2062#[cfg(all(feature = "python", feature = "cuda"))]
2063use cust::context::Context;
2064#[cfg(all(feature = "python", feature = "cuda"))]
2065use std::sync::Arc;
2066
2067#[cfg(all(feature = "python", feature = "cuda"))]
2068#[pyclass(module = "ta_indicators.cuda", name = "PviDeviceArrayF32", unsendable)]
2069pub struct PviDeviceArrayF32Py {
2070    pub(crate) inner: Option<DeviceArrayF32>,
2071    pub(crate) _ctx: Arc<Context>,
2072    pub(crate) device_id: u32,
2073}
2074
2075#[cfg(all(feature = "python", feature = "cuda"))]
2076#[pymethods]
2077impl PviDeviceArrayF32Py {
2078    #[getter]
2079    fn __cuda_array_interface__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
2080        let inner = self
2081            .inner
2082            .as_ref()
2083            .ok_or_else(|| PyValueError::new_err("buffer already exported via __dlpack__"))?;
2084        let d = PyDict::new(py);
2085        let itemsize = std::mem::size_of::<f32>();
2086        d.set_item("shape", (inner.rows, inner.cols))?;
2087        d.set_item("typestr", "<f4")?;
2088        d.set_item("strides", (inner.cols * itemsize, itemsize))?;
2089        d.set_item("data", (inner.device_ptr() as usize, false))?;
2090
2091        d.set_item("version", 3)?;
2092        Ok(d)
2093    }
2094
2095    fn __dlpack_device__(&self) -> (i32, i32) {
2096        (2, self.device_id as i32)
2097    }
2098
2099    #[pyo3(signature=(stream=None, max_version=None, dl_device=None, copy=None))]
2100    fn __dlpack__<'py>(
2101        &mut self,
2102        py: Python<'py>,
2103        stream: Option<PyObject>,
2104        max_version: Option<PyObject>,
2105        dl_device: Option<PyObject>,
2106        copy: Option<PyObject>,
2107    ) -> PyResult<PyObject> {
2108        if let Some(s_obj) = stream.as_ref() {
2109            if let Ok(s) = s_obj.extract::<usize>(py) {
2110                if s == 0 {
2111                    return Err(PyValueError::new_err(
2112                        "__dlpack__ stream=0 is invalid for CUDA",
2113                    ));
2114                }
2115            }
2116        }
2117
2118        let (kdl, alloc_dev) = self.__dlpack_device__();
2119        if let Some(dev_obj) = dl_device.as_ref() {
2120            if let Ok((dev_ty, dev_id)) = dev_obj.extract::<(i32, i32)>(py) {
2121                if dev_ty != kdl || dev_id != alloc_dev {
2122                    return Err(PyValueError::new_err(
2123                        "dl_device mismatch; cross-device copy not supported for PviDeviceArrayF32",
2124                    ));
2125                }
2126            }
2127        }
2128
2129        if copy.as_ref().and_then(|c| c.extract::<bool>(py).ok()) == Some(true) {
2130            return Err(PyValueError::new_err(
2131                "copy=True not supported for PviDeviceArrayF32",
2132            ));
2133        }
2134
2135        let _ = stream;
2136
2137        let inner = self
2138            .inner
2139            .take()
2140            .ok_or_else(|| PyValueError::new_err("__dlpack__ may only be called once"))?;
2141
2142        let rows = inner.rows;
2143        let cols = inner.cols;
2144        let buf = inner.buf;
2145
2146        let max_version_bound = max_version.map(|obj| obj.into_bound(py));
2147
2148        export_f32_cuda_dlpack_2d(py, buf, rows, cols, alloc_dev, max_version_bound)
2149    }
2150}
2151
2152#[cfg(all(feature = "python", feature = "cuda"))]
2153impl PviDeviceArrayF32Py {
2154    pub fn new_from_rust(inner: DeviceArrayF32, ctx_guard: Arc<Context>, device_id: u32) -> Self {
2155        Self {
2156            inner: Some(inner),
2157            _ctx: ctx_guard,
2158            device_id,
2159        }
2160    }
2161}
2162
2163#[cfg(all(feature = "python", feature = "cuda"))]
2164#[pyfunction(name = "pvi_cuda_batch_dev")]
2165#[pyo3(signature = (close, volume, initial_values, device_id=0))]
2166pub fn pvi_cuda_batch_dev_py(
2167    py: Python<'_>,
2168    close: PyReadonlyArray1<'_, f32>,
2169    volume: PyReadonlyArray1<'_, f32>,
2170    initial_values: PyReadonlyArray1<'_, f32>,
2171    device_id: usize,
2172) -> PyResult<PviDeviceArrayF32Py> {
2173    if !cuda_available() {
2174        return Err(PyValueError::new_err("CUDA not available"));
2175    }
2176    let close_slice = close.as_slice()?;
2177    let volume_slice = volume.as_slice()?;
2178    let inits_slice = initial_values.as_slice()?;
2179    if close_slice.len() != volume_slice.len() {
2180        return Err(PyValueError::new_err("mismatched input lengths"));
2181    }
2182    if inits_slice.is_empty() {
2183        return Err(PyValueError::new_err("initial_values must be non-empty"));
2184    }
2185    let (inner, ctx, dev_id) = py.allow_threads(|| {
2186        let cuda = CudaPvi::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
2187        let ctx = cuda.context_arc();
2188        let dev_id = cuda.device_id();
2189        let arr = cuda
2190            .pvi_batch_dev(close_slice, volume_slice, inits_slice)
2191            .map_err(|e| PyValueError::new_err(e.to_string()))?;
2192        Ok::<_, pyo3::PyErr>((arr, ctx, dev_id))
2193    })?;
2194    Ok(PviDeviceArrayF32Py::new_from_rust(inner, ctx, dev_id))
2195}
2196
2197#[cfg(all(feature = "python", feature = "cuda"))]
2198#[pyfunction(name = "pvi_cuda_many_series_one_param_dev")]
2199#[pyo3(signature = (close_tm, volume_tm, cols, rows, initial_value, device_id=0))]
2200pub fn pvi_cuda_many_series_one_param_dev_py(
2201    py: Python<'_>,
2202    close_tm: PyReadonlyArray1<'_, f32>,
2203    volume_tm: PyReadonlyArray1<'_, f32>,
2204    cols: usize,
2205    rows: usize,
2206    initial_value: f32,
2207    device_id: usize,
2208) -> PyResult<PviDeviceArrayF32Py> {
2209    if !cuda_available() {
2210        return Err(PyValueError::new_err("CUDA not available"));
2211    }
2212    let close_slice = close_tm.as_slice()?;
2213    let volume_slice = volume_tm.as_slice()?;
2214    let (inner, ctx, dev_id) = py.allow_threads(|| {
2215        let cuda = CudaPvi::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
2216        let ctx = cuda.context_arc();
2217        let dev_id = cuda.device_id();
2218        let arr = cuda
2219            .pvi_many_series_one_param_time_major_dev(
2220                close_slice,
2221                volume_slice,
2222                cols,
2223                rows,
2224                initial_value,
2225            )
2226            .map_err(|e| PyValueError::new_err(e.to_string()))?;
2227        Ok::<_, pyo3::PyErr>((arr, ctx, dev_id))
2228    })?;
2229    Ok(PviDeviceArrayF32Py::new_from_rust(inner, ctx, dev_id))
2230}