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, ¶ms)
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}