Skip to main content

vector_ta/indicators/
volatility_quality_index.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, MaybeUninit};
25use thiserror::Error;
26
27#[derive(Debug, Clone)]
28pub enum VolatilityQualityIndexData<'a> {
29    Candles {
30        candles: &'a Candles,
31    },
32    Slices {
33        open: &'a [f64],
34        high: &'a [f64],
35        low: &'a [f64],
36        close: &'a [f64],
37    },
38}
39
40#[derive(Debug, Clone)]
41pub struct VolatilityQualityIndexOutput {
42    pub vqi_sum: Vec<f64>,
43    pub fast_sma: Vec<f64>,
44    pub slow_sma: Vec<f64>,
45}
46
47#[derive(Debug, Clone)]
48#[cfg_attr(
49    all(target_arch = "wasm32", feature = "wasm"),
50    derive(Serialize, Deserialize)
51)]
52pub struct VolatilityQualityIndexParams {
53    pub fast_length: Option<usize>,
54    pub slow_length: Option<usize>,
55}
56
57impl Default for VolatilityQualityIndexParams {
58    fn default() -> Self {
59        Self {
60            fast_length: Some(9),
61            slow_length: Some(200),
62        }
63    }
64}
65
66#[derive(Debug, Clone)]
67pub struct VolatilityQualityIndexInput<'a> {
68    pub data: VolatilityQualityIndexData<'a>,
69    pub params: VolatilityQualityIndexParams,
70}
71
72impl<'a> VolatilityQualityIndexInput<'a> {
73    #[inline]
74    pub fn from_candles(candles: &'a Candles, params: VolatilityQualityIndexParams) -> Self {
75        Self {
76            data: VolatilityQualityIndexData::Candles { candles },
77            params,
78        }
79    }
80
81    #[inline]
82    pub fn from_slices(
83        open: &'a [f64],
84        high: &'a [f64],
85        low: &'a [f64],
86        close: &'a [f64],
87        params: VolatilityQualityIndexParams,
88    ) -> Self {
89        Self {
90            data: VolatilityQualityIndexData::Slices {
91                open,
92                high,
93                low,
94                close,
95            },
96            params,
97        }
98    }
99
100    #[inline]
101    pub fn with_default_candles(candles: &'a Candles) -> Self {
102        Self::from_candles(candles, VolatilityQualityIndexParams::default())
103    }
104
105    #[inline]
106    pub fn get_fast_length(&self) -> usize {
107        self.params.fast_length.unwrap_or(9)
108    }
109
110    #[inline]
111    pub fn get_slow_length(&self) -> usize {
112        self.params.slow_length.unwrap_or(200)
113    }
114}
115
116#[derive(Copy, Clone, Debug)]
117pub struct VolatilityQualityIndexBuilder {
118    fast_length: Option<usize>,
119    slow_length: Option<usize>,
120    kernel: Kernel,
121}
122
123impl Default for VolatilityQualityIndexBuilder {
124    fn default() -> Self {
125        Self {
126            fast_length: None,
127            slow_length: None,
128            kernel: Kernel::Auto,
129        }
130    }
131}
132
133impl VolatilityQualityIndexBuilder {
134    #[inline(always)]
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    #[inline(always)]
140    pub fn fast_length(mut self, value: usize) -> Self {
141        self.fast_length = Some(value);
142        self
143    }
144
145    #[inline(always)]
146    pub fn slow_length(mut self, value: usize) -> Self {
147        self.slow_length = Some(value);
148        self
149    }
150
151    #[inline(always)]
152    pub fn kernel(mut self, kernel: Kernel) -> Self {
153        self.kernel = kernel;
154        self
155    }
156
157    #[inline(always)]
158    pub fn apply(
159        self,
160        candles: &Candles,
161    ) -> Result<VolatilityQualityIndexOutput, VolatilityQualityIndexError> {
162        let input = VolatilityQualityIndexInput::from_candles(
163            candles,
164            VolatilityQualityIndexParams {
165                fast_length: self.fast_length,
166                slow_length: self.slow_length,
167            },
168        );
169        volatility_quality_index_with_kernel(&input, self.kernel)
170    }
171
172    #[inline(always)]
173    pub fn apply_slices(
174        self,
175        open: &[f64],
176        high: &[f64],
177        low: &[f64],
178        close: &[f64],
179    ) -> Result<VolatilityQualityIndexOutput, VolatilityQualityIndexError> {
180        let input = VolatilityQualityIndexInput::from_slices(
181            open,
182            high,
183            low,
184            close,
185            VolatilityQualityIndexParams {
186                fast_length: self.fast_length,
187                slow_length: self.slow_length,
188            },
189        );
190        volatility_quality_index_with_kernel(&input, self.kernel)
191    }
192
193    #[inline(always)]
194    pub fn into_stream(self) -> Result<VolatilityQualityIndexStream, VolatilityQualityIndexError> {
195        VolatilityQualityIndexStream::try_new(VolatilityQualityIndexParams {
196            fast_length: self.fast_length,
197            slow_length: self.slow_length,
198        })
199    }
200}
201
202#[derive(Debug, Error)]
203pub enum VolatilityQualityIndexError {
204    #[error("volatility_quality_index: Input data slice is empty.")]
205    EmptyInputData,
206    #[error("volatility_quality_index: All values are NaN.")]
207    AllValuesNaN,
208    #[error("volatility_quality_index: Inconsistent slice lengths: open={open_len}, high={high_len}, low={low_len}, close={close_len}")]
209    InconsistentSliceLengths {
210        open_len: usize,
211        high_len: usize,
212        low_len: usize,
213        close_len: usize,
214    },
215    #[error("volatility_quality_index: Invalid fast_length: {fast_length}")]
216    InvalidFastLength { fast_length: usize },
217    #[error("volatility_quality_index: Invalid slow_length: {slow_length}")]
218    InvalidSlowLength { slow_length: usize },
219    #[error(
220        "volatility_quality_index: Output length mismatch: expected = {expected}, got = {got}"
221    )]
222    OutputLengthMismatch { expected: usize, got: usize },
223    #[error("volatility_quality_index: Invalid range: start={start}, end={end}, step={step}")]
224    InvalidRange {
225        start: String,
226        end: String,
227        step: String,
228    },
229    #[error("volatility_quality_index: Invalid kernel for batch: {0:?}")]
230    InvalidKernelForBatch(Kernel),
231}
232
233#[derive(Debug, Clone)]
234pub struct VolatilityQualityIndexStream {
235    prev_close: f64,
236    prev_vqi_t: f64,
237    cumulative: f64,
238    fast: RunningSma,
239    slow: RunningSma,
240}
241
242#[derive(Debug, Clone)]
243struct RunningSma {
244    period: usize,
245    sum: f64,
246    values: Vec<f64>,
247    head: usize,
248    count: usize,
249}
250
251impl RunningSma {
252    #[inline]
253    fn new(period: usize) -> Self {
254        Self {
255            period,
256            sum: 0.0,
257            values: vec![0.0; period],
258            head: 0,
259            count: 0,
260        }
261    }
262
263    #[inline]
264    fn update(&mut self, value: f64) -> f64 {
265        if self.count == self.period {
266            self.sum -= self.values[self.head];
267        } else {
268            self.count += 1;
269        }
270        self.values[self.head] = value;
271        self.sum += value;
272        self.head += 1;
273        if self.head == self.period {
274            self.head = 0;
275        }
276        if self.count == self.period {
277            self.sum / self.period as f64
278        } else {
279            f64::NAN
280        }
281    }
282}
283
284impl VolatilityQualityIndexStream {
285    pub fn try_new(
286        params: VolatilityQualityIndexParams,
287    ) -> Result<Self, VolatilityQualityIndexError> {
288        let fast_length = validate_fast_length(params.fast_length.unwrap_or(9))?;
289        let slow_length = validate_slow_length(params.slow_length.unwrap_or(200))?;
290        Ok(Self {
291            prev_close: f64::NAN,
292            prev_vqi_t: 0.0,
293            cumulative: 0.0,
294            fast: RunningSma::new(fast_length),
295            slow: RunningSma::new(slow_length),
296        })
297    }
298
299    #[inline]
300    pub fn update(&mut self, open: f64, high: f64, low: f64, close: f64) -> (f64, f64, f64) {
301        let (vqi_t, raw) =
302            compute_vqi_point(self.prev_close, self.prev_vqi_t, open, high, low, close);
303        self.prev_vqi_t = vqi_t;
304        self.prev_close = close;
305        self.cumulative += raw;
306        (
307            self.cumulative,
308            self.fast.update(self.cumulative),
309            self.slow.update(self.cumulative),
310        )
311    }
312}
313
314#[inline(always)]
315fn validate_fast_length(fast_length: usize) -> Result<usize, VolatilityQualityIndexError> {
316    if fast_length == 0 {
317        return Err(VolatilityQualityIndexError::InvalidFastLength { fast_length });
318    }
319    Ok(fast_length)
320}
321
322#[inline(always)]
323fn validate_slow_length(slow_length: usize) -> Result<usize, VolatilityQualityIndexError> {
324    if slow_length == 0 {
325        return Err(VolatilityQualityIndexError::InvalidSlowLength { slow_length });
326    }
327    Ok(slow_length)
328}
329
330#[inline(always)]
331fn extract_ohlc<'a>(
332    input: &'a VolatilityQualityIndexInput<'a>,
333) -> Result<(&'a [f64], &'a [f64], &'a [f64], &'a [f64]), VolatilityQualityIndexError> {
334    let (open, high, low, close) = match &input.data {
335        VolatilityQualityIndexData::Candles { candles } => (
336            candles.open.as_slice(),
337            candles.high.as_slice(),
338            candles.low.as_slice(),
339            candles.close.as_slice(),
340        ),
341        VolatilityQualityIndexData::Slices {
342            open,
343            high,
344            low,
345            close,
346        } => (*open, *high, *low, *close),
347    };
348    if open.is_empty() || high.is_empty() || low.is_empty() || close.is_empty() {
349        return Err(VolatilityQualityIndexError::EmptyInputData);
350    }
351    if open.len() != high.len() || open.len() != low.len() || open.len() != close.len() {
352        return Err(VolatilityQualityIndexError::InconsistentSliceLengths {
353            open_len: open.len(),
354            high_len: high.len(),
355            low_len: low.len(),
356            close_len: close.len(),
357        });
358    }
359    Ok((open, high, low, close))
360}
361
362#[inline(always)]
363fn compute_vqi_point(
364    prev_close: f64,
365    prev_vqi_t: f64,
366    open: f64,
367    high: f64,
368    low: f64,
369    close: f64,
370) -> (f64, f64) {
371    let range = high - low;
372    let tr = if high.is_finite() && low.is_finite() {
373        if prev_close.is_finite() {
374            let mut tr = range;
375            let hc = (high - prev_close).abs();
376            if hc > tr {
377                tr = hc;
378            }
379            let lc = (low - prev_close).abs();
380            if lc > tr {
381                tr = lc;
382            }
383            tr
384        } else {
385            range
386        }
387    } else {
388        f64::NAN
389    };
390
391    let vqi_t = if prev_close.is_finite()
392        && open.is_finite()
393        && high.is_finite()
394        && low.is_finite()
395        && close.is_finite()
396        && tr.is_finite()
397        && tr != 0.0
398        && range.is_finite()
399        && range != 0.0
400    {
401        0.5 * (((close - prev_close) / tr) + ((close - open) / range))
402    } else {
403        prev_vqi_t
404    };
405
406    let raw = if prev_close.is_finite() && open.is_finite() && close.is_finite() {
407        vqi_t.abs() * 0.5 * ((close - prev_close) + (close - open))
408    } else {
409        0.0
410    };
411
412    (vqi_t, raw)
413}
414
415#[inline(always)]
416fn compute_vqi_sum_series(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
417    let len = close.len();
418    let mut out = vec![0.0; len];
419    let mut prev_close = f64::NAN;
420    let mut prev_vqi_t = 0.0;
421    let mut cumulative = 0.0;
422    for i in 0..len {
423        let (vqi_t, raw) =
424            compute_vqi_point(prev_close, prev_vqi_t, open[i], high[i], low[i], close[i]);
425        prev_vqi_t = vqi_t;
426        prev_close = close[i];
427        cumulative += raw;
428        out[i] = cumulative;
429    }
430    out
431}
432
433#[inline(always)]
434fn sma_into(src: &[f64], period: usize, dst: &mut [f64]) {
435    let len = src.len();
436    let warm = period.saturating_sub(1).min(len);
437    if warm > 0 {
438        dst[..warm].fill(f64::NAN);
439    }
440    if period > len {
441        return;
442    }
443    let mut sum = 0.0;
444    for &value in &src[..period] {
445        sum += value;
446    }
447    dst[period - 1] = sum / period as f64;
448    for i in period..len {
449        sum += src[i] - src[i - period];
450        dst[i] = sum / period as f64;
451    }
452}
453
454#[inline]
455pub fn volatility_quality_index(
456    input: &VolatilityQualityIndexInput,
457) -> Result<VolatilityQualityIndexOutput, VolatilityQualityIndexError> {
458    volatility_quality_index_with_kernel(input, Kernel::Auto)
459}
460
461#[inline]
462pub fn volatility_quality_index_with_kernel(
463    input: &VolatilityQualityIndexInput,
464    kernel: Kernel,
465) -> Result<VolatilityQualityIndexOutput, VolatilityQualityIndexError> {
466    let (open, high, low, close) = extract_ohlc(input)?;
467    let fast_length = validate_fast_length(input.get_fast_length())?;
468    let slow_length = validate_slow_length(input.get_slow_length())?;
469    let chosen = match kernel {
470        Kernel::Auto => Kernel::Scalar,
471        other => other.to_non_batch(),
472    };
473    let _ = chosen;
474    let vqi_sum = compute_vqi_sum_series(open, high, low, close);
475    let mut fast_sma =
476        alloc_with_nan_prefix(close.len(), fast_length.saturating_sub(1).min(close.len()));
477    let mut slow_sma =
478        alloc_with_nan_prefix(close.len(), slow_length.saturating_sub(1).min(close.len()));
479    sma_into(&vqi_sum, fast_length, &mut fast_sma);
480    sma_into(&vqi_sum, slow_length, &mut slow_sma);
481    Ok(VolatilityQualityIndexOutput {
482        vqi_sum,
483        fast_sma,
484        slow_sma,
485    })
486}
487
488#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
489#[inline]
490pub fn volatility_quality_index_into(
491    input: &VolatilityQualityIndexInput,
492    out_vqi_sum: &mut [f64],
493    out_fast_sma: &mut [f64],
494    out_slow_sma: &mut [f64],
495) -> Result<(), VolatilityQualityIndexError> {
496    volatility_quality_index_into_slice(
497        out_vqi_sum,
498        out_fast_sma,
499        out_slow_sma,
500        input,
501        Kernel::Auto,
502    )
503}
504
505#[inline]
506pub fn volatility_quality_index_into_slice(
507    out_vqi_sum: &mut [f64],
508    out_fast_sma: &mut [f64],
509    out_slow_sma: &mut [f64],
510    input: &VolatilityQualityIndexInput,
511    kernel: Kernel,
512) -> Result<(), VolatilityQualityIndexError> {
513    let (open, high, low, close) = extract_ohlc(input)?;
514    let len = close.len();
515    if out_vqi_sum.len() != len {
516        return Err(VolatilityQualityIndexError::OutputLengthMismatch {
517            expected: len,
518            got: out_vqi_sum.len(),
519        });
520    }
521    if out_fast_sma.len() != len {
522        return Err(VolatilityQualityIndexError::OutputLengthMismatch {
523            expected: len,
524            got: out_fast_sma.len(),
525        });
526    }
527    if out_slow_sma.len() != len {
528        return Err(VolatilityQualityIndexError::OutputLengthMismatch {
529            expected: len,
530            got: out_slow_sma.len(),
531        });
532    }
533    let fast_length = validate_fast_length(input.get_fast_length())?;
534    let slow_length = validate_slow_length(input.get_slow_length())?;
535    let chosen = match kernel {
536        Kernel::Auto => Kernel::Scalar,
537        other => other.to_non_batch(),
538    };
539    let _ = chosen;
540    let vqi_sum = compute_vqi_sum_series(open, high, low, close);
541    out_vqi_sum.copy_from_slice(&vqi_sum);
542    sma_into(&vqi_sum, fast_length, out_fast_sma);
543    sma_into(&vqi_sum, slow_length, out_slow_sma);
544    Ok(())
545}
546
547#[derive(Copy, Clone, Debug)]
548pub struct VolatilityQualityIndexBatchRange {
549    pub fast_length: (usize, usize, usize),
550    pub slow_length: (usize, usize, usize),
551}
552
553impl Default for VolatilityQualityIndexBatchRange {
554    fn default() -> Self {
555        Self {
556            fast_length: (9, 9, 0),
557            slow_length: (200, 200, 0),
558        }
559    }
560}
561
562#[derive(Debug, Clone)]
563pub struct VolatilityQualityIndexBatchOutput {
564    pub vqi_sum: Vec<f64>,
565    pub fast_sma: Vec<f64>,
566    pub slow_sma: Vec<f64>,
567    pub combos: Vec<VolatilityQualityIndexParams>,
568    pub rows: usize,
569    pub cols: usize,
570}
571
572impl VolatilityQualityIndexBatchOutput {
573    pub fn row_for_params(&self, params: &VolatilityQualityIndexParams) -> Option<usize> {
574        let fast_length = params.fast_length.unwrap_or(9);
575        let slow_length = params.slow_length.unwrap_or(200);
576        self.combos.iter().position(|combo| {
577            combo.fast_length.unwrap_or(9) == fast_length
578                && combo.slow_length.unwrap_or(200) == slow_length
579        })
580    }
581
582    pub fn vqi_sum_for(&self, params: &VolatilityQualityIndexParams) -> Option<&[f64]> {
583        self.row_for_params(params).and_then(|row| {
584            let start = row * self.cols;
585            self.vqi_sum.get(start..start + self.cols)
586        })
587    }
588
589    pub fn fast_sma_for(&self, params: &VolatilityQualityIndexParams) -> Option<&[f64]> {
590        self.row_for_params(params).and_then(|row| {
591            let start = row * self.cols;
592            self.fast_sma.get(start..start + self.cols)
593        })
594    }
595
596    pub fn slow_sma_for(&self, params: &VolatilityQualityIndexParams) -> Option<&[f64]> {
597        self.row_for_params(params).and_then(|row| {
598            let start = row * self.cols;
599            self.slow_sma.get(start..start + self.cols)
600        })
601    }
602
603    pub fn values_for(
604        &self,
605        params: &VolatilityQualityIndexParams,
606    ) -> Option<(&[f64], &[f64], &[f64])> {
607        self.row_for_params(params).map(|row| {
608            let start = row * self.cols;
609            (
610                &self.vqi_sum[start..start + self.cols],
611                &self.fast_sma[start..start + self.cols],
612                &self.slow_sma[start..start + self.cols],
613            )
614        })
615    }
616}
617
618#[derive(Copy, Clone, Debug)]
619pub struct VolatilityQualityIndexBatchBuilder {
620    range: VolatilityQualityIndexBatchRange,
621    kernel: Kernel,
622}
623
624impl Default for VolatilityQualityIndexBatchBuilder {
625    fn default() -> Self {
626        Self {
627            range: VolatilityQualityIndexBatchRange::default(),
628            kernel: Kernel::Auto,
629        }
630    }
631}
632
633impl VolatilityQualityIndexBatchBuilder {
634    #[inline(always)]
635    pub fn new() -> Self {
636        Self::default()
637    }
638
639    #[inline(always)]
640    pub fn fast_length_range(mut self, start: usize, end: usize, step: usize) -> Self {
641        self.range.fast_length = (start, end, step);
642        self
643    }
644
645    #[inline(always)]
646    pub fn fast_length_static(mut self, value: usize) -> Self {
647        self.range.fast_length = (value, value, 0);
648        self
649    }
650
651    #[inline(always)]
652    pub fn slow_length_range(mut self, start: usize, end: usize, step: usize) -> Self {
653        self.range.slow_length = (start, end, step);
654        self
655    }
656
657    #[inline(always)]
658    pub fn slow_length_static(mut self, value: usize) -> Self {
659        self.range.slow_length = (value, value, 0);
660        self
661    }
662
663    #[inline(always)]
664    pub fn kernel(mut self, kernel: Kernel) -> Self {
665        self.kernel = kernel;
666        self
667    }
668
669    #[inline(always)]
670    pub fn apply(
671        self,
672        candles: &Candles,
673    ) -> Result<VolatilityQualityIndexBatchOutput, VolatilityQualityIndexError> {
674        volatility_quality_index_batch_with_kernel(
675            candles.open.as_slice(),
676            candles.high.as_slice(),
677            candles.low.as_slice(),
678            candles.close.as_slice(),
679            &self.range,
680            self.kernel,
681        )
682    }
683
684    #[inline(always)]
685    pub fn apply_candles(
686        self,
687        candles: &Candles,
688    ) -> Result<VolatilityQualityIndexBatchOutput, VolatilityQualityIndexError> {
689        self.apply(candles)
690    }
691
692    #[inline(always)]
693    pub fn apply_slices(
694        self,
695        open: &[f64],
696        high: &[f64],
697        low: &[f64],
698        close: &[f64],
699    ) -> Result<VolatilityQualityIndexBatchOutput, VolatilityQualityIndexError> {
700        volatility_quality_index_batch_with_kernel(open, high, low, close, &self.range, self.kernel)
701    }
702}
703
704#[inline(always)]
705fn axis_usize(
706    start: usize,
707    end: usize,
708    step: usize,
709) -> Result<Vec<usize>, VolatilityQualityIndexError> {
710    if start == end {
711        return Ok(vec![start]);
712    }
713    if step == 0 {
714        return Err(VolatilityQualityIndexError::InvalidRange {
715            start: start.to_string(),
716            end: end.to_string(),
717            step: step.to_string(),
718        });
719    }
720    let mut out = Vec::new();
721    if start < end {
722        let mut x = start;
723        while x <= end {
724            out.push(x);
725            match x.checked_add(step) {
726                Some(next) => x = next,
727                None => break,
728            }
729        }
730    } else {
731        let mut x = start;
732        while x >= end {
733            out.push(x);
734            match x.checked_sub(step) {
735                Some(next) => x = next,
736                None => break,
737            }
738            if x > start {
739                break;
740            }
741        }
742    }
743    if out.is_empty() {
744        return Err(VolatilityQualityIndexError::InvalidRange {
745            start: start.to_string(),
746            end: end.to_string(),
747            step: step.to_string(),
748        });
749    }
750    Ok(out)
751}
752
753#[inline(always)]
754pub fn expand_grid(
755    range: &VolatilityQualityIndexBatchRange,
756) -> Result<Vec<VolatilityQualityIndexParams>, VolatilityQualityIndexError> {
757    let fast_values = axis_usize(
758        range.fast_length.0,
759        range.fast_length.1,
760        range.fast_length.2,
761    )?;
762    let slow_values = axis_usize(
763        range.slow_length.0,
764        range.slow_length.1,
765        range.slow_length.2,
766    )?;
767    let mut out = Vec::with_capacity(fast_values.len() * slow_values.len());
768    for fast_length in fast_values {
769        for &slow_length in &slow_values {
770            out.push(VolatilityQualityIndexParams {
771                fast_length: Some(fast_length),
772                slow_length: Some(slow_length),
773            });
774        }
775    }
776    Ok(out)
777}
778
779pub fn volatility_quality_index_batch_with_kernel(
780    open: &[f64],
781    high: &[f64],
782    low: &[f64],
783    close: &[f64],
784    sweep: &VolatilityQualityIndexBatchRange,
785    kernel: Kernel,
786) -> Result<VolatilityQualityIndexBatchOutput, VolatilityQualityIndexError> {
787    let batch_kernel = match kernel {
788        Kernel::Auto => detect_best_batch_kernel(),
789        other if other.is_batch() => other,
790        _ => return Err(VolatilityQualityIndexError::InvalidKernelForBatch(kernel)),
791    };
792    volatility_quality_index_batch_par_slice(
793        open,
794        high,
795        low,
796        close,
797        sweep,
798        batch_kernel.to_non_batch(),
799    )
800}
801
802#[inline(always)]
803pub fn volatility_quality_index_batch_slice(
804    open: &[f64],
805    high: &[f64],
806    low: &[f64],
807    close: &[f64],
808    sweep: &VolatilityQualityIndexBatchRange,
809    kernel: Kernel,
810) -> Result<VolatilityQualityIndexBatchOutput, VolatilityQualityIndexError> {
811    volatility_quality_index_batch_inner(open, high, low, close, sweep, kernel, false)
812}
813
814#[inline(always)]
815pub fn volatility_quality_index_batch_par_slice(
816    open: &[f64],
817    high: &[f64],
818    low: &[f64],
819    close: &[f64],
820    sweep: &VolatilityQualityIndexBatchRange,
821    kernel: Kernel,
822) -> Result<VolatilityQualityIndexBatchOutput, VolatilityQualityIndexError> {
823    volatility_quality_index_batch_inner(open, high, low, close, sweep, kernel, true)
824}
825
826fn volatility_quality_index_batch_inner(
827    open: &[f64],
828    high: &[f64],
829    low: &[f64],
830    close: &[f64],
831    sweep: &VolatilityQualityIndexBatchRange,
832    kernel: Kernel,
833    parallel: bool,
834) -> Result<VolatilityQualityIndexBatchOutput, VolatilityQualityIndexError> {
835    let combos = expand_grid(sweep)?;
836    if open.is_empty() || high.is_empty() || low.is_empty() || close.is_empty() {
837        return Err(VolatilityQualityIndexError::EmptyInputData);
838    }
839    if open.len() != high.len() || open.len() != low.len() || open.len() != close.len() {
840        return Err(VolatilityQualityIndexError::InconsistentSliceLengths {
841            open_len: open.len(),
842            high_len: high.len(),
843            low_len: low.len(),
844            close_len: close.len(),
845        });
846    }
847    if !open
848        .iter()
849        .zip(high.iter())
850        .zip(low.iter())
851        .zip(close.iter())
852        .any(|(((o, h), l), c)| o.is_finite() || h.is_finite() || l.is_finite() || c.is_finite())
853    {
854        return Err(VolatilityQualityIndexError::AllValuesNaN);
855    }
856    let rows = combos.len();
857    let cols = close.len();
858    let mut vqi_sum = vec![0.0; rows * cols];
859    let mut fast_sma = vec![f64::NAN; rows * cols];
860    let mut slow_sma = vec![f64::NAN; rows * cols];
861
862    volatility_quality_index_batch_inner_into(
863        open,
864        high,
865        low,
866        close,
867        sweep,
868        kernel,
869        parallel,
870        &mut vqi_sum,
871        &mut fast_sma,
872        &mut slow_sma,
873    )?;
874
875    Ok(VolatilityQualityIndexBatchOutput {
876        vqi_sum,
877        fast_sma,
878        slow_sma,
879        combos,
880        rows,
881        cols,
882    })
883}
884
885pub fn volatility_quality_index_batch_into_slice(
886    out_vqi_sum: &mut [f64],
887    out_fast_sma: &mut [f64],
888    out_slow_sma: &mut [f64],
889    open: &[f64],
890    high: &[f64],
891    low: &[f64],
892    close: &[f64],
893    sweep: &VolatilityQualityIndexBatchRange,
894    kernel: Kernel,
895) -> Result<(), VolatilityQualityIndexError> {
896    volatility_quality_index_batch_inner_into(
897        open,
898        high,
899        low,
900        close,
901        sweep,
902        kernel,
903        false,
904        out_vqi_sum,
905        out_fast_sma,
906        out_slow_sma,
907    )?;
908    Ok(())
909}
910
911fn volatility_quality_index_batch_inner_into(
912    open: &[f64],
913    high: &[f64],
914    low: &[f64],
915    close: &[f64],
916    sweep: &VolatilityQualityIndexBatchRange,
917    kernel: Kernel,
918    parallel: bool,
919    out_vqi_sum: &mut [f64],
920    out_fast_sma: &mut [f64],
921    out_slow_sma: &mut [f64],
922) -> Result<Vec<VolatilityQualityIndexParams>, VolatilityQualityIndexError> {
923    let combos = expand_grid(sweep)?;
924    if open.is_empty() || high.is_empty() || low.is_empty() || close.is_empty() {
925        return Err(VolatilityQualityIndexError::EmptyInputData);
926    }
927    if open.len() != high.len() || open.len() != low.len() || open.len() != close.len() {
928        return Err(VolatilityQualityIndexError::InconsistentSliceLengths {
929            open_len: open.len(),
930            high_len: high.len(),
931            low_len: low.len(),
932            close_len: close.len(),
933        });
934    }
935    let rows = combos.len();
936    let cols = close.len();
937    let expected =
938        rows.checked_mul(cols)
939            .ok_or_else(|| VolatilityQualityIndexError::InvalidRange {
940                start: rows.to_string(),
941                end: cols.to_string(),
942                step: "rows*cols".to_string(),
943            })?;
944    if out_vqi_sum.len() != expected {
945        return Err(VolatilityQualityIndexError::OutputLengthMismatch {
946            expected,
947            got: out_vqi_sum.len(),
948        });
949    }
950    if out_fast_sma.len() != expected {
951        return Err(VolatilityQualityIndexError::OutputLengthMismatch {
952            expected,
953            got: out_fast_sma.len(),
954        });
955    }
956    if out_slow_sma.len() != expected {
957        return Err(VolatilityQualityIndexError::OutputLengthMismatch {
958            expected,
959            got: out_slow_sma.len(),
960        });
961    }
962    let chosen = match kernel {
963        Kernel::Auto => Kernel::Scalar,
964        other => other.to_non_batch(),
965    };
966    let _ = chosen;
967
968    let vqi_sum = compute_vqi_sum_series(open, high, low, close);
969    let fast_lengths: Vec<usize> = combos
970        .iter()
971        .map(|combo| validate_fast_length(combo.fast_length.unwrap_or(9)))
972        .collect::<Result<_, _>>()?;
973    let slow_lengths: Vec<usize> = combos
974        .iter()
975        .map(|combo| validate_slow_length(combo.slow_length.unwrap_or(200)))
976        .collect::<Result<_, _>>()?;
977
978    let do_row = |row: usize,
979                  dst_vqi_sum: &mut [f64],
980                  dst_fast_sma: &mut [f64],
981                  dst_slow_sma: &mut [f64]| {
982        dst_vqi_sum.copy_from_slice(&vqi_sum);
983        sma_into(&vqi_sum, fast_lengths[row], dst_fast_sma);
984        sma_into(&vqi_sum, slow_lengths[row], dst_slow_sma);
985        Ok::<(), VolatilityQualityIndexError>(())
986    };
987
988    if parallel {
989        #[cfg(not(target_arch = "wasm32"))]
990        {
991            out_vqi_sum
992                .par_chunks_mut(cols)
993                .zip(out_fast_sma.par_chunks_mut(cols))
994                .zip(out_slow_sma.par_chunks_mut(cols))
995                .enumerate()
996                .try_for_each(|(row, ((dst_vqi_sum, dst_fast_sma), dst_slow_sma))| {
997                    do_row(row, dst_vqi_sum, dst_fast_sma, dst_slow_sma)
998                })?;
999        }
1000
1001        #[cfg(target_arch = "wasm32")]
1002        {
1003            for (row, ((dst_vqi_sum, dst_fast_sma), dst_slow_sma)) in out_vqi_sum
1004                .chunks_mut(cols)
1005                .zip(out_fast_sma.chunks_mut(cols))
1006                .zip(out_slow_sma.chunks_mut(cols))
1007                .enumerate()
1008            {
1009                do_row(row, dst_vqi_sum, dst_fast_sma, dst_slow_sma)?;
1010            }
1011        }
1012    } else {
1013        for (row, ((dst_vqi_sum, dst_fast_sma), dst_slow_sma)) in out_vqi_sum
1014            .chunks_mut(cols)
1015            .zip(out_fast_sma.chunks_mut(cols))
1016            .zip(out_slow_sma.chunks_mut(cols))
1017            .enumerate()
1018        {
1019            do_row(row, dst_vqi_sum, dst_fast_sma, dst_slow_sma)?;
1020        }
1021    }
1022
1023    Ok(combos)
1024}
1025
1026#[cfg(feature = "python")]
1027#[pyfunction(name = "volatility_quality_index")]
1028#[pyo3(signature = (open, high, low, close, fast_length=9, slow_length=200, kernel=None))]
1029pub fn volatility_quality_index_py<'py>(
1030    py: Python<'py>,
1031    open: PyReadonlyArray1<'py, f64>,
1032    high: PyReadonlyArray1<'py, f64>,
1033    low: PyReadonlyArray1<'py, f64>,
1034    close: PyReadonlyArray1<'py, f64>,
1035    fast_length: usize,
1036    slow_length: usize,
1037    kernel: Option<&str>,
1038) -> PyResult<(
1039    Bound<'py, PyArray1<f64>>,
1040    Bound<'py, PyArray1<f64>>,
1041    Bound<'py, PyArray1<f64>>,
1042)> {
1043    let o = open.as_slice()?;
1044    let h = high.as_slice()?;
1045    let l = low.as_slice()?;
1046    let c = close.as_slice()?;
1047    if o.len() != h.len() || o.len() != l.len() || o.len() != c.len() {
1048        return Err(PyValueError::new_err("OHLC slice length mismatch"));
1049    }
1050    let kern = validate_kernel(kernel, false)?;
1051    let input = VolatilityQualityIndexInput::from_slices(
1052        o,
1053        h,
1054        l,
1055        c,
1056        VolatilityQualityIndexParams {
1057            fast_length: Some(fast_length),
1058            slow_length: Some(slow_length),
1059        },
1060    );
1061    let out = py
1062        .allow_threads(|| volatility_quality_index_with_kernel(&input, kern))
1063        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1064    Ok((
1065        out.vqi_sum.into_pyarray(py),
1066        out.fast_sma.into_pyarray(py),
1067        out.slow_sma.into_pyarray(py),
1068    ))
1069}
1070
1071#[cfg(feature = "python")]
1072#[pyclass(name = "VolatilityQualityIndexStream")]
1073pub struct VolatilityQualityIndexStreamPy {
1074    stream: VolatilityQualityIndexStream,
1075}
1076
1077#[cfg(feature = "python")]
1078#[pymethods]
1079impl VolatilityQualityIndexStreamPy {
1080    #[new]
1081    #[pyo3(signature = (fast_length=9, slow_length=200))]
1082    fn new(fast_length: usize, slow_length: usize) -> PyResult<Self> {
1083        let stream = VolatilityQualityIndexStream::try_new(VolatilityQualityIndexParams {
1084            fast_length: Some(fast_length),
1085            slow_length: Some(slow_length),
1086        })
1087        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1088        Ok(Self { stream })
1089    }
1090
1091    fn update(&mut self, open: f64, high: f64, low: f64, close: f64) -> (f64, f64, f64) {
1092        self.stream.update(open, high, low, close)
1093    }
1094}
1095
1096#[cfg(feature = "python")]
1097#[pyfunction(name = "volatility_quality_index_batch")]
1098#[pyo3(signature = (open, high, low, close, fast_length_range=(9,9,0), slow_length_range=(200,200,0), kernel=None))]
1099pub fn volatility_quality_index_batch_py<'py>(
1100    py: Python<'py>,
1101    open: PyReadonlyArray1<'py, f64>,
1102    high: PyReadonlyArray1<'py, f64>,
1103    low: PyReadonlyArray1<'py, f64>,
1104    close: PyReadonlyArray1<'py, f64>,
1105    fast_length_range: (usize, usize, usize),
1106    slow_length_range: (usize, usize, usize),
1107    kernel: Option<&str>,
1108) -> PyResult<Bound<'py, PyDict>> {
1109    let o = open.as_slice()?;
1110    let h = high.as_slice()?;
1111    let l = low.as_slice()?;
1112    let c = close.as_slice()?;
1113    if o.len() != h.len() || o.len() != l.len() || o.len() != c.len() {
1114        return Err(PyValueError::new_err("OHLC slice length mismatch"));
1115    }
1116    let sweep = VolatilityQualityIndexBatchRange {
1117        fast_length: fast_length_range,
1118        slow_length: slow_length_range,
1119    };
1120    let combos = expand_grid(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
1121    let rows = combos.len();
1122    let cols = c.len();
1123    let total = rows
1124        .checked_mul(cols)
1125        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
1126
1127    let vqi_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1128    let fast_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1129    let slow_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1130    let vqi_out = unsafe { vqi_arr.as_slice_mut()? };
1131    let fast_out = unsafe { fast_arr.as_slice_mut()? };
1132    let slow_out = unsafe { slow_arr.as_slice_mut()? };
1133
1134    let kern = validate_kernel(kernel, true)?;
1135    py.allow_threads(|| {
1136        let batch = match kern {
1137            Kernel::Auto => detect_best_batch_kernel(),
1138            other => other,
1139        };
1140        volatility_quality_index_batch_inner_into(
1141            o,
1142            h,
1143            l,
1144            c,
1145            &sweep,
1146            batch.to_non_batch(),
1147            true,
1148            vqi_out,
1149            fast_out,
1150            slow_out,
1151        )
1152    })
1153    .map_err(|e| PyValueError::new_err(e.to_string()))?;
1154
1155    let dict = PyDict::new(py);
1156    dict.set_item("vqi_sum", vqi_arr.reshape((rows, cols))?)?;
1157    dict.set_item("fast_sma", fast_arr.reshape((rows, cols))?)?;
1158    dict.set_item("slow_sma", slow_arr.reshape((rows, cols))?)?;
1159    dict.set_item(
1160        "fast_lengths",
1161        combos
1162            .iter()
1163            .map(|p| p.fast_length.unwrap_or(9) as u64)
1164            .collect::<Vec<_>>()
1165            .into_pyarray(py),
1166    )?;
1167    dict.set_item(
1168        "slow_lengths",
1169        combos
1170            .iter()
1171            .map(|p| p.slow_length.unwrap_or(200) as u64)
1172            .collect::<Vec<_>>()
1173            .into_pyarray(py),
1174    )?;
1175    dict.set_item("rows", rows)?;
1176    dict.set_item("cols", cols)?;
1177    Ok(dict)
1178}
1179
1180#[cfg(feature = "python")]
1181pub fn register_volatility_quality_index_module(
1182    m: &Bound<'_, pyo3::types::PyModule>,
1183) -> PyResult<()> {
1184    m.add_function(wrap_pyfunction!(volatility_quality_index_py, m)?)?;
1185    m.add_function(wrap_pyfunction!(volatility_quality_index_batch_py, m)?)?;
1186    m.add_class::<VolatilityQualityIndexStreamPy>()?;
1187    Ok(())
1188}
1189
1190#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1191#[wasm_bindgen(js_name = "volatility_quality_index_js")]
1192pub fn volatility_quality_index_js(
1193    open: &[f64],
1194    high: &[f64],
1195    low: &[f64],
1196    close: &[f64],
1197    fast_length: usize,
1198    slow_length: usize,
1199) -> Result<JsValue, JsValue> {
1200    if open.len() != high.len() || open.len() != low.len() || open.len() != close.len() {
1201        return Err(JsValue::from_str("OHLC slice length mismatch"));
1202    }
1203    let input = VolatilityQualityIndexInput::from_slices(
1204        open,
1205        high,
1206        low,
1207        close,
1208        VolatilityQualityIndexParams {
1209            fast_length: Some(fast_length),
1210            slow_length: Some(slow_length),
1211        },
1212    );
1213    let out = volatility_quality_index_with_kernel(&input, Kernel::Auto)
1214        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1215    let obj = js_sys::Object::new();
1216    js_sys::Reflect::set(
1217        &obj,
1218        &JsValue::from_str("vqi_sum"),
1219        &serde_wasm_bindgen::to_value(&out.vqi_sum).unwrap(),
1220    )?;
1221    js_sys::Reflect::set(
1222        &obj,
1223        &JsValue::from_str("fast_sma"),
1224        &serde_wasm_bindgen::to_value(&out.fast_sma).unwrap(),
1225    )?;
1226    js_sys::Reflect::set(
1227        &obj,
1228        &JsValue::from_str("slow_sma"),
1229        &serde_wasm_bindgen::to_value(&out.slow_sma).unwrap(),
1230    )?;
1231    Ok(obj.into())
1232}
1233
1234#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1235#[derive(Serialize, Deserialize)]
1236pub struct VolatilityQualityIndexBatchConfig {
1237    pub fast_length_range: Vec<usize>,
1238    pub slow_length_range: Vec<usize>,
1239}
1240
1241#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1242#[derive(Serialize, Deserialize)]
1243pub struct VolatilityQualityIndexBatchJsOutput {
1244    pub vqi_sum: Vec<f64>,
1245    pub fast_sma: Vec<f64>,
1246    pub slow_sma: Vec<f64>,
1247    pub combos: Vec<VolatilityQualityIndexParams>,
1248    pub rows: usize,
1249    pub cols: usize,
1250}
1251
1252#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1253#[wasm_bindgen(js_name = "volatility_quality_index_batch_js")]
1254pub fn volatility_quality_index_batch_js(
1255    open: &[f64],
1256    high: &[f64],
1257    low: &[f64],
1258    close: &[f64],
1259    config: JsValue,
1260) -> Result<JsValue, JsValue> {
1261    if open.len() != high.len() || open.len() != low.len() || open.len() != close.len() {
1262        return Err(JsValue::from_str("OHLC slice length mismatch"));
1263    }
1264    let config: VolatilityQualityIndexBatchConfig = serde_wasm_bindgen::from_value(config)
1265        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
1266    if config.fast_length_range.len() != 3 {
1267        return Err(JsValue::from_str(
1268            "Invalid config: fast_length_range must have exactly 3 elements [start, end, step]",
1269        ));
1270    }
1271    if config.slow_length_range.len() != 3 {
1272        return Err(JsValue::from_str(
1273            "Invalid config: slow_length_range must have exactly 3 elements [start, end, step]",
1274        ));
1275    }
1276    let out = volatility_quality_index_batch_with_kernel(
1277        open,
1278        high,
1279        low,
1280        close,
1281        &VolatilityQualityIndexBatchRange {
1282            fast_length: (
1283                config.fast_length_range[0],
1284                config.fast_length_range[1],
1285                config.fast_length_range[2],
1286            ),
1287            slow_length: (
1288                config.slow_length_range[0],
1289                config.slow_length_range[1],
1290                config.slow_length_range[2],
1291            ),
1292        },
1293        Kernel::Auto,
1294    )
1295    .map_err(|e| JsValue::from_str(&e.to_string()))?;
1296    serde_wasm_bindgen::to_value(&VolatilityQualityIndexBatchJsOutput {
1297        vqi_sum: out.vqi_sum,
1298        fast_sma: out.fast_sma,
1299        slow_sma: out.slow_sma,
1300        combos: out.combos,
1301        rows: out.rows,
1302        cols: out.cols,
1303    })
1304    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
1305}
1306
1307#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1308#[wasm_bindgen]
1309pub fn volatility_quality_index_alloc(len: usize) -> *mut f64 {
1310    let mut vec = Vec::<f64>::with_capacity(len);
1311    let ptr = vec.as_mut_ptr();
1312    std::mem::forget(vec);
1313    ptr
1314}
1315
1316#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1317#[wasm_bindgen]
1318pub fn volatility_quality_index_free(ptr: *mut f64, len: usize) {
1319    if !ptr.is_null() {
1320        unsafe {
1321            let _ = Vec::from_raw_parts(ptr, len, len);
1322        }
1323    }
1324}
1325
1326#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1327#[wasm_bindgen]
1328pub fn volatility_quality_index_into(
1329    open_ptr: *const f64,
1330    high_ptr: *const f64,
1331    low_ptr: *const f64,
1332    close_ptr: *const f64,
1333    out_ptr: *mut f64,
1334    len: usize,
1335    fast_length: usize,
1336    slow_length: usize,
1337) -> Result<(), JsValue> {
1338    if open_ptr.is_null()
1339        || high_ptr.is_null()
1340        || low_ptr.is_null()
1341        || close_ptr.is_null()
1342        || out_ptr.is_null()
1343    {
1344        return Err(JsValue::from_str(
1345            "null pointer passed to volatility_quality_index_into",
1346        ));
1347    }
1348    unsafe {
1349        let open = std::slice::from_raw_parts(open_ptr, len);
1350        let high = std::slice::from_raw_parts(high_ptr, len);
1351        let low = std::slice::from_raw_parts(low_ptr, len);
1352        let close = std::slice::from_raw_parts(close_ptr, len);
1353        let out = std::slice::from_raw_parts_mut(out_ptr, 3 * len);
1354        let (out_vqi_sum, rest) = out.split_at_mut(len);
1355        let (out_fast_sma, out_slow_sma) = rest.split_at_mut(len);
1356        let input = VolatilityQualityIndexInput::from_slices(
1357            open,
1358            high,
1359            low,
1360            close,
1361            VolatilityQualityIndexParams {
1362                fast_length: Some(fast_length),
1363                slow_length: Some(slow_length),
1364            },
1365        );
1366        volatility_quality_index_into_slice(
1367            out_vqi_sum,
1368            out_fast_sma,
1369            out_slow_sma,
1370            &input,
1371            Kernel::Auto,
1372        )
1373        .map_err(|e| JsValue::from_str(&e.to_string()))
1374    }
1375}
1376
1377#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1378#[wasm_bindgen]
1379pub fn volatility_quality_index_batch_into(
1380    open_ptr: *const f64,
1381    high_ptr: *const f64,
1382    low_ptr: *const f64,
1383    close_ptr: *const f64,
1384    vqi_sum_ptr: *mut f64,
1385    fast_sma_ptr: *mut f64,
1386    slow_sma_ptr: *mut f64,
1387    len: usize,
1388    fast_start: usize,
1389    fast_end: usize,
1390    fast_step: usize,
1391    slow_start: usize,
1392    slow_end: usize,
1393    slow_step: usize,
1394) -> Result<usize, JsValue> {
1395    if open_ptr.is_null()
1396        || high_ptr.is_null()
1397        || low_ptr.is_null()
1398        || close_ptr.is_null()
1399        || vqi_sum_ptr.is_null()
1400        || fast_sma_ptr.is_null()
1401        || slow_sma_ptr.is_null()
1402    {
1403        return Err(JsValue::from_str(
1404            "null pointer passed to volatility_quality_index_batch_into",
1405        ));
1406    }
1407    unsafe {
1408        let open = std::slice::from_raw_parts(open_ptr, len);
1409        let high = std::slice::from_raw_parts(high_ptr, len);
1410        let low = std::slice::from_raw_parts(low_ptr, len);
1411        let close = std::slice::from_raw_parts(close_ptr, len);
1412        let sweep = VolatilityQualityIndexBatchRange {
1413            fast_length: (fast_start, fast_end, fast_step),
1414            slow_length: (slow_start, slow_end, slow_step),
1415        };
1416        let combos = expand_grid(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
1417        let rows = combos.len();
1418        let total = rows
1419            .checked_mul(len)
1420            .ok_or_else(|| JsValue::from_str("rows*len overflow"))?;
1421        let out_vqi_sum = std::slice::from_raw_parts_mut(vqi_sum_ptr, total);
1422        let out_fast_sma = std::slice::from_raw_parts_mut(fast_sma_ptr, total);
1423        let out_slow_sma = std::slice::from_raw_parts_mut(slow_sma_ptr, total);
1424        volatility_quality_index_batch_inner_into(
1425            open,
1426            high,
1427            low,
1428            close,
1429            &sweep,
1430            Kernel::Scalar,
1431            false,
1432            out_vqi_sum,
1433            out_fast_sma,
1434            out_slow_sma,
1435        )
1436        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1437        Ok(rows)
1438    }
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443    use super::*;
1444
1445    fn sample_ohlc(n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
1446        let mut open = Vec::with_capacity(n);
1447        let mut high = Vec::with_capacity(n);
1448        let mut low = Vec::with_capacity(n);
1449        let mut close = Vec::with_capacity(n);
1450        let mut price = 100.0;
1451        for i in 0..n {
1452            let drift = 0.25 + (i as f64) * 0.005;
1453            let o = price;
1454            let c = price + drift;
1455            let h = o.max(c) + 0.4;
1456            let l = o.min(c) - 0.3;
1457            open.push(o);
1458            high.push(h);
1459            low.push(l);
1460            close.push(c);
1461            price = c;
1462        }
1463        (open, high, low, close)
1464    }
1465
1466    fn manual_vqi(
1467        open: &[f64],
1468        high: &[f64],
1469        low: &[f64],
1470        close: &[f64],
1471        fast_length: usize,
1472        slow_length: usize,
1473    ) -> VolatilityQualityIndexOutput {
1474        let vqi_sum = compute_vqi_sum_series(open, high, low, close);
1475        let mut fast_sma =
1476            alloc_with_nan_prefix(close.len(), fast_length.saturating_sub(1).min(close.len()));
1477        let mut slow_sma =
1478            alloc_with_nan_prefix(close.len(), slow_length.saturating_sub(1).min(close.len()));
1479        sma_into(&vqi_sum, fast_length, &mut fast_sma);
1480        sma_into(&vqi_sum, slow_length, &mut slow_sma);
1481        VolatilityQualityIndexOutput {
1482            vqi_sum,
1483            fast_sma,
1484            slow_sma,
1485        }
1486    }
1487
1488    fn assert_close(lhs: &[f64], rhs: &[f64], eps: f64) {
1489        assert_eq!(lhs.len(), rhs.len());
1490        for i in 0..lhs.len() {
1491            let a = lhs[i];
1492            let b = rhs[i];
1493            assert!(
1494                (a.is_nan() && b.is_nan()) || (a - b).abs() <= eps,
1495                "mismatch at {i}: {a} vs {b}"
1496            );
1497        }
1498    }
1499
1500    #[test]
1501    fn volatility_quality_index_matches_manual_reference() {
1502        let (open, high, low, close) = sample_ohlc(256);
1503        let input = VolatilityQualityIndexInput::from_slices(
1504            &open,
1505            &high,
1506            &low,
1507            &close,
1508            VolatilityQualityIndexParams {
1509                fast_length: Some(9),
1510                slow_length: Some(21),
1511            },
1512        );
1513        let out = volatility_quality_index(&input).unwrap();
1514        let manual = manual_vqi(&open, &high, &low, &close, 9, 21);
1515        assert_close(&out.vqi_sum, &manual.vqi_sum, 1e-12);
1516        assert_close(&out.fast_sma, &manual.fast_sma, 1e-12);
1517        assert_close(&out.slow_sma, &manual.slow_sma, 1e-12);
1518    }
1519
1520    #[test]
1521    fn volatility_quality_index_stream_matches_batch() {
1522        let (open, high, low, close) = sample_ohlc(128);
1523        let input = VolatilityQualityIndexInput::from_slices(
1524            &open,
1525            &high,
1526            &low,
1527            &close,
1528            VolatilityQualityIndexParams {
1529                fast_length: Some(9),
1530                slow_length: Some(21),
1531            },
1532        );
1533        let batch = volatility_quality_index(&input).unwrap();
1534        let mut stream = VolatilityQualityIndexStream::try_new(input.params.clone()).unwrap();
1535        let mut vqi_sum = Vec::with_capacity(close.len());
1536        let mut fast_sma = Vec::with_capacity(close.len());
1537        let mut slow_sma = Vec::with_capacity(close.len());
1538        for i in 0..close.len() {
1539            let (vqi, fast, slow) = stream.update(open[i], high[i], low[i], close[i]);
1540            vqi_sum.push(vqi);
1541            fast_sma.push(fast);
1542            slow_sma.push(slow);
1543        }
1544        assert_close(&vqi_sum, &batch.vqi_sum, 1e-12);
1545        assert_close(&fast_sma, &batch.fast_sma, 1e-12);
1546        assert_close(&slow_sma, &batch.slow_sma, 1e-12);
1547    }
1548
1549    #[test]
1550    fn volatility_quality_index_batch_rows_match_single() {
1551        let (open, high, low, close) = sample_ohlc(96);
1552        let sweep = VolatilityQualityIndexBatchRange {
1553            fast_length: (9, 11, 2),
1554            slow_length: (20, 24, 4),
1555        };
1556        let batch = volatility_quality_index_batch_with_kernel(
1557            &open,
1558            &high,
1559            &low,
1560            &close,
1561            &sweep,
1562            Kernel::Auto,
1563        )
1564        .unwrap();
1565        assert_eq!(batch.rows, 4);
1566        assert_eq!(batch.cols, close.len());
1567        let first = VolatilityQualityIndexInput::from_slices(
1568            &open,
1569            &high,
1570            &low,
1571            &close,
1572            batch.combos[0].clone(),
1573        );
1574        let direct = volatility_quality_index(&first).unwrap();
1575        assert_close(&batch.vqi_sum[..close.len()], &direct.vqi_sum, 1e-12);
1576        assert_close(&batch.fast_sma[..close.len()], &direct.fast_sma, 1e-12);
1577        assert_close(&batch.slow_sma[..close.len()], &direct.slow_sma, 1e-12);
1578    }
1579
1580    #[test]
1581    fn volatility_quality_index_into_slice_matches_single() {
1582        let (open, high, low, close) = sample_ohlc(64);
1583        let input = VolatilityQualityIndexInput::from_slices(
1584            &open,
1585            &high,
1586            &low,
1587            &close,
1588            VolatilityQualityIndexParams {
1589                fast_length: Some(9),
1590                slow_length: Some(21),
1591            },
1592        );
1593        let direct = volatility_quality_index(&input).unwrap();
1594        let mut vqi_sum = vec![0.0; close.len()];
1595        let mut fast_sma = alloc_with_nan_prefix(close.len(), 8);
1596        let mut slow_sma = alloc_with_nan_prefix(close.len(), 20);
1597        volatility_quality_index_into_slice(
1598            &mut vqi_sum,
1599            &mut fast_sma,
1600            &mut slow_sma,
1601            &input,
1602            Kernel::Auto,
1603        )
1604        .unwrap();
1605        assert_close(&vqi_sum, &direct.vqi_sum, 1e-12);
1606        assert_close(&fast_sma, &direct.fast_sma, 1e-12);
1607        assert_close(&slow_sma, &direct.slow_sma, 1e-12);
1608    }
1609
1610    #[test]
1611    fn volatility_quality_index_invalid_lengths_error() {
1612        let (open, high, low, close) = sample_ohlc(32);
1613        let input = VolatilityQualityIndexInput::from_slices(
1614            &open,
1615            &high,
1616            &low,
1617            &close,
1618            VolatilityQualityIndexParams {
1619                fast_length: Some(0),
1620                slow_length: Some(21),
1621            },
1622        );
1623        assert!(matches!(
1624            volatility_quality_index(&input),
1625            Err(VolatilityQualityIndexError::InvalidFastLength { .. })
1626        ));
1627    }
1628
1629    #[test]
1630    fn volatility_quality_index_matches_hand_values() {
1631        let open = [10.0, 11.0, 12.0, 12.0];
1632        let high = [12.0, 13.0, 12.0, 15.0];
1633        let low = [9.0, 10.0, 12.0, 11.0];
1634        let close = [11.0, 12.0, 12.0, 14.0];
1635        let input = VolatilityQualityIndexInput::from_slices(
1636            &open,
1637            &high,
1638            &low,
1639            &close,
1640            VolatilityQualityIndexParams {
1641                fast_length: Some(2),
1642                slow_length: Some(3),
1643            },
1644        );
1645        let out = volatility_quality_index(&input).unwrap();
1646        let expected_sum = [0.0, 1.0 / 3.0, 1.0 / 3.0, 4.0 / 3.0];
1647        let expected_fast = [f64::NAN, 1.0 / 6.0, 1.0 / 3.0, 5.0 / 6.0];
1648        let expected_slow = [f64::NAN, f64::NAN, 2.0 / 9.0, 2.0 / 3.0];
1649        assert_close(&out.vqi_sum, &expected_sum, 1e-12);
1650        assert_close(&out.fast_sma, &expected_fast, 1e-12);
1651        assert_close(&out.slow_sma, &expected_slow, 1e-12);
1652    }
1653}