Skip to main content

vector_ta/indicators/
kvo.rs

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