Skip to main content

vector_ta/indicators/
volume_zone_oscillator.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
10#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
11use serde::{Deserialize, Serialize};
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use wasm_bindgen::prelude::*;
14
15use crate::utilities::data_loader::Candles;
16use crate::utilities::enums::Kernel;
17use crate::utilities::helpers::{
18    alloc_with_nan_prefix, detect_best_batch_kernel, init_matrix_prefixes, make_uninit_matrix,
19};
20#[cfg(feature = "python")]
21use crate::utilities::kernel_validation::validate_kernel;
22#[cfg(not(target_arch = "wasm32"))]
23use rayon::prelude::*;
24use std::mem::ManuallyDrop;
25use thiserror::Error;
26
27const DEFAULT_LENGTH: usize = 14;
28const DEFAULT_INTRADAY_SMOOTHING: bool = true;
29const DEFAULT_NOISE_FILTER: usize = 4;
30
31#[derive(Debug, Clone)]
32pub enum VolumeZoneOscillatorData<'a> {
33    Candles { candles: &'a Candles },
34    Slices { close: &'a [f64], volume: &'a [f64] },
35}
36
37#[derive(Debug, Clone)]
38pub struct VolumeZoneOscillatorOutput {
39    pub values: Vec<f64>,
40}
41
42#[derive(Debug, Clone)]
43#[cfg_attr(
44    all(target_arch = "wasm32", feature = "wasm"),
45    derive(Serialize, Deserialize)
46)]
47pub struct VolumeZoneOscillatorParams {
48    pub length: Option<usize>,
49    pub intraday_smoothing: Option<bool>,
50    pub noise_filter: Option<usize>,
51}
52
53impl Default for VolumeZoneOscillatorParams {
54    fn default() -> Self {
55        Self {
56            length: Some(DEFAULT_LENGTH),
57            intraday_smoothing: Some(DEFAULT_INTRADAY_SMOOTHING),
58            noise_filter: Some(DEFAULT_NOISE_FILTER),
59        }
60    }
61}
62
63#[derive(Debug, Clone)]
64pub struct VolumeZoneOscillatorInput<'a> {
65    pub data: VolumeZoneOscillatorData<'a>,
66    pub params: VolumeZoneOscillatorParams,
67}
68
69impl<'a> VolumeZoneOscillatorInput<'a> {
70    #[inline]
71    pub fn from_candles(candles: &'a Candles, params: VolumeZoneOscillatorParams) -> Self {
72        Self {
73            data: VolumeZoneOscillatorData::Candles { candles },
74            params,
75        }
76    }
77
78    #[inline]
79    pub fn from_slices(
80        close: &'a [f64],
81        volume: &'a [f64],
82        params: VolumeZoneOscillatorParams,
83    ) -> Self {
84        Self {
85            data: VolumeZoneOscillatorData::Slices { close, volume },
86            params,
87        }
88    }
89
90    #[inline]
91    pub fn with_default_candles(candles: &'a Candles) -> Self {
92        Self::from_candles(candles, VolumeZoneOscillatorParams::default())
93    }
94
95    #[inline]
96    pub fn get_length(&self) -> usize {
97        self.params.length.unwrap_or(DEFAULT_LENGTH)
98    }
99
100    #[inline]
101    pub fn get_intraday_smoothing(&self) -> bool {
102        self.params
103            .intraday_smoothing
104            .unwrap_or(DEFAULT_INTRADAY_SMOOTHING)
105    }
106
107    #[inline]
108    pub fn get_noise_filter(&self) -> usize {
109        self.params.noise_filter.unwrap_or(DEFAULT_NOISE_FILTER)
110    }
111}
112
113#[derive(Copy, Clone, Debug)]
114pub struct VolumeZoneOscillatorBuilder {
115    length: Option<usize>,
116    intraday_smoothing: Option<bool>,
117    noise_filter: Option<usize>,
118    kernel: Kernel,
119}
120
121impl Default for VolumeZoneOscillatorBuilder {
122    fn default() -> Self {
123        Self {
124            length: None,
125            intraday_smoothing: None,
126            noise_filter: None,
127            kernel: Kernel::Auto,
128        }
129    }
130}
131
132impl VolumeZoneOscillatorBuilder {
133    #[inline(always)]
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    #[inline(always)]
139    pub fn length(mut self, value: usize) -> Self {
140        self.length = Some(value);
141        self
142    }
143
144    #[inline(always)]
145    pub fn intraday_smoothing(mut self, value: bool) -> Self {
146        self.intraday_smoothing = Some(value);
147        self
148    }
149
150    #[inline(always)]
151    pub fn noise_filter(mut self, value: usize) -> Self {
152        self.noise_filter = Some(value);
153        self
154    }
155
156    #[inline(always)]
157    pub fn kernel(mut self, kernel: Kernel) -> Self {
158        self.kernel = kernel;
159        self
160    }
161
162    #[inline(always)]
163    pub fn apply(
164        self,
165        candles: &Candles,
166    ) -> Result<VolumeZoneOscillatorOutput, VolumeZoneOscillatorError> {
167        let input = VolumeZoneOscillatorInput::from_candles(
168            candles,
169            VolumeZoneOscillatorParams {
170                length: self.length,
171                intraday_smoothing: self.intraday_smoothing,
172                noise_filter: self.noise_filter,
173            },
174        );
175        volume_zone_oscillator_with_kernel(&input, self.kernel)
176    }
177
178    #[inline(always)]
179    pub fn apply_slices(
180        self,
181        close: &[f64],
182        volume: &[f64],
183    ) -> Result<VolumeZoneOscillatorOutput, VolumeZoneOscillatorError> {
184        let input = VolumeZoneOscillatorInput::from_slices(
185            close,
186            volume,
187            VolumeZoneOscillatorParams {
188                length: self.length,
189                intraday_smoothing: self.intraday_smoothing,
190                noise_filter: self.noise_filter,
191            },
192        );
193        volume_zone_oscillator_with_kernel(&input, self.kernel)
194    }
195
196    #[inline(always)]
197    pub fn into_stream(self) -> Result<VolumeZoneOscillatorStream, VolumeZoneOscillatorError> {
198        VolumeZoneOscillatorStream::try_new(VolumeZoneOscillatorParams {
199            length: self.length,
200            intraday_smoothing: self.intraday_smoothing,
201            noise_filter: self.noise_filter,
202        })
203    }
204}
205
206#[derive(Debug, Error)]
207pub enum VolumeZoneOscillatorError {
208    #[error("volume_zone_oscillator: Input data slice is empty.")]
209    EmptyInputData,
210    #[error("volume_zone_oscillator: All values are NaN.")]
211    AllValuesNaN,
212    #[error("volume_zone_oscillator: Inconsistent slice lengths: close={close_len}, volume={volume_len}")]
213    InconsistentSliceLengths { close_len: usize, volume_len: usize },
214    #[error("volume_zone_oscillator: Invalid length: {length}")]
215    InvalidLength { length: usize },
216    #[error("volume_zone_oscillator: Invalid noise_filter: {noise_filter}")]
217    InvalidNoiseFilter { noise_filter: usize },
218    #[error("volume_zone_oscillator: Output length mismatch: expected = {expected}, got = {got}")]
219    OutputLengthMismatch { expected: usize, got: usize },
220    #[error("volume_zone_oscillator: Invalid range: start={start}, end={end}, step={step}")]
221    InvalidRange {
222        start: String,
223        end: String,
224        step: String,
225    },
226    #[error("volume_zone_oscillator: Invalid kernel for batch: {0:?}")]
227    InvalidKernelForBatch(Kernel),
228}
229
230#[inline(always)]
231fn validate_length(length: usize) -> Result<usize, VolumeZoneOscillatorError> {
232    if length < 2 {
233        return Err(VolumeZoneOscillatorError::InvalidLength { length });
234    }
235    Ok(length)
236}
237
238#[inline(always)]
239fn validate_noise_filter(noise_filter: usize) -> Result<usize, VolumeZoneOscillatorError> {
240    if noise_filter < 2 {
241        return Err(VolumeZoneOscillatorError::InvalidNoiseFilter { noise_filter });
242    }
243    Ok(noise_filter)
244}
245
246#[inline(always)]
247fn ema_alpha(period: usize) -> f64 {
248    2.0 / (period as f64 + 1.0)
249}
250
251#[inline(always)]
252fn extract_close_volume<'a>(
253    input: &'a VolumeZoneOscillatorInput<'a>,
254) -> Result<(&'a [f64], &'a [f64], usize), VolumeZoneOscillatorError> {
255    let (close, volume) = match &input.data {
256        VolumeZoneOscillatorData::Candles { candles } => {
257            (candles.close.as_slice(), candles.volume.as_slice())
258        }
259        VolumeZoneOscillatorData::Slices { close, volume } => (*close, *volume),
260    };
261
262    if close.is_empty() || volume.is_empty() {
263        return Err(VolumeZoneOscillatorError::EmptyInputData);
264    }
265    if close.len() != volume.len() {
266        return Err(VolumeZoneOscillatorError::InconsistentSliceLengths {
267            close_len: close.len(),
268            volume_len: volume.len(),
269        });
270    }
271    let first_valid = volume
272        .iter()
273        .position(|v| v.is_finite())
274        .ok_or(VolumeZoneOscillatorError::AllValuesNaN)?;
275    Ok((close, volume, first_valid))
276}
277
278#[inline(always)]
279fn compute_vzo_value(
280    current_close: f64,
281    prev_close: f64,
282    volume: f64,
283    ema_direction: &mut f64,
284    ema_total: &mut f64,
285    alpha: f64,
286    beta: f64,
287) -> Option<f64> {
288    if !volume.is_finite() {
289        return if *ema_total != 0.0 {
290            Some(100.0 * *ema_direction / *ema_total)
291        } else {
292            None
293        };
294    }
295
296    let directed =
297        if current_close.is_finite() && prev_close.is_finite() && current_close > prev_close {
298            volume
299        } else {
300            -volume
301        };
302
303    *ema_direction = beta.mul_add(*ema_direction, alpha * directed);
304    *ema_total = beta.mul_add(*ema_total, alpha * volume);
305
306    if *ema_total != 0.0 {
307        Some(100.0 * *ema_direction / *ema_total)
308    } else {
309        None
310    }
311}
312
313#[inline(always)]
314fn compute_volume_zone_oscillator_into(
315    close: &[f64],
316    volume: &[f64],
317    length: usize,
318    intraday_smoothing: bool,
319    noise_filter: usize,
320    first_valid: usize,
321    out: &mut [f64],
322) {
323    let alpha = ema_alpha(length);
324    let beta = 1.0 - alpha;
325    let smooth_alpha = ema_alpha(noise_filter);
326    let smooth_beta = 1.0 - smooth_alpha;
327
328    let mut prev_close = f64::NAN;
329    let mut ema_direction = 0.0;
330    let mut ema_total = 0.0;
331    let mut smooth = 0.0;
332    let mut smooth_valid = false;
333
334    let warm = first_valid.min(out.len());
335    for value in &mut out[..warm] {
336        *value = f64::NAN;
337    }
338
339    for i in first_valid..close.len() {
340        let raw = compute_vzo_value(
341            close[i],
342            prev_close,
343            volume[i],
344            &mut ema_direction,
345            &mut ema_total,
346            alpha,
347            beta,
348        );
349
350        if close[i].is_finite() {
351            prev_close = close[i];
352        }
353
354        if intraday_smoothing {
355            if let Some(value) = raw {
356                smooth = smooth_beta.mul_add(smooth, smooth_alpha * value);
357                smooth_valid = true;
358                out[i] = smooth;
359            } else if smooth_valid {
360                out[i] = smooth;
361            } else {
362                out[i] = f64::NAN;
363            }
364        } else {
365            out[i] = raw.unwrap_or(f64::NAN);
366        }
367    }
368}
369
370#[inline]
371pub fn volume_zone_oscillator(
372    input: &VolumeZoneOscillatorInput,
373) -> Result<VolumeZoneOscillatorOutput, VolumeZoneOscillatorError> {
374    volume_zone_oscillator_with_kernel(input, Kernel::Auto)
375}
376
377#[inline]
378pub fn volume_zone_oscillator_with_kernel(
379    input: &VolumeZoneOscillatorInput,
380    kernel: Kernel,
381) -> Result<VolumeZoneOscillatorOutput, VolumeZoneOscillatorError> {
382    let (close, volume, first_valid) = extract_close_volume(input)?;
383    let length = validate_length(input.get_length())?;
384    let intraday_smoothing = input.get_intraday_smoothing();
385    let noise_filter = validate_noise_filter(input.get_noise_filter())?;
386    let chosen = match kernel {
387        Kernel::Auto => Kernel::Scalar,
388        other => other.to_non_batch(),
389    };
390    let _ = chosen;
391
392    let mut out = alloc_with_nan_prefix(close.len(), first_valid);
393    compute_volume_zone_oscillator_into(
394        close,
395        volume,
396        length,
397        intraday_smoothing,
398        noise_filter,
399        first_valid,
400        &mut out,
401    );
402    Ok(VolumeZoneOscillatorOutput { values: out })
403}
404
405#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
406#[inline]
407pub fn volume_zone_oscillator_into(
408    input: &VolumeZoneOscillatorInput,
409    out: &mut [f64],
410) -> Result<(), VolumeZoneOscillatorError> {
411    volume_zone_oscillator_into_slice(out, input, Kernel::Auto)
412}
413
414#[inline]
415pub fn volume_zone_oscillator_into_slice(
416    out: &mut [f64],
417    input: &VolumeZoneOscillatorInput,
418    kernel: Kernel,
419) -> Result<(), VolumeZoneOscillatorError> {
420    let (close, volume, first_valid) = extract_close_volume(input)?;
421    if out.len() != close.len() {
422        return Err(VolumeZoneOscillatorError::OutputLengthMismatch {
423            expected: close.len(),
424            got: out.len(),
425        });
426    }
427    let length = validate_length(input.get_length())?;
428    let intraday_smoothing = input.get_intraday_smoothing();
429    let noise_filter = validate_noise_filter(input.get_noise_filter())?;
430    let chosen = match kernel {
431        Kernel::Auto => Kernel::Scalar,
432        other => other.to_non_batch(),
433    };
434    let _ = chosen;
435
436    compute_volume_zone_oscillator_into(
437        close,
438        volume,
439        length,
440        intraday_smoothing,
441        noise_filter,
442        first_valid,
443        out,
444    );
445    Ok(())
446}
447
448#[derive(Debug, Clone)]
449pub struct VolumeZoneOscillatorStream {
450    alpha: f64,
451    beta: f64,
452    intraday_smoothing: bool,
453    smooth_alpha: f64,
454    smooth_beta: f64,
455    prev_close: f64,
456    ema_direction: f64,
457    ema_total: f64,
458    smooth: f64,
459    smooth_valid: bool,
460    seen_volume: bool,
461}
462
463impl VolumeZoneOscillatorStream {
464    pub fn try_new(params: VolumeZoneOscillatorParams) -> Result<Self, VolumeZoneOscillatorError> {
465        let length = validate_length(params.length.unwrap_or(DEFAULT_LENGTH))?;
466        let intraday_smoothing = params
467            .intraday_smoothing
468            .unwrap_or(DEFAULT_INTRADAY_SMOOTHING);
469        let noise_filter =
470            validate_noise_filter(params.noise_filter.unwrap_or(DEFAULT_NOISE_FILTER))?;
471        let alpha = ema_alpha(length);
472        let smooth_alpha = ema_alpha(noise_filter);
473        Ok(Self {
474            alpha,
475            beta: 1.0 - alpha,
476            intraday_smoothing,
477            smooth_alpha,
478            smooth_beta: 1.0 - smooth_alpha,
479            prev_close: f64::NAN,
480            ema_direction: 0.0,
481            ema_total: 0.0,
482            smooth: 0.0,
483            smooth_valid: false,
484            seen_volume: false,
485        })
486    }
487
488    #[inline]
489    pub fn update(&mut self, close: f64, volume: f64) -> f64 {
490        let raw = compute_vzo_value(
491            close,
492            self.prev_close,
493            volume,
494            &mut self.ema_direction,
495            &mut self.ema_total,
496            self.alpha,
497            self.beta,
498        );
499
500        if volume.is_finite() {
501            self.seen_volume = true;
502        }
503        if close.is_finite() {
504            self.prev_close = close;
505        }
506
507        if self.intraday_smoothing {
508            if let Some(value) = raw {
509                self.smooth = self
510                    .smooth_beta
511                    .mul_add(self.smooth, self.smooth_alpha * value);
512                self.smooth_valid = true;
513                self.smooth
514            } else if self.smooth_valid {
515                self.smooth
516            } else {
517                f64::NAN
518            }
519        } else if self.seen_volume {
520            raw.unwrap_or(f64::NAN)
521        } else {
522            f64::NAN
523        }
524    }
525
526    #[inline]
527    pub fn get_warmup_period(&self) -> usize {
528        0
529    }
530}
531
532#[derive(Clone, Debug)]
533pub struct VolumeZoneOscillatorBatchRange {
534    pub length: (usize, usize, usize),
535    pub noise_filter: (usize, usize, usize),
536    pub intraday_smoothing: Option<bool>,
537}
538
539impl Default for VolumeZoneOscillatorBatchRange {
540    fn default() -> Self {
541        Self {
542            length: (DEFAULT_LENGTH, DEFAULT_LENGTH, 0),
543            noise_filter: (DEFAULT_NOISE_FILTER, DEFAULT_NOISE_FILTER, 0),
544            intraday_smoothing: Some(DEFAULT_INTRADAY_SMOOTHING),
545        }
546    }
547}
548
549#[derive(Clone, Debug, Default)]
550pub struct VolumeZoneOscillatorBatchBuilder {
551    range: VolumeZoneOscillatorBatchRange,
552    kernel: Kernel,
553}
554
555impl VolumeZoneOscillatorBatchBuilder {
556    pub fn new() -> Self {
557        Self::default()
558    }
559
560    pub fn kernel(mut self, kernel: Kernel) -> Self {
561        self.kernel = kernel;
562        self
563    }
564
565    pub fn length_range(mut self, start: usize, end: usize, step: usize) -> Self {
566        self.range.length = (start, end, step);
567        self
568    }
569
570    pub fn length_static(mut self, value: usize) -> Self {
571        self.range.length = (value, value, 0);
572        self
573    }
574
575    pub fn noise_filter_range(mut self, start: usize, end: usize, step: usize) -> Self {
576        self.range.noise_filter = (start, end, step);
577        self
578    }
579
580    pub fn noise_filter_static(mut self, value: usize) -> Self {
581        self.range.noise_filter = (value, value, 0);
582        self
583    }
584
585    pub fn intraday_smoothing(mut self, value: bool) -> Self {
586        self.range.intraday_smoothing = Some(value);
587        self
588    }
589
590    pub fn apply_slice(
591        self,
592        close: &[f64],
593        volume: &[f64],
594    ) -> Result<VolumeZoneOscillatorBatchOutput, VolumeZoneOscillatorError> {
595        volume_zone_oscillator_batch_with_kernel(close, volume, &self.range, self.kernel)
596    }
597
598    pub fn apply_candles(
599        self,
600        candles: &Candles,
601    ) -> Result<VolumeZoneOscillatorBatchOutput, VolumeZoneOscillatorError> {
602        self.apply_slice(&candles.close, &candles.volume)
603    }
604}
605
606#[derive(Clone, Debug)]
607pub struct VolumeZoneOscillatorBatchOutput {
608    pub values: Vec<f64>,
609    pub combos: Vec<VolumeZoneOscillatorParams>,
610    pub rows: usize,
611    pub cols: usize,
612}
613
614impl VolumeZoneOscillatorBatchOutput {
615    pub fn row_for_params(&self, params: &VolumeZoneOscillatorParams) -> Option<usize> {
616        let length = params.length.unwrap_or(DEFAULT_LENGTH);
617        let intraday_smoothing = params
618            .intraday_smoothing
619            .unwrap_or(DEFAULT_INTRADAY_SMOOTHING);
620        let noise_filter = params.noise_filter.unwrap_or(DEFAULT_NOISE_FILTER);
621        self.combos.iter().position(|combo| {
622            combo.length.unwrap_or(DEFAULT_LENGTH) == length
623                && combo
624                    .intraday_smoothing
625                    .unwrap_or(DEFAULT_INTRADAY_SMOOTHING)
626                    == intraday_smoothing
627                && combo.noise_filter.unwrap_or(DEFAULT_NOISE_FILTER) == noise_filter
628        })
629    }
630
631    pub fn values_for(&self, params: &VolumeZoneOscillatorParams) -> Option<&[f64]> {
632        self.row_for_params(params).and_then(|row| {
633            let start = row * self.cols;
634            self.values.get(start..start + self.cols)
635        })
636    }
637}
638
639#[inline(always)]
640fn axis_usize(
641    (start, end, step): (usize, usize, usize),
642) -> Result<Vec<usize>, VolumeZoneOscillatorError> {
643    if start == end {
644        return Ok(vec![start]);
645    }
646    if step == 0 {
647        return Err(VolumeZoneOscillatorError::InvalidRange {
648            start: start.to_string(),
649            end: end.to_string(),
650            step: step.to_string(),
651        });
652    }
653    let mut out = Vec::new();
654    if start < end {
655        let mut x = start;
656        while x <= end {
657            out.push(x);
658            match x.checked_add(step) {
659                Some(next) => x = next,
660                None => break,
661            }
662        }
663    } else {
664        let mut x = start;
665        while x >= end {
666            out.push(x);
667            match x.checked_sub(step) {
668                Some(next) => x = next,
669                None => break,
670            }
671            if x > start {
672                break;
673            }
674        }
675    }
676    if out.is_empty() {
677        return Err(VolumeZoneOscillatorError::InvalidRange {
678            start: start.to_string(),
679            end: end.to_string(),
680            step: step.to_string(),
681        });
682    }
683    Ok(out)
684}
685
686#[inline(always)]
687pub fn expand_grid(
688    range: &VolumeZoneOscillatorBatchRange,
689) -> Result<Vec<VolumeZoneOscillatorParams>, VolumeZoneOscillatorError> {
690    let intraday_smoothing = range
691        .intraday_smoothing
692        .unwrap_or(DEFAULT_INTRADAY_SMOOTHING);
693    let lengths = axis_usize(range.length)?;
694    let noise_filters = axis_usize(range.noise_filter)?;
695    let mut out = Vec::with_capacity(lengths.len() * noise_filters.len());
696    for length in lengths {
697        for &noise_filter in &noise_filters {
698            out.push(VolumeZoneOscillatorParams {
699                length: Some(length),
700                intraday_smoothing: Some(intraday_smoothing),
701                noise_filter: Some(noise_filter),
702            });
703        }
704    }
705    Ok(out)
706}
707
708pub fn volume_zone_oscillator_batch_with_kernel(
709    close: &[f64],
710    volume: &[f64],
711    sweep: &VolumeZoneOscillatorBatchRange,
712    kernel: Kernel,
713) -> Result<VolumeZoneOscillatorBatchOutput, VolumeZoneOscillatorError> {
714    let batch_kernel = match kernel {
715        Kernel::Auto => detect_best_batch_kernel(),
716        other if other.is_batch() => other,
717        _ => return Err(VolumeZoneOscillatorError::InvalidKernelForBatch(kernel)),
718    };
719    volume_zone_oscillator_batch_par_slice(close, volume, sweep, batch_kernel.to_non_batch())
720}
721
722#[inline(always)]
723pub fn volume_zone_oscillator_batch_slice(
724    close: &[f64],
725    volume: &[f64],
726    sweep: &VolumeZoneOscillatorBatchRange,
727    kernel: Kernel,
728) -> Result<VolumeZoneOscillatorBatchOutput, VolumeZoneOscillatorError> {
729    volume_zone_oscillator_batch_inner(close, volume, sweep, kernel, false)
730}
731
732#[inline(always)]
733pub fn volume_zone_oscillator_batch_par_slice(
734    close: &[f64],
735    volume: &[f64],
736    sweep: &VolumeZoneOscillatorBatchRange,
737    kernel: Kernel,
738) -> Result<VolumeZoneOscillatorBatchOutput, VolumeZoneOscillatorError> {
739    volume_zone_oscillator_batch_inner(close, volume, sweep, kernel, true)
740}
741
742fn volume_zone_oscillator_batch_inner(
743    close: &[f64],
744    volume: &[f64],
745    sweep: &VolumeZoneOscillatorBatchRange,
746    kernel: Kernel,
747    parallel: bool,
748) -> Result<VolumeZoneOscillatorBatchOutput, VolumeZoneOscillatorError> {
749    let combos = expand_grid(sweep)?;
750    if close.is_empty() || volume.is_empty() {
751        return Err(VolumeZoneOscillatorError::EmptyInputData);
752    }
753    if close.len() != volume.len() {
754        return Err(VolumeZoneOscillatorError::InconsistentSliceLengths {
755            close_len: close.len(),
756            volume_len: volume.len(),
757        });
758    }
759    let first_valid = volume
760        .iter()
761        .position(|v| v.is_finite())
762        .ok_or(VolumeZoneOscillatorError::AllValuesNaN)?;
763    let rows = combos.len();
764    let cols = close.len();
765
766    let warmups = vec![first_valid; rows];
767    let mut buf_mu = make_uninit_matrix(rows, cols);
768    init_matrix_prefixes(&mut buf_mu, cols, &warmups);
769    let mut buf_guard = ManuallyDrop::new(buf_mu);
770    let out: &mut [f64] = unsafe {
771        core::slice::from_raw_parts_mut(buf_guard.as_mut_ptr() as *mut f64, buf_guard.len())
772    };
773
774    volume_zone_oscillator_batch_inner_into(close, volume, sweep, kernel, parallel, out)?;
775
776    let values = unsafe {
777        Vec::from_raw_parts(
778            buf_guard.as_mut_ptr() as *mut f64,
779            buf_guard.len(),
780            buf_guard.capacity(),
781        )
782    };
783
784    Ok(VolumeZoneOscillatorBatchOutput {
785        values,
786        combos,
787        rows,
788        cols,
789    })
790}
791
792pub fn volume_zone_oscillator_batch_into_slice(
793    out: &mut [f64],
794    close: &[f64],
795    volume: &[f64],
796    sweep: &VolumeZoneOscillatorBatchRange,
797    kernel: Kernel,
798) -> Result<(), VolumeZoneOscillatorError> {
799    volume_zone_oscillator_batch_inner_into(close, volume, sweep, kernel, false, out)?;
800    Ok(())
801}
802
803fn volume_zone_oscillator_batch_inner_into(
804    close: &[f64],
805    volume: &[f64],
806    sweep: &VolumeZoneOscillatorBatchRange,
807    kernel: Kernel,
808    parallel: bool,
809    out: &mut [f64],
810) -> Result<Vec<VolumeZoneOscillatorParams>, VolumeZoneOscillatorError> {
811    let combos = expand_grid(sweep)?;
812    if close.is_empty() || volume.is_empty() {
813        return Err(VolumeZoneOscillatorError::EmptyInputData);
814    }
815    if close.len() != volume.len() {
816        return Err(VolumeZoneOscillatorError::InconsistentSliceLengths {
817            close_len: close.len(),
818            volume_len: volume.len(),
819        });
820    }
821    let first_valid = volume
822        .iter()
823        .position(|v| v.is_finite())
824        .ok_or(VolumeZoneOscillatorError::AllValuesNaN)?;
825    let rows = combos.len();
826    let cols = close.len();
827    let expected =
828        rows.checked_mul(cols)
829            .ok_or_else(|| VolumeZoneOscillatorError::InvalidRange {
830                start: rows.to_string(),
831                end: cols.to_string(),
832                step: "rows*cols".to_string(),
833            })?;
834    if out.len() != expected {
835        return Err(VolumeZoneOscillatorError::OutputLengthMismatch {
836            expected,
837            got: out.len(),
838        });
839    }
840
841    let chosen = match kernel {
842        Kernel::Auto => Kernel::Scalar,
843        other => other.to_non_batch(),
844    };
845    let _ = chosen;
846
847    let lengths: Vec<usize> = combos
848        .iter()
849        .map(|combo| validate_length(combo.length.unwrap_or(DEFAULT_LENGTH)))
850        .collect::<Result<_, _>>()?;
851    let intraday_flags: Vec<bool> = combos
852        .iter()
853        .map(|combo| {
854            combo
855                .intraday_smoothing
856                .unwrap_or(DEFAULT_INTRADAY_SMOOTHING)
857        })
858        .collect();
859    let noise_filters: Vec<usize> = combos
860        .iter()
861        .map(|combo| validate_noise_filter(combo.noise_filter.unwrap_or(DEFAULT_NOISE_FILTER)))
862        .collect::<Result<_, _>>()?;
863
864    for row in 0..rows {
865        out[row * cols..row * cols + first_valid.min(cols)].fill(f64::NAN);
866    }
867
868    let do_row = |row: usize, dst: &mut [f64]| -> Result<(), VolumeZoneOscillatorError> {
869        compute_volume_zone_oscillator_into(
870            close,
871            volume,
872            lengths[row],
873            intraday_flags[row],
874            noise_filters[row],
875            first_valid,
876            dst,
877        );
878        Ok(())
879    };
880
881    if parallel {
882        #[cfg(not(target_arch = "wasm32"))]
883        {
884            out.par_chunks_mut(cols)
885                .enumerate()
886                .try_for_each(|(row, dst)| do_row(row, dst))?;
887        }
888        #[cfg(target_arch = "wasm32")]
889        {
890            for (row, dst) in out.chunks_mut(cols).enumerate() {
891                do_row(row, dst)?;
892            }
893        }
894    } else {
895        for (row, dst) in out.chunks_mut(cols).enumerate() {
896            do_row(row, dst)?;
897        }
898    }
899
900    Ok(combos)
901}
902
903#[cfg(feature = "python")]
904#[pyfunction(name = "volume_zone_oscillator")]
905#[pyo3(signature = (close, volume, length=14, intraday_smoothing=true, noise_filter=4, kernel=None))]
906pub fn volume_zone_oscillator_py<'py>(
907    py: Python<'py>,
908    close: PyReadonlyArray1<'py, f64>,
909    volume: PyReadonlyArray1<'py, f64>,
910    length: usize,
911    intraday_smoothing: bool,
912    noise_filter: usize,
913    kernel: Option<&str>,
914) -> PyResult<Bound<'py, PyArray1<f64>>> {
915    let close = close.as_slice()?;
916    let volume = volume.as_slice()?;
917    let kernel = validate_kernel(kernel, false)?;
918    let input = VolumeZoneOscillatorInput::from_slices(
919        close,
920        volume,
921        VolumeZoneOscillatorParams {
922            length: Some(length),
923            intraday_smoothing: Some(intraday_smoothing),
924            noise_filter: Some(noise_filter),
925        },
926    );
927    let out = py
928        .allow_threads(|| volume_zone_oscillator_with_kernel(&input, kernel))
929        .map_err(|e| PyValueError::new_err(e.to_string()))?;
930    Ok(out.values.into_pyarray(py))
931}
932
933#[cfg(feature = "python")]
934#[pyclass(name = "VolumeZoneOscillatorStream")]
935pub struct VolumeZoneOscillatorStreamPy {
936    stream: VolumeZoneOscillatorStream,
937}
938
939#[cfg(feature = "python")]
940#[pymethods]
941impl VolumeZoneOscillatorStreamPy {
942    #[new]
943    #[pyo3(signature = (length=14, intraday_smoothing=true, noise_filter=4))]
944    fn new(length: usize, intraday_smoothing: bool, noise_filter: usize) -> PyResult<Self> {
945        let stream = VolumeZoneOscillatorStream::try_new(VolumeZoneOscillatorParams {
946            length: Some(length),
947            intraday_smoothing: Some(intraday_smoothing),
948            noise_filter: Some(noise_filter),
949        })
950        .map_err(|e| PyValueError::new_err(e.to_string()))?;
951        Ok(Self { stream })
952    }
953
954    fn update(&mut self, close: f64, volume: f64) -> f64 {
955        self.stream.update(close, volume)
956    }
957}
958
959#[cfg(feature = "python")]
960#[pyfunction(name = "volume_zone_oscillator_batch")]
961#[pyo3(signature = (close, volume, length_range=(14,14,0), intraday_smoothing=true, noise_filter_range=(4,4,0), kernel=None))]
962pub fn volume_zone_oscillator_batch_py<'py>(
963    py: Python<'py>,
964    close: PyReadonlyArray1<'py, f64>,
965    volume: PyReadonlyArray1<'py, f64>,
966    length_range: (usize, usize, usize),
967    intraday_smoothing: bool,
968    noise_filter_range: (usize, usize, usize),
969    kernel: Option<&str>,
970) -> PyResult<Bound<'py, PyDict>> {
971    let close = close.as_slice()?;
972    let volume = volume.as_slice()?;
973    let sweep = VolumeZoneOscillatorBatchRange {
974        length: length_range,
975        noise_filter: noise_filter_range,
976        intraday_smoothing: Some(intraday_smoothing),
977    };
978    let combos = expand_grid(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
979    let rows = combos.len();
980    let cols = close.len();
981    let total = rows
982        .checked_mul(cols)
983        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
984
985    let values_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
986    let values_out = unsafe { values_arr.as_slice_mut()? };
987
988    let kern = validate_kernel(kernel, true)?;
989    py.allow_threads(|| {
990        let batch = match kern {
991            Kernel::Auto => detect_best_batch_kernel(),
992            other => other,
993        };
994        volume_zone_oscillator_batch_inner_into(
995            close,
996            volume,
997            &sweep,
998            batch.to_non_batch(),
999            true,
1000            values_out,
1001        )
1002    })
1003    .map_err(|e| PyValueError::new_err(e.to_string()))?;
1004
1005    let dict = PyDict::new(py);
1006    dict.set_item("values", values_arr.reshape((rows, cols))?)?;
1007    dict.set_item(
1008        "lengths",
1009        combos
1010            .iter()
1011            .map(|p| p.length.unwrap_or(DEFAULT_LENGTH) as u64)
1012            .collect::<Vec<_>>()
1013            .into_pyarray(py),
1014    )?;
1015    dict.set_item(
1016        "intraday_smoothing",
1017        combos
1018            .iter()
1019            .map(|p| p.intraday_smoothing.unwrap_or(DEFAULT_INTRADAY_SMOOTHING))
1020            .collect::<Vec<_>>(),
1021    )?;
1022    dict.set_item(
1023        "noise_filters",
1024        combos
1025            .iter()
1026            .map(|p| p.noise_filter.unwrap_or(DEFAULT_NOISE_FILTER) as u64)
1027            .collect::<Vec<_>>()
1028            .into_pyarray(py),
1029    )?;
1030    dict.set_item("rows", rows)?;
1031    dict.set_item("cols", cols)?;
1032    Ok(dict)
1033}
1034
1035#[cfg(feature = "python")]
1036pub fn register_volume_zone_oscillator_module(
1037    m: &Bound<'_, pyo3::types::PyModule>,
1038) -> PyResult<()> {
1039    m.add_function(wrap_pyfunction!(volume_zone_oscillator_py, m)?)?;
1040    m.add_function(wrap_pyfunction!(volume_zone_oscillator_batch_py, m)?)?;
1041    m.add_class::<VolumeZoneOscillatorStreamPy>()?;
1042    Ok(())
1043}
1044
1045#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1046#[wasm_bindgen(js_name = "volume_zone_oscillator_js")]
1047pub fn volume_zone_oscillator_js(
1048    close: &[f64],
1049    volume: &[f64],
1050    length: usize,
1051    intraday_smoothing: bool,
1052    noise_filter: usize,
1053) -> Result<Vec<f64>, JsValue> {
1054    let input = VolumeZoneOscillatorInput::from_slices(
1055        close,
1056        volume,
1057        VolumeZoneOscillatorParams {
1058            length: Some(length),
1059            intraday_smoothing: Some(intraday_smoothing),
1060            noise_filter: Some(noise_filter),
1061        },
1062    );
1063    let mut out = vec![0.0; close.len()];
1064    volume_zone_oscillator_into_slice(&mut out, &input, Kernel::Auto)
1065        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1066    Ok(out)
1067}
1068
1069#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1070#[derive(Serialize, Deserialize)]
1071pub struct VolumeZoneOscillatorBatchConfig {
1072    pub length_range: Vec<f64>,
1073    pub intraday_smoothing: bool,
1074    pub noise_filter_range: Vec<f64>,
1075}
1076
1077#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1078#[derive(Serialize, Deserialize)]
1079pub struct VolumeZoneOscillatorBatchJsOutput {
1080    pub values: Vec<f64>,
1081    pub combos: Vec<VolumeZoneOscillatorParams>,
1082    pub rows: usize,
1083    pub cols: usize,
1084}
1085
1086#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1087fn vec3_to_usize(name: &str, values: &[f64]) -> Result<(usize, usize, usize), JsValue> {
1088    if values.len() != 3 {
1089        return Err(JsValue::from_str(&format!(
1090            "Invalid config: {name} must have exactly 3 elements [start, end, step]"
1091        )));
1092    }
1093    let mut out = [0usize; 3];
1094    for (i, value) in values.iter().copied().enumerate() {
1095        if !value.is_finite() || value < 0.0 {
1096            return Err(JsValue::from_str(&format!(
1097                "Invalid config: {name}[{i}] must be a finite non-negative whole number"
1098            )));
1099        }
1100        let rounded = value.round();
1101        if (value - rounded).abs() > 1e-9 {
1102            return Err(JsValue::from_str(&format!(
1103                "Invalid config: {name}[{i}] must be a whole number"
1104            )));
1105        }
1106        out[i] = rounded as usize;
1107    }
1108    Ok((out[0], out[1], out[2]))
1109}
1110
1111#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1112#[wasm_bindgen(js_name = "volume_zone_oscillator_batch_js")]
1113pub fn volume_zone_oscillator_batch_js(
1114    close: &[f64],
1115    volume: &[f64],
1116    config: JsValue,
1117) -> Result<JsValue, JsValue> {
1118    let config: VolumeZoneOscillatorBatchConfig = serde_wasm_bindgen::from_value(config)
1119        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
1120    let length = vec3_to_usize("length_range", &config.length_range)?;
1121    let noise_filter = vec3_to_usize("noise_filter_range", &config.noise_filter_range)?;
1122    let out = volume_zone_oscillator_batch_with_kernel(
1123        close,
1124        volume,
1125        &VolumeZoneOscillatorBatchRange {
1126            length,
1127            noise_filter,
1128            intraday_smoothing: Some(config.intraday_smoothing),
1129        },
1130        Kernel::Auto,
1131    )
1132    .map_err(|e| JsValue::from_str(&e.to_string()))?;
1133    serde_wasm_bindgen::to_value(&VolumeZoneOscillatorBatchJsOutput {
1134        values: out.values,
1135        combos: out.combos,
1136        rows: out.rows,
1137        cols: out.cols,
1138    })
1139    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
1140}
1141
1142#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1143#[wasm_bindgen]
1144pub fn volume_zone_oscillator_alloc(len: usize) -> *mut f64 {
1145    let mut vec = Vec::<f64>::with_capacity(len);
1146    let ptr = vec.as_mut_ptr();
1147    std::mem::forget(vec);
1148    ptr
1149}
1150
1151#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1152#[wasm_bindgen]
1153pub fn volume_zone_oscillator_free(ptr: *mut f64, len: usize) {
1154    if !ptr.is_null() {
1155        unsafe {
1156            let _ = Vec::from_raw_parts(ptr, len, len);
1157        }
1158    }
1159}
1160
1161#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1162#[wasm_bindgen]
1163pub fn volume_zone_oscillator_into(
1164    close_ptr: *const f64,
1165    volume_ptr: *const f64,
1166    out_ptr: *mut f64,
1167    len: usize,
1168    length: usize,
1169    intraday_smoothing: bool,
1170    noise_filter: usize,
1171) -> Result<(), JsValue> {
1172    if close_ptr.is_null() || volume_ptr.is_null() || out_ptr.is_null() {
1173        return Err(JsValue::from_str("Null pointer provided"));
1174    }
1175    unsafe {
1176        let close = std::slice::from_raw_parts(close_ptr, len);
1177        let volume = std::slice::from_raw_parts(volume_ptr, len);
1178        let out = std::slice::from_raw_parts_mut(out_ptr, len);
1179        let input = VolumeZoneOscillatorInput::from_slices(
1180            close,
1181            volume,
1182            VolumeZoneOscillatorParams {
1183                length: Some(length),
1184                intraday_smoothing: Some(intraday_smoothing),
1185                noise_filter: Some(noise_filter),
1186            },
1187        );
1188        volume_zone_oscillator_into_slice(out, &input, Kernel::Auto)
1189            .map_err(|e| JsValue::from_str(&e.to_string()))
1190    }
1191}
1192
1193#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1194#[wasm_bindgen]
1195pub fn volume_zone_oscillator_batch_into(
1196    close_ptr: *const f64,
1197    volume_ptr: *const f64,
1198    out_ptr: *mut f64,
1199    len: usize,
1200    length_start: usize,
1201    length_end: usize,
1202    length_step: usize,
1203    intraday_smoothing: bool,
1204    noise_filter_start: usize,
1205    noise_filter_end: usize,
1206    noise_filter_step: usize,
1207) -> Result<usize, JsValue> {
1208    if close_ptr.is_null() || volume_ptr.is_null() || out_ptr.is_null() {
1209        return Err(JsValue::from_str(
1210            "null pointer passed to volume_zone_oscillator_batch_into",
1211        ));
1212    }
1213    unsafe {
1214        let close = std::slice::from_raw_parts(close_ptr, len);
1215        let volume = std::slice::from_raw_parts(volume_ptr, len);
1216        let sweep = VolumeZoneOscillatorBatchRange {
1217            length: (length_start, length_end, length_step),
1218            noise_filter: (noise_filter_start, noise_filter_end, noise_filter_step),
1219            intraday_smoothing: Some(intraday_smoothing),
1220        };
1221        let combos = expand_grid(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
1222        let rows = combos.len();
1223        let out = std::slice::from_raw_parts_mut(out_ptr, rows * len);
1224        volume_zone_oscillator_batch_into_slice(out, close, volume, &sweep, Kernel::Auto)
1225            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1226        Ok(rows)
1227    }
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232    use super::*;
1233
1234    fn assert_close_series(lhs: &[f64], rhs: &[f64], tol: f64) {
1235        assert_eq!(lhs.len(), rhs.len());
1236        for (i, (&a, &b)) in lhs.iter().zip(rhs.iter()).enumerate() {
1237            assert!(
1238                (a.is_nan() && b.is_nan()) || (a - b).abs() <= tol,
1239                "mismatch at {i}: {a} vs {b}"
1240            );
1241        }
1242    }
1243
1244    fn manual_vzo(
1245        close: &[f64],
1246        volume: &[f64],
1247        length: usize,
1248        intraday_smoothing: bool,
1249        noise_filter: usize,
1250    ) -> Vec<f64> {
1251        let mut out = vec![f64::NAN; close.len()];
1252        let alpha = ema_alpha(length);
1253        let beta = 1.0 - alpha;
1254        let smooth_alpha = ema_alpha(noise_filter);
1255        let smooth_beta = 1.0 - smooth_alpha;
1256        let mut prev_close = f64::NAN;
1257        let mut ema_dir = 0.0;
1258        let mut ema_total = 0.0;
1259        let mut smooth = 0.0;
1260        let mut smooth_valid = false;
1261        let first_valid = volume.iter().position(|v| v.is_finite()).unwrap();
1262        for i in first_valid..close.len() {
1263            let raw = compute_vzo_value(
1264                close[i],
1265                prev_close,
1266                volume[i],
1267                &mut ema_dir,
1268                &mut ema_total,
1269                alpha,
1270                beta,
1271            );
1272            if close[i].is_finite() {
1273                prev_close = close[i];
1274            }
1275            if intraday_smoothing {
1276                if let Some(value) = raw {
1277                    smooth = smooth_beta.mul_add(smooth, smooth_alpha * value);
1278                    smooth_valid = true;
1279                    out[i] = smooth;
1280                } else if smooth_valid {
1281                    out[i] = smooth;
1282                }
1283            } else {
1284                out[i] = raw.unwrap_or(f64::NAN);
1285            }
1286        }
1287        out
1288    }
1289
1290    #[test]
1291    fn volume_zone_oscillator_matches_manual_reference() {
1292        let close = [10.0, 10.5, 10.25, 10.75, 10.2, 10.9, 10.95];
1293        let volume = [100.0, 120.0, 80.0, 140.0, 90.0, 160.0, 110.0];
1294        let input = VolumeZoneOscillatorInput::from_slices(
1295            &close,
1296            &volume,
1297            VolumeZoneOscillatorParams {
1298                length: Some(4),
1299                intraday_smoothing: Some(true),
1300                noise_filter: Some(3),
1301            },
1302        );
1303        let out = volume_zone_oscillator(&input).unwrap();
1304        let expected = manual_vzo(&close, &volume, 4, true, 3);
1305        assert_close_series(&out.values, &expected, 1e-12);
1306    }
1307
1308    #[test]
1309    fn volume_zone_oscillator_stream_matches_batch() {
1310        let close = [10.0, 10.5, 10.25, 10.75, 10.2, 10.9, 10.95, 11.1];
1311        let volume = [100.0, 120.0, 80.0, 140.0, 90.0, 160.0, 110.0, 170.0];
1312        let input = VolumeZoneOscillatorInput::from_slices(
1313            &close,
1314            &volume,
1315            VolumeZoneOscillatorParams {
1316                length: Some(4),
1317                intraday_smoothing: Some(false),
1318                noise_filter: Some(3),
1319            },
1320        );
1321        let batch = volume_zone_oscillator(&input).unwrap();
1322        let mut stream = VolumeZoneOscillatorStream::try_new(input.params.clone()).unwrap();
1323        let streamed: Vec<f64> = close
1324            .iter()
1325            .zip(volume.iter())
1326            .map(|(&c, &v)| stream.update(c, v))
1327            .collect();
1328        assert_close_series(&streamed, &batch.values, 1e-12);
1329    }
1330
1331    #[test]
1332    fn volume_zone_oscillator_batch_rows_match_single() {
1333        let close = [10.0, 10.5, 10.25, 10.75, 10.2, 10.9, 10.95, 11.1];
1334        let volume = [100.0, 120.0, 80.0, 140.0, 90.0, 160.0, 110.0, 170.0];
1335        let sweep = VolumeZoneOscillatorBatchRange {
1336            length: (4, 6, 2),
1337            noise_filter: (3, 3, 0),
1338            intraday_smoothing: Some(true),
1339        };
1340        let batch = volume_zone_oscillator_batch_with_kernel(&close, &volume, &sweep, Kernel::Auto)
1341            .unwrap();
1342        let single = volume_zone_oscillator(&VolumeZoneOscillatorInput::from_slices(
1343            &close,
1344            &volume,
1345            VolumeZoneOscillatorParams {
1346                length: Some(4),
1347                intraday_smoothing: Some(true),
1348                noise_filter: Some(3),
1349            },
1350        ))
1351        .unwrap();
1352        assert_close_series(&batch.values[..close.len()], &single.values, 1e-12);
1353    }
1354
1355    #[test]
1356    fn volume_zone_oscillator_into_slice_matches_single() {
1357        let close = [10.0, 10.5, 10.25, 10.75, 10.2, 10.9];
1358        let volume = [100.0, 120.0, 80.0, 140.0, 90.0, 160.0];
1359        let input = VolumeZoneOscillatorInput::from_slices(
1360            &close,
1361            &volume,
1362            VolumeZoneOscillatorParams::default(),
1363        );
1364        let direct = volume_zone_oscillator(&input).unwrap();
1365        let mut out = vec![0.0; close.len()];
1366        volume_zone_oscillator_into_slice(&mut out, &input, Kernel::Auto).unwrap();
1367        assert_close_series(&out, &direct.values, 1e-12);
1368    }
1369
1370    #[test]
1371    fn volume_zone_oscillator_invalid_length_errors() {
1372        let close = [10.0, 10.5, 10.25];
1373        let volume = [100.0, 120.0, 80.0];
1374        let input = VolumeZoneOscillatorInput::from_slices(
1375            &close,
1376            &volume,
1377            VolumeZoneOscillatorParams {
1378                length: Some(1),
1379                intraday_smoothing: Some(true),
1380                noise_filter: Some(4),
1381            },
1382        );
1383        let err = volume_zone_oscillator(&input).unwrap_err();
1384        match err {
1385            VolumeZoneOscillatorError::InvalidLength { length } => assert_eq!(length, 1),
1386            other => panic!("unexpected error: {other:?}"),
1387        }
1388    }
1389}