Skip to main content

vector_ta/indicators/
vwap_deviation_oscillator.rs

1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7#[cfg(feature = "python")]
8use pyo3::types::PyDict;
9
10#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
11use serde::{Deserialize, Serialize};
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use wasm_bindgen::prelude::*;
14
15use crate::utilities::data_loader::Candles;
16use crate::utilities::enums::Kernel;
17use crate::utilities::helpers::detect_best_batch_kernel;
18#[cfg(feature = "python")]
19use crate::utilities::kernel_validation::validate_kernel;
20#[cfg(not(target_arch = "wasm32"))]
21use rayon::prelude::*;
22use std::collections::{HashMap, VecDeque};
23use thiserror::Error;
24
25const DEFAULT_ROLLING_PERIOD: usize = 20;
26const DEFAULT_ROLLING_DAYS: usize = 30;
27const DEFAULT_Z_WINDOW: usize = 50;
28const DEFAULT_PCT_VOL_LOOKBACK: usize = 100;
29const DEFAULT_PCT_MIN_SIGMA: f64 = 0.1;
30const DEFAULT_ABS_VOL_LOOKBACK: usize = 100;
31const DAY_MS: i64 = 86_400_000;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34#[cfg_attr(
35    all(target_arch = "wasm32", feature = "wasm"),
36    derive(Serialize, Deserialize)
37)]
38pub enum VwapDeviationSessionMode {
39    FourHours,
40    Daily,
41    Weekly,
42    RollingBars,
43    RollingDays,
44}
45
46impl Default for VwapDeviationSessionMode {
47    fn default() -> Self {
48        Self::RollingBars
49    }
50}
51
52impl std::str::FromStr for VwapDeviationSessionMode {
53    type Err = String;
54
55    fn from_str(s: &str) -> Result<Self, Self::Err> {
56        match s.trim().to_ascii_lowercase().as_str() {
57            "4_hours" | "4h" | "4 hours" => Ok(Self::FourHours),
58            "daily" | "1d" | "day" => Ok(Self::Daily),
59            "weekly" | "1w" | "week" => Ok(Self::Weekly),
60            "rolling_bars" | "rolling bars" | "rolling (lookback: bars)" => Ok(Self::RollingBars),
61            "rolling_days" | "rolling days" | "rolling (lookback: days)" => Ok(Self::RollingDays),
62            _ => Err(format!("Unknown session_mode: {s}")),
63        }
64    }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68#[cfg_attr(
69    all(target_arch = "wasm32", feature = "wasm"),
70    derive(Serialize, Deserialize)
71)]
72pub enum VwapDeviationMode {
73    Percent,
74    Absolute,
75    ZScore,
76}
77
78impl Default for VwapDeviationMode {
79    fn default() -> Self {
80        Self::Absolute
81    }
82}
83
84impl std::str::FromStr for VwapDeviationMode {
85    type Err = String;
86
87    fn from_str(s: &str) -> Result<Self, Self::Err> {
88        match s.trim().to_ascii_lowercase().as_str() {
89            "percent" => Ok(Self::Percent),
90            "absolute" => Ok(Self::Absolute),
91            "zscore" | "z-score" => Ok(Self::ZScore),
92            _ => Err(format!("Unknown deviation_mode: {s}")),
93        }
94    }
95}
96
97#[derive(Debug, Clone)]
98pub enum VwapDeviationOscillatorData<'a> {
99    Candles {
100        candles: &'a Candles,
101    },
102    Slices {
103        timestamps: &'a [i64],
104        high: &'a [f64],
105        low: &'a [f64],
106        close: &'a [f64],
107        volume: &'a [f64],
108    },
109}
110
111#[derive(Debug, Clone)]
112pub struct VwapDeviationOscillatorOutput {
113    pub osc: Vec<f64>,
114    pub std1: Vec<f64>,
115    pub std2: Vec<f64>,
116    pub std3: Vec<f64>,
117}
118
119#[derive(Debug, Clone)]
120#[cfg_attr(
121    all(target_arch = "wasm32", feature = "wasm"),
122    derive(Serialize, Deserialize)
123)]
124pub struct VwapDeviationOscillatorParams {
125    pub session_mode: Option<VwapDeviationSessionMode>,
126    pub rolling_period: Option<usize>,
127    pub rolling_days: Option<usize>,
128    pub use_close: Option<bool>,
129    pub deviation_mode: Option<VwapDeviationMode>,
130    pub z_window: Option<usize>,
131    pub pct_vol_lookback: Option<usize>,
132    pub pct_min_sigma: Option<f64>,
133    pub abs_vol_lookback: Option<usize>,
134}
135
136impl Default for VwapDeviationOscillatorParams {
137    fn default() -> Self {
138        Self {
139            session_mode: Some(VwapDeviationSessionMode::RollingBars),
140            rolling_period: Some(DEFAULT_ROLLING_PERIOD),
141            rolling_days: Some(DEFAULT_ROLLING_DAYS),
142            use_close: Some(false),
143            deviation_mode: Some(VwapDeviationMode::Absolute),
144            z_window: Some(DEFAULT_Z_WINDOW),
145            pct_vol_lookback: Some(DEFAULT_PCT_VOL_LOOKBACK),
146            pct_min_sigma: Some(DEFAULT_PCT_MIN_SIGMA),
147            abs_vol_lookback: Some(DEFAULT_ABS_VOL_LOOKBACK),
148        }
149    }
150}
151
152#[derive(Debug, Clone)]
153pub struct VwapDeviationOscillatorInput<'a> {
154    pub data: VwapDeviationOscillatorData<'a>,
155    pub params: VwapDeviationOscillatorParams,
156}
157
158impl<'a> VwapDeviationOscillatorInput<'a> {
159    #[inline]
160    pub fn from_candles(candles: &'a Candles, params: VwapDeviationOscillatorParams) -> Self {
161        Self {
162            data: VwapDeviationOscillatorData::Candles { candles },
163            params,
164        }
165    }
166
167    #[inline]
168    pub fn from_slices(
169        timestamps: &'a [i64],
170        high: &'a [f64],
171        low: &'a [f64],
172        close: &'a [f64],
173        volume: &'a [f64],
174        params: VwapDeviationOscillatorParams,
175    ) -> Self {
176        Self {
177            data: VwapDeviationOscillatorData::Slices {
178                timestamps,
179                high,
180                low,
181                close,
182                volume,
183            },
184            params,
185        }
186    }
187
188    #[inline]
189    pub fn with_default_candles(candles: &'a Candles) -> Self {
190        Self::from_candles(candles, VwapDeviationOscillatorParams::default())
191    }
192
193    #[inline]
194    pub fn get_session_mode(&self) -> VwapDeviationSessionMode {
195        self.params.session_mode.unwrap_or_default()
196    }
197
198    #[inline]
199    pub fn get_rolling_period(&self) -> usize {
200        self.params.rolling_period.unwrap_or(DEFAULT_ROLLING_PERIOD)
201    }
202
203    #[inline]
204    pub fn get_rolling_days(&self) -> usize {
205        self.params.rolling_days.unwrap_or(DEFAULT_ROLLING_DAYS)
206    }
207
208    #[inline]
209    pub fn get_use_close(&self) -> bool {
210        self.params.use_close.unwrap_or(false)
211    }
212
213    #[inline]
214    pub fn get_deviation_mode(&self) -> VwapDeviationMode {
215        self.params.deviation_mode.unwrap_or_default()
216    }
217
218    #[inline]
219    pub fn get_z_window(&self) -> usize {
220        self.params.z_window.unwrap_or(DEFAULT_Z_WINDOW)
221    }
222
223    #[inline]
224    pub fn get_pct_vol_lookback(&self) -> usize {
225        self.params
226            .pct_vol_lookback
227            .unwrap_or(DEFAULT_PCT_VOL_LOOKBACK)
228    }
229
230    #[inline]
231    pub fn get_pct_min_sigma(&self) -> f64 {
232        self.params.pct_min_sigma.unwrap_or(DEFAULT_PCT_MIN_SIGMA)
233    }
234
235    #[inline]
236    pub fn get_abs_vol_lookback(&self) -> usize {
237        self.params
238            .abs_vol_lookback
239            .unwrap_or(DEFAULT_ABS_VOL_LOOKBACK)
240    }
241}
242
243#[derive(Copy, Clone, Debug)]
244pub struct VwapDeviationOscillatorBuilder {
245    session_mode: Option<VwapDeviationSessionMode>,
246    rolling_period: Option<usize>,
247    rolling_days: Option<usize>,
248    use_close: Option<bool>,
249    deviation_mode: Option<VwapDeviationMode>,
250    z_window: Option<usize>,
251    pct_vol_lookback: Option<usize>,
252    pct_min_sigma: Option<f64>,
253    abs_vol_lookback: Option<usize>,
254    kernel: Kernel,
255}
256
257impl Default for VwapDeviationOscillatorBuilder {
258    fn default() -> Self {
259        Self {
260            session_mode: None,
261            rolling_period: None,
262            rolling_days: None,
263            use_close: None,
264            deviation_mode: None,
265            z_window: None,
266            pct_vol_lookback: None,
267            pct_min_sigma: None,
268            abs_vol_lookback: None,
269            kernel: Kernel::Auto,
270        }
271    }
272}
273
274impl VwapDeviationOscillatorBuilder {
275    #[inline(always)]
276    pub fn new() -> Self {
277        Self::default()
278    }
279
280    #[inline(always)]
281    pub fn session_mode(mut self, value: VwapDeviationSessionMode) -> Self {
282        self.session_mode = Some(value);
283        self
284    }
285
286    #[inline(always)]
287    pub fn rolling_period(mut self, value: usize) -> Self {
288        self.rolling_period = Some(value);
289        self
290    }
291
292    #[inline(always)]
293    pub fn rolling_days(mut self, value: usize) -> Self {
294        self.rolling_days = Some(value);
295        self
296    }
297
298    #[inline(always)]
299    pub fn use_close(mut self, value: bool) -> Self {
300        self.use_close = Some(value);
301        self
302    }
303
304    #[inline(always)]
305    pub fn deviation_mode(mut self, value: VwapDeviationMode) -> Self {
306        self.deviation_mode = Some(value);
307        self
308    }
309
310    #[inline(always)]
311    pub fn z_window(mut self, value: usize) -> Self {
312        self.z_window = Some(value);
313        self
314    }
315
316    #[inline(always)]
317    pub fn pct_vol_lookback(mut self, value: usize) -> Self {
318        self.pct_vol_lookback = Some(value);
319        self
320    }
321
322    #[inline(always)]
323    pub fn pct_min_sigma(mut self, value: f64) -> Self {
324        self.pct_min_sigma = Some(value);
325        self
326    }
327
328    #[inline(always)]
329    pub fn abs_vol_lookback(mut self, value: usize) -> Self {
330        self.abs_vol_lookback = Some(value);
331        self
332    }
333
334    #[inline(always)]
335    pub fn kernel(mut self, value: Kernel) -> Self {
336        self.kernel = value;
337        self
338    }
339}
340
341#[derive(Debug, Error)]
342pub enum VwapDeviationOscillatorError {
343    #[error("vwap_deviation_oscillator: Input data slice is empty.")]
344    EmptyInputData,
345    #[error("vwap_deviation_oscillator: All values are NaN.")]
346    AllValuesNaN,
347    #[error("vwap_deviation_oscillator: Inconsistent slice lengths: timestamps={timestamps_len}, high={high_len}, low={low_len}, close={close_len}, volume={volume_len}")]
348    InconsistentSliceLengths {
349        timestamps_len: usize,
350        high_len: usize,
351        low_len: usize,
352        close_len: usize,
353        volume_len: usize,
354    },
355    #[error("vwap_deviation_oscillator: Invalid rolling_period: {rolling_period}")]
356    InvalidRollingPeriod { rolling_period: usize },
357    #[error("vwap_deviation_oscillator: Invalid rolling_days: {rolling_days}")]
358    InvalidRollingDays { rolling_days: usize },
359    #[error("vwap_deviation_oscillator: Invalid z_window: {z_window}")]
360    InvalidZWindow { z_window: usize },
361    #[error("vwap_deviation_oscillator: Invalid pct_vol_lookback: {pct_vol_lookback}")]
362    InvalidPctVolLookback { pct_vol_lookback: usize },
363    #[error("vwap_deviation_oscillator: Invalid pct_min_sigma: {pct_min_sigma}")]
364    InvalidPctMinSigma { pct_min_sigma: f64 },
365    #[error("vwap_deviation_oscillator: Invalid abs_vol_lookback: {abs_vol_lookback}")]
366    InvalidAbsVolLookback { abs_vol_lookback: usize },
367    #[error(
368        "vwap_deviation_oscillator: Output length mismatch: expected = {expected}, got = {got}"
369    )]
370    OutputLengthMismatch { expected: usize, got: usize },
371    #[error("vwap_deviation_oscillator: Invalid range: start={start}, end={end}, step={step}")]
372    InvalidRange {
373        start: String,
374        end: String,
375        step: String,
376    },
377    #[error("vwap_deviation_oscillator: Invalid kernel for batch: {0:?}")]
378    InvalidKernelForBatch(Kernel),
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
382struct BaseKey {
383    session_mode: VwapDeviationSessionMode,
384    rolling_period: usize,
385    rolling_days: usize,
386    use_close: bool,
387}
388
389#[derive(Debug, Clone)]
390struct NormalizedParams {
391    session_mode: VwapDeviationSessionMode,
392    rolling_period: usize,
393    rolling_days: usize,
394    use_close: bool,
395    deviation_mode: VwapDeviationMode,
396    z_window: usize,
397    pct_vol_lookback: usize,
398    pct_min_sigma: f64,
399    abs_vol_lookback: usize,
400}
401
402impl NormalizedParams {
403    #[inline(always)]
404    fn base_key(&self) -> BaseKey {
405        BaseKey {
406            session_mode: self.session_mode,
407            rolling_period: self.rolling_period,
408            rolling_days: self.rolling_days,
409            use_close: self.use_close,
410        }
411    }
412
413    #[inline(always)]
414    fn into_params(self) -> VwapDeviationOscillatorParams {
415        VwapDeviationOscillatorParams {
416            session_mode: Some(self.session_mode),
417            rolling_period: Some(self.rolling_period),
418            rolling_days: Some(self.rolling_days),
419            use_close: Some(self.use_close),
420            deviation_mode: Some(self.deviation_mode),
421            z_window: Some(self.z_window),
422            pct_vol_lookback: Some(self.pct_vol_lookback),
423            pct_min_sigma: Some(self.pct_min_sigma),
424            abs_vol_lookback: Some(self.abs_vol_lookback),
425        }
426    }
427}
428
429#[derive(Debug, Clone)]
430struct BaseSeries {
431    resid_abs: Vec<f64>,
432    resid_pct: Vec<f64>,
433}
434
435#[derive(Debug, Clone)]
436struct RollingFiniteWindow {
437    period: usize,
438    values: Vec<f64>,
439    head: usize,
440    count: usize,
441    sum: f64,
442    sumsq: f64,
443}
444
445impl RollingFiniteWindow {
446    #[inline(always)]
447    fn new(period: usize) -> Self {
448        Self {
449            period,
450            values: vec![0.0; period],
451            head: 0,
452            count: 0,
453            sum: 0.0,
454            sumsq: 0.0,
455        }
456    }
457
458    #[inline(always)]
459    fn update(&mut self, value: f64) -> Option<(f64, f64)> {
460        if value.is_finite() {
461            if self.count == self.period {
462                let old = self.values[self.head];
463                self.sum -= old;
464                self.sumsq -= old * old;
465            } else {
466                self.count += 1;
467            }
468            self.values[self.head] = value;
469            self.sum += value;
470            self.sumsq += value * value;
471            self.head += 1;
472            if self.head == self.period {
473                self.head = 0;
474            }
475        }
476        self.stats()
477    }
478
479    #[inline(always)]
480    fn stats(&self) -> Option<(f64, f64)> {
481        if self.count != self.period {
482            return None;
483        }
484        let n = self.period as f64;
485        let mean = self.sum / n;
486        let var = (self.sumsq / n - mean * mean).max(0.0);
487        Some((mean, var.sqrt()))
488    }
489}
490
491#[derive(Debug, Clone)]
492enum StreamVwapState {
493    Anchored {
494        session_mode: VwapDeviationSessionMode,
495        current_period_id: Option<i64>,
496        sum_pv: f64,
497        sum_vol: f64,
498    },
499    RollingBars {
500        period: usize,
501        entries: Vec<(f64, f64)>,
502        head: usize,
503        count: usize,
504        sum_pv: f64,
505        sum_vol: f64,
506    },
507    RollingDays {
508        days_ms: i64,
509        entries: VecDeque<(i64, f64, f64)>,
510        sum_pv: f64,
511        sum_vol: f64,
512    },
513}
514
515#[derive(Debug, Clone)]
516pub struct VwapDeviationOscillatorStream {
517    params: NormalizedParams,
518    vwap_state: StreamVwapState,
519    z_stats: RollingFiniteWindow,
520    pct_stats: RollingFiniteWindow,
521    abs_stats: RollingFiniteWindow,
522}
523
524#[inline(always)]
525fn validate_rolling_period(rolling_period: usize) -> Result<usize, VwapDeviationOscillatorError> {
526    if rolling_period == 0 {
527        return Err(VwapDeviationOscillatorError::InvalidRollingPeriod { rolling_period });
528    }
529    Ok(rolling_period)
530}
531
532#[inline(always)]
533fn validate_rolling_days(rolling_days: usize) -> Result<usize, VwapDeviationOscillatorError> {
534    if rolling_days == 0 {
535        return Err(VwapDeviationOscillatorError::InvalidRollingDays { rolling_days });
536    }
537    Ok(rolling_days)
538}
539
540#[inline(always)]
541fn validate_z_window(z_window: usize) -> Result<usize, VwapDeviationOscillatorError> {
542    if z_window < 5 {
543        return Err(VwapDeviationOscillatorError::InvalidZWindow { z_window });
544    }
545    Ok(z_window)
546}
547
548#[inline(always)]
549fn validate_pct_vol_lookback(
550    pct_vol_lookback: usize,
551) -> Result<usize, VwapDeviationOscillatorError> {
552    if pct_vol_lookback < 10 {
553        return Err(VwapDeviationOscillatorError::InvalidPctVolLookback { pct_vol_lookback });
554    }
555    Ok(pct_vol_lookback)
556}
557
558#[inline(always)]
559fn validate_pct_min_sigma(pct_min_sigma: f64) -> Result<f64, VwapDeviationOscillatorError> {
560    if !pct_min_sigma.is_finite() || pct_min_sigma < 0.01 {
561        return Err(VwapDeviationOscillatorError::InvalidPctMinSigma { pct_min_sigma });
562    }
563    Ok(pct_min_sigma)
564}
565
566#[inline(always)]
567fn validate_abs_vol_lookback(
568    abs_vol_lookback: usize,
569) -> Result<usize, VwapDeviationOscillatorError> {
570    if abs_vol_lookback < 10 {
571        return Err(VwapDeviationOscillatorError::InvalidAbsVolLookback { abs_vol_lookback });
572    }
573    Ok(abs_vol_lookback)
574}
575
576#[inline(always)]
577fn price_ref(high: f64, low: f64, close: f64, use_close: bool) -> f64 {
578    if use_close {
579        close
580    } else if high.is_finite() && low.is_finite() && close.is_finite() {
581        (high + low + close) / 3.0
582    } else {
583        f64::NAN
584    }
585}
586
587#[inline(always)]
588fn period_id(timestamp_ms: i64, session_mode: VwapDeviationSessionMode) -> i64 {
589    let sec = timestamp_ms.div_euclid(1000);
590    match session_mode {
591        VwapDeviationSessionMode::FourHours => sec.div_euclid(3600).div_euclid(4),
592        VwapDeviationSessionMode::Daily => sec.div_euclid(86_400),
593        VwapDeviationSessionMode::Weekly => (sec.div_euclid(86_400) + 3).div_euclid(7),
594        VwapDeviationSessionMode::RollingBars | VwapDeviationSessionMode::RollingDays => 0,
595    }
596}
597
598#[inline(always)]
599fn scale_or_nan(value: f64, factor: f64) -> f64 {
600    if value.is_finite() {
601        value * factor
602    } else {
603        f64::NAN
604    }
605}
606
607#[inline(always)]
608fn guarded_sigma(stats: &mut RollingFiniteWindow, value: f64, guard: f64) -> f64 {
609    match stats.update(value) {
610        Some((_, std)) if std.is_finite() => std.max(guard),
611        _ => f64::NAN,
612    }
613}
614
615#[inline(always)]
616fn zscore_value(stats: &mut RollingFiniteWindow, value: f64) -> f64 {
617    let Some((mean, std)) = stats.update(value) else {
618        return f64::NAN;
619    };
620    if !value.is_finite() || std == 0.0 || !std.is_finite() {
621        return f64::NAN;
622    }
623    (value - mean) / std
624}
625
626impl NormalizedParams {
627    #[inline(always)]
628    fn from_input(
629        input: &VwapDeviationOscillatorInput,
630    ) -> Result<Self, VwapDeviationOscillatorError> {
631        Ok(Self {
632            session_mode: input.get_session_mode(),
633            rolling_period: validate_rolling_period(input.get_rolling_period())?,
634            rolling_days: validate_rolling_days(input.get_rolling_days())?,
635            use_close: input.get_use_close(),
636            deviation_mode: input.get_deviation_mode(),
637            z_window: validate_z_window(input.get_z_window())?,
638            pct_vol_lookback: validate_pct_vol_lookback(input.get_pct_vol_lookback())?,
639            pct_min_sigma: validate_pct_min_sigma(input.get_pct_min_sigma())?,
640            abs_vol_lookback: validate_abs_vol_lookback(input.get_abs_vol_lookback())?,
641        })
642    }
643
644    #[inline(always)]
645    fn from_params(
646        params: &VwapDeviationOscillatorParams,
647    ) -> Result<Self, VwapDeviationOscillatorError> {
648        Self::from_input(&VwapDeviationOscillatorInput {
649            data: VwapDeviationOscillatorData::Slices {
650                timestamps: &[],
651                high: &[],
652                low: &[],
653                close: &[],
654                volume: &[],
655            },
656            params: params.clone(),
657        })
658    }
659}
660
661#[inline(always)]
662fn extract_input<'a>(
663    input: &'a VwapDeviationOscillatorInput<'a>,
664) -> Result<(&'a [i64], &'a [f64], &'a [f64], &'a [f64], &'a [f64]), VwapDeviationOscillatorError> {
665    let (timestamps, high, low, close, volume) = match &input.data {
666        VwapDeviationOscillatorData::Candles { candles } => (
667            candles.timestamp.as_slice(),
668            candles.high.as_slice(),
669            candles.low.as_slice(),
670            candles.close.as_slice(),
671            candles.volume.as_slice(),
672        ),
673        VwapDeviationOscillatorData::Slices {
674            timestamps,
675            high,
676            low,
677            close,
678            volume,
679        } => (*timestamps, *high, *low, *close, *volume),
680    };
681    if timestamps.is_empty()
682        || high.is_empty()
683        || low.is_empty()
684        || close.is_empty()
685        || volume.is_empty()
686    {
687        return Err(VwapDeviationOscillatorError::EmptyInputData);
688    }
689    if timestamps.len() != high.len()
690        || timestamps.len() != low.len()
691        || timestamps.len() != close.len()
692        || timestamps.len() != volume.len()
693    {
694        return Err(VwapDeviationOscillatorError::InconsistentSliceLengths {
695            timestamps_len: timestamps.len(),
696            high_len: high.len(),
697            low_len: low.len(),
698            close_len: close.len(),
699            volume_len: volume.len(),
700        });
701    }
702    if !high
703        .iter()
704        .zip(low.iter())
705        .zip(close.iter())
706        .zip(volume.iter())
707        .any(|(((h, l), c), v)| h.is_finite() || l.is_finite() || c.is_finite() || v.is_finite())
708    {
709        return Err(VwapDeviationOscillatorError::AllValuesNaN);
710    }
711    Ok((timestamps, high, low, close, volume))
712}
713
714#[inline(always)]
715fn validate_raw_slices(
716    timestamps: &[i64],
717    high: &[f64],
718    low: &[f64],
719    close: &[f64],
720    volume: &[f64],
721) -> Result<(), VwapDeviationOscillatorError> {
722    if timestamps.is_empty()
723        || high.is_empty()
724        || low.is_empty()
725        || close.is_empty()
726        || volume.is_empty()
727    {
728        return Err(VwapDeviationOscillatorError::EmptyInputData);
729    }
730    if timestamps.len() != high.len()
731        || timestamps.len() != low.len()
732        || timestamps.len() != close.len()
733        || timestamps.len() != volume.len()
734    {
735        return Err(VwapDeviationOscillatorError::InconsistentSliceLengths {
736            timestamps_len: timestamps.len(),
737            high_len: high.len(),
738            low_len: low.len(),
739            close_len: close.len(),
740            volume_len: volume.len(),
741        });
742    }
743    if !high
744        .iter()
745        .zip(low.iter())
746        .zip(close.iter())
747        .zip(volume.iter())
748        .any(|(((h, l), c), v)| h.is_finite() || l.is_finite() || c.is_finite() || v.is_finite())
749    {
750        return Err(VwapDeviationOscillatorError::AllValuesNaN);
751    }
752    Ok(())
753}
754
755fn compute_base_series(
756    timestamps: &[i64],
757    high: &[f64],
758    low: &[f64],
759    close: &[f64],
760    volume: &[f64],
761    key: BaseKey,
762) -> BaseSeries {
763    let len = close.len();
764    let mut resid_abs = vec![f64::NAN; len];
765    let mut resid_pct = vec![f64::NAN; len];
766
767    match key.session_mode {
768        VwapDeviationSessionMode::RollingBars => {
769            let period = key.rolling_period;
770            let mut entries = vec![(0.0, 0.0); period];
771            let mut head = 0usize;
772            let mut count = 0usize;
773            let mut sum_pv = 0.0;
774            let mut sum_vol = 0.0;
775            for i in 0..len {
776                let pr = price_ref(high[i], low[i], close[i], key.use_close);
777                let contrib = if pr.is_finite() && volume[i].is_finite() {
778                    (pr * volume[i], volume[i])
779                } else {
780                    (0.0, 0.0)
781                };
782                if count == period {
783                    let (old_pv, old_vol) = entries[head];
784                    sum_pv -= old_pv;
785                    sum_vol -= old_vol;
786                } else {
787                    count += 1;
788                }
789                entries[head] = contrib;
790                sum_pv += contrib.0;
791                sum_vol += contrib.1;
792                head += 1;
793                if head == period {
794                    head = 0;
795                }
796                let vwap = if sum_vol != 0.0 {
797                    sum_pv / sum_vol
798                } else {
799                    f64::NAN
800                };
801                if pr.is_finite() && vwap.is_finite() {
802                    resid_abs[i] = pr - vwap;
803                    if vwap != 0.0 {
804                        resid_pct[i] = 100.0 * (pr / vwap - 1.0);
805                    }
806                }
807            }
808        }
809        VwapDeviationSessionMode::RollingDays => {
810            let mut entries: VecDeque<(i64, f64, f64)> = VecDeque::new();
811            let mut sum_pv = 0.0;
812            let mut sum_vol = 0.0;
813            let days_ms = (key.rolling_days as i64).saturating_mul(DAY_MS);
814            for i in 0..len {
815                let pr = price_ref(high[i], low[i], close[i], key.use_close);
816                let contrib = if pr.is_finite() && volume[i].is_finite() {
817                    (pr * volume[i], volume[i])
818                } else {
819                    (0.0, 0.0)
820                };
821                entries.push_back((timestamps[i], contrib.0, contrib.1));
822                sum_pv += contrib.0;
823                sum_vol += contrib.1;
824                let cutoff = timestamps[i].saturating_sub(days_ms);
825                while entries
826                    .front()
827                    .map(|(ts, _, _)| *ts < cutoff)
828                    .unwrap_or(false)
829                {
830                    if let Some((_, old_pv, old_vol)) = entries.pop_front() {
831                        sum_pv -= old_pv;
832                        sum_vol -= old_vol;
833                    }
834                }
835                let vwap = if sum_vol != 0.0 {
836                    sum_pv / sum_vol
837                } else {
838                    f64::NAN
839                };
840                if pr.is_finite() && vwap.is_finite() {
841                    resid_abs[i] = pr - vwap;
842                    if vwap != 0.0 {
843                        resid_pct[i] = 100.0 * (pr / vwap - 1.0);
844                    }
845                }
846            }
847        }
848        VwapDeviationSessionMode::FourHours
849        | VwapDeviationSessionMode::Daily
850        | VwapDeviationSessionMode::Weekly => {
851            let mut last_id: Option<i64> = None;
852            let mut sum_pv = 0.0;
853            let mut sum_vol = 0.0;
854            for i in 0..len {
855                let id = period_id(timestamps[i], key.session_mode);
856                if last_id.map(|prev| prev != id).unwrap_or(true) {
857                    last_id = Some(id);
858                    sum_pv = 0.0;
859                    sum_vol = 0.0;
860                }
861                let pr = price_ref(high[i], low[i], close[i], key.use_close);
862                if pr.is_finite() && volume[i].is_finite() {
863                    sum_pv += pr * volume[i];
864                    sum_vol += volume[i];
865                }
866                let vwap = if sum_vol != 0.0 {
867                    sum_pv / sum_vol
868                } else {
869                    f64::NAN
870                };
871                if pr.is_finite() && vwap.is_finite() {
872                    resid_abs[i] = pr - vwap;
873                    if vwap != 0.0 {
874                        resid_pct[i] = 100.0 * (pr / vwap - 1.0);
875                    }
876                }
877            }
878        }
879    }
880
881    BaseSeries {
882        resid_abs,
883        resid_pct,
884    }
885}
886
887fn compute_outputs_from_base(
888    base: &BaseSeries,
889    params: &NormalizedParams,
890    out_osc: &mut [f64],
891    out_std1: &mut [f64],
892    out_std2: &mut [f64],
893    out_std3: &mut [f64],
894) {
895    match params.deviation_mode {
896        VwapDeviationMode::Absolute => {
897            let mut stats = RollingFiniteWindow::new(params.abs_vol_lookback);
898            for i in 0..base.resid_abs.len() {
899                let osc = base.resid_abs[i];
900                let std1 = guarded_sigma(&mut stats, osc, 1.0);
901                out_osc[i] = osc;
902                out_std1[i] = std1;
903                out_std2[i] = scale_or_nan(std1, 2.0);
904                out_std3[i] = scale_or_nan(std1, 3.0);
905            }
906        }
907        VwapDeviationMode::Percent => {
908            let mut stats = RollingFiniteWindow::new(params.pct_vol_lookback);
909            for i in 0..base.resid_pct.len() {
910                let osc = base.resid_pct[i];
911                let std1 = guarded_sigma(&mut stats, osc, params.pct_min_sigma);
912                out_osc[i] = osc;
913                out_std1[i] = std1;
914                out_std2[i] = scale_or_nan(std1, 2.0);
915                out_std3[i] = scale_or_nan(std1, 3.0);
916            }
917        }
918        VwapDeviationMode::ZScore => {
919            let mut stats = RollingFiniteWindow::new(params.z_window);
920            for i in 0..base.resid_abs.len() {
921                out_osc[i] = zscore_value(&mut stats, base.resid_abs[i]);
922                out_std1[i] = 1.0;
923                out_std2[i] = 2.0;
924                out_std3[i] = 3.0;
925            }
926        }
927    }
928}
929
930#[inline]
931pub fn vwap_deviation_oscillator(
932    input: &VwapDeviationOscillatorInput,
933) -> Result<VwapDeviationOscillatorOutput, VwapDeviationOscillatorError> {
934    vwap_deviation_oscillator_with_kernel(input, Kernel::Auto)
935}
936
937pub fn vwap_deviation_oscillator_with_kernel(
938    input: &VwapDeviationOscillatorInput,
939    kernel: Kernel,
940) -> Result<VwapDeviationOscillatorOutput, VwapDeviationOscillatorError> {
941    let (timestamps, high, low, close, volume) = extract_input(input)?;
942    let params = NormalizedParams::from_input(input)?;
943    let _chosen = match kernel {
944        Kernel::Auto => Kernel::Scalar,
945        other => other.to_non_batch(),
946    };
947    let len = close.len();
948    let mut osc = vec![f64::NAN; len];
949    let mut std1 = vec![f64::NAN; len];
950    let mut std2 = vec![f64::NAN; len];
951    let mut std3 = vec![f64::NAN; len];
952    let base = compute_base_series(timestamps, high, low, close, volume, params.base_key());
953    compute_outputs_from_base(&base, &params, &mut osc, &mut std1, &mut std2, &mut std3);
954    Ok(VwapDeviationOscillatorOutput {
955        osc,
956        std1,
957        std2,
958        std3,
959    })
960}
961
962#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
963pub fn vwap_deviation_oscillator_into(
964    timestamps: &[i64],
965    high: &[f64],
966    low: &[f64],
967    close: &[f64],
968    volume: &[f64],
969    out_osc: &mut [f64],
970    out_std1: &mut [f64],
971    out_std2: &mut [f64],
972    out_std3: &mut [f64],
973    params: VwapDeviationOscillatorParams,
974    kernel: Kernel,
975) -> Result<(), VwapDeviationOscillatorError> {
976    let input =
977        VwapDeviationOscillatorInput::from_slices(timestamps, high, low, close, volume, params);
978    vwap_deviation_oscillator_into_slice(out_osc, out_std1, out_std2, out_std3, &input, kernel)
979}
980
981pub fn vwap_deviation_oscillator_into_slice(
982    out_osc: &mut [f64],
983    out_std1: &mut [f64],
984    out_std2: &mut [f64],
985    out_std3: &mut [f64],
986    input: &VwapDeviationOscillatorInput,
987    kernel: Kernel,
988) -> Result<(), VwapDeviationOscillatorError> {
989    let (timestamps, high, low, close, volume) = extract_input(input)?;
990    let params = NormalizedParams::from_input(input)?;
991    let _chosen = match kernel {
992        Kernel::Auto => Kernel::Scalar,
993        other => other.to_non_batch(),
994    };
995    let len = close.len();
996    if out_osc.len() != len {
997        return Err(VwapDeviationOscillatorError::OutputLengthMismatch {
998            expected: len,
999            got: out_osc.len(),
1000        });
1001    }
1002    if out_std1.len() != len {
1003        return Err(VwapDeviationOscillatorError::OutputLengthMismatch {
1004            expected: len,
1005            got: out_std1.len(),
1006        });
1007    }
1008    if out_std2.len() != len {
1009        return Err(VwapDeviationOscillatorError::OutputLengthMismatch {
1010            expected: len,
1011            got: out_std2.len(),
1012        });
1013    }
1014    if out_std3.len() != len {
1015        return Err(VwapDeviationOscillatorError::OutputLengthMismatch {
1016            expected: len,
1017            got: out_std3.len(),
1018        });
1019    }
1020    let base = compute_base_series(timestamps, high, low, close, volume, params.base_key());
1021    compute_outputs_from_base(&base, &params, out_osc, out_std1, out_std2, out_std3);
1022    Ok(())
1023}
1024
1025impl VwapDeviationOscillatorBuilder {
1026    #[inline(always)]
1027    pub fn apply(
1028        self,
1029        candles: &Candles,
1030    ) -> Result<VwapDeviationOscillatorOutput, VwapDeviationOscillatorError> {
1031        let input = VwapDeviationOscillatorInput::from_candles(
1032            candles,
1033            VwapDeviationOscillatorParams {
1034                session_mode: self.session_mode,
1035                rolling_period: self.rolling_period,
1036                rolling_days: self.rolling_days,
1037                use_close: self.use_close,
1038                deviation_mode: self.deviation_mode,
1039                z_window: self.z_window,
1040                pct_vol_lookback: self.pct_vol_lookback,
1041                pct_min_sigma: self.pct_min_sigma,
1042                abs_vol_lookback: self.abs_vol_lookback,
1043            },
1044        );
1045        vwap_deviation_oscillator_with_kernel(&input, self.kernel)
1046    }
1047
1048    #[inline(always)]
1049    pub fn apply_slices(
1050        self,
1051        timestamps: &[i64],
1052        high: &[f64],
1053        low: &[f64],
1054        close: &[f64],
1055        volume: &[f64],
1056    ) -> Result<VwapDeviationOscillatorOutput, VwapDeviationOscillatorError> {
1057        let input = VwapDeviationOscillatorInput::from_slices(
1058            timestamps,
1059            high,
1060            low,
1061            close,
1062            volume,
1063            VwapDeviationOscillatorParams {
1064                session_mode: self.session_mode,
1065                rolling_period: self.rolling_period,
1066                rolling_days: self.rolling_days,
1067                use_close: self.use_close,
1068                deviation_mode: self.deviation_mode,
1069                z_window: self.z_window,
1070                pct_vol_lookback: self.pct_vol_lookback,
1071                pct_min_sigma: self.pct_min_sigma,
1072                abs_vol_lookback: self.abs_vol_lookback,
1073            },
1074        );
1075        vwap_deviation_oscillator_with_kernel(&input, self.kernel)
1076    }
1077
1078    #[inline(always)]
1079    pub fn into_stream(
1080        self,
1081    ) -> Result<VwapDeviationOscillatorStream, VwapDeviationOscillatorError> {
1082        VwapDeviationOscillatorStream::try_new(VwapDeviationOscillatorParams {
1083            session_mode: self.session_mode,
1084            rolling_period: self.rolling_period,
1085            rolling_days: self.rolling_days,
1086            use_close: self.use_close,
1087            deviation_mode: self.deviation_mode,
1088            z_window: self.z_window,
1089            pct_vol_lookback: self.pct_vol_lookback,
1090            pct_min_sigma: self.pct_min_sigma,
1091            abs_vol_lookback: self.abs_vol_lookback,
1092        })
1093    }
1094}
1095
1096impl VwapDeviationOscillatorStream {
1097    pub fn try_new(
1098        params: VwapDeviationOscillatorParams,
1099    ) -> Result<Self, VwapDeviationOscillatorError> {
1100        let params = NormalizedParams::from_params(&params)?;
1101        let vwap_state = match params.session_mode {
1102            VwapDeviationSessionMode::FourHours
1103            | VwapDeviationSessionMode::Daily
1104            | VwapDeviationSessionMode::Weekly => StreamVwapState::Anchored {
1105                session_mode: params.session_mode,
1106                current_period_id: None,
1107                sum_pv: 0.0,
1108                sum_vol: 0.0,
1109            },
1110            VwapDeviationSessionMode::RollingBars => StreamVwapState::RollingBars {
1111                period: params.rolling_period,
1112                entries: vec![(0.0, 0.0); params.rolling_period],
1113                head: 0,
1114                count: 0,
1115                sum_pv: 0.0,
1116                sum_vol: 0.0,
1117            },
1118            VwapDeviationSessionMode::RollingDays => StreamVwapState::RollingDays {
1119                days_ms: (params.rolling_days as i64).saturating_mul(DAY_MS),
1120                entries: VecDeque::new(),
1121                sum_pv: 0.0,
1122                sum_vol: 0.0,
1123            },
1124        };
1125        Ok(Self {
1126            z_stats: RollingFiniteWindow::new(params.z_window),
1127            pct_stats: RollingFiniteWindow::new(params.pct_vol_lookback),
1128            abs_stats: RollingFiniteWindow::new(params.abs_vol_lookback),
1129            params,
1130            vwap_state,
1131        })
1132    }
1133
1134    #[inline]
1135    pub fn update(
1136        &mut self,
1137        timestamp: i64,
1138        high: f64,
1139        low: f64,
1140        close: f64,
1141        volume: f64,
1142    ) -> (f64, f64, f64, f64) {
1143        let pr = price_ref(high, low, close, self.params.use_close);
1144        let contrib = if pr.is_finite() && volume.is_finite() {
1145            (pr * volume, volume)
1146        } else {
1147            (0.0, 0.0)
1148        };
1149        let vwap = match &mut self.vwap_state {
1150            StreamVwapState::Anchored {
1151                session_mode,
1152                current_period_id,
1153                sum_pv,
1154                sum_vol,
1155            } => {
1156                let id = period_id(timestamp, *session_mode);
1157                if current_period_id.map(|prev| prev != id).unwrap_or(true) {
1158                    *current_period_id = Some(id);
1159                    *sum_pv = 0.0;
1160                    *sum_vol = 0.0;
1161                }
1162                *sum_pv += contrib.0;
1163                *sum_vol += contrib.1;
1164                if *sum_vol != 0.0 {
1165                    *sum_pv / *sum_vol
1166                } else {
1167                    f64::NAN
1168                }
1169            }
1170            StreamVwapState::RollingBars {
1171                period,
1172                entries,
1173                head,
1174                count,
1175                sum_pv,
1176                sum_vol,
1177            } => {
1178                if *count == *period {
1179                    let (old_pv, old_vol) = entries[*head];
1180                    *sum_pv -= old_pv;
1181                    *sum_vol -= old_vol;
1182                } else {
1183                    *count += 1;
1184                }
1185                entries[*head] = contrib;
1186                *sum_pv += contrib.0;
1187                *sum_vol += contrib.1;
1188                *head += 1;
1189                if *head == *period {
1190                    *head = 0;
1191                }
1192                if *sum_vol != 0.0 {
1193                    *sum_pv / *sum_vol
1194                } else {
1195                    f64::NAN
1196                }
1197            }
1198            StreamVwapState::RollingDays {
1199                days_ms,
1200                entries,
1201                sum_pv,
1202                sum_vol,
1203            } => {
1204                entries.push_back((timestamp, contrib.0, contrib.1));
1205                *sum_pv += contrib.0;
1206                *sum_vol += contrib.1;
1207                let cutoff = timestamp.saturating_sub(*days_ms);
1208                while entries
1209                    .front()
1210                    .map(|(ts, _, _)| *ts < cutoff)
1211                    .unwrap_or(false)
1212                {
1213                    if let Some((_, old_pv, old_vol)) = entries.pop_front() {
1214                        *sum_pv -= old_pv;
1215                        *sum_vol -= old_vol;
1216                    }
1217                }
1218                if *sum_vol != 0.0 {
1219                    *sum_pv / *sum_vol
1220                } else {
1221                    f64::NAN
1222                }
1223            }
1224        };
1225        let resid_abs = if pr.is_finite() && vwap.is_finite() {
1226            pr - vwap
1227        } else {
1228            f64::NAN
1229        };
1230        let resid_pct = if pr.is_finite() && vwap.is_finite() && vwap != 0.0 {
1231            100.0 * (pr / vwap - 1.0)
1232        } else {
1233            f64::NAN
1234        };
1235        match self.params.deviation_mode {
1236            VwapDeviationMode::Absolute => {
1237                let std1 = guarded_sigma(&mut self.abs_stats, resid_abs, 1.0);
1238                (
1239                    resid_abs,
1240                    std1,
1241                    scale_or_nan(std1, 2.0),
1242                    scale_or_nan(std1, 3.0),
1243                )
1244            }
1245            VwapDeviationMode::Percent => {
1246                let std1 = guarded_sigma(&mut self.pct_stats, resid_pct, self.params.pct_min_sigma);
1247                (
1248                    resid_pct,
1249                    std1,
1250                    scale_or_nan(std1, 2.0),
1251                    scale_or_nan(std1, 3.0),
1252                )
1253            }
1254            VwapDeviationMode::ZScore => {
1255                (zscore_value(&mut self.z_stats, resid_abs), 1.0, 2.0, 3.0)
1256            }
1257        }
1258    }
1259}
1260
1261#[derive(Copy, Clone, Debug)]
1262pub struct VwapDeviationOscillatorBatchRange {
1263    pub rolling_period: (usize, usize, usize),
1264    pub rolling_days: (usize, usize, usize),
1265    pub z_window: (usize, usize, usize),
1266    pub pct_vol_lookback: (usize, usize, usize),
1267    pub pct_min_sigma: (f64, f64, f64),
1268    pub abs_vol_lookback: (usize, usize, usize),
1269    pub session_mode: VwapDeviationSessionMode,
1270    pub use_close: bool,
1271    pub deviation_mode: VwapDeviationMode,
1272}
1273
1274impl Default for VwapDeviationOscillatorBatchRange {
1275    fn default() -> Self {
1276        Self {
1277            rolling_period: (DEFAULT_ROLLING_PERIOD, DEFAULT_ROLLING_PERIOD, 0),
1278            rolling_days: (DEFAULT_ROLLING_DAYS, DEFAULT_ROLLING_DAYS, 0),
1279            z_window: (DEFAULT_Z_WINDOW, DEFAULT_Z_WINDOW, 0),
1280            pct_vol_lookback: (DEFAULT_PCT_VOL_LOOKBACK, DEFAULT_PCT_VOL_LOOKBACK, 0),
1281            pct_min_sigma: (DEFAULT_PCT_MIN_SIGMA, DEFAULT_PCT_MIN_SIGMA, 0.0),
1282            abs_vol_lookback: (DEFAULT_ABS_VOL_LOOKBACK, DEFAULT_ABS_VOL_LOOKBACK, 0),
1283            session_mode: VwapDeviationSessionMode::RollingBars,
1284            use_close: false,
1285            deviation_mode: VwapDeviationMode::Absolute,
1286        }
1287    }
1288}
1289
1290#[derive(Debug, Clone)]
1291pub struct VwapDeviationOscillatorBatchOutput {
1292    pub osc: Vec<f64>,
1293    pub std1: Vec<f64>,
1294    pub std2: Vec<f64>,
1295    pub std3: Vec<f64>,
1296    pub combos: Vec<VwapDeviationOscillatorParams>,
1297    pub rows: usize,
1298    pub cols: usize,
1299}
1300
1301#[derive(Copy, Clone, Debug)]
1302pub struct VwapDeviationOscillatorBatchBuilder {
1303    range: VwapDeviationOscillatorBatchRange,
1304    kernel: Kernel,
1305}
1306
1307impl Default for VwapDeviationOscillatorBatchBuilder {
1308    fn default() -> Self {
1309        Self {
1310            range: VwapDeviationOscillatorBatchRange::default(),
1311            kernel: Kernel::Auto,
1312        }
1313    }
1314}
1315
1316impl VwapDeviationOscillatorBatchBuilder {
1317    #[inline(always)]
1318    pub fn new() -> Self {
1319        Self::default()
1320    }
1321
1322    #[inline(always)]
1323    pub fn session_mode(mut self, value: VwapDeviationSessionMode) -> Self {
1324        self.range.session_mode = value;
1325        self
1326    }
1327
1328    #[inline(always)]
1329    pub fn use_close(mut self, value: bool) -> Self {
1330        self.range.use_close = value;
1331        self
1332    }
1333
1334    #[inline(always)]
1335    pub fn deviation_mode(mut self, value: VwapDeviationMode) -> Self {
1336        self.range.deviation_mode = value;
1337        self
1338    }
1339
1340    #[inline(always)]
1341    pub fn rolling_period_range(mut self, start: usize, end: usize, step: usize) -> Self {
1342        self.range.rolling_period = (start, end, step);
1343        self
1344    }
1345
1346    #[inline(always)]
1347    pub fn rolling_days_range(mut self, start: usize, end: usize, step: usize) -> Self {
1348        self.range.rolling_days = (start, end, step);
1349        self
1350    }
1351
1352    #[inline(always)]
1353    pub fn z_window_range(mut self, start: usize, end: usize, step: usize) -> Self {
1354        self.range.z_window = (start, end, step);
1355        self
1356    }
1357
1358    #[inline(always)]
1359    pub fn pct_vol_lookback_range(mut self, start: usize, end: usize, step: usize) -> Self {
1360        self.range.pct_vol_lookback = (start, end, step);
1361        self
1362    }
1363
1364    #[inline(always)]
1365    pub fn pct_min_sigma_range(mut self, start: f64, end: f64, step: f64) -> Self {
1366        self.range.pct_min_sigma = (start, end, step);
1367        self
1368    }
1369
1370    #[inline(always)]
1371    pub fn abs_vol_lookback_range(mut self, start: usize, end: usize, step: usize) -> Self {
1372        self.range.abs_vol_lookback = (start, end, step);
1373        self
1374    }
1375
1376    #[inline(always)]
1377    pub fn kernel(mut self, kernel: Kernel) -> Self {
1378        self.kernel = kernel;
1379        self
1380    }
1381
1382    #[inline(always)]
1383    pub fn apply(
1384        self,
1385        candles: &Candles,
1386    ) -> Result<VwapDeviationOscillatorBatchOutput, VwapDeviationOscillatorError> {
1387        vwap_deviation_oscillator_batch_with_kernel(
1388            candles.timestamp.as_slice(),
1389            candles.high.as_slice(),
1390            candles.low.as_slice(),
1391            candles.close.as_slice(),
1392            candles.volume.as_slice(),
1393            &self.range,
1394            self.kernel,
1395        )
1396    }
1397
1398    #[inline(always)]
1399    pub fn apply_slices(
1400        self,
1401        timestamps: &[i64],
1402        high: &[f64],
1403        low: &[f64],
1404        close: &[f64],
1405        volume: &[f64],
1406    ) -> Result<VwapDeviationOscillatorBatchOutput, VwapDeviationOscillatorError> {
1407        vwap_deviation_oscillator_batch_with_kernel(
1408            timestamps,
1409            high,
1410            low,
1411            close,
1412            volume,
1413            &self.range,
1414            self.kernel,
1415        )
1416    }
1417}
1418
1419#[inline(always)]
1420fn axis_usize(
1421    start: usize,
1422    end: usize,
1423    step: usize,
1424) -> Result<Vec<usize>, VwapDeviationOscillatorError> {
1425    if start == end {
1426        return Ok(vec![start]);
1427    }
1428    if step == 0 {
1429        return Err(VwapDeviationOscillatorError::InvalidRange {
1430            start: start.to_string(),
1431            end: end.to_string(),
1432            step: step.to_string(),
1433        });
1434    }
1435    let mut out = Vec::new();
1436    if start < end {
1437        let mut x = start;
1438        while x <= end {
1439            out.push(x);
1440            match x.checked_add(step) {
1441                Some(next) => x = next,
1442                None => break,
1443            }
1444        }
1445    } else {
1446        let mut x = start;
1447        while x >= end {
1448            out.push(x);
1449            match x.checked_sub(step) {
1450                Some(next) => x = next,
1451                None => break,
1452            }
1453            if x > start {
1454                break;
1455            }
1456        }
1457    }
1458    if out.is_empty() {
1459        return Err(VwapDeviationOscillatorError::InvalidRange {
1460            start: start.to_string(),
1461            end: end.to_string(),
1462            step: step.to_string(),
1463        });
1464    }
1465    Ok(out)
1466}
1467
1468#[inline(always)]
1469fn axis_f64(start: f64, end: f64, step: f64) -> Result<Vec<f64>, VwapDeviationOscillatorError> {
1470    if (start - end).abs() <= 1e-12 {
1471        return Ok(vec![start]);
1472    }
1473    if !step.is_finite() || step <= 0.0 {
1474        return Err(VwapDeviationOscillatorError::InvalidRange {
1475            start: start.to_string(),
1476            end: end.to_string(),
1477            step: step.to_string(),
1478        });
1479    }
1480    let mut out = Vec::new();
1481    let mut x = start;
1482    if start < end {
1483        while x <= end + 1e-12 {
1484            out.push(x);
1485            x += step;
1486        }
1487    } else {
1488        while x >= end - 1e-12 {
1489            out.push(x);
1490            x -= step;
1491        }
1492    }
1493    if out.is_empty() {
1494        return Err(VwapDeviationOscillatorError::InvalidRange {
1495            start: start.to_string(),
1496            end: end.to_string(),
1497            step: step.to_string(),
1498        });
1499    }
1500    Ok(out)
1501}
1502
1503pub fn expand_grid(
1504    range: &VwapDeviationOscillatorBatchRange,
1505) -> Result<Vec<VwapDeviationOscillatorParams>, VwapDeviationOscillatorError> {
1506    let rolling_periods = axis_usize(
1507        range.rolling_period.0,
1508        range.rolling_period.1,
1509        range.rolling_period.2,
1510    )?;
1511    let rolling_days = axis_usize(
1512        range.rolling_days.0,
1513        range.rolling_days.1,
1514        range.rolling_days.2,
1515    )?;
1516    let z_windows = axis_usize(range.z_window.0, range.z_window.1, range.z_window.2)?;
1517    let pct_vol_lookbacks = axis_usize(
1518        range.pct_vol_lookback.0,
1519        range.pct_vol_lookback.1,
1520        range.pct_vol_lookback.2,
1521    )?;
1522    let pct_min_sigmas = axis_f64(
1523        range.pct_min_sigma.0,
1524        range.pct_min_sigma.1,
1525        range.pct_min_sigma.2,
1526    )?;
1527    let abs_vol_lookbacks = axis_usize(
1528        range.abs_vol_lookback.0,
1529        range.abs_vol_lookback.1,
1530        range.abs_vol_lookback.2,
1531    )?;
1532    let mut out = Vec::with_capacity(
1533        rolling_periods.len()
1534            * rolling_days.len()
1535            * z_windows.len()
1536            * pct_vol_lookbacks.len()
1537            * pct_min_sigmas.len()
1538            * abs_vol_lookbacks.len(),
1539    );
1540    for &rolling_period in &rolling_periods {
1541        for &rolling_days in &rolling_days {
1542            for &z_window in &z_windows {
1543                for &pct_vol_lookback in &pct_vol_lookbacks {
1544                    for &pct_min_sigma in &pct_min_sigmas {
1545                        for &abs_vol_lookback in &abs_vol_lookbacks {
1546                            out.push(VwapDeviationOscillatorParams {
1547                                session_mode: Some(range.session_mode),
1548                                rolling_period: Some(rolling_period),
1549                                rolling_days: Some(rolling_days),
1550                                use_close: Some(range.use_close),
1551                                deviation_mode: Some(range.deviation_mode),
1552                                z_window: Some(z_window),
1553                                pct_vol_lookback: Some(pct_vol_lookback),
1554                                pct_min_sigma: Some(pct_min_sigma),
1555                                abs_vol_lookback: Some(abs_vol_lookback),
1556                            });
1557                        }
1558                    }
1559                }
1560            }
1561        }
1562    }
1563    Ok(out)
1564}
1565
1566pub fn vwap_deviation_oscillator_batch_with_kernel(
1567    timestamps: &[i64],
1568    high: &[f64],
1569    low: &[f64],
1570    close: &[f64],
1571    volume: &[f64],
1572    sweep: &VwapDeviationOscillatorBatchRange,
1573    kernel: Kernel,
1574) -> Result<VwapDeviationOscillatorBatchOutput, VwapDeviationOscillatorError> {
1575    let batch_kernel = match kernel {
1576        Kernel::Auto => detect_best_batch_kernel(),
1577        other if other.is_batch() => other,
1578        _ => return Err(VwapDeviationOscillatorError::InvalidKernelForBatch(kernel)),
1579    };
1580    vwap_deviation_oscillator_batch_par_slice(
1581        timestamps,
1582        high,
1583        low,
1584        close,
1585        volume,
1586        sweep,
1587        batch_kernel.to_non_batch(),
1588    )
1589}
1590
1591#[inline(always)]
1592pub fn vwap_deviation_oscillator_batch_slice(
1593    timestamps: &[i64],
1594    high: &[f64],
1595    low: &[f64],
1596    close: &[f64],
1597    volume: &[f64],
1598    sweep: &VwapDeviationOscillatorBatchRange,
1599    kernel: Kernel,
1600) -> Result<VwapDeviationOscillatorBatchOutput, VwapDeviationOscillatorError> {
1601    vwap_deviation_oscillator_batch_inner(
1602        timestamps, high, low, close, volume, sweep, kernel, false,
1603    )
1604}
1605
1606#[inline(always)]
1607pub fn vwap_deviation_oscillator_batch_par_slice(
1608    timestamps: &[i64],
1609    high: &[f64],
1610    low: &[f64],
1611    close: &[f64],
1612    volume: &[f64],
1613    sweep: &VwapDeviationOscillatorBatchRange,
1614    kernel: Kernel,
1615) -> Result<VwapDeviationOscillatorBatchOutput, VwapDeviationOscillatorError> {
1616    vwap_deviation_oscillator_batch_inner(timestamps, high, low, close, volume, sweep, kernel, true)
1617}
1618
1619fn vwap_deviation_oscillator_batch_inner(
1620    timestamps: &[i64],
1621    high: &[f64],
1622    low: &[f64],
1623    close: &[f64],
1624    volume: &[f64],
1625    sweep: &VwapDeviationOscillatorBatchRange,
1626    kernel: Kernel,
1627    parallel: bool,
1628) -> Result<VwapDeviationOscillatorBatchOutput, VwapDeviationOscillatorError> {
1629    validate_raw_slices(timestamps, high, low, close, volume)?;
1630    let combos = expand_grid(sweep)?;
1631    let rows = combos.len();
1632    let cols = close.len();
1633    let total =
1634        rows.checked_mul(cols)
1635            .ok_or_else(|| VwapDeviationOscillatorError::InvalidRange {
1636                start: rows.to_string(),
1637                end: cols.to_string(),
1638                step: "rows*cols".to_string(),
1639            })?;
1640    let mut osc = vec![f64::NAN; total];
1641    let mut std1 = vec![f64::NAN; total];
1642    let mut std2 = vec![f64::NAN; total];
1643    let mut std3 = vec![f64::NAN; total];
1644    vwap_deviation_oscillator_batch_inner_into(
1645        timestamps, high, low, close, volume, sweep, kernel, parallel, &mut osc, &mut std1,
1646        &mut std2, &mut std3,
1647    )?;
1648    Ok(VwapDeviationOscillatorBatchOutput {
1649        osc,
1650        std1,
1651        std2,
1652        std3,
1653        combos,
1654        rows,
1655        cols,
1656    })
1657}
1658
1659pub fn vwap_deviation_oscillator_batch_into_slice(
1660    out_osc: &mut [f64],
1661    out_std1: &mut [f64],
1662    out_std2: &mut [f64],
1663    out_std3: &mut [f64],
1664    timestamps: &[i64],
1665    high: &[f64],
1666    low: &[f64],
1667    close: &[f64],
1668    volume: &[f64],
1669    sweep: &VwapDeviationOscillatorBatchRange,
1670    kernel: Kernel,
1671) -> Result<(), VwapDeviationOscillatorError> {
1672    vwap_deviation_oscillator_batch_inner_into(
1673        timestamps, high, low, close, volume, sweep, kernel, false, out_osc, out_std1, out_std2,
1674        out_std3,
1675    )?;
1676    Ok(())
1677}
1678
1679fn vwap_deviation_oscillator_batch_inner_into(
1680    timestamps: &[i64],
1681    high: &[f64],
1682    low: &[f64],
1683    close: &[f64],
1684    volume: &[f64],
1685    sweep: &VwapDeviationOscillatorBatchRange,
1686    kernel: Kernel,
1687    parallel: bool,
1688    out_osc: &mut [f64],
1689    out_std1: &mut [f64],
1690    out_std2: &mut [f64],
1691    out_std3: &mut [f64],
1692) -> Result<Vec<VwapDeviationOscillatorParams>, VwapDeviationOscillatorError> {
1693    validate_raw_slices(timestamps, high, low, close, volume)?;
1694    let combos = expand_grid(sweep)?;
1695    let params = combos
1696        .iter()
1697        .map(NormalizedParams::from_params)
1698        .collect::<Result<Vec<_>, _>>()?;
1699    let rows = params.len();
1700    let cols = close.len();
1701    let expected =
1702        rows.checked_mul(cols)
1703            .ok_or_else(|| VwapDeviationOscillatorError::InvalidRange {
1704                start: rows.to_string(),
1705                end: cols.to_string(),
1706                step: "rows*cols".to_string(),
1707            })?;
1708    for out in [&*out_osc, &*out_std1, &*out_std2, &*out_std3] {
1709        if out.len() != expected {
1710            return Err(VwapDeviationOscillatorError::OutputLengthMismatch {
1711                expected,
1712                got: out.len(),
1713            });
1714        }
1715    }
1716    let _chosen = match kernel {
1717        Kernel::Auto => Kernel::Scalar,
1718        other => other.to_non_batch(),
1719    };
1720
1721    let mut base_map: HashMap<BaseKey, BaseSeries> = HashMap::new();
1722    for p in &params {
1723        base_map.entry(p.base_key()).or_insert_with(|| {
1724            compute_base_series(timestamps, high, low, close, volume, p.base_key())
1725        });
1726    }
1727    let do_row = |row: usize,
1728                  dst_osc: &mut [f64],
1729                  dst_std1: &mut [f64],
1730                  dst_std2: &mut [f64],
1731                  dst_std3: &mut [f64]| {
1732        let p = &params[row];
1733        let base = base_map.get(&p.base_key()).unwrap();
1734        compute_outputs_from_base(base, p, dst_osc, dst_std1, dst_std2, dst_std3);
1735        Ok::<(), VwapDeviationOscillatorError>(())
1736    };
1737
1738    if parallel {
1739        #[cfg(not(target_arch = "wasm32"))]
1740        {
1741            out_osc
1742                .par_chunks_mut(cols)
1743                .zip(out_std1.par_chunks_mut(cols))
1744                .zip(out_std2.par_chunks_mut(cols))
1745                .zip(out_std3.par_chunks_mut(cols))
1746                .enumerate()
1747                .try_for_each(|(row, (((dst_osc, dst_std1), dst_std2), dst_std3))| {
1748                    do_row(row, dst_osc, dst_std1, dst_std2, dst_std3)
1749                })?;
1750        }
1751        #[cfg(target_arch = "wasm32")]
1752        {
1753            for (row, (((dst_osc, dst_std1), dst_std2), dst_std3)) in out_osc
1754                .chunks_mut(cols)
1755                .zip(out_std1.chunks_mut(cols))
1756                .zip(out_std2.chunks_mut(cols))
1757                .zip(out_std3.chunks_mut(cols))
1758                .enumerate()
1759            {
1760                do_row(row, dst_osc, dst_std1, dst_std2, dst_std3)?;
1761            }
1762        }
1763    } else {
1764        for (row, (((dst_osc, dst_std1), dst_std2), dst_std3)) in out_osc
1765            .chunks_mut(cols)
1766            .zip(out_std1.chunks_mut(cols))
1767            .zip(out_std2.chunks_mut(cols))
1768            .zip(out_std3.chunks_mut(cols))
1769            .enumerate()
1770        {
1771            do_row(row, dst_osc, dst_std1, dst_std2, dst_std3)?;
1772        }
1773    }
1774
1775    Ok(combos)
1776}
1777
1778#[cfg(feature = "python")]
1779#[pyfunction(name = "vwap_deviation_oscillator")]
1780#[pyo3(signature = (timestamps, high, low, close, volume, session_mode="rolling_bars", rolling_period=20, rolling_days=30, use_close=false, deviation_mode="absolute", z_window=50, pct_vol_lookback=100, pct_min_sigma=0.1, abs_vol_lookback=100, kernel=None))]
1781pub fn vwap_deviation_oscillator_py<'py>(
1782    py: Python<'py>,
1783    timestamps: PyReadonlyArray1<'py, i64>,
1784    high: PyReadonlyArray1<'py, f64>,
1785    low: PyReadonlyArray1<'py, f64>,
1786    close: PyReadonlyArray1<'py, f64>,
1787    volume: PyReadonlyArray1<'py, f64>,
1788    session_mode: &str,
1789    rolling_period: usize,
1790    rolling_days: usize,
1791    use_close: bool,
1792    deviation_mode: &str,
1793    z_window: usize,
1794    pct_vol_lookback: usize,
1795    pct_min_sigma: f64,
1796    abs_vol_lookback: usize,
1797    kernel: Option<&str>,
1798) -> PyResult<(
1799    Bound<'py, PyArray1<f64>>,
1800    Bound<'py, PyArray1<f64>>,
1801    Bound<'py, PyArray1<f64>>,
1802    Bound<'py, PyArray1<f64>>,
1803)> {
1804    let ts = timestamps.as_slice()?;
1805    let h = high.as_slice()?;
1806    let l = low.as_slice()?;
1807    let c = close.as_slice()?;
1808    let v = volume.as_slice()?;
1809    if ts.len() != h.len() || ts.len() != l.len() || ts.len() != c.len() || ts.len() != v.len() {
1810        return Err(PyValueError::new_err(
1811            "timestamp/high/low/close/volume slice length mismatch",
1812        ));
1813    }
1814    let session_mode = session_mode
1815        .parse::<VwapDeviationSessionMode>()
1816        .map_err(PyValueError::new_err)?;
1817    let deviation_mode = deviation_mode
1818        .parse::<VwapDeviationMode>()
1819        .map_err(PyValueError::new_err)?;
1820    let kern = validate_kernel(kernel, false)?;
1821    let input = VwapDeviationOscillatorInput::from_slices(
1822        ts,
1823        h,
1824        l,
1825        c,
1826        v,
1827        VwapDeviationOscillatorParams {
1828            session_mode: Some(session_mode),
1829            rolling_period: Some(rolling_period),
1830            rolling_days: Some(rolling_days),
1831            use_close: Some(use_close),
1832            deviation_mode: Some(deviation_mode),
1833            z_window: Some(z_window),
1834            pct_vol_lookback: Some(pct_vol_lookback),
1835            pct_min_sigma: Some(pct_min_sigma),
1836            abs_vol_lookback: Some(abs_vol_lookback),
1837        },
1838    );
1839    let out = py
1840        .allow_threads(|| vwap_deviation_oscillator_with_kernel(&input, kern))
1841        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1842    Ok((
1843        out.osc.into_pyarray(py),
1844        out.std1.into_pyarray(py),
1845        out.std2.into_pyarray(py),
1846        out.std3.into_pyarray(py),
1847    ))
1848}
1849
1850#[cfg(feature = "python")]
1851#[pyclass(name = "VwapDeviationOscillatorStream")]
1852pub struct VwapDeviationOscillatorStreamPy {
1853    stream: VwapDeviationOscillatorStream,
1854}
1855
1856#[cfg(feature = "python")]
1857#[pymethods]
1858impl VwapDeviationOscillatorStreamPy {
1859    #[new]
1860    #[pyo3(signature = (session_mode="rolling_bars", rolling_period=20, rolling_days=30, use_close=false, deviation_mode="absolute", z_window=50, pct_vol_lookback=100, pct_min_sigma=0.1, abs_vol_lookback=100))]
1861    fn new(
1862        session_mode: &str,
1863        rolling_period: usize,
1864        rolling_days: usize,
1865        use_close: bool,
1866        deviation_mode: &str,
1867        z_window: usize,
1868        pct_vol_lookback: usize,
1869        pct_min_sigma: f64,
1870        abs_vol_lookback: usize,
1871    ) -> PyResult<Self> {
1872        let session_mode = session_mode
1873            .parse::<VwapDeviationSessionMode>()
1874            .map_err(PyValueError::new_err)?;
1875        let deviation_mode = deviation_mode
1876            .parse::<VwapDeviationMode>()
1877            .map_err(PyValueError::new_err)?;
1878        let stream = VwapDeviationOscillatorStream::try_new(VwapDeviationOscillatorParams {
1879            session_mode: Some(session_mode),
1880            rolling_period: Some(rolling_period),
1881            rolling_days: Some(rolling_days),
1882            use_close: Some(use_close),
1883            deviation_mode: Some(deviation_mode),
1884            z_window: Some(z_window),
1885            pct_vol_lookback: Some(pct_vol_lookback),
1886            pct_min_sigma: Some(pct_min_sigma),
1887            abs_vol_lookback: Some(abs_vol_lookback),
1888        })
1889        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1890        Ok(Self { stream })
1891    }
1892
1893    fn update(
1894        &mut self,
1895        timestamp: i64,
1896        high: f64,
1897        low: f64,
1898        close: f64,
1899        volume: f64,
1900    ) -> (f64, f64, f64, f64) {
1901        self.stream.update(timestamp, high, low, close, volume)
1902    }
1903}
1904
1905#[cfg(feature = "python")]
1906#[pyfunction(name = "vwap_deviation_oscillator_batch")]
1907#[pyo3(signature = (timestamps, high, low, close, volume, session_mode="rolling_bars", use_close=false, deviation_mode="absolute", rolling_period_range=(20,20,0), rolling_days_range=(30,30,0), z_window_range=(50,50,0), pct_vol_lookback_range=(100,100,0), pct_min_sigma_range=(0.1,0.1,0.0), abs_vol_lookback_range=(100,100,0), kernel=None))]
1908pub fn vwap_deviation_oscillator_batch_py<'py>(
1909    py: Python<'py>,
1910    timestamps: PyReadonlyArray1<'py, i64>,
1911    high: PyReadonlyArray1<'py, f64>,
1912    low: PyReadonlyArray1<'py, f64>,
1913    close: PyReadonlyArray1<'py, f64>,
1914    volume: PyReadonlyArray1<'py, f64>,
1915    session_mode: &str,
1916    use_close: bool,
1917    deviation_mode: &str,
1918    rolling_period_range: (usize, usize, usize),
1919    rolling_days_range: (usize, usize, usize),
1920    z_window_range: (usize, usize, usize),
1921    pct_vol_lookback_range: (usize, usize, usize),
1922    pct_min_sigma_range: (f64, f64, f64),
1923    abs_vol_lookback_range: (usize, usize, usize),
1924    kernel: Option<&str>,
1925) -> PyResult<Bound<'py, PyDict>> {
1926    let ts = timestamps.as_slice()?;
1927    let h = high.as_slice()?;
1928    let l = low.as_slice()?;
1929    let c = close.as_slice()?;
1930    let v = volume.as_slice()?;
1931    if ts.len() != h.len() || ts.len() != l.len() || ts.len() != c.len() || ts.len() != v.len() {
1932        return Err(PyValueError::new_err(
1933            "timestamp/high/low/close/volume slice length mismatch",
1934        ));
1935    }
1936    let session_mode = session_mode
1937        .parse::<VwapDeviationSessionMode>()
1938        .map_err(PyValueError::new_err)?;
1939    let deviation_mode = deviation_mode
1940        .parse::<VwapDeviationMode>()
1941        .map_err(PyValueError::new_err)?;
1942    let sweep = VwapDeviationOscillatorBatchRange {
1943        rolling_period: rolling_period_range,
1944        rolling_days: rolling_days_range,
1945        z_window: z_window_range,
1946        pct_vol_lookback: pct_vol_lookback_range,
1947        pct_min_sigma: pct_min_sigma_range,
1948        abs_vol_lookback: abs_vol_lookback_range,
1949        session_mode,
1950        use_close,
1951        deviation_mode,
1952    };
1953    let combos = expand_grid(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
1954    let rows = combos.len();
1955    let cols = c.len();
1956    let total = rows
1957        .checked_mul(cols)
1958        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
1959
1960    let osc_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1961    let std1_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1962    let std2_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1963    let std3_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1964    let osc_out = unsafe { osc_arr.as_slice_mut()? };
1965    let std1_out = unsafe { std1_arr.as_slice_mut()? };
1966    let std2_out = unsafe { std2_arr.as_slice_mut()? };
1967    let std3_out = unsafe { std3_arr.as_slice_mut()? };
1968
1969    let kern = validate_kernel(kernel, true)?;
1970    py.allow_threads(|| {
1971        let batch = match kern {
1972            Kernel::Auto => detect_best_batch_kernel(),
1973            other => other,
1974        };
1975        vwap_deviation_oscillator_batch_inner_into(
1976            ts,
1977            h,
1978            l,
1979            c,
1980            v,
1981            &sweep,
1982            batch.to_non_batch(),
1983            true,
1984            osc_out,
1985            std1_out,
1986            std2_out,
1987            std3_out,
1988        )
1989    })
1990    .map_err(|e| PyValueError::new_err(e.to_string()))?;
1991
1992    let dict = PyDict::new(py);
1993    dict.set_item("osc", osc_arr.reshape((rows, cols))?)?;
1994    dict.set_item("std1", std1_arr.reshape((rows, cols))?)?;
1995    dict.set_item("std2", std2_arr.reshape((rows, cols))?)?;
1996    dict.set_item("std3", std3_arr.reshape((rows, cols))?)?;
1997    dict.set_item(
1998        "rolling_periods",
1999        combos
2000            .iter()
2001            .map(|p| p.rolling_period.unwrap_or(DEFAULT_ROLLING_PERIOD) as u64)
2002            .collect::<Vec<_>>()
2003            .into_pyarray(py),
2004    )?;
2005    dict.set_item(
2006        "rolling_days",
2007        combos
2008            .iter()
2009            .map(|p| p.rolling_days.unwrap_or(DEFAULT_ROLLING_DAYS) as u64)
2010            .collect::<Vec<_>>()
2011            .into_pyarray(py),
2012    )?;
2013    dict.set_item(
2014        "z_windows",
2015        combos
2016            .iter()
2017            .map(|p| p.z_window.unwrap_or(DEFAULT_Z_WINDOW) as u64)
2018            .collect::<Vec<_>>()
2019            .into_pyarray(py),
2020    )?;
2021    dict.set_item(
2022        "pct_vol_lookbacks",
2023        combos
2024            .iter()
2025            .map(|p| p.pct_vol_lookback.unwrap_or(DEFAULT_PCT_VOL_LOOKBACK) as u64)
2026            .collect::<Vec<_>>()
2027            .into_pyarray(py),
2028    )?;
2029    dict.set_item(
2030        "pct_min_sigmas",
2031        combos
2032            .iter()
2033            .map(|p| p.pct_min_sigma.unwrap_or(DEFAULT_PCT_MIN_SIGMA))
2034            .collect::<Vec<_>>()
2035            .into_pyarray(py),
2036    )?;
2037    dict.set_item(
2038        "abs_vol_lookbacks",
2039        combos
2040            .iter()
2041            .map(|p| p.abs_vol_lookback.unwrap_or(DEFAULT_ABS_VOL_LOOKBACK) as u64)
2042            .collect::<Vec<_>>()
2043            .into_pyarray(py),
2044    )?;
2045    dict.set_item("rows", rows)?;
2046    dict.set_item("cols", cols)?;
2047    Ok(dict)
2048}
2049
2050#[cfg(feature = "python")]
2051pub fn register_vwap_deviation_oscillator_module(
2052    m: &Bound<'_, pyo3::types::PyModule>,
2053) -> PyResult<()> {
2054    m.add_function(wrap_pyfunction!(vwap_deviation_oscillator_py, m)?)?;
2055    m.add_function(wrap_pyfunction!(vwap_deviation_oscillator_batch_py, m)?)?;
2056    m.add_class::<VwapDeviationOscillatorStreamPy>()?;
2057    Ok(())
2058}
2059
2060#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2061fn f64_timestamps_to_i64(ts: &[f64]) -> Result<Vec<i64>, JsValue> {
2062    let mut out = Vec::with_capacity(ts.len());
2063    for &t in ts {
2064        if !t.is_finite() {
2065            return Err(JsValue::from_str("invalid timestamp"));
2066        }
2067        out.push(t as i64);
2068    }
2069    Ok(out)
2070}
2071
2072#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2073#[wasm_bindgen(js_name = "vwap_deviation_oscillator_js")]
2074pub fn vwap_deviation_oscillator_js(
2075    timestamps: &[f64],
2076    high: &[f64],
2077    low: &[f64],
2078    close: &[f64],
2079    volume: &[f64],
2080    session_mode: &str,
2081    rolling_period: usize,
2082    rolling_days: usize,
2083    use_close: bool,
2084    deviation_mode: &str,
2085    z_window: usize,
2086    pct_vol_lookback: usize,
2087    pct_min_sigma: f64,
2088    abs_vol_lookback: usize,
2089) -> Result<JsValue, JsValue> {
2090    if timestamps.len() != high.len()
2091        || timestamps.len() != low.len()
2092        || timestamps.len() != close.len()
2093        || timestamps.len() != volume.len()
2094    {
2095        return Err(JsValue::from_str(
2096            "timestamp/high/low/close/volume slice length mismatch",
2097        ));
2098    }
2099    let ts = f64_timestamps_to_i64(timestamps)?;
2100    let session_mode = session_mode
2101        .parse::<VwapDeviationSessionMode>()
2102        .map_err(|e| JsValue::from_str(&e))?;
2103    let deviation_mode = deviation_mode
2104        .parse::<VwapDeviationMode>()
2105        .map_err(|e| JsValue::from_str(&e))?;
2106    let input = VwapDeviationOscillatorInput::from_slices(
2107        &ts,
2108        high,
2109        low,
2110        close,
2111        volume,
2112        VwapDeviationOscillatorParams {
2113            session_mode: Some(session_mode),
2114            rolling_period: Some(rolling_period),
2115            rolling_days: Some(rolling_days),
2116            use_close: Some(use_close),
2117            deviation_mode: Some(deviation_mode),
2118            z_window: Some(z_window),
2119            pct_vol_lookback: Some(pct_vol_lookback),
2120            pct_min_sigma: Some(pct_min_sigma),
2121            abs_vol_lookback: Some(abs_vol_lookback),
2122        },
2123    );
2124    let out = vwap_deviation_oscillator_with_kernel(&input, Kernel::Auto)
2125        .map_err(|e| JsValue::from_str(&e.to_string()))?;
2126    let obj = js_sys::Object::new();
2127    js_sys::Reflect::set(
2128        &obj,
2129        &JsValue::from_str("osc"),
2130        &serde_wasm_bindgen::to_value(&out.osc).unwrap(),
2131    )?;
2132    js_sys::Reflect::set(
2133        &obj,
2134        &JsValue::from_str("std1"),
2135        &serde_wasm_bindgen::to_value(&out.std1).unwrap(),
2136    )?;
2137    js_sys::Reflect::set(
2138        &obj,
2139        &JsValue::from_str("std2"),
2140        &serde_wasm_bindgen::to_value(&out.std2).unwrap(),
2141    )?;
2142    js_sys::Reflect::set(
2143        &obj,
2144        &JsValue::from_str("std3"),
2145        &serde_wasm_bindgen::to_value(&out.std3).unwrap(),
2146    )?;
2147    Ok(obj.into())
2148}
2149
2150#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2151#[derive(Serialize, Deserialize)]
2152pub struct VwapDeviationOscillatorBatchConfig {
2153    pub session_mode: String,
2154    pub use_close: bool,
2155    pub deviation_mode: String,
2156    pub rolling_period_range: Vec<usize>,
2157    pub rolling_days_range: Vec<usize>,
2158    pub z_window_range: Vec<usize>,
2159    pub pct_vol_lookback_range: Vec<usize>,
2160    pub pct_min_sigma_range: Vec<f64>,
2161    pub abs_vol_lookback_range: Vec<usize>,
2162}
2163
2164#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2165#[derive(Serialize, Deserialize)]
2166pub struct VwapDeviationOscillatorBatchJsOutput {
2167    pub osc: Vec<f64>,
2168    pub std1: Vec<f64>,
2169    pub std2: Vec<f64>,
2170    pub std3: Vec<f64>,
2171    pub combos: Vec<VwapDeviationOscillatorParams>,
2172    pub rows: usize,
2173    pub cols: usize,
2174}
2175
2176#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2177#[wasm_bindgen(js_name = "vwap_deviation_oscillator_batch_js")]
2178pub fn vwap_deviation_oscillator_batch_js(
2179    timestamps: &[f64],
2180    high: &[f64],
2181    low: &[f64],
2182    close: &[f64],
2183    volume: &[f64],
2184    config: JsValue,
2185) -> Result<JsValue, JsValue> {
2186    if timestamps.len() != high.len()
2187        || timestamps.len() != low.len()
2188        || timestamps.len() != close.len()
2189        || timestamps.len() != volume.len()
2190    {
2191        return Err(JsValue::from_str(
2192            "timestamp/high/low/close/volume slice length mismatch",
2193        ));
2194    }
2195    let ts = f64_timestamps_to_i64(timestamps)?;
2196    let config: VwapDeviationOscillatorBatchConfig = serde_wasm_bindgen::from_value(config)
2197        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
2198    if config.rolling_period_range.len() != 3
2199        || config.rolling_days_range.len() != 3
2200        || config.z_window_range.len() != 3
2201        || config.pct_vol_lookback_range.len() != 3
2202        || config.pct_min_sigma_range.len() != 3
2203        || config.abs_vol_lookback_range.len() != 3
2204    {
2205        return Err(JsValue::from_str(
2206            "Invalid config: each range must have exactly 3 elements [start, end, step]",
2207        ));
2208    }
2209    let session_mode = config
2210        .session_mode
2211        .parse::<VwapDeviationSessionMode>()
2212        .map_err(|e| JsValue::from_str(&e))?;
2213    let deviation_mode = config
2214        .deviation_mode
2215        .parse::<VwapDeviationMode>()
2216        .map_err(|e| JsValue::from_str(&e))?;
2217    let out = vwap_deviation_oscillator_batch_with_kernel(
2218        &ts,
2219        high,
2220        low,
2221        close,
2222        volume,
2223        &VwapDeviationOscillatorBatchRange {
2224            session_mode,
2225            use_close: config.use_close,
2226            deviation_mode,
2227            rolling_period: (
2228                config.rolling_period_range[0],
2229                config.rolling_period_range[1],
2230                config.rolling_period_range[2],
2231            ),
2232            rolling_days: (
2233                config.rolling_days_range[0],
2234                config.rolling_days_range[1],
2235                config.rolling_days_range[2],
2236            ),
2237            z_window: (
2238                config.z_window_range[0],
2239                config.z_window_range[1],
2240                config.z_window_range[2],
2241            ),
2242            pct_vol_lookback: (
2243                config.pct_vol_lookback_range[0],
2244                config.pct_vol_lookback_range[1],
2245                config.pct_vol_lookback_range[2],
2246            ),
2247            pct_min_sigma: (
2248                config.pct_min_sigma_range[0],
2249                config.pct_min_sigma_range[1],
2250                config.pct_min_sigma_range[2],
2251            ),
2252            abs_vol_lookback: (
2253                config.abs_vol_lookback_range[0],
2254                config.abs_vol_lookback_range[1],
2255                config.abs_vol_lookback_range[2],
2256            ),
2257        },
2258        Kernel::Auto,
2259    )
2260    .map_err(|e| JsValue::from_str(&e.to_string()))?;
2261    serde_wasm_bindgen::to_value(&VwapDeviationOscillatorBatchJsOutput {
2262        osc: out.osc,
2263        std1: out.std1,
2264        std2: out.std2,
2265        std3: out.std3,
2266        combos: out.combos,
2267        rows: out.rows,
2268        cols: out.cols,
2269    })
2270    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
2271}
2272
2273#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2274#[wasm_bindgen]
2275pub fn vwap_deviation_oscillator_alloc(len: usize) -> *mut f64 {
2276    let mut vec = Vec::<f64>::with_capacity(len);
2277    let ptr = vec.as_mut_ptr();
2278    std::mem::forget(vec);
2279    ptr
2280}
2281
2282#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2283#[wasm_bindgen]
2284pub fn vwap_deviation_oscillator_free(ptr: *mut f64, len: usize) {
2285    if !ptr.is_null() {
2286        unsafe {
2287            let _ = Vec::from_raw_parts(ptr, len, len);
2288        }
2289    }
2290}
2291
2292#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2293#[wasm_bindgen]
2294pub fn vwap_deviation_oscillator_into(
2295    timestamps_ptr: *const f64,
2296    high_ptr: *const f64,
2297    low_ptr: *const f64,
2298    close_ptr: *const f64,
2299    volume_ptr: *const f64,
2300    out_ptr: *mut f64,
2301    len: usize,
2302    session_mode: &str,
2303    rolling_period: usize,
2304    rolling_days: usize,
2305    use_close: bool,
2306    deviation_mode: &str,
2307    z_window: usize,
2308    pct_vol_lookback: usize,
2309    pct_min_sigma: f64,
2310    abs_vol_lookback: usize,
2311) -> Result<(), JsValue> {
2312    if timestamps_ptr.is_null()
2313        || high_ptr.is_null()
2314        || low_ptr.is_null()
2315        || close_ptr.is_null()
2316        || volume_ptr.is_null()
2317        || out_ptr.is_null()
2318    {
2319        return Err(JsValue::from_str(
2320            "null pointer passed to vwap_deviation_oscillator_into",
2321        ));
2322    }
2323    unsafe {
2324        let ts_f64 = std::slice::from_raw_parts(timestamps_ptr, len);
2325        let ts = f64_timestamps_to_i64(ts_f64)?;
2326        let high = std::slice::from_raw_parts(high_ptr, len);
2327        let low = std::slice::from_raw_parts(low_ptr, len);
2328        let close = std::slice::from_raw_parts(close_ptr, len);
2329        let volume = std::slice::from_raw_parts(volume_ptr, len);
2330        let out = std::slice::from_raw_parts_mut(out_ptr, 4 * len);
2331        let (out_osc, rest) = out.split_at_mut(len);
2332        let (out_std1, rest) = rest.split_at_mut(len);
2333        let (out_std2, out_std3) = rest.split_at_mut(len);
2334        let session_mode = session_mode
2335            .parse::<VwapDeviationSessionMode>()
2336            .map_err(|e| JsValue::from_str(&e))?;
2337        let deviation_mode = deviation_mode
2338            .parse::<VwapDeviationMode>()
2339            .map_err(|e| JsValue::from_str(&e))?;
2340        let input = VwapDeviationOscillatorInput::from_slices(
2341            &ts,
2342            high,
2343            low,
2344            close,
2345            volume,
2346            VwapDeviationOscillatorParams {
2347                session_mode: Some(session_mode),
2348                rolling_period: Some(rolling_period),
2349                rolling_days: Some(rolling_days),
2350                use_close: Some(use_close),
2351                deviation_mode: Some(deviation_mode),
2352                z_window: Some(z_window),
2353                pct_vol_lookback: Some(pct_vol_lookback),
2354                pct_min_sigma: Some(pct_min_sigma),
2355                abs_vol_lookback: Some(abs_vol_lookback),
2356            },
2357        );
2358        vwap_deviation_oscillator_into_slice(
2359            out_osc,
2360            out_std1,
2361            out_std2,
2362            out_std3,
2363            &input,
2364            Kernel::Auto,
2365        )
2366        .map_err(|e| JsValue::from_str(&e.to_string()))
2367    }
2368}
2369
2370#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2371#[wasm_bindgen]
2372pub fn vwap_deviation_oscillator_batch_into(
2373    timestamps_ptr: *const f64,
2374    high_ptr: *const f64,
2375    low_ptr: *const f64,
2376    close_ptr: *const f64,
2377    volume_ptr: *const f64,
2378    osc_ptr: *mut f64,
2379    std1_ptr: *mut f64,
2380    std2_ptr: *mut f64,
2381    std3_ptr: *mut f64,
2382    len: usize,
2383    session_mode: &str,
2384    use_close: bool,
2385    deviation_mode: &str,
2386    rolling_period_start: usize,
2387    rolling_period_end: usize,
2388    rolling_period_step: usize,
2389    rolling_days_start: usize,
2390    rolling_days_end: usize,
2391    rolling_days_step: usize,
2392    z_window_start: usize,
2393    z_window_end: usize,
2394    z_window_step: usize,
2395    pct_vol_lookback_start: usize,
2396    pct_vol_lookback_end: usize,
2397    pct_vol_lookback_step: usize,
2398    pct_min_sigma_start: f64,
2399    pct_min_sigma_end: f64,
2400    pct_min_sigma_step: f64,
2401    abs_vol_lookback_start: usize,
2402    abs_vol_lookback_end: usize,
2403    abs_vol_lookback_step: usize,
2404) -> Result<usize, JsValue> {
2405    if timestamps_ptr.is_null()
2406        || high_ptr.is_null()
2407        || low_ptr.is_null()
2408        || close_ptr.is_null()
2409        || volume_ptr.is_null()
2410        || osc_ptr.is_null()
2411        || std1_ptr.is_null()
2412        || std2_ptr.is_null()
2413        || std3_ptr.is_null()
2414    {
2415        return Err(JsValue::from_str(
2416            "null pointer passed to vwap_deviation_oscillator_batch_into",
2417        ));
2418    }
2419    unsafe {
2420        let ts_f64 = std::slice::from_raw_parts(timestamps_ptr, len);
2421        let ts = f64_timestamps_to_i64(ts_f64)?;
2422        let high = std::slice::from_raw_parts(high_ptr, len);
2423        let low = std::slice::from_raw_parts(low_ptr, len);
2424        let close = std::slice::from_raw_parts(close_ptr, len);
2425        let volume = std::slice::from_raw_parts(volume_ptr, len);
2426        let session_mode = session_mode
2427            .parse::<VwapDeviationSessionMode>()
2428            .map_err(|e| JsValue::from_str(&e))?;
2429        let deviation_mode = deviation_mode
2430            .parse::<VwapDeviationMode>()
2431            .map_err(|e| JsValue::from_str(&e))?;
2432        let sweep = VwapDeviationOscillatorBatchRange {
2433            session_mode,
2434            use_close,
2435            deviation_mode,
2436            rolling_period: (
2437                rolling_period_start,
2438                rolling_period_end,
2439                rolling_period_step,
2440            ),
2441            rolling_days: (rolling_days_start, rolling_days_end, rolling_days_step),
2442            z_window: (z_window_start, z_window_end, z_window_step),
2443            pct_vol_lookback: (
2444                pct_vol_lookback_start,
2445                pct_vol_lookback_end,
2446                pct_vol_lookback_step,
2447            ),
2448            pct_min_sigma: (pct_min_sigma_start, pct_min_sigma_end, pct_min_sigma_step),
2449            abs_vol_lookback: (
2450                abs_vol_lookback_start,
2451                abs_vol_lookback_end,
2452                abs_vol_lookback_step,
2453            ),
2454        };
2455        let combos = expand_grid(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
2456        let rows = combos.len();
2457        let total = rows
2458            .checked_mul(len)
2459            .ok_or_else(|| JsValue::from_str("rows*len overflow"))?;
2460        let out_osc = std::slice::from_raw_parts_mut(osc_ptr, total);
2461        let out_std1 = std::slice::from_raw_parts_mut(std1_ptr, total);
2462        let out_std2 = std::slice::from_raw_parts_mut(std2_ptr, total);
2463        let out_std3 = std::slice::from_raw_parts_mut(std3_ptr, total);
2464        vwap_deviation_oscillator_batch_inner_into(
2465            &ts,
2466            high,
2467            low,
2468            close,
2469            volume,
2470            &sweep,
2471            Kernel::Scalar,
2472            false,
2473            out_osc,
2474            out_std1,
2475            out_std2,
2476            out_std3,
2477        )
2478        .map_err(|e| JsValue::from_str(&e.to_string()))?;
2479        Ok(rows)
2480    }
2481}
2482
2483#[cfg(test)]
2484mod tests {
2485    use super::*;
2486
2487    fn sample_ohlcv(n: usize) -> (Vec<i64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
2488        let mut timestamps = Vec::with_capacity(n);
2489        let mut high = Vec::with_capacity(n);
2490        let mut low = Vec::with_capacity(n);
2491        let mut close = Vec::with_capacity(n);
2492        let mut volume = Vec::with_capacity(n);
2493        let mut base = 100.0;
2494        for i in 0..n {
2495            timestamps.push(1_700_000_000_000i64 + (i as i64) * 14_400_000);
2496            let c = base + ((i as f64) * 0.07).sin() * 2.0 + (i as f64) * 0.03;
2497            let h = c + 1.0 + ((i as f64) * 0.03).cos().abs();
2498            let l = c - 1.1 - ((i as f64) * 0.05).sin().abs();
2499            let v = 1000.0 + (i as f64) * 3.0 + ((i as f64) * 0.11).cos() * 35.0;
2500            high.push(h);
2501            low.push(l);
2502            close.push(c);
2503            volume.push(v.max(1.0));
2504            base = c;
2505        }
2506        (timestamps, high, low, close, volume)
2507    }
2508
2509    fn manual_std_ignore_nan(values: &[f64], period: usize, guard: f64) -> Vec<f64> {
2510        let mut out = vec![f64::NAN; values.len()];
2511        let mut stats = RollingFiniteWindow::new(period);
2512        for i in 0..values.len() {
2513            out[i] = guarded_sigma(&mut stats, values[i], guard);
2514        }
2515        out
2516    }
2517
2518    #[test]
2519    fn absolute_mode_matches_manual_sigma_series() {
2520        let (timestamps, high, low, close, volume) = sample_ohlcv(160);
2521        let input = VwapDeviationOscillatorInput::from_slices(
2522            &timestamps,
2523            &high,
2524            &low,
2525            &close,
2526            &volume,
2527            VwapDeviationOscillatorParams {
2528                session_mode: Some(VwapDeviationSessionMode::RollingBars),
2529                rolling_period: Some(20),
2530                rolling_days: Some(30),
2531                use_close: Some(false),
2532                deviation_mode: Some(VwapDeviationMode::Absolute),
2533                z_window: Some(50),
2534                pct_vol_lookback: Some(100),
2535                pct_min_sigma: Some(0.1),
2536                abs_vol_lookback: Some(20),
2537            },
2538        );
2539        let out = vwap_deviation_oscillator(&input).unwrap();
2540        let params = NormalizedParams::from_input(&input).unwrap();
2541        let base =
2542            compute_base_series(&timestamps, &high, &low, &close, &volume, params.base_key());
2543        let expected_std1 = manual_std_ignore_nan(&base.resid_abs, 20, 1.0);
2544        for i in 0..out.osc.len() {
2545            if out.osc[i].is_finite() && base.resid_abs[i].is_finite() {
2546                assert!((out.osc[i] - base.resid_abs[i]).abs() <= 1e-12);
2547            }
2548            if out.std1[i].is_nan() && expected_std1[i].is_nan() {
2549                continue;
2550            }
2551            assert!((out.std1[i] - expected_std1[i]).abs() <= 1e-12);
2552        }
2553    }
2554
2555    #[test]
2556    fn stream_matches_batch() {
2557        let (timestamps, high, low, close, volume) = sample_ohlcv(128);
2558        let params = VwapDeviationOscillatorParams {
2559            session_mode: Some(VwapDeviationSessionMode::RollingDays),
2560            rolling_period: Some(20),
2561            rolling_days: Some(5),
2562            use_close: Some(true),
2563            deviation_mode: Some(VwapDeviationMode::Percent),
2564            z_window: Some(50),
2565            pct_vol_lookback: Some(25),
2566            pct_min_sigma: Some(0.1),
2567            abs_vol_lookback: Some(100),
2568        };
2569        let input = VwapDeviationOscillatorInput::from_slices(
2570            &timestamps,
2571            &high,
2572            &low,
2573            &close,
2574            &volume,
2575            params.clone(),
2576        );
2577        let batch = vwap_deviation_oscillator(&input).unwrap();
2578        let mut stream = VwapDeviationOscillatorStream::try_new(params).unwrap();
2579        let mut osc = Vec::with_capacity(close.len());
2580        let mut std1 = Vec::with_capacity(close.len());
2581        let mut std2 = Vec::with_capacity(close.len());
2582        let mut std3 = Vec::with_capacity(close.len());
2583        for i in 0..close.len() {
2584            let row = stream.update(timestamps[i], high[i], low[i], close[i], volume[i]);
2585            osc.push(row.0);
2586            std1.push(row.1);
2587            std2.push(row.2);
2588            std3.push(row.3);
2589        }
2590        for (lhs, rhs) in osc.iter().zip(batch.osc.iter()) {
2591            if lhs.is_nan() && rhs.is_nan() {
2592                continue;
2593            }
2594            assert!((lhs - rhs).abs() <= 1e-12);
2595        }
2596        for (lhs, rhs) in std1.iter().zip(batch.std1.iter()) {
2597            if lhs.is_nan() && rhs.is_nan() {
2598                continue;
2599            }
2600            assert!((lhs - rhs).abs() <= 1e-12);
2601        }
2602        for (lhs, rhs) in std2.iter().zip(batch.std2.iter()) {
2603            if lhs.is_nan() && rhs.is_nan() {
2604                continue;
2605            }
2606            assert!((lhs - rhs).abs() <= 1e-12);
2607        }
2608        for (lhs, rhs) in std3.iter().zip(batch.std3.iter()) {
2609            if lhs.is_nan() && rhs.is_nan() {
2610                continue;
2611            }
2612            assert!((lhs - rhs).abs() <= 1e-12);
2613        }
2614    }
2615
2616    #[test]
2617    fn batch_first_row_matches_single() {
2618        let (timestamps, high, low, close, volume) = sample_ohlcv(96);
2619        let batch = vwap_deviation_oscillator_batch_with_kernel(
2620            &timestamps,
2621            &high,
2622            &low,
2623            &close,
2624            &volume,
2625            &VwapDeviationOscillatorBatchRange {
2626                session_mode: VwapDeviationSessionMode::RollingBars,
2627                use_close: false,
2628                deviation_mode: VwapDeviationMode::ZScore,
2629                rolling_period: (20, 22, 2),
2630                rolling_days: (30, 30, 0),
2631                z_window: (25, 25, 0),
2632                pct_vol_lookback: (100, 100, 0),
2633                pct_min_sigma: (0.1, 0.1, 0.0),
2634                abs_vol_lookback: (100, 100, 0),
2635            },
2636            Kernel::Auto,
2637        )
2638        .unwrap();
2639        let input = VwapDeviationOscillatorInput::from_slices(
2640            &timestamps,
2641            &high,
2642            &low,
2643            &close,
2644            &volume,
2645            VwapDeviationOscillatorParams {
2646                session_mode: Some(VwapDeviationSessionMode::RollingBars),
2647                rolling_period: Some(20),
2648                rolling_days: Some(30),
2649                use_close: Some(false),
2650                deviation_mode: Some(VwapDeviationMode::ZScore),
2651                z_window: Some(25),
2652                pct_vol_lookback: Some(100),
2653                pct_min_sigma: Some(0.1),
2654                abs_vol_lookback: Some(100),
2655            },
2656        );
2657        let single = vwap_deviation_oscillator(&input).unwrap();
2658        let cols = close.len();
2659        assert_eq!(batch.rows, 2);
2660        for i in 0..cols {
2661            let lhs = batch.osc[i];
2662            let rhs = single.osc[i];
2663            if lhs.is_nan() && rhs.is_nan() {
2664                continue;
2665            }
2666            assert!((lhs - rhs).abs() <= 1e-12);
2667        }
2668    }
2669
2670    #[test]
2671    fn into_slice_matches_single() {
2672        let (timestamps, high, low, close, volume) = sample_ohlcv(80);
2673        let input = VwapDeviationOscillatorInput::from_slices(
2674            &timestamps,
2675            &high,
2676            &low,
2677            &close,
2678            &volume,
2679            VwapDeviationOscillatorParams::default(),
2680        );
2681        let single = vwap_deviation_oscillator(&input).unwrap();
2682        let mut osc = vec![f64::NAN; close.len()];
2683        let mut std1 = vec![f64::NAN; close.len()];
2684        let mut std2 = vec![f64::NAN; close.len()];
2685        let mut std3 = vec![f64::NAN; close.len()];
2686        vwap_deviation_oscillator_into_slice(
2687            &mut osc,
2688            &mut std1,
2689            &mut std2,
2690            &mut std3,
2691            &input,
2692            Kernel::Auto,
2693        )
2694        .unwrap();
2695        for i in 0..close.len() {
2696            if osc[i].is_nan() && single.osc[i].is_nan() {
2697                continue;
2698            }
2699            assert!((osc[i] - single.osc[i]).abs() <= 1e-12);
2700        }
2701    }
2702
2703    #[test]
2704    fn invalid_z_window_is_rejected() {
2705        let (timestamps, high, low, close, volume) = sample_ohlcv(32);
2706        let input = VwapDeviationOscillatorInput::from_slices(
2707            &timestamps,
2708            &high,
2709            &low,
2710            &close,
2711            &volume,
2712            VwapDeviationOscillatorParams {
2713                z_window: Some(4),
2714                ..VwapDeviationOscillatorParams::default()
2715            },
2716        );
2717        let err = vwap_deviation_oscillator(&input).unwrap_err();
2718        assert!(matches!(
2719            err,
2720            VwapDeviationOscillatorError::InvalidZWindow { z_window: 4 }
2721        ));
2722    }
2723}