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}