Skip to main content

vector_ta/indicators/
parkinson_volatility.rs

1#[cfg(all(feature = "python", feature = "cuda"))]
2use crate::utilities::dlpack_cuda::export_f32_cuda_dlpack_2d;
3#[cfg(all(feature = "python", feature = "cuda"))]
4use cust::context::Context;
5#[cfg(all(feature = "python", feature = "cuda"))]
6use cust::memory::DeviceBuffer;
7#[cfg(feature = "python")]
8use numpy::{
9    IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2,
10    PyUntypedArrayMethods,
11};
12#[cfg(feature = "python")]
13use pyo3::exceptions::PyValueError;
14#[cfg(feature = "python")]
15use pyo3::prelude::*;
16#[cfg(feature = "python")]
17use pyo3::types::PyDict;
18
19#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
20use serde::{Deserialize, Serialize};
21#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
22use wasm_bindgen::prelude::*;
23
24use crate::utilities::data_loader::Candles;
25use crate::utilities::enums::Kernel;
26use crate::utilities::helpers::{
27    alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
28    make_uninit_matrix,
29};
30#[cfg(feature = "python")]
31use crate::utilities::kernel_validation::validate_kernel;
32#[cfg(not(target_arch = "wasm32"))]
33use rayon::prelude::*;
34use std::mem::{ManuallyDrop, MaybeUninit};
35#[cfg(all(feature = "python", feature = "cuda"))]
36use std::sync::Arc;
37use thiserror::Error;
38
39const FOUR_LN_2: f64 = 4.0 * std::f64::consts::LN_2;
40
41#[derive(Debug, Clone)]
42pub enum ParkinsonVolatilityData<'a> {
43    Candles { candles: &'a Candles },
44    Slices { high: &'a [f64], low: &'a [f64] },
45}
46
47#[derive(Debug, Clone)]
48pub struct ParkinsonVolatilityOutput {
49    pub volatility: Vec<f64>,
50    pub variance: Vec<f64>,
51}
52
53#[derive(Debug, Clone)]
54#[cfg_attr(
55    all(target_arch = "wasm32", feature = "wasm"),
56    derive(Serialize, Deserialize)
57)]
58pub struct ParkinsonVolatilityParams {
59    pub period: Option<usize>,
60}
61
62impl Default for ParkinsonVolatilityParams {
63    fn default() -> Self {
64        Self { period: Some(8) }
65    }
66}
67
68#[derive(Debug, Clone)]
69pub struct ParkinsonVolatilityInput<'a> {
70    pub data: ParkinsonVolatilityData<'a>,
71    pub params: ParkinsonVolatilityParams,
72}
73
74impl<'a> ParkinsonVolatilityInput<'a> {
75    #[inline]
76    pub fn from_candles(candles: &'a Candles, params: ParkinsonVolatilityParams) -> Self {
77        Self {
78            data: ParkinsonVolatilityData::Candles { candles },
79            params,
80        }
81    }
82
83    #[inline]
84    pub fn from_slices(high: &'a [f64], low: &'a [f64], params: ParkinsonVolatilityParams) -> Self {
85        Self {
86            data: ParkinsonVolatilityData::Slices { high, low },
87            params,
88        }
89    }
90
91    #[inline]
92    pub fn with_default_candles(candles: &'a Candles) -> Self {
93        Self::from_candles(candles, ParkinsonVolatilityParams::default())
94    }
95
96    #[inline]
97    pub fn get_period(&self) -> usize {
98        self.params.period.unwrap_or(8)
99    }
100
101    #[inline]
102    pub fn as_refs(&'a self) -> Result<(&'a [f64], &'a [f64]), ParkinsonVolatilityError> {
103        match &self.data {
104            ParkinsonVolatilityData::Candles { candles } => {
105                let high = candles
106                    .select_candle_field("high")
107                    .map_err(|_| ParkinsonVolatilityError::CandleFieldError { field: "high" })?;
108                let low = candles
109                    .select_candle_field("low")
110                    .map_err(|_| ParkinsonVolatilityError::CandleFieldError { field: "low" })?;
111                Ok((high, low))
112            }
113            ParkinsonVolatilityData::Slices { high, low } => Ok((*high, *low)),
114        }
115    }
116}
117
118#[derive(Copy, Clone, Debug)]
119pub struct ParkinsonVolatilityBuilder {
120    period: Option<usize>,
121    kernel: Kernel,
122}
123
124impl Default for ParkinsonVolatilityBuilder {
125    fn default() -> Self {
126        Self {
127            period: None,
128            kernel: Kernel::Auto,
129        }
130    }
131}
132
133impl ParkinsonVolatilityBuilder {
134    #[inline(always)]
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    #[inline(always)]
140    pub fn period(mut self, n: usize) -> Self {
141        self.period = Some(n);
142        self
143    }
144
145    #[inline(always)]
146    pub fn kernel(mut self, k: Kernel) -> Self {
147        self.kernel = k;
148        self
149    }
150
151    #[inline(always)]
152    pub fn apply(
153        self,
154        candles: &Candles,
155    ) -> Result<ParkinsonVolatilityOutput, ParkinsonVolatilityError> {
156        let params = ParkinsonVolatilityParams {
157            period: self.period,
158        };
159        let input = ParkinsonVolatilityInput::from_candles(candles, params);
160        parkinson_volatility_with_kernel(&input, self.kernel)
161    }
162
163    #[inline(always)]
164    pub fn apply_slices(
165        self,
166        high: &[f64],
167        low: &[f64],
168    ) -> Result<ParkinsonVolatilityOutput, ParkinsonVolatilityError> {
169        let params = ParkinsonVolatilityParams {
170            period: self.period,
171        };
172        let input = ParkinsonVolatilityInput::from_slices(high, low, params);
173        parkinson_volatility_with_kernel(&input, self.kernel)
174    }
175
176    #[inline(always)]
177    pub fn into_stream(self) -> Result<ParkinsonVolatilityStream, ParkinsonVolatilityError> {
178        let params = ParkinsonVolatilityParams {
179            period: self.period,
180        };
181        ParkinsonVolatilityStream::try_new(params)
182    }
183}
184
185#[derive(Debug, Error)]
186pub enum ParkinsonVolatilityError {
187    #[error("parkinson_volatility: Empty input data.")]
188    EmptyInputData,
189    #[error("parkinson_volatility: Data length mismatch between high and low.")]
190    DataLengthMismatch,
191    #[error("parkinson_volatility: Invalid period: period = {period}, data length = {data_len}")]
192    InvalidPeriod { period: usize, data_len: usize },
193    #[error("parkinson_volatility: Not enough valid data: needed = {needed}, valid = {valid}")]
194    NotEnoughValidData { needed: usize, valid: usize },
195    #[error("parkinson_volatility: All values are invalid in high or low.")]
196    AllValuesNaN,
197    #[error("parkinson_volatility: Candle field error: {field}")]
198    CandleFieldError { field: &'static str },
199    #[error("parkinson_volatility: Output length mismatch (expected {expected}, got {got})")]
200    OutputLengthMismatch { expected: usize, got: usize },
201    #[error("parkinson_volatility: invalid input: {0}")]
202    InvalidInput(&'static str),
203    #[error("parkinson_volatility: invalid range: start={start} end={end} step={step}")]
204    InvalidRange {
205        start: usize,
206        end: usize,
207        step: usize,
208    },
209    #[error("parkinson_volatility: invalid kernel for batch path: {0:?}")]
210    InvalidKernelForBatch(Kernel),
211}
212
213#[inline]
214pub fn parkinson_volatility(
215    input: &ParkinsonVolatilityInput,
216) -> Result<ParkinsonVolatilityOutput, ParkinsonVolatilityError> {
217    parkinson_volatility_with_kernel(input, Kernel::Auto)
218}
219
220#[inline(always)]
221fn is_valid_high_low(high: f64, low: f64) -> bool {
222    high.is_finite() && low.is_finite() && high > 0.0 && low > 0.0
223}
224
225#[inline(always)]
226fn first_valid_high_low(high: &[f64], low: &[f64]) -> Option<usize> {
227    high.iter()
228        .zip(low.iter())
229        .position(|(&h, &l)| is_valid_high_low(h, l))
230}
231
232#[inline(always)]
233fn log_range_sq(high: f64, low: f64) -> f64 {
234    let x = (high / low).ln();
235    x * x
236}
237
238#[inline(always)]
239fn outputs_from_sum(sum_log_sq: f64, period: usize) -> (f64, f64) {
240    let variance = ((sum_log_sq / (period as f64)) / FOUR_LN_2).max(0.0);
241    (variance.sqrt(), variance)
242}
243
244#[inline(always)]
245fn parkinson_prepare<'a>(
246    input: &'a ParkinsonVolatilityInput,
247    kernel: Kernel,
248) -> Result<(&'a [f64], &'a [f64], usize, usize, Kernel), ParkinsonVolatilityError> {
249    let (high, low) = input.as_refs()?;
250    if high.is_empty() || low.is_empty() {
251        return Err(ParkinsonVolatilityError::EmptyInputData);
252    }
253    if high.len() != low.len() {
254        return Err(ParkinsonVolatilityError::DataLengthMismatch);
255    }
256
257    let period = input.get_period();
258    if period == 0 || period > high.len() {
259        return Err(ParkinsonVolatilityError::InvalidPeriod {
260            period,
261            data_len: high.len(),
262        });
263    }
264
265    let first = first_valid_high_low(high, low).ok_or(ParkinsonVolatilityError::AllValuesNaN)?;
266    if high.len() - first < period {
267        return Err(ParkinsonVolatilityError::NotEnoughValidData {
268            needed: period,
269            valid: high.len() - first,
270        });
271    }
272
273    let chosen = match kernel {
274        Kernel::Auto => detect_best_kernel(),
275        other => other.to_non_batch(),
276    };
277    Ok((high, low, period, first, chosen))
278}
279
280#[inline(always)]
281fn parkinson_compute_into(
282    high: &[f64],
283    low: &[f64],
284    period: usize,
285    first: usize,
286    out_volatility: &mut [f64],
287    out_variance: &mut [f64],
288) {
289    let warm = first + period - 1;
290    if warm >= high.len() {
291        return;
292    }
293
294    let mut invalid = 0usize;
295    let mut sum_log_sq = 0.0f64;
296
297    for i in first..=warm {
298        if is_valid_high_low(high[i], low[i]) {
299            sum_log_sq += log_range_sq(high[i], low[i]);
300        } else {
301            invalid += 1;
302        }
303    }
304
305    if invalid == 0 {
306        let (vol, var) = outputs_from_sum(sum_log_sq, period);
307        out_volatility[warm] = vol;
308        out_variance[warm] = var;
309    }
310
311    for i in (warm + 1)..high.len() {
312        let old_idx = i - period;
313        if is_valid_high_low(high[old_idx], low[old_idx]) {
314            sum_log_sq -= log_range_sq(high[old_idx], low[old_idx]);
315        } else {
316            invalid -= 1;
317        }
318
319        if is_valid_high_low(high[i], low[i]) {
320            sum_log_sq += log_range_sq(high[i], low[i]);
321        } else {
322            invalid += 1;
323        }
324
325        if invalid == 0 {
326            let (vol, var) = outputs_from_sum(sum_log_sq, period);
327            out_volatility[i] = vol;
328            out_variance[i] = var;
329        }
330    }
331}
332
333#[inline]
334pub fn parkinson_volatility_with_kernel(
335    input: &ParkinsonVolatilityInput,
336    kernel: Kernel,
337) -> Result<ParkinsonVolatilityOutput, ParkinsonVolatilityError> {
338    let (high, low, period, first, _chosen) = parkinson_prepare(input, kernel)?;
339    let warm = first + period - 1;
340    let mut volatility = alloc_with_nan_prefix(high.len(), warm);
341    let mut variance = alloc_with_nan_prefix(high.len(), warm);
342    parkinson_compute_into(high, low, period, first, &mut volatility, &mut variance);
343    Ok(ParkinsonVolatilityOutput {
344        volatility,
345        variance,
346    })
347}
348
349#[inline]
350pub fn parkinson_volatility_into_slice(
351    dst_volatility: &mut [f64],
352    dst_variance: &mut [f64],
353    input: &ParkinsonVolatilityInput,
354    kernel: Kernel,
355) -> Result<(), ParkinsonVolatilityError> {
356    let (high, low, period, first, _chosen) = parkinson_prepare(input, kernel)?;
357    let expected = high.len();
358    if dst_volatility.len() != expected || dst_variance.len() != expected {
359        return Err(ParkinsonVolatilityError::OutputLengthMismatch {
360            expected,
361            got: dst_volatility.len().max(dst_variance.len()),
362        });
363    }
364
365    dst_volatility.fill(f64::NAN);
366    dst_variance.fill(f64::NAN);
367    parkinson_compute_into(high, low, period, first, dst_volatility, dst_variance);
368    Ok(())
369}
370
371#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
372#[inline]
373pub fn parkinson_volatility_into(
374    input: &ParkinsonVolatilityInput,
375    out_volatility: &mut [f64],
376    out_variance: &mut [f64],
377) -> Result<(), ParkinsonVolatilityError> {
378    parkinson_volatility_into_slice(out_volatility, out_variance, input, Kernel::Auto)
379}
380
381#[derive(Debug, Clone)]
382pub struct ParkinsonVolatilityStream {
383    period: usize,
384    buffer: Vec<f64>,
385    head: usize,
386    len: usize,
387    invalid: usize,
388    sum_log_sq: f64,
389}
390
391impl ParkinsonVolatilityStream {
392    #[inline]
393    pub fn try_new(params: ParkinsonVolatilityParams) -> Result<Self, ParkinsonVolatilityError> {
394        let period = params.period.unwrap_or(8);
395        if period == 0 {
396            return Err(ParkinsonVolatilityError::InvalidPeriod {
397                period,
398                data_len: 0,
399            });
400        }
401
402        Ok(Self {
403            period,
404            buffer: vec![f64::NAN; period],
405            head: 0,
406            len: 0,
407            invalid: 0,
408            sum_log_sq: 0.0,
409        })
410    }
411
412    #[inline(always)]
413    pub fn update(&mut self, high: f64, low: f64) -> Option<(f64, f64)> {
414        if self.len == self.period {
415            let old = self.buffer[self.head];
416            if old.is_nan() {
417                self.invalid -= 1;
418            } else {
419                self.sum_log_sq -= old;
420            }
421        }
422
423        let contrib = if is_valid_high_low(high, low) {
424            log_range_sq(high, low)
425        } else {
426            f64::NAN
427        };
428        self.buffer[self.head] = contrib;
429        if contrib.is_nan() {
430            self.invalid += 1;
431        } else {
432            self.sum_log_sq += contrib;
433        }
434
435        self.head += 1;
436        if self.head == self.period {
437            self.head = 0;
438        }
439        if self.len < self.period {
440            self.len += 1;
441        }
442
443        if self.len < self.period {
444            return None;
445        }
446        if self.invalid != 0 {
447            return Some((f64::NAN, f64::NAN));
448        }
449        Some(outputs_from_sum(self.sum_log_sq, self.period))
450    }
451
452    #[inline(always)]
453    pub fn get_warmup_period(&self) -> usize {
454        self.period
455    }
456}
457
458#[derive(Clone, Debug)]
459pub struct ParkinsonVolatilityBatchRange {
460    pub period: (usize, usize, usize),
461}
462
463impl Default for ParkinsonVolatilityBatchRange {
464    fn default() -> Self {
465        Self {
466            period: (8, 256, 1),
467        }
468    }
469}
470
471#[derive(Clone, Debug, Default)]
472pub struct ParkinsonVolatilityBatchBuilder {
473    range: ParkinsonVolatilityBatchRange,
474    kernel: Kernel,
475}
476
477impl ParkinsonVolatilityBatchBuilder {
478    pub fn new() -> Self {
479        Self::default()
480    }
481
482    pub fn kernel(mut self, k: Kernel) -> Self {
483        self.kernel = k;
484        self
485    }
486
487    #[inline]
488    pub fn period_range(mut self, start: usize, end: usize, step: usize) -> Self {
489        self.range.period = (start, end, step);
490        self
491    }
492
493    #[inline]
494    pub fn period_static(mut self, p: usize) -> Self {
495        self.range.period = (p, p, 0);
496        self
497    }
498
499    pub fn apply_slices(
500        self,
501        high: &[f64],
502        low: &[f64],
503    ) -> Result<ParkinsonVolatilityBatchOutput, ParkinsonVolatilityError> {
504        parkinson_volatility_batch_with_kernel(high, low, &self.range, self.kernel)
505    }
506}
507
508#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
509#[derive(Serialize, Deserialize)]
510pub struct ParkinsonVolatilityBatchConfig {
511    pub period_range: Vec<usize>,
512}
513
514#[derive(Clone, Debug)]
515pub struct ParkinsonVolatilityBatchOutput {
516    pub volatility: Vec<f64>,
517    pub variance: Vec<f64>,
518    pub combos: Vec<ParkinsonVolatilityParams>,
519    pub rows: usize,
520    pub cols: usize,
521}
522
523impl ParkinsonVolatilityBatchOutput {
524    pub fn row_for_params(&self, params: &ParkinsonVolatilityParams) -> Option<usize> {
525        self.combos
526            .iter()
527            .position(|c| c.period.unwrap_or(8) == params.period.unwrap_or(8))
528    }
529
530    pub fn volatility_for(&self, params: &ParkinsonVolatilityParams) -> Option<&[f64]> {
531        self.row_for_params(params).and_then(|row| {
532            let start = row * self.cols;
533            self.volatility.get(start..start + self.cols)
534        })
535    }
536
537    pub fn variance_for(&self, params: &ParkinsonVolatilityParams) -> Option<&[f64]> {
538        self.row_for_params(params).and_then(|row| {
539            let start = row * self.cols;
540            self.variance.get(start..start + self.cols)
541        })
542    }
543}
544
545#[inline]
546pub fn expand_grid_parkinson(
547    range: &ParkinsonVolatilityBatchRange,
548) -> Result<Vec<ParkinsonVolatilityParams>, ParkinsonVolatilityError> {
549    fn axis_usize(
550        (start, end, step): (usize, usize, usize),
551    ) -> Result<Vec<usize>, ParkinsonVolatilityError> {
552        if step == 0 || start == end {
553            return Ok(vec![start]);
554        }
555        if start < end {
556            let mut values = Vec::new();
557            let mut x = start;
558            while x <= end {
559                values.push(x);
560                match x.checked_add(step) {
561                    Some(next) if next > x => x = next,
562                    _ => break,
563                }
564            }
565            if values.is_empty() {
566                return Err(ParkinsonVolatilityError::InvalidRange { start, end, step });
567            }
568            Ok(values)
569        } else {
570            let mut values = Vec::new();
571            let st = step.max(1);
572            let mut x = start;
573            while x >= end {
574                values.push(x);
575                if x == end {
576                    break;
577                }
578                let next = x.saturating_sub(st);
579                if next == x || next < end {
580                    break;
581                }
582                x = next;
583            }
584            if values.is_empty() {
585                return Err(ParkinsonVolatilityError::InvalidRange { start, end, step });
586            }
587            Ok(values)
588        }
589    }
590
591    Ok(axis_usize(range.period)?
592        .into_iter()
593        .map(|period| ParkinsonVolatilityParams {
594            period: Some(period),
595        })
596        .collect())
597}
598
599#[inline]
600pub fn parkinson_volatility_batch_with_kernel(
601    high: &[f64],
602    low: &[f64],
603    sweep: &ParkinsonVolatilityBatchRange,
604    kernel: Kernel,
605) -> Result<ParkinsonVolatilityBatchOutput, ParkinsonVolatilityError> {
606    let batch = match kernel {
607        Kernel::Auto => detect_best_batch_kernel(),
608        other if other.is_batch() => other,
609        other => return Err(ParkinsonVolatilityError::InvalidKernelForBatch(other)),
610    };
611    parkinson_volatility_batch_par_slice(high, low, sweep, batch.to_non_batch())
612}
613
614#[inline(always)]
615pub fn parkinson_volatility_batch_slice(
616    high: &[f64],
617    low: &[f64],
618    sweep: &ParkinsonVolatilityBatchRange,
619    kernel: Kernel,
620) -> Result<ParkinsonVolatilityBatchOutput, ParkinsonVolatilityError> {
621    parkinson_volatility_batch_inner(high, low, sweep, kernel, false)
622}
623
624#[inline(always)]
625pub fn parkinson_volatility_batch_par_slice(
626    high: &[f64],
627    low: &[f64],
628    sweep: &ParkinsonVolatilityBatchRange,
629    kernel: Kernel,
630) -> Result<ParkinsonVolatilityBatchOutput, ParkinsonVolatilityError> {
631    parkinson_volatility_batch_inner(high, low, sweep, kernel, true)
632}
633
634#[inline(always)]
635fn parkinson_volatility_batch_inner(
636    high: &[f64],
637    low: &[f64],
638    sweep: &ParkinsonVolatilityBatchRange,
639    _kernel: Kernel,
640    parallel: bool,
641) -> Result<ParkinsonVolatilityBatchOutput, ParkinsonVolatilityError> {
642    let combos = expand_grid_parkinson(sweep)?;
643    if high.is_empty() || low.is_empty() {
644        return Err(ParkinsonVolatilityError::EmptyInputData);
645    }
646    if high.len() != low.len() {
647        return Err(ParkinsonVolatilityError::DataLengthMismatch);
648    }
649
650    let first = first_valid_high_low(high, low).ok_or(ParkinsonVolatilityError::AllValuesNaN)?;
651    let max_period = combos
652        .iter()
653        .map(|c| c.period.unwrap_or(8))
654        .max()
655        .unwrap_or(0);
656    if max_period == 0 || high.len() - first < max_period {
657        return Err(ParkinsonVolatilityError::NotEnoughValidData {
658            needed: max_period,
659            valid: high.len() - first,
660        });
661    }
662
663    let rows = combos.len();
664    let cols = high.len();
665    let mut volatility_mu = make_uninit_matrix(rows, cols);
666    let mut variance_mu = make_uninit_matrix(rows, cols);
667    let warmups: Vec<usize> = combos
668        .iter()
669        .map(|c| first + c.period.unwrap_or(8) - 1)
670        .collect();
671    init_matrix_prefixes(&mut volatility_mu, cols, &warmups);
672    init_matrix_prefixes(&mut variance_mu, cols, &warmups);
673
674    let mut volatility_guard = ManuallyDrop::new(volatility_mu);
675    let mut variance_guard = ManuallyDrop::new(variance_mu);
676    let volatility = unsafe {
677        core::slice::from_raw_parts_mut(
678            volatility_guard.as_mut_ptr() as *mut f64,
679            volatility_guard.len(),
680        )
681    };
682    let variance = unsafe {
683        core::slice::from_raw_parts_mut(
684            variance_guard.as_mut_ptr() as *mut f64,
685            variance_guard.len(),
686        )
687    };
688
689    parkinson_volatility_batch_inner_into(
690        high,
691        low,
692        sweep,
693        Kernel::Scalar,
694        parallel,
695        volatility,
696        variance,
697    )?;
698
699    let volatility_values = unsafe {
700        Vec::from_raw_parts(
701            volatility_guard.as_mut_ptr() as *mut f64,
702            volatility_guard.len(),
703            volatility_guard.capacity(),
704        )
705    };
706    let variance_values = unsafe {
707        Vec::from_raw_parts(
708            variance_guard.as_mut_ptr() as *mut f64,
709            variance_guard.len(),
710            variance_guard.capacity(),
711        )
712    };
713
714    Ok(ParkinsonVolatilityBatchOutput {
715        volatility: volatility_values,
716        variance: variance_values,
717        combos,
718        rows,
719        cols,
720    })
721}
722
723#[inline(always)]
724fn parkinson_volatility_batch_inner_into(
725    high: &[f64],
726    low: &[f64],
727    sweep: &ParkinsonVolatilityBatchRange,
728    _kernel: Kernel,
729    parallel: bool,
730    out_volatility: &mut [f64],
731    out_variance: &mut [f64],
732) -> Result<Vec<ParkinsonVolatilityParams>, ParkinsonVolatilityError> {
733    let combos = expand_grid_parkinson(sweep)?;
734    if high.is_empty() || low.is_empty() {
735        return Err(ParkinsonVolatilityError::EmptyInputData);
736    }
737    if high.len() != low.len() {
738        return Err(ParkinsonVolatilityError::DataLengthMismatch);
739    }
740
741    let first = first_valid_high_low(high, low).ok_or(ParkinsonVolatilityError::AllValuesNaN)?;
742    let max_period = combos
743        .iter()
744        .map(|c| c.period.unwrap_or(8))
745        .max()
746        .unwrap_or(0);
747    if max_period == 0 || high.len() - first < max_period {
748        return Err(ParkinsonVolatilityError::NotEnoughValidData {
749            needed: max_period,
750            valid: high.len() - first,
751        });
752    }
753
754    let rows = combos.len();
755    let cols = high.len();
756    let total = rows
757        .checked_mul(cols)
758        .ok_or(ParkinsonVolatilityError::InvalidInput("rows*cols overflow"))?;
759    if out_volatility.len() != total || out_variance.len() != total {
760        return Err(ParkinsonVolatilityError::OutputLengthMismatch {
761            expected: total,
762            got: out_volatility.len().max(out_variance.len()),
763        });
764    }
765
766    let vol_mu = unsafe {
767        core::slice::from_raw_parts_mut(out_volatility.as_mut_ptr() as *mut MaybeUninit<f64>, total)
768    };
769    let var_mu = unsafe {
770        core::slice::from_raw_parts_mut(out_variance.as_mut_ptr() as *mut MaybeUninit<f64>, total)
771    };
772    let warmups: Vec<usize> = combos
773        .iter()
774        .map(|c| first + c.period.unwrap_or(8) - 1)
775        .collect();
776    init_matrix_prefixes(vol_mu, cols, &warmups);
777    init_matrix_prefixes(var_mu, cols, &warmups);
778
779    let n = high.len();
780    let mut prefix_sum = vec![0.0f64; n + 1];
781    let mut prefix_invalid = vec![0i32; n + 1];
782    for i in 0..n {
783        if is_valid_high_low(high[i], low[i]) {
784            prefix_sum[i + 1] = prefix_sum[i] + log_range_sq(high[i], low[i]);
785            prefix_invalid[i + 1] = prefix_invalid[i];
786        } else {
787            prefix_sum[i + 1] = prefix_sum[i];
788            prefix_invalid[i + 1] = prefix_invalid[i] + 1;
789        }
790    }
791
792    let do_row = |row: usize, vol_row: &mut [f64], var_row: &mut [f64]| {
793        let period = combos[row].period.unwrap_or(8);
794        let warm = first + period - 1;
795        for i in warm..n {
796            let end = i + 1;
797            let start = end - period;
798            if prefix_invalid[end] - prefix_invalid[start] != 0 {
799                vol_row[i] = f64::NAN;
800                var_row[i] = f64::NAN;
801            } else {
802                let sum = prefix_sum[end] - prefix_sum[start];
803                let (vol, var) = outputs_from_sum(sum, period);
804                vol_row[i] = vol;
805                var_row[i] = var;
806            }
807        }
808    };
809
810    if parallel {
811        #[cfg(not(target_arch = "wasm32"))]
812        {
813            out_volatility
814                .par_chunks_mut(cols)
815                .zip(out_variance.par_chunks_mut(cols))
816                .enumerate()
817                .for_each(|(row, (vol, var))| do_row(row, vol, var));
818        }
819        #[cfg(target_arch = "wasm32")]
820        {
821            for (row, (vol, var)) in out_volatility
822                .chunks_mut(cols)
823                .zip(out_variance.chunks_mut(cols))
824                .enumerate()
825            {
826                do_row(row, vol, var);
827            }
828        }
829    } else {
830        for (row, (vol, var)) in out_volatility
831            .chunks_mut(cols)
832            .zip(out_variance.chunks_mut(cols))
833            .enumerate()
834        {
835            do_row(row, vol, var);
836        }
837    }
838
839    Ok(combos)
840}
841
842#[cfg(feature = "python")]
843#[pyfunction(name = "parkinson_volatility")]
844#[pyo3(signature = (high, low, period, kernel=None))]
845pub fn parkinson_volatility_py<'py>(
846    py: Python<'py>,
847    high: PyReadonlyArray1<'py, f64>,
848    low: PyReadonlyArray1<'py, f64>,
849    period: usize,
850    kernel: Option<&str>,
851) -> PyResult<(Bound<'py, PyArray1<f64>>, Bound<'py, PyArray1<f64>>)> {
852    let high = high.as_slice()?;
853    let low = low.as_slice()?;
854    let kernel = validate_kernel(kernel, false)?;
855    let params = ParkinsonVolatilityParams {
856        period: Some(period),
857    };
858    let input = ParkinsonVolatilityInput::from_slices(high, low, params);
859    let output = py
860        .allow_threads(|| parkinson_volatility_with_kernel(&input, kernel))
861        .map_err(|e| PyValueError::new_err(e.to_string()))?;
862    Ok((
863        output.volatility.into_pyarray(py),
864        output.variance.into_pyarray(py),
865    ))
866}
867
868#[cfg(feature = "python")]
869#[pyclass(name = "ParkinsonVolatilityStream")]
870pub struct ParkinsonVolatilityStreamPy {
871    stream: ParkinsonVolatilityStream,
872}
873
874#[cfg(feature = "python")]
875#[pymethods]
876impl ParkinsonVolatilityStreamPy {
877    #[new]
878    fn new(period: usize) -> PyResult<Self> {
879        let params = ParkinsonVolatilityParams {
880            period: Some(period),
881        };
882        let stream = ParkinsonVolatilityStream::try_new(params)
883            .map_err(|e| PyValueError::new_err(e.to_string()))?;
884        Ok(Self { stream })
885    }
886
887    fn update(&mut self, high: f64, low: f64) -> Option<(f64, f64)> {
888        self.stream.update(high, low)
889    }
890}
891
892#[cfg(feature = "python")]
893#[pyfunction(name = "parkinson_volatility_batch")]
894#[pyo3(signature = (high, low, period_range, kernel=None))]
895pub fn parkinson_volatility_batch_py<'py>(
896    py: Python<'py>,
897    high: PyReadonlyArray1<'py, f64>,
898    low: PyReadonlyArray1<'py, f64>,
899    period_range: (usize, usize, usize),
900    kernel: Option<&str>,
901) -> PyResult<Bound<'py, PyDict>> {
902    let high = high.as_slice()?;
903    let low = low.as_slice()?;
904    let sweep = ParkinsonVolatilityBatchRange {
905        period: period_range,
906    };
907    let combos = expand_grid_parkinson(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
908    let rows = combos.len();
909    let cols = high.len();
910    let total = rows
911        .checked_mul(cols)
912        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
913    let volatility_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
914    let variance_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
915    let volatility_out = unsafe { volatility_arr.as_slice_mut()? };
916    let variance_out = unsafe { variance_arr.as_slice_mut()? };
917    let kernel = validate_kernel(kernel, true)?;
918
919    py.allow_threads(|| {
920        let batch_kernel = match kernel {
921            Kernel::Auto => detect_best_batch_kernel(),
922            other => other,
923        };
924        parkinson_volatility_batch_inner_into(
925            high,
926            low,
927            &sweep,
928            batch_kernel.to_non_batch(),
929            true,
930            volatility_out,
931            variance_out,
932        )
933    })
934    .map_err(|e| PyValueError::new_err(e.to_string()))?;
935
936    let dict = PyDict::new(py);
937    dict.set_item("volatility", volatility_arr.reshape((rows, cols))?)?;
938    dict.set_item("variance", variance_arr.reshape((rows, cols))?)?;
939    dict.set_item(
940        "periods",
941        combos
942            .iter()
943            .map(|p| p.period.unwrap_or(8) as u64)
944            .collect::<Vec<_>>()
945            .into_pyarray(py),
946    )?;
947    dict.set_item("rows", rows)?;
948    dict.set_item("cols", cols)?;
949    Ok(dict)
950}
951
952#[cfg(all(feature = "python", feature = "cuda"))]
953#[pyclass(
954    module = "ta_indicators.cuda",
955    name = "ParkinsonVolatilityDeviceArrayF32",
956    unsendable
957)]
958pub struct ParkinsonVolatilityDeviceArrayF32Py {
959    pub(crate) buf: Option<DeviceBuffer<f32>>,
960    pub(crate) rows: usize,
961    pub(crate) cols: usize,
962    pub(crate) ctx: Arc<Context>,
963    pub(crate) device_id: u32,
964}
965
966#[cfg(all(feature = "python", feature = "cuda"))]
967#[pymethods]
968impl ParkinsonVolatilityDeviceArrayF32Py {
969    #[getter]
970    fn __cuda_array_interface__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
971        let d = PyDict::new(py);
972        d.set_item("shape", (self.rows, self.cols))?;
973        d.set_item("typestr", "<f4")?;
974        let row_stride = self
975            .cols
976            .checked_mul(std::mem::size_of::<f32>())
977            .ok_or_else(|| PyValueError::new_err("stride overflow in __cuda_array_interface__"))?;
978        d.set_item("strides", (row_stride, std::mem::size_of::<f32>()))?;
979        let buf = self
980            .buf
981            .as_ref()
982            .ok_or_else(|| PyValueError::new_err("buffer already exported via __dlpack__"))?;
983        let ptr = buf.as_device_ptr().as_raw() as usize;
984        d.set_item("data", (ptr, false))?;
985        d.set_item("version", 3)?;
986        Ok(d)
987    }
988
989    fn __dlpack_device__(&self) -> (i32, i32) {
990        (2, self.device_id as i32)
991    }
992
993    #[pyo3(signature = (stream=None, max_version=None, dl_device=None, copy=None))]
994    fn __dlpack__<'py>(
995        &mut self,
996        py: Python<'py>,
997        stream: Option<PyObject>,
998        max_version: Option<(u8, u8)>,
999        dl_device: Option<(i32, i32)>,
1000        copy: Option<bool>,
1001    ) -> PyResult<PyObject> {
1002        let _ = stream;
1003        let _ = max_version;
1004        let _ = &self.ctx;
1005        if let Some((_ty, dev)) = dl_device {
1006            if dev != self.device_id as i32 {
1007                return Err(PyValueError::new_err("dlpack device mismatch"));
1008            }
1009        }
1010        if matches!(copy, Some(true)) {
1011            return Err(PyValueError::new_err(
1012                "copy=True not supported for ParkinsonVolatilityDeviceArrayF32",
1013            ));
1014        }
1015
1016        let buf = self
1017            .buf
1018            .take()
1019            .ok_or_else(|| PyValueError::new_err("__dlpack__ may only be called once"))?;
1020        export_f32_cuda_dlpack_2d(py, buf, self.rows, self.cols, self.device_id as i32, None)
1021    }
1022}
1023
1024#[cfg(all(feature = "python", feature = "cuda"))]
1025#[pyfunction(name = "parkinson_volatility_cuda_batch_dev")]
1026#[pyo3(signature = (high_f32, low_f32, period_range, device_id=0))]
1027pub fn parkinson_volatility_cuda_batch_dev_py<'py>(
1028    py: Python<'py>,
1029    high_f32: PyReadonlyArray1<'py, f32>,
1030    low_f32: PyReadonlyArray1<'py, f32>,
1031    period_range: (usize, usize, usize),
1032    device_id: usize,
1033) -> PyResult<Bound<'py, PyDict>> {
1034    if !crate::cuda::cuda_available() {
1035        return Err(PyValueError::new_err("CUDA not available"));
1036    }
1037    let high = high_f32.as_slice()?;
1038    let low = low_f32.as_slice()?;
1039    let sweep = ParkinsonVolatilityBatchRange {
1040        period: period_range,
1041    };
1042    let (result, ctx, dev_id) = py.allow_threads(|| -> PyResult<_> {
1043        let cuda = crate::cuda::CudaParkinsonVolatility::new(device_id)
1044            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1045        let result = cuda
1046            .parkinson_volatility_batch_dev(high, low, &sweep)
1047            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1048        Ok((result, cuda.context_arc(), cuda.device_id()))
1049    })?;
1050
1051    let rows = result.outputs.rows();
1052    let cols = result.outputs.cols();
1053    let dict = PyDict::new(py);
1054    dict.set_item(
1055        "volatility",
1056        Py::new(
1057            py,
1058            ParkinsonVolatilityDeviceArrayF32Py {
1059                buf: Some(result.outputs.volatility.buf),
1060                rows,
1061                cols,
1062                ctx: ctx.clone(),
1063                device_id: dev_id,
1064            },
1065        )?,
1066    )?;
1067    dict.set_item(
1068        "variance",
1069        Py::new(
1070            py,
1071            ParkinsonVolatilityDeviceArrayF32Py {
1072                buf: Some(result.outputs.variance.buf),
1073                rows,
1074                cols,
1075                ctx,
1076                device_id: dev_id,
1077            },
1078        )?,
1079    )?;
1080    dict.set_item(
1081        "periods",
1082        result
1083            .combos
1084            .iter()
1085            .map(|p| p.period.unwrap_or(8) as u64)
1086            .collect::<Vec<_>>()
1087            .into_pyarray(py),
1088    )?;
1089    dict.set_item("rows", rows)?;
1090    dict.set_item("cols", cols)?;
1091    Ok(dict)
1092}
1093
1094#[cfg(all(feature = "python", feature = "cuda"))]
1095#[pyfunction(name = "parkinson_volatility_cuda_many_series_one_param_dev")]
1096#[pyo3(signature = (high_tm_f32, low_tm_f32, period, device_id=0))]
1097pub fn parkinson_volatility_cuda_many_series_one_param_dev_py<'py>(
1098    py: Python<'py>,
1099    high_tm_f32: PyReadonlyArray2<'py, f32>,
1100    low_tm_f32: PyReadonlyArray2<'py, f32>,
1101    period: usize,
1102    device_id: usize,
1103) -> PyResult<Bound<'py, PyDict>> {
1104    if !crate::cuda::cuda_available() {
1105        return Err(PyValueError::new_err("CUDA not available"));
1106    }
1107    let sh = high_tm_f32.shape();
1108    let sl = low_tm_f32.shape();
1109    if sh.len() != 2 || sl.len() != 2 || sh != sl {
1110        return Err(PyValueError::new_err(
1111            "expected 2D arrays with identical shape",
1112        ));
1113    }
1114    let rows = sh[0];
1115    let cols = sh[1];
1116    let high = high_tm_f32.as_slice()?;
1117    let low = low_tm_f32.as_slice()?;
1118    let (outputs, ctx, dev_id) = py.allow_threads(|| -> PyResult<_> {
1119        let cuda = crate::cuda::CudaParkinsonVolatility::new(device_id)
1120            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1121        let outputs = cuda
1122            .parkinson_volatility_many_series_one_param_time_major_dev(
1123                high, low, cols, rows, period,
1124            )
1125            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1126        Ok((outputs, cuda.context_arc(), cuda.device_id()))
1127    })?;
1128    let dict = PyDict::new(py);
1129    dict.set_item(
1130        "volatility",
1131        Py::new(
1132            py,
1133            ParkinsonVolatilityDeviceArrayF32Py {
1134                buf: Some(outputs.volatility.buf),
1135                rows,
1136                cols,
1137                ctx: ctx.clone(),
1138                device_id: dev_id,
1139            },
1140        )?,
1141    )?;
1142    dict.set_item(
1143        "variance",
1144        Py::new(
1145            py,
1146            ParkinsonVolatilityDeviceArrayF32Py {
1147                buf: Some(outputs.variance.buf),
1148                rows,
1149                cols,
1150                ctx,
1151                device_id: dev_id,
1152            },
1153        )?,
1154    )?;
1155    dict.set_item("rows", rows)?;
1156    dict.set_item("cols", cols)?;
1157    Ok(dict)
1158}
1159
1160#[cfg(feature = "python")]
1161pub fn register_parkinson_volatility_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
1162    m.add_function(wrap_pyfunction!(parkinson_volatility_py, m)?)?;
1163    m.add_function(wrap_pyfunction!(parkinson_volatility_batch_py, m)?)?;
1164    m.add_class::<ParkinsonVolatilityStreamPy>()?;
1165    #[cfg(feature = "cuda")]
1166    {
1167        m.add_class::<ParkinsonVolatilityDeviceArrayF32Py>()?;
1168        m.add_function(wrap_pyfunction!(parkinson_volatility_cuda_batch_dev_py, m)?)?;
1169        m.add_function(wrap_pyfunction!(
1170            parkinson_volatility_cuda_many_series_one_param_dev_py,
1171            m
1172        )?)?;
1173    }
1174    Ok(())
1175}
1176
1177#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1178#[wasm_bindgen(js_name = "parkinson_volatility_js")]
1179pub fn parkinson_volatility_js(
1180    high: &[f64],
1181    low: &[f64],
1182    period: usize,
1183) -> Result<JsValue, JsValue> {
1184    if high.len() != low.len() {
1185        return Err(JsValue::from_str("high/low slice length mismatch"));
1186    }
1187
1188    let params = ParkinsonVolatilityParams {
1189        period: Some(period),
1190    };
1191    let input = ParkinsonVolatilityInput::from_slices(high, low, params);
1192    let mut volatility = vec![0.0; high.len()];
1193    let mut variance = vec![0.0; high.len()];
1194    parkinson_volatility_into_slice(&mut volatility, &mut variance, &input, Kernel::Auto)
1195        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1196
1197    let obj = js_sys::Object::new();
1198    js_sys::Reflect::set(
1199        &obj,
1200        &JsValue::from_str("volatility"),
1201        &serde_wasm_bindgen::to_value(&volatility).unwrap(),
1202    )?;
1203    js_sys::Reflect::set(
1204        &obj,
1205        &JsValue::from_str("variance"),
1206        &serde_wasm_bindgen::to_value(&variance).unwrap(),
1207    )?;
1208    Ok(obj.into())
1209}
1210
1211#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1212#[wasm_bindgen(js_name = "parkinson_volatility_batch_js")]
1213pub fn parkinson_volatility_batch_js(
1214    high: &[f64],
1215    low: &[f64],
1216    config: JsValue,
1217) -> Result<JsValue, JsValue> {
1218    if high.len() != low.len() {
1219        return Err(JsValue::from_str("high/low slice length mismatch"));
1220    }
1221    let config: ParkinsonVolatilityBatchConfig = serde_wasm_bindgen::from_value(config)
1222        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
1223    if config.period_range.len() != 3 {
1224        return Err(JsValue::from_str(
1225            "Invalid config: period_range must have exactly 3 elements [start, end, step]",
1226        ));
1227    }
1228
1229    let sweep = ParkinsonVolatilityBatchRange {
1230        period: (
1231            config.period_range[0],
1232            config.period_range[1],
1233            config.period_range[2],
1234        ),
1235    };
1236    let combos = expand_grid_parkinson(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
1237    let rows = combos.len();
1238    let cols = high.len();
1239    let total = rows
1240        .checked_mul(cols)
1241        .ok_or_else(|| JsValue::from_str("rows*cols overflow"))?;
1242    let mut volatility = vec![0.0; total];
1243    let mut variance = vec![0.0; total];
1244    parkinson_volatility_batch_inner_into(
1245        high,
1246        low,
1247        &sweep,
1248        Kernel::Scalar,
1249        false,
1250        &mut volatility,
1251        &mut variance,
1252    )
1253    .map_err(|e| JsValue::from_str(&e.to_string()))?;
1254
1255    let obj = js_sys::Object::new();
1256    js_sys::Reflect::set(
1257        &obj,
1258        &JsValue::from_str("volatility"),
1259        &serde_wasm_bindgen::to_value(&volatility).unwrap(),
1260    )?;
1261    js_sys::Reflect::set(
1262        &obj,
1263        &JsValue::from_str("variance"),
1264        &serde_wasm_bindgen::to_value(&variance).unwrap(),
1265    )?;
1266    js_sys::Reflect::set(
1267        &obj,
1268        &JsValue::from_str("rows"),
1269        &JsValue::from_f64(rows as f64),
1270    )?;
1271    js_sys::Reflect::set(
1272        &obj,
1273        &JsValue::from_str("cols"),
1274        &JsValue::from_f64(cols as f64),
1275    )?;
1276    js_sys::Reflect::set(
1277        &obj,
1278        &JsValue::from_str("combos"),
1279        &serde_wasm_bindgen::to_value(&combos).unwrap(),
1280    )?;
1281    Ok(obj.into())
1282}
1283
1284#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1285#[wasm_bindgen]
1286pub fn parkinson_volatility_alloc(len: usize) -> *mut f64 {
1287    let mut v = Vec::<f64>::with_capacity(2 * len);
1288    let ptr = v.as_mut_ptr();
1289    std::mem::forget(v);
1290    ptr
1291}
1292
1293#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1294#[wasm_bindgen]
1295pub fn parkinson_volatility_free(ptr: *mut f64, len: usize) {
1296    unsafe {
1297        let _ = Vec::from_raw_parts(ptr, 2 * len, 2 * len);
1298    }
1299}
1300
1301#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1302#[wasm_bindgen]
1303pub fn parkinson_volatility_into(
1304    high_ptr: *const f64,
1305    low_ptr: *const f64,
1306    out_ptr: *mut f64,
1307    len: usize,
1308    period: usize,
1309) -> Result<(), JsValue> {
1310    if high_ptr.is_null() || low_ptr.is_null() || out_ptr.is_null() {
1311        return Err(JsValue::from_str(
1312            "null pointer passed to parkinson_volatility_into",
1313        ));
1314    }
1315
1316    unsafe {
1317        let high = std::slice::from_raw_parts(high_ptr, len);
1318        let low = std::slice::from_raw_parts(low_ptr, len);
1319        let out = std::slice::from_raw_parts_mut(out_ptr, 2 * len);
1320        let (volatility, variance) = out.split_at_mut(len);
1321        let params = ParkinsonVolatilityParams {
1322            period: Some(period),
1323        };
1324        let input = ParkinsonVolatilityInput::from_slices(high, low, params);
1325        parkinson_volatility_into_slice(volatility, variance, &input, Kernel::Auto)
1326            .map_err(|e| JsValue::from_str(&e.to_string()))
1327    }
1328}
1329
1330#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1331#[wasm_bindgen(js_name = "parkinson_volatility_into_host")]
1332pub fn parkinson_volatility_into_host(
1333    high: &[f64],
1334    low: &[f64],
1335    out_ptr: *mut f64,
1336    period: usize,
1337) -> Result<(), JsValue> {
1338    if out_ptr.is_null() {
1339        return Err(JsValue::from_str(
1340            "null pointer passed to parkinson_volatility_into_host",
1341        ));
1342    }
1343    if high.len() != low.len() {
1344        return Err(JsValue::from_str("high/low slice length mismatch"));
1345    }
1346
1347    unsafe {
1348        let out = std::slice::from_raw_parts_mut(out_ptr, 2 * high.len());
1349        let (volatility, variance) = out.split_at_mut(high.len());
1350        let params = ParkinsonVolatilityParams {
1351            period: Some(period),
1352        };
1353        let input = ParkinsonVolatilityInput::from_slices(high, low, params);
1354        parkinson_volatility_into_slice(volatility, variance, &input, Kernel::Auto)
1355            .map_err(|e| JsValue::from_str(&e.to_string()))
1356    }
1357}
1358
1359#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1360#[wasm_bindgen]
1361pub fn parkinson_volatility_batch_into(
1362    high_ptr: *const f64,
1363    low_ptr: *const f64,
1364    volatility_ptr: *mut f64,
1365    variance_ptr: *mut f64,
1366    len: usize,
1367    period_start: usize,
1368    period_end: usize,
1369    period_step: usize,
1370) -> Result<usize, JsValue> {
1371    if high_ptr.is_null() || low_ptr.is_null() || volatility_ptr.is_null() || variance_ptr.is_null()
1372    {
1373        return Err(JsValue::from_str(
1374            "null pointer passed to parkinson_volatility_batch_into",
1375        ));
1376    }
1377
1378    unsafe {
1379        let high = std::slice::from_raw_parts(high_ptr, len);
1380        let low = std::slice::from_raw_parts(low_ptr, len);
1381        let sweep = ParkinsonVolatilityBatchRange {
1382            period: (period_start, period_end, period_step),
1383        };
1384        let combos =
1385            expand_grid_parkinson(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
1386        let rows = combos.len();
1387        let total = rows
1388            .checked_mul(len)
1389            .ok_or_else(|| JsValue::from_str("rows*cols overflow"))?;
1390        let volatility = std::slice::from_raw_parts_mut(volatility_ptr, total);
1391        let variance = std::slice::from_raw_parts_mut(variance_ptr, total);
1392        parkinson_volatility_batch_inner_into(
1393            high,
1394            low,
1395            &sweep,
1396            Kernel::Scalar,
1397            false,
1398            volatility,
1399            variance,
1400        )
1401        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1402        Ok(rows)
1403    }
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408    use super::*;
1409
1410    fn vecs_match(a: &[f64], b: &[f64]) -> bool {
1411        a.len() == b.len()
1412            && a.iter().zip(b.iter()).all(|(&x, &y)| {
1413                (x.is_nan() && y.is_nan()) || (!x.is_nan() && !y.is_nan() && (x - y).abs() < 1e-12)
1414            })
1415    }
1416
1417    fn sample_high_low() -> (Vec<f64>, Vec<f64>) {
1418        let high = vec![10.0, 10.4, 10.6, 10.8, 10.7, 11.0, 11.2, 11.4];
1419        let low = vec![9.6, 10.0, 10.1, 10.2, 10.1, 10.5, 10.8, 11.0];
1420        (high, low)
1421    }
1422
1423    #[test]
1424    fn parkinson_output_contract() {
1425        let (high, low) = sample_high_low();
1426        let input = ParkinsonVolatilityInput::from_slices(
1427            &high,
1428            &low,
1429            ParkinsonVolatilityParams { period: Some(3) },
1430        );
1431        let out = parkinson_volatility(&input).expect("parkinson output");
1432        assert_eq!(out.volatility.len(), high.len());
1433        assert_eq!(out.variance.len(), high.len());
1434        assert!(out.volatility[..2].iter().all(|v| v.is_nan()));
1435        assert!(out.variance[..2].iter().all(|v| v.is_nan()));
1436        assert!(out.volatility[2].is_finite());
1437        assert!(out.variance[2].is_finite());
1438        assert!((out.volatility[2] * out.volatility[2] - out.variance[2]).abs() < 1e-12);
1439    }
1440
1441    #[test]
1442    fn parkinson_into_matches_api() {
1443        let (high, low) = sample_high_low();
1444        let input = ParkinsonVolatilityInput::from_slices(
1445            &high,
1446            &low,
1447            ParkinsonVolatilityParams { period: Some(4) },
1448        );
1449        let direct = parkinson_volatility(&input).expect("direct output");
1450        let mut volatility = vec![0.0; high.len()];
1451        let mut variance = vec![0.0; high.len()];
1452        parkinson_volatility_into(&input, &mut volatility, &mut variance).expect("into output");
1453        assert!(vecs_match(&direct.volatility, &volatility));
1454        assert!(vecs_match(&direct.variance, &variance));
1455    }
1456
1457    #[test]
1458    fn parkinson_stream_matches_batch() {
1459        let (high, low) = sample_high_low();
1460        let input = ParkinsonVolatilityInput::from_slices(
1461            &high,
1462            &low,
1463            ParkinsonVolatilityParams { period: Some(3) },
1464        );
1465        let batch = parkinson_volatility(&input).expect("batch output");
1466        let mut stream =
1467            ParkinsonVolatilityStream::try_new(ParkinsonVolatilityParams { period: Some(3) })
1468                .expect("stream");
1469        let mut stream_volatility = Vec::new();
1470        let mut stream_variance = Vec::new();
1471        for (&h, &l) in high.iter().zip(low.iter()) {
1472            match stream.update(h, l) {
1473                Some((vol, var)) => {
1474                    stream_volatility.push(vol);
1475                    stream_variance.push(var);
1476                }
1477                None => {
1478                    stream_volatility.push(f64::NAN);
1479                    stream_variance.push(f64::NAN);
1480                }
1481            }
1482        }
1483        assert!(vecs_match(&stream_volatility, &batch.volatility));
1484        assert!(vecs_match(&stream_variance, &batch.variance));
1485    }
1486
1487    #[test]
1488    fn parkinson_batch_single_param_matches_single() {
1489        let (high, low) = sample_high_low();
1490        let sweep = ParkinsonVolatilityBatchRange { period: (3, 3, 0) };
1491        let batch = parkinson_volatility_batch_with_kernel(&high, &low, &sweep, Kernel::Auto)
1492            .expect("batch output");
1493        let input = ParkinsonVolatilityInput::from_slices(
1494            &high,
1495            &low,
1496            ParkinsonVolatilityParams { period: Some(3) },
1497        );
1498        let single = parkinson_volatility(&input).expect("single output");
1499        assert_eq!(batch.rows, 1);
1500        assert_eq!(batch.cols, high.len());
1501        assert!(vecs_match(&batch.volatility, &single.volatility));
1502        assert!(vecs_match(&batch.variance, &single.variance));
1503    }
1504
1505    #[test]
1506    fn parkinson_rejects_invalid_period() {
1507        let (high, low) = sample_high_low();
1508        let input = ParkinsonVolatilityInput::from_slices(
1509            &high,
1510            &low,
1511            ParkinsonVolatilityParams { period: Some(0) },
1512        );
1513        let err = parkinson_volatility(&input).expect_err("invalid period should fail");
1514        assert!(matches!(
1515            err,
1516            ParkinsonVolatilityError::InvalidPeriod { .. }
1517        ));
1518    }
1519}